siddhm11 Claude Opus 5 commited on
Commit
99c77d4
Β·
1 Parent(s): 534132a

feat(frontend): infinite paginated feed and a hand-written design system

Browse files

The 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 CHANGED
@@ -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
+
app/groq_svc.py CHANGED
@@ -34,7 +34,18 @@ def _get_client():
34
  if _client is not None:
35
  return _client
36
 
37
- from groq import Groq
 
 
 
 
 
 
 
 
 
 
 
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")
app/routers/events.py CHANGED
@@ -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
- {"paper_id": paper_id, "saved": True, "dismissed": False, "source": source},
 
 
 
 
 
 
 
 
 
 
 
 
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
app/routers/recommendations.py CHANGED
@@ -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
- from app.recommend.diversity import mmr_rerank, inject_exploration
 
 
 
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
- _RANKER_VERSION = "v6.5_lightgbm_real_cosines"
 
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
- # Phase 6.5 B1: one query_id per feed request for per-feed CTR analysis
64
- query_id = str(uuid.uuid4())
 
65
 
66
  def _empty_resp():
67
- r = templates.TemplateResponse(
68
- request,
69
- "partials/empty_recs.html",
70
- {"min_saves": REC_MIN_POSITIVES},
71
- )
72
- r.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
73
- return r
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=REC_LIMIT,
83
  )
84
  if trending:
85
- papers = []
86
- for idx, paper in enumerate(trending):
87
- paper["saved"] = False
88
- paper["dismissed"] = False
89
- paper["ranker_version"] = _RANKER_VERSION
90
- paper["candidate_source"] = "trending_category_fallback"
91
- paper["cluster_id"] = ""
92
- paper["query_id"] = query_id
93
- paper["position"] = idx
94
- paper["propensity"] = 1.0 # deterministic
95
- paper["policy_id"] = _RANKER_VERSION
96
- papers.append(paper)
97
-
98
- r = templates.TemplateResponse(
99
- request,
100
- "partials/recommendations.html",
101
- {"papers": papers, "source": "recommendation", "trending": True},
102
- )
103
- r.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
104
- return r
105
-
106
- return _empty_resp()
107
 
108
  seen = us.all_seen(user_id)
109
 
110
- # Phase 4.5: paper_tags maps arxiv_id β†’ instrumentation metadata
111
- # populated by whichever tier serves the result.
112
- paper_tags: dict[str, dict] = {}
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 rec_arxiv_ids:
124
- rec_arxiv_ids = await _ewma_recommend(user_id, seen, REC_LIMIT)
125
- for aid in rec_arxiv_ids:
126
- paper_tags[aid] = {
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 fallback) ───────────────────
136
- if not rec_arxiv_ids:
137
- rec_arxiv_ids = await qdrant_svc.recommend(
138
  positive_arxiv_ids=state.positive_list,
139
  negative_arxiv_ids=state.negative_list,
140
  seen_arxiv_ids=seen,
141
- limit=REC_LIMIT,
142
  )
143
- for aid in rec_arxiv_ids:
144
- paper_tags[aid] = {
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
- if not rec_arxiv_ids:
154
- return _empty_resp()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
 
156
- # Phase 3.5: Turso primary, arXiv API fallback
157
- t0_meta = time.time()
158
- meta = await turso_svc.fetch_metadata_batch(rec_arxiv_ids)
159
- missing = [aid for aid in rec_arxiv_ids if aid not in meta]
160
  if missing:
161
  try:
162
- arxiv_meta = await arxiv_svc.fetch_metadata_batch(missing)
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 suppression JOINs work (Phase 4.3)
170
  await db.cache_turso_metadata_batch(list(meta.values()))
171
 
172
- papers = []
173
- for idx, aid in enumerate(rec_arxiv_ids):
 
 
 
 
 
 
 
174
  if aid not in meta:
175
  continue
176
- tags = paper_tags.get(aid, {})
 
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
- "candidate_source": tags.get("candidate_source", ""),
184
- "cluster_id": tags.get("cluster_id", ""),
185
- # Phase 6.5 B1: query_id + position for per-feed CTR
186
- "query_id": tags.get("query_id", query_id),
187
- "position": idx,
188
- # Phase 6.5 B2: propensity + policy_id for counterfactual eval
189
- "propensity": tags.get("propensity", 1.0),
 
 
190
  "policy_id": tags.get("policy_id", _RANKER_VERSION),
191
  })
 
192
 
193
- resp = templates.TemplateResponse(
194
- request,
195
- "partials/recommendations.html",
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
- resp.set_cookie(COOKIE_NAME, user_id, max_age=365 * 24 * 3600, httponly=True)
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: Exploration injection ���────────────────────────────────
512
- final = inject_exploration(
513
- selected_ids=mmr_selected,
514
- all_candidate_ids=reranked_ids,
515
- n_explore=2,
516
- )
517
- final = final[:limit + 2]
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
- explore_pool_size = max(1, len(reranked_ids) - len(mmr_set))
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 final:
532
  cluster_idx = paper_cluster_map.get(aid)
533
- if aid in exploration_set:
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": explore_propensity if aid in exploration_set else 1.0,
547
  "policy_id": _RANKER_VERSION,
548
  }
549
 
550
- return final, paper_tags, rerank_time_ms, timing
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 ────────────────────────────────────────
app/static/animations.css DELETED
@@ -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
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/static/app.js CHANGED
@@ -1,282 +1,238 @@
1
  /* ═══════════════════════════════════════════════════════════════════════════
2
- ResearchIT β€” Client-Side JavaScript
3
- Theme toggle, toasts, progressive loading messages, optimistic UI,
4
- abstract expand/collapse, HTMX event handlers.
 
 
 
 
 
 
 
 
 
5
  ═══════════════════════════════════════════════════════════════════════════ */
6
 
7
- // ── Theme Toggle ─────────────────────────────────────────────────────────────
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
- // ── Toast Notification System ────────────────────────────────────────────────
 
 
37
 
38
- function showToast(message, type = 'info', duration = 3000) {
39
- const container = document.getElementById('toast-container');
40
- if (!container) return;
 
41
 
42
- const iconMap = {
43
- success: 'βœ“',
44
- error: 'βœ•',
45
- info: 'β„Ή',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  };
47
 
48
- const toast = document.createElement('div');
49
- toast.className = `rit-toast rit-toast-${type}`;
50
- toast.innerHTML = `
51
- <span class="rit-toast-icon">${iconMap[type] || 'β„Ή'}</span>
52
- <span>${message}</span>
53
- `;
54
-
55
- container.appendChild(toast);
56
-
57
- setTimeout(() => {
58
- toast.classList.add('toast-exit');
59
- toast.addEventListener('animationend', () => toast.remove());
60
- }, duration);
61
- }
62
-
63
- // ── Progressive Loading Messages ─────────────────────────────────────────────
64
-
65
- class ProgressMessages {
66
- constructor(containerId, messages) {
67
- this.container = document.getElementById(containerId);
68
- this.messages = messages || [
69
- 'Encoding your query with BGE-M3…',
70
- 'Searching across 1.6M papers…',
71
- 'Ranking the best matches for you…',
72
- 'Almost there β€” curating results…',
73
- ];
74
- this.index = 0;
75
- this.timer = null;
76
  }
77
 
78
- start(intervalMs = 2500) {
79
- if (!this.container) return;
80
- this.index = 0;
81
- this._show();
82
- this.timer = setInterval(() => {
83
- this.index = Math.min(this.index + 1, this.messages.length - 1);
84
- this._show();
85
- }, intervalMs);
86
  }
87
 
88
- stop() {
89
- if (this.timer) {
90
- clearInterval(this.timer);
91
- this.timer = null;
92
- }
 
 
 
 
93
  }
94
 
95
- _show() {
96
- if (!this.container) return;
97
- this.container.innerHTML = `
98
- <span class="rit-progress-msg">${this.messages[this.index]}
99
- <span class="rit-progress-dots"><span></span><span></span><span></span></span>
100
- </span>
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
- // Recommendations loading
187
- if (target.id === 'rec-section') {
188
- if (recProgress) recProgress.start(2000);
189
- }
190
- });
191
 
192
- // ── After HTMX Request (success or error) ────────────────────────────────
193
- document.body.addEventListener('htmx:afterRequest', function (evt) {
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
- // Recommendations loaded
211
- if (target.id === 'rec-section') {
212
- if (recProgress) recProgress.stop();
213
- }
214
- });
 
 
 
215
 
216
- // ── HTMX Error Handler ─────────────────────────────────────────────────
217
- document.body.addEventListener('htmx:responseError', function (evt) {
218
- const target = evt.detail.target;
219
- if (target) {
220
- // Show friendly error for search/rec failures
221
- if (target.id === 'search-results' || target.id === 'rec-section') {
222
- target.innerHTML = `
223
- <div class="rit-error-state">
224
- <div class="text-3xl mb-3 opacity-70">⚑</div>
225
- <p class="font-medium mb-1">Our servers are thinking extra hard</p>
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
- // ── Optimistic Save Feedback ─────────────────────────────────────────────
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
- // Card dismissed (card removed from DOM)
256
- if (target.id && target.id.startsWith('paper-') && target.innerHTML.trim() === '') {
257
- showToast('Paper dismissed', 'info', 2000);
258
- }
259
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
260
 
261
- // ── Mobile Menu Toggle ─────────────────────────────────────────────────
262
- const menuToggle = document.querySelector('.mobile-menu-toggle');
263
- if (menuToggle) {
264
- menuToggle.addEventListener('change', function () {
265
- const menu = this.closest('.flex-none').querySelector('.mobile-menu');
266
- if (menu) {
267
- menu.style.display = this.checked ? 'flex' : 'none';
268
- }
 
 
269
  });
 
 
 
 
 
 
 
270
  }
271
 
272
- // ── Set Active Nav Link ────────────────────────────────────────────────
273
- const path = window.location.pathname;
274
- document.querySelectorAll('.rit-nav-link, .rit-bottom-nav-item').forEach(link => {
275
- const href = link.getAttribute('href');
276
- if (href === path || (href === '/' && path === '/')) {
277
- link.classList.add('active');
278
- } else if (href !== '/' && path.startsWith(href)) {
279
- link.classList.add('active');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ })();
app/static/htmx.min.js ADDED
@@ -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}()});
app/static/styles.css CHANGED
@@ -1,983 +1,737 @@
1
  /* ═══════════════════════════════════════════════════════════════════════════
2
- ResearchIT β€” Master Stylesheet
3
- Premium dark-first design system for a personalized research feed.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  ═══════════════════════════════════════════════════════════════════════════ */
5
 
6
- /* ── 1. Typography ────────────────────────────────────────────────────────── */
7
- @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=Outfit:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap');
 
 
 
 
 
8
 
9
  :root {
10
- --rit-accent: #0EA5E9;
11
- --rit-accent-light: #38BDF8;
12
- --rit-accent-dim: #0284C7;
13
- --rit-accent-glow: rgba(14, 165, 233, 0.12);
14
- --rit-accent-glow-strong: rgba(14, 165, 233, 0.25);
15
- --rit-brand-from: #0EA5E9;
16
- --rit-brand-via: #06B6D4;
17
- --rit-brand-to: #22D3EE;
18
- --rit-radius: 14px;
19
- --rit-radius-sm: 10px;
20
- --rit-transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
21
- }
22
-
23
- body {
24
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
25
- -webkit-font-smoothing: antialiased;
26
- -moz-osx-font-smoothing: grayscale;
27
- letter-spacing: -0.01em;
28
- }
29
-
30
- h1, h2, h3, .font-display {
31
- font-family: 'Outfit', 'Inter', system-ui, sans-serif;
32
- }
33
-
34
- code, .mono, .font-mono {
35
- font-family: 'JetBrains Mono', 'Fira Code', monospace;
36
- }
37
-
38
- /* ── 2. DaisyUI Theme Overrides ───────────────────────────────────────────── */
39
- /* Override DaisyUI primary color to teal/sky for dark and light themes */
40
- [data-theme="dark"] {
41
- --p: 68.5% 0.163 221.4; /* oklch: sky-500 */
42
- --pf: 60% 0.163 221.4; /* primary-focus: darker */
43
- --pc: 98% 0.01 221.4; /* primary-content: near white */
44
- --b1: 15.7% 0.02 262; /* base-100: deep navy-black */
45
- --b2: 12.5% 0.02 262; /* base-200: darker */
46
- --b3: 10% 0.02 262; /* base-300: darkest */
47
- --bc: 91% 0.01 260; /* base-content */
48
- --rounded-btn: 10px;
49
- }
50
-
51
- [data-theme="light"] {
52
- --p: 59% 0.18 221.4;
53
- --pf: 52% 0.18 221.4;
54
- --pc: 98% 0.005 0;
55
- --b1: 98% 0.003 260;
56
- --b2: 96% 0.005 260;
57
- --b3: 93% 0.008 260;
58
- --bc: 22% 0.02 260;
59
- --rounded-btn: 10px;
60
- }
61
-
62
- /* ── 3. Base & Resets ─────────────────────────────────────────────────────── */
63
- html {
64
- scroll-behavior: smooth;
65
- }
66
-
67
- html, body {
68
- transition: background-color 0.3s ease, color 0.3s ease;
69
- }
70
-
71
- ::selection {
72
- background: var(--rit-accent);
73
- color: white;
74
- }
75
-
76
- /* ── 4. Navbar ─────────���──────────────────────────────────────────────────── */
77
- .rit-navbar {
78
- background: rgba(11, 13, 20, 0.8);
79
- backdrop-filter: blur(20px) saturate(180%);
80
- -webkit-backdrop-filter: blur(20px) saturate(180%);
81
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
82
- transition: background var(--rit-transition);
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
- .abstract-toggle:hover {
252
- opacity: 1;
253
- }
 
 
 
 
254
 
255
- /* ── 7. Category Badges ───────────────────────────────────────────────────── */
256
- .cat-badge {
257
- font-size: 0.625rem;
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
- .cat-cs { background: rgba(56,189,248,0.12); color: #38BDF8; }
267
- .cat-phys { background: rgba(192,132,252,0.12); color: #C084FC; }
268
- .cat-math { background: rgba(52,211,153,0.12); color: #34D399; }
269
- .cat-bio { background: rgba(251,191,36,0.12); color: #FBBF24; }
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
- [data-theme="light"] .cat-cs { background: rgba(14,165,233,0.1); color: #0284C7; }
276
- [data-theme="light"] .cat-phys { background: rgba(168,85,247,0.1); color: #7C3AED; }
277
- [data-theme="light"] .cat-math { background: rgba(16,185,129,0.1); color: #059669; }
278
- [data-theme="light"] .cat-bio { background: rgba(245,158,11,0.1); color: #B45309; }
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
- /* ── 8. Action Buttons ────────────────────────────────────────────────────── */
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
- .rit-action-btn .btn-icon {
300
- font-size: 0.875rem;
301
- transition: transform 0.2s ease;
302
  }
303
 
304
- .rit-btn-save {
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
- .rit-btn-save:hover {
311
- background: rgba(14, 165, 233, 0.15);
312
- border-color: rgba(14, 165, 233, 0.3);
313
- transform: translateY(-1px);
314
- }
315
 
316
- .rit-btn-save:hover .btn-icon {
317
- transform: scale(1.15);
318
- }
319
 
320
- .rit-btn-save.saved {
321
- background: rgba(16, 185, 129, 0.12);
322
- border-color: rgba(16, 185, 129, 0.3);
323
- color: #10B981;
324
- }
325
-
326
- .rit-btn-dismiss {
327
- color: rgba(255, 255, 255, 0.4);
328
- border-color: transparent;
 
329
  }
330
 
331
- .rit-btn-dismiss:hover {
332
- color: #EF4444;
333
- background: rgba(239, 68, 68, 0.08);
334
- border-color: rgba(239, 68, 68, 0.15);
335
- }
 
336
 
337
- .rit-btn-link {
338
- color: rgba(255, 255, 255, 0.4);
339
- border-color: transparent;
340
- }
341
 
342
- .rit-btn-link:hover {
343
- color: rgba(255, 255, 255, 0.8);
344
- background: rgba(255, 255, 255, 0.06);
 
 
345
  }
346
 
347
- [data-theme="light"] .rit-btn-save {
348
- color: #0284C7;
349
- border-color: rgba(14, 165, 233, 0.25);
350
- background: rgba(14, 165, 233, 0.06);
 
 
351
  }
352
 
353
- [data-theme="light"] .rit-btn-dismiss {
354
- color: rgba(0, 0, 0, 0.35);
 
 
 
 
355
  }
 
356
 
357
- [data-theme="light"] .rit-btn-link {
358
- color: rgba(0, 0, 0, 0.35);
359
- }
360
 
361
- [data-theme="light"] .rit-btn-link:hover {
362
- color: rgba(0, 0, 0, 0.7);
363
- background: rgba(0, 0, 0, 0.04);
364
- }
365
 
366
- /* ── 9. Skeleton Loaders ──────────────────────────────────────────────────── */
367
- .skeleton-card {
368
- border-radius: var(--rit-radius);
369
- padding: 1.25rem;
370
- border: 1px solid rgba(255, 255, 255, 0.04);
371
- background: rgba(255, 255, 255, 0.02);
372
  }
373
 
374
- [data-theme="light"] .skeleton-card {
375
- border-color: rgba(0, 0, 0, 0.04);
376
- background: rgba(0, 0, 0, 0.015);
377
- }
378
 
379
- .skeleton-line {
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
- [data-theme="light"] .skeleton-line {
392
- background: linear-gradient(
393
- 90deg,
394
- rgba(100, 116, 139, 0.08) 25%,
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
- @media (min-width: 768px) {
423
- #toast-container {
424
- bottom: 24px;
425
- right: 24px;
426
- }
427
- }
 
 
428
 
429
- .rit-toast {
430
- pointer-events: auto;
431
- display: flex;
432
- align-items: center;
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
- [data-theme="light"] .rit-toast {
448
- background: rgba(255, 255, 255, 0.95);
449
- border-color: rgba(0, 0, 0, 0.08);
450
- color: #1E293B;
451
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
452
  }
 
453
 
454
- .rit-toast.toast-exit {
455
- animation: toastOut 0.25s ease-in forwards;
 
456
  }
457
-
458
- .rit-toast-icon {
459
- font-size: 1rem;
460
- flex-shrink: 0;
461
  }
462
 
463
- .rit-toast-success { border-left: 3px solid #10B981; }
464
- .rit-toast-error { border-left: 3px solid #EF4444; }
465
- .rit-toast-info { border-left: 3px solid var(--rit-accent); }
466
 
467
- /* ── 11. Coming Soon Badges ───────────────────────────────────────────────── */
468
- .coming-soon-wrapper {
469
  position: relative;
470
- opacity: 0.5;
471
- pointer-events: none;
472
- cursor: default;
473
  }
474
-
475
- .coming-soon-wrapper.interactive {
476
- pointer-events: auto;
477
- cursor: pointer;
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
- [data-theme="light"] .coming-soon-section {
505
- border-color: rgba(0, 0, 0, 0.08);
506
- background: rgba(0, 0, 0, 0.01);
 
507
  }
 
 
508
 
509
- /* ── 12. Mobile Bottom Nav ────────────────────────────────────────────────── */
510
- .rit-bottom-nav {
511
- position: fixed;
512
- bottom: 0;
513
- left: 0;
514
- right: 0;
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
- [data-theme="light"] .rit-bottom-nav {
524
- background: rgba(255, 255, 255, 0.92);
525
- border-top-color: rgba(0, 0, 0, 0.06);
526
- }
527
 
528
- @media (max-width: 767px) {
529
- .rit-bottom-nav { display: flex; }
530
- body { padding-bottom: 68px; }
 
 
 
531
  }
 
532
 
533
- .rit-bottom-nav-item {
534
  flex: 1;
535
- display: flex;
536
- flex-direction: column;
537
- align-items: center;
538
- gap: 2px;
539
- padding: 6px 0;
540
- color: rgba(255, 255, 255, 0.4);
541
- font-size: 0.625rem;
542
- font-weight: 500;
543
- text-decoration: none;
544
- transition: color var(--rit-transition);
545
- }
546
-
547
- .rit-bottom-nav-item svg {
548
- width: 22px;
549
- height: 22px;
550
- transition: transform var(--rit-transition);
551
- }
552
-
553
- .rit-bottom-nav-item:hover,
554
- .rit-bottom-nav-item.active {
555
- color: var(--rit-accent);
556
- }
557
-
558
- .rit-bottom-nav-item.active svg {
559
- transform: scale(1.1);
560
- }
561
-
562
- [data-theme="light"] .rit-bottom-nav-item {
563
- color: rgba(0, 0, 0, 0.35);
564
- }
565
-
566
- [data-theme="light"] .rit-bottom-nav-item:hover,
567
- [data-theme="light"] .rit-bottom-nav-item.active {
568
- color: var(--rit-accent-dim);
569
- }
570
-
571
- /* ── 13. Onboarding ───────────────────────────────────────────────────────── */
572
- .category-tile {
573
- cursor: pointer;
574
- border: 2px solid rgba(255, 255, 255, 0.06);
575
- border-radius: 12px;
576
- padding: 1rem;
577
- transition: all var(--rit-transition);
578
- user-select: none;
579
- text-align: center;
580
- position: relative;
581
- background: rgba(255, 255, 255, 0.02);
582
- }
583
-
584
- [data-theme="light"] .category-tile {
585
- background: rgba(255, 255, 255, 0.8);
586
- border-color: rgba(0, 0, 0, 0.06);
587
- }
588
-
589
- .category-tile:hover {
590
- border-color: rgba(14, 165, 233, 0.3);
591
- transform: translateY(-2px);
592
- box-shadow: 0 4px 20px var(--rit-accent-glow);
593
- }
594
-
595
- .category-tile.selected {
596
- border-color: var(--rit-accent);
597
- background: rgba(14, 165, 233, 0.06);
598
- box-shadow: 0 0 0 1px rgba(14, 165, 233, 0.2),
599
- 0 4px 20px var(--rit-accent-glow);
600
- }
601
-
602
- .category-tile .tile-icon {
603
- font-size: 2rem;
604
- margin-bottom: 0.5rem;
605
- display: block;
606
- }
607
-
608
- .category-tile .tile-name {
609
- font-weight: 600;
610
- font-size: 0.8rem;
611
- margin-bottom: 0.2rem;
612
- }
613
-
614
- .category-tile .tile-desc {
615
- font-size: 0.65rem;
616
- opacity: 0.5;
617
- line-height: 1.3;
618
- }
619
-
620
- .category-tile .check-mark {
621
- position: absolute;
622
- top: 8px;
623
- right: 8px;
624
- width: 22px;
625
- height: 22px;
626
- border-radius: 50%;
627
- background: var(--rit-accent);
628
- color: white;
629
- display: none;
630
- align-items: center;
631
- justify-content: center;
632
- font-size: 0.65rem;
633
- font-weight: 700;
634
- animation: checkPop 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
635
- }
636
-
637
- .category-tile.selected .check-mark {
638
- display: flex;
639
- }
640
-
641
- /* Onboarding progress dots */
642
- .step-dot {
643
- width: 10px;
644
- height: 10px;
645
- border-radius: 50%;
646
- background: rgba(148, 163, 184, 0.2);
647
- transition: all 0.3s ease;
648
- }
649
-
650
- .step-dot.active {
651
- background: var(--rit-accent);
652
- box-shadow: 0 0 10px var(--rit-accent-glow-strong);
653
- transform: scale(1.3);
654
- }
655
-
656
- .step-dot.completed {
657
- background: #10B981;
658
- }
659
-
660
- /* Onboarding seed progress bar */
661
- .seed-progress-track {
662
- height: 4px;
663
- background: rgba(255, 255, 255, 0.06);
664
- border-radius: 2px;
665
- overflow: hidden;
666
- }
667
-
668
- .seed-progress-fill {
669
- height: 100%;
670
- background: linear-gradient(90deg, var(--rit-brand-from), var(--rit-brand-to));
671
- border-radius: 2px;
672
- transition: width 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
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
- .ai-summary-card::before {
692
- content: "";
693
- position: absolute;
694
- top: 0;
695
- left: 0;
696
- right: 0;
697
- height: 2px;
698
- background: linear-gradient(90deg, var(--rit-brand-from), var(--rit-brand-via), var(--rit-brand-to));
699
- background-size: 200% auto;
700
- animation: shimmer 3s linear infinite;
701
- }
702
-
703
- /* ── 15. Search Enhancements ──────────────────────────────────────────────── */
704
- .rit-search-wrapper {
705
- position: relative;
706
- }
707
-
708
- .rit-search-wrapper::before {
709
- content: '';
710
- position: absolute;
711
- inset: -2px;
712
- border-radius: calc(var(--rit-radius) + 2px);
713
- background: linear-gradient(135deg, var(--rit-brand-from), var(--rit-brand-via), var(--rit-brand-to));
714
- opacity: 0;
715
- transition: opacity 0.4s ease;
716
- z-index: -1;
717
- }
718
-
719
- .rit-search-wrapper:focus-within::before {
720
- opacity: 0.15;
721
- }
722
-
723
- .rit-search-input {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- .year-pill {
753
- font-size: 0.65rem;
754
- font-weight: 600;
755
- padding: 2px 6px;
756
- border-radius: 5px;
757
- background: rgba(148, 163, 184, 0.1);
758
- color: #94A3B8;
759
  }
 
760
 
761
- .year-pill.recent {
762
- background: rgba(16, 185, 129, 0.1);
763
- color: #34D399;
764
- }
765
-
766
- [data-theme="light"] .year-pill {
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
- /* ── 20. Scrollbar ────────────────────────────────────────────────────────── */
876
- [data-theme="dark"] ::-webkit-scrollbar { width: 6px; }
877
- [data-theme="dark"] ::-webkit-scrollbar-track { background: transparent; }
878
- [data-theme="dark"] ::-webkit-scrollbar-thumb {
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
- .htmx-added {
893
- opacity: 0;
894
- }
895
-
896
- .htmx-settling {
897
- opacity: 1;
898
- transition: opacity 0.25s ease-in;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
  }
 
 
900
 
901
- /* ── 22. Mobile Menu ──────────────────────────────────────────────────────── */
902
- .mobile-menu-toggle:checked ~ .mobile-menu {
903
- display: flex;
904
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
905
 
906
- .mobile-menu {
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
- [data-theme="light"] .mobile-menu {
922
- background: rgba(255, 255, 255, 0.96);
923
- border-bottom-color: rgba(0, 0, 0, 0.06);
924
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1);
 
 
 
 
 
 
 
925
  }
 
926
 
927
- /* ── 23. Utilities ─────────────────────────────────────────────────────────── */
928
- .fade-in {
929
- animation: fadeInUp 0.4s ease-out;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
930
  }
931
-
932
- .line-clamp-1 {
933
- display: -webkit-box;
934
- -webkit-line-clamp: 1;
935
- -webkit-box-orient: vertical;
936
- overflow: hidden;
937
  }
 
938
 
939
- .line-clamp-3 {
940
- display: -webkit-box;
941
- -webkit-line-clamp: 3;
942
- -webkit-box-orient: vertical;
943
- overflow: hidden;
944
- }
945
 
946
- /* Error state */
947
- .rit-error-state {
948
- text-align: center;
949
- padding: 3rem 1.5rem;
950
- border-radius: var(--rit-radius);
951
- border: 1px dashed rgba(239, 68, 68, 0.2);
952
- background: rgba(239, 68, 68, 0.03);
953
- }
954
 
955
- /* Hero gradient background */
956
- .rit-hero-glow {
957
- position: absolute;
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
- /* Pipeline details */
968
- .rit-pipeline-detail {
969
- padding: 0.75rem 1rem;
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
- [data-theme="light"] .rit-pipeline-detail {
976
- background: rgba(0, 0, 0, 0.015);
977
- border-color: rgba(0, 0, 0, 0.04);
978
- }
979
 
980
- /* ── 24. Save animation ───────────────────────────────────────────────────── */
981
- .save-success {
982
- animation: savePulse 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
 
 
 
 
 
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
  }
app/templates/base.html CHANGED
@@ -1,137 +1,121 @@
1
  <!DOCTYPE html>
2
- <html lang="en" data-theme="dark">
 
 
 
 
 
 
 
 
 
 
 
 
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 β€” AI-powered arXiv paper recommendations. Search, save, and discover research tailored to your interests." />
 
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
- <!-- HTMX -->
18
- <script src="https://unpkg.com/htmx.org@1.9.12"></script>
19
-
20
- <style>
21
- /* HTMX indicator visibility */
22
- .htmx-request .htmx-indicator { display: inline-block !important; }
23
- .htmx-indicator { display: none; }
24
- </style>
25
-
26
- <!-- Theme restore (runs before body to prevent flash) -->
27
  <script>
28
- (function() {
29
- const saved = localStorage.getItem('researchit-theme');
30
- if (saved) document.documentElement.setAttribute('data-theme', saved);
 
 
 
 
31
  })();
32
  </script>
33
 
34
  {% block head %}{% endblock %}
35
  </head>
36
- <body class="bg-base-200 min-h-screen">
 
 
 
37
 
38
- <!-- ═══ Navbar ═══ -->
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
- <!-- Desktop Menu -->
51
- <div class="hidden md:flex flex-none items-center gap-1">
52
- <a href="/" class="rit-nav-link">Home</a>
53
- <a href="/search" class="rit-nav-link">Search</a>
54
- <a href="/saved" class="rit-nav-link">Saved</a>
55
- <div class="w-px h-5 bg-base-content/10 mx-2"></div>
56
- <button id="theme-toggle" class="btn btn-ghost btn-sm btn-circle" title="Toggle theme"
57
- onclick="toggleTheme()">
58
- <svg class="theme-icon-sun w-4 h-4 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>
59
- <svg class="theme-icon-moon w-4 h-4 hidden" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
60
- </button>
61
- </div>
62
 
63
- <!-- Mobile Menu Toggle -->
64
- <div class="flex-none md:hidden relative">
65
- <label class="btn btn-ghost btn-sm btn-circle swap swap-rotate">
66
- <input type="checkbox" class="mobile-menu-toggle hidden" />
67
- <svg class="swap-off fill-current" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 512 512"><path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z"/></svg>
68
- <svg class="swap-on fill-current" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 512 512"><polygon points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"/></svg>
69
- </label>
70
- <div class="mobile-menu hidden">
71
- <a href="/" class="btn btn-ghost btn-sm justify-start">Home</a>
72
- <a href="/search" class="btn btn-ghost btn-sm justify-start">Search</a>
73
- <a href="/saved" class="btn btn-ghost btn-sm justify-start">Saved</a>
74
- <div class="divider my-1 opacity-20"></div>
75
- <button class="btn btn-ghost btn-sm justify-start" onclick="toggleTheme()">
76
- <span class="theme-text-dark hidden">Switch to Light β˜€οΈ</span>
77
- <span class="theme-text-light hidden">Switch to Dark πŸŒ™</span>
78
- </button>
 
79
  </div>
80
- </div>
81
- </nav>
82
 
83
- <!-- ═══ Main Content ═══ -->
84
- <main class="container mx-auto px-4 py-6 md:py-10 max-w-4xl min-h-[70vh]">
85
- {% block content %}{% endblock %}
86
- </main>
87
 
88
- <!-- ═══ Footer ═══ -->
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
- <!-- ═══ Mobile Bottom Navigation ═══ -->
108
- <div class="rit-bottom-nav">
109
- <a href="/" class="rit-bottom-nav-item" data-page="home">
110
- <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
111
- <path stroke-linecap="round" stroke-linejoin="round" d="m3 12 2-2m0 0 7-7 7 7M5 10v10a1 1 0 0 0 1 1h3m10-11 2 2m-2-2v10a1 1 0 0 1-1 1h-3m-4 0a1 1 0 0 1-1-1v-4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-2Z"/>
 
112
  </svg>
113
- <span>Home</span>
114
  </a>
115
- <a href="/search" class="rit-bottom-nav-item" data-page="search">
116
- <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
117
- <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"/>
 
118
  </svg>
119
  <span>Search</span>
120
  </a>
121
- <a href="/saved" class="rit-bottom-nav-item" data-page="saved">
122
- <svg fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
123
- <path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/>
 
124
  </svg>
125
- <span>Saved</span>
126
  </a>
127
- </div>
128
 
129
- <!-- ═══ Toast Container ═══ -->
130
- <div id="toast-container"></div>
131
 
132
- <!-- ═══ Scripts ═══ -->
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>
app/templates/index.html CHANGED
@@ -1,98 +1,33 @@
1
  {% extends "base.html" %}
2
 
3
- {% block title %}ResearchIT β€” Your AI Research Feed{% endblock %}
4
 
5
- {% block content %}
6
- <div class="space-y-10 fade-in">
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
- <h1 class="text-3xl md:text-5xl font-extrabold mb-4 tracking-tight relative z-10 font-display">
14
- <span class="brand-text">Your AI Research Feed</span>
15
- </h1>
16
- <p class="text-sm md:text-base text-base-content/50 mb-8 max-w-xl mx-auto leading-relaxed relative z-10">
17
- Personalized paper recommendations powered by multi-interest clustering, BGE-M3 embeddings, and LightGBM reranking β€” across 1.6M arXiv papers.
18
- </p>
19
 
20
- <!-- Search bar -->
21
- <div class="max-w-2xl mx-auto relative z-10">
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
- <div id="rec-section-wrapper" class="relative">
73
- <div id="rec-section"
74
- hx-get="/api/recommendations"
75
- hx-trigger="load"
76
- hx-swap="innerHTML">
77
- <!-- Skeleton loaders while recommendations load -->
78
- {% set count = 4 %}
79
- {% include "partials/skeleton.html" %}
80
- </div>
81
- </div>
82
  </div>
83
 
84
- <!-- ═══ Coming Soon: Collaborative Filtering ═══ -->
85
- <div class="coming-soon-section" onclick="handleComingSoon(event)">
86
- <div class="flex items-center gap-3">
87
- <span class="text-2xl opacity-50">πŸ‘₯</span>
88
- <div>
89
- <div class="flex items-center gap-2 mb-0.5">
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>
app/templates/onboarding.html CHANGED
@@ -1,187 +1,122 @@
1
  <!DOCTYPE html>
2
- <html lang="en" data-theme="dark">
 
 
 
 
 
3
  <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Welcome to ResearchIT</title>
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
- const saved = localStorage.getItem('researchit-theme');
29
- if (saved) document.documentElement.setAttribute('data-theme', saved);
 
 
30
  })();
31
  </script>
32
  </head>
33
- <body class="bg-base-200 min-h-screen flex items-center justify-center p-4">
 
34
 
35
- <!-- Step Indicator -->
36
- <div class="fixed top-8 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
37
- <div id="dot-1" class="step-dot active"></div>
38
- <div class="w-16 h-0.5 bg-base-content/10"></div>
39
- <div id="dot-2" class="step-dot"></div>
40
- </div>
41
 
42
- <main class="w-full max-w-4xl pb-16 fade-in">
43
-
44
- <!-- ═══ Step 1: Category Selection ═══ -->
45
- <div id="step-1">
46
- <div class="text-center mb-10 relative">
47
- <div class="rit-hero-glow animate-glow" style="top: -50px; left: 50%; transform: translateX(-50%);"></div>
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="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 mb-10 relative z-10">
57
  {% for key, cat in categories.items() %}
58
- <div class="category-tile {% if key in selected %}selected{% endif %} animate-card-in"
59
- style="animation-delay: {{ loop.index0 * 0.03 }}s"
60
- data-key="{{ key }}"
61
- onclick="toggleCategory(this)">
62
-
63
- <div class="check-mark">βœ“</div>
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
- <!-- Action Footer -->
72
- <div class="fixed bottom-0 left-0 right-0 p-4 bg-base-200/95 backdrop-blur-md border-t border-base-content/10 z-50">
73
- <div class="max-w-4xl mx-auto flex items-center justify-between">
74
- <div class="text-sm font-medium">
75
- <span id="selected-count" class="badge badge-primary mr-2">{{ selected|length }}</span>
76
- <span class="text-base-content/60">selected (max 8)</span>
77
- </div>
78
- <div class="flex gap-3">
79
- <button onclick="skipOnboarding()" class="btn btn-ghost btn-sm md:btn-md text-base-content/50">
80
- Skip for now
81
- </button>
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
- <!-- ═══ Step 2: Seed Search ═══ -->
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
- // Initialize selected set from Jinja template
101
- let selectedCategories = new Set({{ selected | tojson }});
102
 
103
- function toggleCategory(element) {
104
- const key = element.dataset.key;
105
-
106
  if (selectedCategories.has(key)) {
107
  selectedCategories.delete(key);
108
- element.classList.remove('selected');
109
  } else {
110
- if (selectedCategories.size >= 8) {
111
- showToast('Maximum 8 categories allowed', 'error');
112
- return;
113
- }
114
  selectedCategories.add(key);
115
- element.classList.add('selected');
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 === 0) return;
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
- const response = await fetch('/api/onboarding/categories', {
145
  method: 'POST',
146
  headers: { 'Content-Type': 'application/json' },
147
  body: JSON.stringify({ categories: Array.from(selectedCategories) })
148
  });
149
-
150
- if (response.ok) {
151
- const html = await response.text();
152
-
153
- // Hide step 1, show step 2
154
- document.getElementById('step-1').classList.add('hidden');
155
- const step2 = document.getElementById('step-2');
156
- step2.innerHTML = html;
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
- await fetch('/api/onboarding/skip', { method: 'POST' });
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>
app/templates/partials/action_buttons.html CHANGED
@@ -1,65 +1,92 @@
1
  {#
2
  Action buttons for a paper card.
3
- Expects: paper_id (or paper.arxiv_id), saved (bool), dismissed (bool)
4
- Optional: source ("search" | "recommendation" | "saved"), position (int)
5
- Phase 4.5: ranker_version, candidate_source, cluster_id (set by recommendations.py)
6
- Phase 6.5: query_id (per-request UUID for feed-level CTR),
7
- propensity (probability this paper was shown), policy_id
8
- These are returned directly by the /api/papers/{id}/save endpoint
9
- so they also work as a standalone partial.
 
 
 
 
 
 
 
 
 
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 _ranker_version = paper.ranker_version | default("") if paper is defined else "" %}
15
- {% set _candidate_source = paper.candidate_source | default("") if paper is defined else "" %}
16
- {% set _cluster_id = paper.cluster_id | default("") if paper is defined else "" %}
17
- {% set _query_id = paper.query_id | default("") if paper is defined else "" %}
18
- {% set _position = paper.position | default(0) if paper is defined else 0 %}
19
- {% set _propensity = paper.propensity | default(0) if paper is defined else 0 %}
20
- {% set _policy_id = paper.policy_id | default("") if paper is defined else "" %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  {% if is_saved %}
23
- <!-- Already saved β€” show saved state -->
24
- <div class="flex items-center gap-2 flex-wrap">
25
- <button class="rit-action-btn rit-btn-save saved save-success" disabled>
26
- <span class="btn-icon">βœ“</span> Saved
27
- </button>
28
- <button class="rit-action-btn rit-btn-dismiss"
29
- hx-post="/api/papers/{{ pid }}/not-interested"
30
- hx-target="#paper-{{ pid }}"
31
- hx-swap="outerHTML swap:200ms"
32
- hx-vals='{"source": "{{ _source }}", "position": "{{ _position }}", "ranker_version": "{{ _ranker_version }}", "candidate_source": "{{ _candidate_source }}", "cluster_id": "{{ _cluster_id }}", "query_id": "{{ _query_id }}", "propensity": "{{ _propensity }}", "policy_id": "{{ _policy_id }}"}'>
33
- <span class="btn-icon">βœ•</span> Remove
34
- </button>
35
- </div>
36
  {% else %}
37
- <div class="flex items-center gap-2 flex-wrap">
38
- <!-- Save -->
39
- <button class="rit-action-btn rit-btn-save"
40
- hx-post="/api/papers/{{ pid }}/save"
41
- hx-target="[id='actions-{{ pid }}']"
42
- hx-swap="innerHTML"
43
- hx-vals='{"source": "{{ _source }}", "position": "{{ _position }}", "ranker_version": "{{ _ranker_version }}", "candidate_source": "{{ _candidate_source }}", "cluster_id": "{{ _cluster_id }}", "query_id": "{{ _query_id }}", "propensity": "{{ _propensity }}", "policy_id": "{{ _policy_id }}"}'>
44
- <span class="btn-icon">⭐</span> Save
45
- </button>
46
- <!-- Not interested (removes the whole card) -->
47
- <button class="rit-action-btn rit-btn-dismiss"
48
- hx-post="/api/papers/{{ pid }}/not-interested"
49
- hx-target="#paper-{{ pid }}"
50
- hx-swap="outerHTML swap:200ms"
51
- hx-vals='{"source": "{{ _source }}", "position": "{{ _position }}", "ranker_version": "{{ _ranker_version }}", "candidate_source": "{{ _candidate_source }}", "cluster_id": "{{ _cluster_id }}", "query_id": "{{ _query_id }}", "propensity": "{{ _propensity }}", "policy_id": "{{ _policy_id }}"}'>
52
- <span class="btn-icon">βœ•</span> Not interested
53
- </button>
54
- <!-- Open on arXiv -->
55
- <a href="https://arxiv.org/abs/{{ pid }}"
56
- target="_blank"
57
- rel="noopener"
58
- class="rit-action-btn rit-btn-link">
59
- <span class="btn-icon">
60
- <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
61
- </span>
62
- arXiv
63
- </a>
64
- </div>
 
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 %}
app/templates/partials/ai_summary.html CHANGED
@@ -1,17 +1,7 @@
1
- {# Partial: AI Summary Card #}
2
  {% if summary_html %}
3
- <div class="ai-summary-card mb-6 fade-in">
4
- <div class="flex items-center gap-2 mb-3">
5
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="currentColor">
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 %}
app/templates/partials/empty_recs.html CHANGED
@@ -1,25 +1,16 @@
1
- {# Shown when the user hasn't saved enough papers yet #}
2
- <div class="text-center py-12 space-y-4 max-w-md mx-auto">
3
- <div class="text-5xl mb-2 opacity-70">πŸ”¬</div>
4
- <h3 class="text-lg font-semibold font-display">Your personal feed is brewing</h3>
5
- <p class="text-sm text-base-content/50 leading-relaxed">
6
- Save {{ min_saves | default(1) }} or more paper{{ "s" if (min_saves | default(1)) > 1 else "" }}
7
- while searching to unlock personalized recommendations powered by your unique research interests.
8
  </p>
9
-
10
- <div class="flex flex-col items-center gap-3 pt-2">
11
- <a href="/search" class="btn btn-primary btn-sm shadow-lg shadow-primary/10">
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>
app/templates/partials/paper_card.html CHANGED
@@ -1,100 +1,76 @@
1
  {#
2
- Reusable paper card.
3
- Expects variables:
4
- paper – dict with arxiv_id, title, abstract, authors (JSON str), category, published, year
5
- source – "search" | "recommendation" (default "search")
6
- position – integer rank in the list
 
 
 
 
 
 
 
 
 
 
7
  #}
8
  {% set source = source | default("search") %}
9
- {% set position = position | default(0) %}
10
- {% set authors_list = paper.authors | default("[]") | tojson_parse | default([]) %}
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
- {# Category badge colour mapping #}
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
- <!-- Title + category badge -->
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
- <span class="cat-badge {{ cat_class }} shrink-0 mt-0.5">{{ paper.category }}</span>
49
  {% endif %}
50
- </div>
51
-
52
- <!-- Meta: year pill Β· arXiv ID Β· authors Β· citations -->
53
- <div class="flex flex-wrap items-center gap-2">
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
- <span class="text-xs text-base-content/40 font-mono font-medium">[{{ paper.arxiv_id }}]</span>
 
 
60
 
61
- {% if authors_list %}
62
- <span class="text-xs text-base-content/60">Β·</span>
63
- <span class="text-xs text-base-content/70">
64
- {{ authors_list[:3] | join(", ") }}
65
- {% if authors_list | length > 3 %}
66
- <span class="opacity-60 italic">+{{ authors_list | length - 3 }} more</span>
67
- {% endif %}
68
- </span>
69
- {% endif %}
70
 
71
- {% if paper.citation_count %}
72
- <span class="text-xs text-base-content/60 hidden sm:inline">Β·</span>
73
- <span class="cite-count" title="{{ paper.influential_citations|default(0) }} influential">
74
- <svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
75
- {{ paper.citation_count }} citations
76
- </span>
 
 
 
77
  {% endif %}
78
- </div>
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
- <!-- Action buttons (HTMX-powered, swap themselves on click) -->
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
- </div>
 
 
 
 
 
 
 
 
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 %} &amp; {{ 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>
app/templates/partials/rec_page.html ADDED
@@ -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 }}&amp;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 %}
app/templates/partials/recommendations.html CHANGED
@@ -1,72 +1,25 @@
1
- {# Partial: personalised recommendations #}
 
 
 
 
 
 
 
 
 
2
  {% if papers %}
3
  {% if trending %}
4
- <div class="flex items-center gap-2 mb-4">
5
- <span class="stat-pill bg-warning/10 text-warning text-xs">πŸ”₯ Trending in your areas</span>
6
- <span class="text-xs text-base-content/35">Save papers to get personalised recommendations</span>
7
- </div>
8
- {% endif %}
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
- <!-- Refresh button -->
58
- <div class="text-center pt-4">
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" %}
app/templates/partials/search_results.html CHANGED
@@ -1,46 +1,35 @@
1
- {# Partial: list of search result cards #}
2
  {% if papers %}
3
- <div class="space-y-4">
4
- <!-- Results header -->
5
- <div class="flex flex-wrap items-center gap-2 text-sm text-base-content/60">
6
- <span class="stat-pill">{{ papers | length }} results</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 class="text-xs opacity-50">({{ search_meta.total_time_ms }}ms)</span>
10
  {% endif %}
11
  {% if search_meta and search_meta.rewritten_query %}
12
- <span class="text-xs opacity-70">
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
- {% set arxiv_ids = papers | map(attribute='arxiv_id') | join(',') %}
21
- <div id="ai-summary-container"
22
- hx-get="/api/search/summary?q={{ query | urlencode }}&ids={{ arxiv_ids }}"
23
- hx-trigger="load"
24
- hx-swap="innerHTML">
25
- <!-- Skeleton loader for AI summary -->
26
- <div class="ai-summary-card">
27
- <div class="flex items-center gap-2 mb-2">
28
- <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" viewBox="0 0 20 20" fill="currentColor">
29
- <path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd" />
30
- </svg>
31
- <span class="font-semibold text-sm brand-text">AI Overview</span>
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
- <!-- Paper cards -->
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
- {# Pipeline timing breakdown #}
 
 
 
 
 
53
  {% if search_meta %}
54
- <details class="mt-6 group">
55
- <summary class="rit-pipeline-detail text-xs font-semibold text-base-content/50 cursor-pointer list-none flex items-center gap-1 w-fit">
56
- <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 transition-transform group-open:rotate-90" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
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>Groq Rewrite: <span class="text-primary">{{ search_meta.groq_time_ms }}ms</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>Turso Title Fetch: <span class="text-primary">{{ search_meta.turso_boost_fetch_ms }}ms</span></span>
89
- <span>Rerank Compute: <span class="text-primary">{{ search_meta.rerank_compute_ms }}ms</span></span>
90
  {% elif search_meta.rerank_time_ms is defined %}
91
- <span>Title+Citation Rerank: <span class="text-primary">{{ search_meta.rerank_time_ms }}ms</span></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
- </div>
98
- </details>
99
  {% endif %}
100
 
101
  {% elif query %}
102
- <div class="rit-error-state text-center py-16">
103
- <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto mb-4 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
104
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
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 &ldquo;{{ query }}&rdquo;</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 &ldquo;{{ search_meta.rewritten_query }}&rdquo;</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 }}&amp;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 &ldquo;{{ query }}&rdquo;</p>
73
+ <p class="empty-body">Try broader wording, or the name of a method or model.</p>
 
 
 
 
 
 
74
  </div>
75
  {% endif %}
app/templates/partials/seed_results.html CHANGED
@@ -1,53 +1,35 @@
 
1
  {% if papers is defined and papers %}
2
- <div class="space-y-3 max-w-xl mx-auto pb-16">
3
  {% for paper in papers %}
4
- <div class="seed-card flex items-start gap-4 animate-card-in" style="animation-delay: {{ loop.index0 * 0.05 }}s" id="seed-paper-{{ paper.arxiv_id }}">
5
-
6
- <div class="flex-1 min-w-0">
7
- <a href="https://arxiv.org/abs/{{ paper.arxiv_id }}" target="_blank" class="paper-title block text-sm mb-1 line-clamp-1">
8
- {{ paper.title }}
9
- </a>
10
- <div class="flex items-center gap-2 flex-wrap">
11
- <span class="font-mono text-[10px] opacity-50">{{ paper.arxiv_id }}</span>
12
-
13
- {# Format category badge #}
14
- {% set cat_class = "cat-other" %}
15
- {% if paper.category.startswith("cs.") %} {% set cat_class = "cat-cs" %}
16
- {% elif paper.category.startswith("physics.") or paper.category.startswith("hep-") or paper.category.startswith("astro-") or paper.category.startswith("quant-ph") %} {% set cat_class = "cat-phys" %}
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
- </div>
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
- hx-target="#seed-paper-{{ paper.arxiv_id }}"
 
 
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="rit-error-state max-w-xl mx-auto">
49
- <div class="text-3xl mb-3 opacity-60">πŸ”</div>
50
- <p class="font-medium text-sm">No papers found for '{{ query }}'</p>
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 &ldquo;{{ query }}&rdquo;</p>
33
+ <p class="empty-body">Try broader wording, or a method or model name.</p>
 
34
  </div>
35
  {% endif %}
app/templates/partials/seed_search.html CHANGED
@@ -1,86 +1,86 @@
1
- <div class="text-center mb-8 relative">
2
- <div class="rit-hero-glow animate-glow" style="top: -30px; left: 50%; transform: translateX(-50%);"></div>
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
- <!-- Seed Progress Bar -->
10
- <div class="max-w-xl mx-auto mb-8">
11
- <div class="flex justify-between items-center mb-2 text-xs font-medium text-base-content/70">
12
- <span>Progress</span>
13
- <span id="seed-counter">{{ seed_count }}/{{ seed_target }} seeds saved</span>
 
 
 
 
 
14
  </div>
15
- <div class="seed-progress-track">
16
- <div class="seed-progress-fill" style="width: {{ (seed_count / seed_target * 100) | round | int }}%"></div>
 
 
 
 
 
 
 
17
  </div>
18
- </div>
19
 
20
- <div class="max-w-xl mx-auto">
21
- <div class="rit-search-wrapper mb-8">
22
- <div class="card bg-base-100 shadow-md rounded-xl">
23
- <form class="flex gap-2 p-2 items-center"
24
- hx-get="/api/onboarding/seed-search"
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
- <div id="seed-loading" class="htmx-indicator w-full text-center py-8">
46
- <span class="loading loading-spinner loading-lg text-primary"></span>
47
- </div>
 
 
 
 
 
 
 
 
 
 
48
 
49
- <div id="seed-results">
50
- {% include "partials/seed_results.html" %}
 
 
 
 
 
51
  </div>
52
 
53
- <!-- Action Footer -->
54
- <div class="fixed bottom-0 left-0 right-0 p-4 bg-base-200/95 backdrop-blur-md border-t border-base-content/10 z-50">
55
- <div class="max-w-4xl mx-auto flex items-center justify-end gap-3">
56
- <form action="/api/onboarding/complete" method="post" class="inline">
57
- <button type="submit" class="btn btn-ghost btn-sm md:btn-md text-base-content/50">Skip</button>
58
- </form>
59
- <form action="/api/onboarding/complete" method="post" class="inline">
60
- <button type="submit" class="btn btn-primary btn-sm md:btn-md px-6 shadow-lg shadow-primary/20">
61
- Finish & View Feed
62
- <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>
63
- </button>
64
- </form>
65
  </div>
66
  </div>
67
 
68
  <script>
69
- let _seedCount = {{ seed_count }};
70
- const _seedTarget = {{ seed_target }};
71
-
72
- function bumpSeedCount() {
73
- _seedCount++;
74
- const counterEl = document.getElementById('seed-counter');
75
- if (counterEl) {
76
- counterEl.innerText = `${_seedCount}/${_seedTarget} seeds saved`;
77
- }
78
-
79
- // Animate progress bar
80
- const fillEl = document.querySelector('.seed-progress-fill');
81
- if (fillEl) {
82
- const percentage = Math.min((_seedCount / _seedTarget) * 100, 100);
83
- fillEl.style.width = `${percentage}%`;
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>
app/templates/partials/skeleton.html CHANGED
@@ -1,37 +1,25 @@
1
  {#
2
- Reusable skeleton loader β€” matches the shape of paper cards.
3
- Include N times for N placeholder cards. Each card has:
4
- - Title line + category badge
5
- - Meta line (year + arxiv id + authors)
6
- - 3 abstract text lines (varying widths)
7
- - Action buttons row
8
- All lines use the shimmer animation.
9
  #}
10
- <div class="space-y-4">
11
  {% for i in range(count | default(3)) %}
12
- <div class="skeleton-card animate-card-in" style="animation-delay: {{ i * 0.08 }}s">
13
- <!-- Title row -->
14
- <div class="flex items-start justify-between gap-3 mb-3">
15
- <div class="skeleton-line skeleton-line-title"></div>
16
- <div class="skeleton-line skeleton-line-badge flex-shrink-0"></div>
 
 
 
 
 
 
 
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>
app/templates/saved.html CHANGED
@@ -1,80 +1,38 @@
1
  {% extends "base.html" %}
2
 
3
- {% block title %}Saved Papers β€” ResearchIT{% endblock %}
 
 
 
 
 
 
4
 
5
  {% block content %}
6
- <div class="fade-in">
7
 
8
- <!-- Header -->
9
- <div class="flex items-center gap-3 mb-2">
10
- <h1 class="text-2xl md:text-3xl font-bold font-display">Your Library</h1>
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="space-y-4">
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
- <!-- Empty State -->
49
- <div class="text-center py-16 space-y-4 max-w-md mx-auto">
50
- <div class="text-5xl mb-4 opacity-70">πŸ“š</div>
51
- <h3 class="text-xl font-bold font-display">Your library is empty</h3>
52
- <p class="text-base-content/50 leading-relaxed">
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
- <div class="pt-4">
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 %}
app/templates/search.html CHANGED
@@ -1,72 +1,40 @@
1
  {% extends "base.html" %}
2
 
3
- {% block title %}Search β€” ResearchIT{% endblock %}
4
 
5
- {% block content %}
6
- <div class="space-y-6 fade-in">
7
-
8
- <!-- Search bar -->
9
- <div class="rit-search-wrapper">
10
- <div class="card bg-base-100 shadow-md rounded-xl p-4">
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
- <!-- Loading overlay (outside search-results so it doesn't get swapped away) -->
42
- <div id="search-loading" class="hidden">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 %}
 
 
 
 
app/templates_env.py CHANGED
@@ -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
requirements-dev.txt ADDED
@@ -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
tests/test_e2e_browser.py ADDED
@@ -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()
tests/test_template_selectors.py ADDED
@@ -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
+ )