dhammawatthumpra commited on
Commit
d2c65ec
Β·
1 Parent(s): 351a621

feat: migrate jina v5, refine AI popup, add reference popup UI

Browse files
.gitignore CHANGED
@@ -156,4 +156,9 @@ merge_corpus.py
156
  migrate_clean_data.py
157
  tipitaka_expert.py
158
 
 
 
 
 
 
159
 
 
156
  migrate_clean_data.py
157
  tipitaka_expert.py
158
 
159
+ # ── Agent Data & Local DBs ──
160
+ .serena/
161
+ webapp/.serena/
162
+ embedding_cache.db
163
+ source_texts.db
164
 
webapp/tipitaka-api/app/routers/health.py CHANGED
@@ -15,7 +15,7 @@ logger = logging.getLogger(__name__)
15
  router = APIRouter(prefix="/health", tags=["Health"])
16
 
17
  BASE_DIR = Path(__file__).resolve().parent.parent.parent
18
- STATE_FILE = "/tmp/tipitaka_health_state.json"
19
 
20
  # ── Alert rules ──
21
  ALERT_COOLDOWN_SEC = 1800 # 30 ΰΈ™ΰΈ²ΰΈ—ΰΈ΅ ΰΉ„ΰΈ‘ΰΉˆΰΉΰΈˆΰΉ‰ΰΈ‡ΰΈ‹ΰΉ‰ΰΈ³ΰΈ–ΰΉ‰ΰΈ²ΰΈ›ΰΈ±ΰΈΰΈ«ΰΈ²ΰΉ€ΰΈ”ΰΈ΄ΰΈ‘
@@ -79,16 +79,55 @@ def check_database(settings) -> dict:
79
 
80
 
81
  def check_qdrant(settings) -> dict:
 
 
 
 
 
82
  try:
83
- import qdrant_client
84
  p = _resolve(settings.QDRANT_PATH)
85
  if not os.path.isdir(p):
86
  return {"status": "error", "error": "Dir not found"}
87
- client = qdrant_client.QdrantClient(path=p)
88
- names = [c.name for c in client.get_collections().collections]
89
- if "tipitaka_chunks" in names:
90
- return {"status": "ok", "collections": names}
91
- return {"status": "degraded", "message": "Collection missing"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  except Exception as e:
93
  return {"status": "error", "error": str(e)[:150]}
94
 
 
15
  router = APIRouter(prefix="/health", tags=["Health"])
16
 
17
  BASE_DIR = Path(__file__).resolve().parent.parent.parent
18
+ STATE_FILE = "tipitaka_health_state.json" # Relative to project root/CWD
19
 
20
  # ── Alert rules ──
21
  ALERT_COOLDOWN_SEC = 1800 # 30 ΰΈ™ΰΈ²ΰΈ—ΰΈ΅ ΰΉ„ΰΈ‘ΰΉˆΰΉΰΈˆΰΉ‰ΰΈ‡ΰΈ‹ΰΉ‰ΰΈ³ΰΈ–ΰΉ‰ΰΈ²ΰΈ›ΰΈ±ΰΈΰΈ«ΰΈ²ΰΉ€ΰΈ”ΰΈ΄ΰΈ‘
 
79
 
80
 
81
  def check_qdrant(settings) -> dict:
82
+ """
83
+ Filesystem-only Qdrant health check β€” does NOT open a new QdrantClient.
84
+ Prevents 'already accessed by another instance' lock conflicts
85
+ since RAGService already holds an open Local Mode client.
86
+ """
87
  try:
 
88
  p = _resolve(settings.QDRANT_PATH)
89
  if not os.path.isdir(p):
90
  return {"status": "error", "error": "Dir not found"}
91
+
92
+ # Try server mode first (auto-detect)
93
+ qdrant_url = getattr(settings, "QDRANT_URL", None)
94
+ if not qdrant_url:
95
+ try:
96
+ import httpx
97
+ r = httpx.get("http://localhost:6333/healthz", timeout=0.5)
98
+ if r.status_code == 200:
99
+ qdrant_url = "http://localhost:6333"
100
+ except Exception:
101
+ pass
102
+
103
+ if qdrant_url:
104
+ try:
105
+ import httpx
106
+ resp = httpx.get(f"{qdrant_url}/collections", timeout=1.0)
107
+ if resp.status_code == 200:
108
+ data = resp.json()
109
+ cols = [c["name"] for c in data.get("result", {}).get("collections", [])]
110
+ has_chunks = any(c.startswith("tipitaka_chunks") for c in cols)
111
+ if has_chunks:
112
+ return {"status": "ok", "collections": cols}
113
+ return {"status": "degraded", "message": "Collection missing"}
114
+ return {"status": "error", "error": f"Server returned {resp.status_code}"}
115
+ except Exception as e:
116
+ return {"status": "error", "error": str(e)[:150]}
117
+
118
+ # Filesystem check (Local Mode β€” safe, no client needed)
119
+ col_dir = Path(p) / "collections" / "tipitaka_chunks"
120
+ if col_dir.is_dir():
121
+ # Read segment count as a rough health indicator
122
+ segments = list(col_dir.glob("*/segments/*"))
123
+ snapshots = list(Path(p).glob("*/snapshot*"))
124
+ return {
125
+ "status": "ok",
126
+ "mode": "local",
127
+ "collections_hint": ["tipitaka_chunks"],
128
+ "segments": len(segments) // 2,
129
+ }
130
+ return {"status": "degraded", "message": "Collection directory missing"}
131
  except Exception as e:
132
  return {"status": "error", "error": str(e)[:150]}
133
 
webapp/tipitaka-api/app/services/page_service.py CHANGED
@@ -36,9 +36,34 @@ class PageService:
36
  return f'<h4 class="content-heading">{text}</h4>'
37
  html = re.sub(r'<B>(.*?)</B>', _replace_b, html, flags=re.IGNORECASE | re.DOTALL)
38
  # Remove footnote markers: [ΰΉ‘], [ΰΉ‘ΰΉ’], [ก], [ΰΈ‚] etc
39
- html = re.sub(r'\[[\u0E50-\u0E59]+\]', '', html) # Thai numeral: [ΰΉ‘], [ΰΉ‘ΰΉ’]
40
- html = re.sub(r'\[[\u0E01-\u0E39]+\]', '', html) # Thai consonant: [ก], [ΰΈ‚]
41
- html = re.sub(r'\[\d+\]', '', html) # Arabic numeral: [1], [12]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # Remove inline footnote refs like ΰΉ‘- (only when preceded by non-space)
43
  html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
44
  # Remove leading item markers: ΰΉ‘- at line start, [ก] etc
@@ -49,10 +74,162 @@ class PageService:
49
  # Convert <I> to <i>, <U> to <u>
50
  html = re.sub(r'<I>(.*?)</I>', r'<i>\1</i>', html, flags=re.IGNORECASE | re.DOTALL)
51
  html = re.sub(r'<U>(.*?)</U>', r'<u>\1</u>', html, flags=re.IGNORECASE | re.DOTALL)
52
- # Normalize whitespace
53
- html = re.sub(r'\n{3,}', '\n\n', html)
54
- html = re.sub(r'\t+', ' ', html)
55
- return html.strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
  def get_page(self, volume_number: int, page_number: int):
58
  with self.db.get_connection() as conn:
 
36
  return f'<h4 class="content-heading">{text}</h4>'
37
  html = re.sub(r'<B>(.*?)</B>', _replace_b, html, flags=re.IGNORECASE | re.DOTALL)
38
  # Remove footnote markers: [ΰΉ‘], [ΰΉ‘ΰΉ’], [ก], [ΰΈ‚] etc
39
+ # Use simpler matching for markers that are NOT at the start of a line
40
+ def _strip_footnotes(html_str):
41
+ lines = html_str.split('\n')
42
+ cleaned_lines = []
43
+ for line in lines:
44
+ if not line.strip():
45
+ cleaned_lines.append(line)
46
+ continue
47
+ # If line starts with a marker, protect it, then strip others
48
+ # Pattern for start of line markers
49
+ start_marker = re.match(r'^(\[[\u0E50-\u0E59]+\]|\[[\u0E01-\u0E39]+\]|\[\d+\])', line)
50
+ if start_marker:
51
+ marker = start_marker.group(1)
52
+ rest = line[len(marker):]
53
+ # Strip markers from the rest
54
+ rest = re.sub(r'\[[\u0E50-\u0E59]+\]', '', rest)
55
+ rest = re.sub(r'\[[\u0E01-\u0E39]+\]', '', rest)
56
+ rest = re.sub(r'\[\d+\]', '', rest)
57
+ cleaned_lines.append(marker + rest)
58
+ else:
59
+ line = re.sub(r'\[[\u0E50-\u0E59]+\]', '', line)
60
+ line = re.sub(r'\[[\u0E01-\u0E39]+\]', '', line)
61
+ line = re.sub(r'\[\d+\]', '', line)
62
+ cleaned_lines.append(line)
63
+ return '\n'.join(cleaned_lines)
64
+
65
+ html = _strip_footnotes(html)
66
+
67
  # Remove inline footnote refs like ΰΉ‘- (only when preceded by non-space)
68
  html = re.sub(r'(?<=[^\s])[\u0E50-\u0E59]+-', '', html)
69
  # Remove leading item markers: ΰΉ‘- at line start, [ก] etc
 
74
  # Convert <I> to <i>, <U> to <u>
75
  html = re.sub(r'<I>(.*?)</I>', r'<i>\1</i>', html, flags=re.IGNORECASE | re.DOTALL)
76
  html = re.sub(r'<U>(.*?)</U>', r'<u>\1</u>', html, flags=re.IGNORECASE | re.DOTALL)
77
+
78
+ # ── Marker Normalization ──
79
+ # Ensure markers like (ΰΉ‘), ΰΈ–ΰΈ²ΰΈ‘., ΰΈ•ΰΈ­ΰΈš. start on a new line if they are currently inline
80
+ # NOTE: Exclude '(' from the preceding char group β€” otherwise (ΰΉ‘) gets split into ( + \nΰΉ‘)
81
+ marker_regex = r'(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|[\u0E50-\u0E59]+\.|ΰΈ–ΰΈ²ΰΈ‘\.|ΰΈ•ΰΈ­ΰΈš\.|\[\d+\]|\d+\.|\(\d+\)|\d+\))'
82
+ html = re.sub(r'([^\s\n\r>(\[])\s*' + marker_regex, r'\1\n\2', html)
83
+
84
+ # ── Semantic Paragraph Wrapping ──
85
+ # Split by existing block elements (h4) to wrap text between them
86
+ parts = re.split(r'(<h4.*?/h4>)', html, flags=re.DOTALL)
87
+ wrapped_parts = []
88
+
89
+ # Pattern 1: Section-heading markers that START a line AND have content after them
90
+ # e.g. ΰΉ‘. ΰΈœΰΈΉΰΉ‰ΰΈ‘ΰΈ΅ΰΈ§ΰΈ΄ΰΈŠΰΈŠΰΈ²... ΰΈ–ΰΈ²ΰΈ‘. / ΰΈ–ΰΈ²ΰΈ‘ : / ΰΈ•ΰΈ­ΰΈš : β€” must have text following the marker
91
+ # Also includes parenthetical Thai numerals: (ΰΉ‘), (ΰΉ’), (๑๐) etc.
92
+ num_pattern = r'^\s*([\u0E50-\u0E59]+[\.\)]|\([\u0E50-\u0E59]+\)|ΰΈ–ΰΈ²ΰΈ‘\s*[\.\:]|ΰΈ•ΰΈ­ΰΈš\s*[\.\:]|\d+[\.\)]|\(\d+\))\s+\S'
93
+
94
+ # Pattern 2: Standalone footnote/endnote reference markers β€” MUST be fully bracketed
95
+ # e.g. (ΰΉ‘) [ΰΉ‘] (1) β€” bare digit) is NOT included because it may be a split tail
96
+ # of a longer expression like (ΰΉ€ΰΈ£ΰΈ·ΰΉˆΰΈ­ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆ ΰΉ“) broken across two raw lines.
97
+ footnote_pattern = r'^\s*(\([\u0E50-\u0E59]+\)|\[[\u0E50-\u0E59]+\]|\(\d+\)|\[\d+\])\s*$'
98
+
99
+ for part in parts:
100
+ if part.startswith('<h4'):
101
+ wrapped_parts.append(part)
102
+ else:
103
+ # Split text into lines/paragraphs
104
+ raw_lines = [line.strip() for line in part.split('\n') if line.strip()]
105
+
106
+ # Merge split lines:
107
+ # Case A: lone '(' or '[' split from its marker, e.g. ['(', 'ΰΉ‘) text'] β†’ ['(ΰΉ‘) text']
108
+ # Case B: previous line ends with unclosed '(' and current line is digits+)
109
+ # e.g. ['... (ΰΉ€ΰΈ£ΰΈ·ΰΉˆΰΈ­ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆ', 'ΰΉ“)'] β†’ ['... (ΰΉ€ΰΈ£ΰΈ·ΰΉˆΰΈ­ΰΈ‡ΰΈ—ΰΈ΅ΰΉˆ ΰΉ“)']
110
+ merged_lines = []
111
+ i = 0
112
+ while i < len(raw_lines):
113
+ line = raw_lines[i]
114
+ if line in ('(', '[') and i + 1 < len(raw_lines):
115
+ # Case A: lone bracket split from its marker
116
+ merged_lines.append(line + raw_lines[i + 1])
117
+ i += 2
118
+ elif (
119
+ merged_lines
120
+ and re.search(r'\([^)]*$', merged_lines[-1]) # previous line has unclosed (
121
+ and re.match(r'^[\u0E50-\u0E59\d]+[\)\]]\s*$', line) # current is digits)
122
+ ):
123
+ # Case B β€” rejoin as continuation, not a new line
124
+ merged_lines[-1] = merged_lines[-1] + line
125
+ i += 1
126
+ elif (
127
+ re.match(r'^[\u0E50-\u0E59]+$', line) # Case C: only Thai digits, no . or )
128
+ and i + 1 < len(raw_lines)
129
+ and re.match(r'^[\u0E50-\u0E59]+[\.\)]', raw_lines[i + 1]) # next starts digit + . or )
130
+ ):
131
+ # e.g. DB row "ΰΉ‘" + next row "๐. ΰΈ«ΰΈ±ΰΈ§ΰΈ‚ΰΉ‰ΰΈ­" β†’ "๑๐. ΰΈ«ΰΈ±ΰΈ§ΰΈ‚ΰΉ‰ΰΈ­"
132
+ merged_lines.append(line + raw_lines[i + 1])
133
+ i += 2
134
+ else:
135
+ merged_lines.append(line)
136
+ i += 1
137
+
138
+ # Accumulate consecutive body lines into single <p> elements.
139
+ # Each DB line is a raw manuscript line β€” multiple DB lines form one
140
+ # semantic paragraph. Grouping them lets text-align:justify work
141
+ # (justify only has effect on lines that aren't the last line of a <p>).
142
+ para_buf = [] # buffered body lines for the current paragraph
143
+
144
+ # Option B: length threshold β€” short merged content = left-align item,
145
+ # long merged content = paragraph (justify applies meaningfully).
146
+ PARA_MIN_CHARS = 80
147
+
148
+ # Pattern: standalone (ฒ่อ) markers in all forms:
149
+ # (ฒ่อ) / ΰΉ“ (ฒ่อ) / (ΰΉ“) (ฒ่อ)
150
+ abbrev_pattern = r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*\(ฒ่อ\)\s*$'
151
+
152
+ def apply_inline_refs(text: str) -> str:
153
+ """Wrap inline (Thai_digit) and (ฒ่อ) references as superscript."""
154
+ text = re.sub(
155
+ r'(?<=\S)\s*\(([\u0E50-\u0E59]+|\d+)\)',
156
+ r'<sup class="footnote-ref">(\1)</sup>',
157
+ text
158
+ )
159
+ text = re.sub(
160
+ r'\s*\(ฒ่อ\)',
161
+ r' <sup class="abbrev-ref">(ฒ่อ)</sup>',
162
+ text
163
+ )
164
+ return text
165
+
166
+ def flush_para():
167
+ """Output buffered lines as a <p>. Short content β†’ left-align."""
168
+ if para_buf:
169
+ text = apply_inline_refs(' '.join(para_buf))
170
+ if len(text) >= PARA_MIN_CHARS:
171
+ wrapped_parts.append(f'<p>{text}</p>')
172
+ else:
173
+ wrapped_parts.append(f'<p class="structured-line">{text}</p>')
174
+ para_buf.clear()
175
+
176
+ for line in merged_lines:
177
+ if re.match(footnote_pattern, line):
178
+ flush_para()
179
+ marker = line.strip()
180
+ # Attach inline to the previous <p> so it doesn't create a gap
181
+ if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
182
+ wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' <sup class="footnote-ref">{marker}</sup></p>'
183
+ else:
184
+ wrapped_parts.append(f'<p class="footnote-ref">{marker}</p>')
185
+ elif re.match(abbrev_pattern, line):
186
+ # (ฒ่อ) / ΰΉ“ (ฒ่อ) / (ΰΉ“) (ฒ่อ) β€” split into separate sups:
187
+ # number part β†’ footnote-ref, (ฒ่อ) β†’ abbrev-ref
188
+ flush_para()
189
+ m = re.match(
190
+ r'^\s*([\u0E50-\u0E59]+|\([\u0E50-\u0E59]+\))?\s*(\(ฒ่อ\))\s*$',
191
+ line.strip()
192
+ )
193
+ if m and m.group(1):
194
+ sup_html = (
195
+ f'<sup class="footnote-ref">{m.group(1)}</sup>'
196
+ f'\u202f<sup class="abbrev-ref">{m.group(2)}</sup>'
197
+ )
198
+ else:
199
+ sup_html = f'<sup class="abbrev-ref">(ฒ่อ)</sup>'
200
+ if wrapped_parts and wrapped_parts[-1].startswith('<p') and wrapped_parts[-1].endswith('</p>'):
201
+ wrapped_parts[-1] = wrapped_parts[-1][:-4] + f' {sup_html}</p>'
202
+ else:
203
+ wrapped_parts.append(sup_html)
204
+
205
+
206
+ elif line.startswith('...'):
207
+ # Continuation / repetition marker in Patthana β€” no paragraph indent
208
+ flush_para()
209
+ formatted_line = apply_inline_refs(line)
210
+ wrapped_parts.append(f'<p class="continuation-line">{formatted_line}</p>')
211
+
212
+ elif re.match(num_pattern, line):
213
+ flush_para()
214
+ wrapped_parts.append(f'<p class="numbered-line">{line}</p>')
215
+ elif re.search(r'[ \t]{4,}', line):
216
+ # Structural line β€” large internal spaces (tabular data)
217
+ flush_para()
218
+ clean = re.sub(r'[ \t]{2,}', ' ', line).strip()
219
+ wrapped_parts.append(f'<p class="structured-line">{clean}</p>')
220
+ elif re.search(r'(ΰΈ‘ΰΈ΅\s*[๐-ΰΉ™\d]+\s*ΰΈ§ΰΈ²ΰΈ£ΰΈ°|\(ฒ่อ\)|\(อฒ่อ\)|ΰΈ―ΰΈ₯ΰΈ―)\s*$', line):
221
+ # Patthana/Abhidhamma terminal β€” flush trigger, then length decides class
222
+ para_buf.append(line)
223
+ flush_para()
224
+ else:
225
+ # Regular body text β€” accumulate into current paragraph
226
+ para_buf.append(line)
227
+
228
+ flush_para() # flush any remaining lines at end of section
229
+
230
+
231
+ return "".join(wrapped_parts)
232
+
233
 
234
  def get_page(self, volume_number: int, page_number: int):
235
  with self.db.get_connection() as conn:
webapp/tipitaka-web/src/components/ai/AIPopup.tsx CHANGED
@@ -338,15 +338,24 @@ const AIPopup: React.FC = () => {
338
  )}
339
  {/* ═══════════ Compact Header + Drag Handle ═══════════ */}
340
  <div
341
- className="flex flex-col flex-shrink-0 bg-[#c8860a] text-white"
342
  onMouseDown={handleMouseDown}
343
  style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
344
  >
 
 
 
 
345
  {/* Row 1: Title + actions */}
346
- <div className="flex items-center justify-between px-3 py-2">
347
- <div className="flex items-center gap-2 min-w-0">
348
- <Sparkles size={16} className="flex-shrink-0" />
349
- <h3 className="font-bold text-sm tracking-wide truncate">ΰΈœΰΈΉΰΉ‰ΰΈŠΰΉˆΰΈ§ΰΈ’ AI</h3>
 
 
 
 
 
350
  </div>
351
  <div className="flex items-center gap-1 flex-shrink-0">
352
  <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
@@ -506,14 +515,18 @@ const AIPopup: React.FC = () => {
506
  </div>
507
 
508
  {/* ═══════════ Input ═══════════ */}
509
- <form onSubmit={handleSubmit} className={`p-2 md:p-3 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
510
- <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
511
- placeholder="ΰΈ–ΰΈ²ΰΈ‘ AI ΰΉ„ΰΈ”ΰΉ‰ΰΉ€ΰΈ₯ΰΈ’..." disabled={isStreaming}
512
- className={`flex-1 px-3 py-2 rounded-2xl outline-none text-sm disabled:opacity-50 border ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] transition-colors`}
513
- />
514
- <button type="submit" disabled={!input.trim() || isStreaming}
515
- className="p-2 bg-[#c8860a] text-white rounded-full disabled:opacity-50 hover:bg-[#9a6307] transition-colors flex-shrink-0"
516
- ><Send size={16} /></button>
 
 
 
 
517
  </form>
518
  </motion.div>
519
  </>
 
338
  )}
339
  {/* ═══════════ Compact Header + Drag Handle ═══════════ */}
340
  <div
341
+ className="flex flex-col flex-shrink-0 relative overflow-hidden"
342
  onMouseDown={handleMouseDown}
343
  style={{ cursor: window.innerWidth >= 768 ? 'grab' : undefined }}
344
  >
345
+ {/* Glass background layer */}
346
+ <div className="absolute inset-0 bg-[#c8860a] opacity-90" />
347
+ <div className="absolute inset-0 bg-gradient-to-br from-white/20 to-transparent pointer-events-none" />
348
+
349
  {/* Row 1: Title + actions */}
350
+ <div className="relative flex items-center justify-between px-3 py-2.5 z-10">
351
+ <div className="flex items-center gap-2.5 min-w-0">
352
+ <div className="w-7 h-7 rounded-lg bg-white/20 flex items-center justify-center shadow-inner">
353
+ <Sparkles size={16} className="text-white" />
354
+ </div>
355
+ <div>
356
+ <h3 className="font-bold text-sm tracking-wide text-white">ΰΈœΰΈΉΰΉ‰ΰΈŠΰΉˆΰΈ§ΰΈ’ AI</h3>
357
+ <p className="text-[9px] text-white/70 font-medium uppercase tracking-tighter">Dhamma Assistant</p>
358
+ </div>
359
  </div>
360
  <div className="flex items-center gap-1 flex-shrink-0">
361
  <div className="flex bg-black/15 rounded-lg p-0.5 mr-1">
 
515
  </div>
516
 
517
  {/* ═══════════ Input ═══════════ */}
518
+ <form onSubmit={handleSubmit} className={`p-3 md:p-4 border-t ${s.border} flex gap-2 flex-shrink-0 ${s.bg}`}>
519
+ <div className="relative flex-1">
520
+ <input type="text" value={input} onChange={(e) => setInput(e.target.value)}
521
+ placeholder="ΰΈ–ΰΈ²ΰΈ‘ΰΉ€ΰΈΰΈ΅ΰΉˆΰΈ’ΰΈ§ΰΈΰΈ±ΰΈšΰΈžΰΈ£ΰΈ°ΰΈ˜ΰΈ£ΰΈ£ΰΈ‘ΰΉƒΰΈ™ΰΈ«ΰΈ™ΰΉ‰ΰΈ²ΰΈ™ΰΈ΅ΰΉ‰..." disabled={isStreaming}
522
+ className={`w-full pl-4 pr-12 py-2.5 rounded-2xl outline-none text-sm disabled:opacity-50 border shadow-inner transition-all ${s.border} ${s.msgBg} ${s.text} focus:border-[#c8860a] focus:ring-2 focus:ring-[#c8860a]/20`}
523
+ />
524
+ <button type="submit" disabled={!input.trim() || isStreaming}
525
+ className="absolute right-1.5 top-1.5 p-2 bg-[#c8860a] text-white rounded-xl disabled:opacity-50 hover:bg-[#9a6307] transition-all shadow-md active:scale-95"
526
+ >
527
+ <Send size={14} />
528
+ </button>
529
+ </div>
530
  </form>
531
  </motion.div>
532
  </>
webapp/tipitaka-web/src/components/layout/AppShell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React from 'react';
2
  import NavDrawer from './NavDrawer';
3
  import ReaderPanel from './ReaderPanel';
4
  import RightToolbar from '../toolbar/RightToolbar';
@@ -7,14 +7,14 @@ import AIPopup from '../ai/AIPopup';
7
  import { Menu } from 'lucide-react';
8
  import { useUIStore, useThemeStore } from '../../stores/appStore';
9
  import { useAIStore } from '../../stores/aiStore';
10
- import { AnimatePresence } from 'framer-motion';
11
  import { useKeyboardNav } from '../../hooks/useKeyboardNav';
12
 
13
  // Shell = NavDrawer bg per theme β€” seamless sidebar integration
14
  const SHELL_BG: Record<string, string> = {
15
- dark: 'bg-[#1a1a2e]', // Indigo Midnight
16
- light: 'bg-[#f8f6f0]', // Warm White
17
- classic: 'bg-[#f5edd8]', // Cream Dark
18
  };
19
 
20
  // Menu button bg per theme (matches RightToolbar bg for visual consistency)
@@ -35,6 +35,20 @@ const AppShell: React.FC = () => {
35
  const shellBg = SHELL_BG[theme] ?? SHELL_BG.dark;
36
  const menuBtn = MENU_BTN[theme] ?? MENU_BTN.dark;
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return (
39
  <div className={`flex h-screen overflow-hidden ${shellBg}`}>
40
  {/* Left: NavDrawer */}
@@ -42,6 +56,16 @@ const AppShell: React.FC = () => {
42
 
43
  {/* Centre: Reader β€” right-padded to clear RightToolbar (44px) on desktop */}
44
  <div className="flex-1 flex flex-col relative overflow-hidden lg:pr-11">
 
 
 
 
 
 
 
 
 
 
45
  {/* Menu button β€” visible only when drawer is closed */}
46
  {!isNavOpen && (
47
  <button
 
1
+ import React, { useEffect } from 'react';
2
  import NavDrawer from './NavDrawer';
3
  import ReaderPanel from './ReaderPanel';
4
  import RightToolbar from '../toolbar/RightToolbar';
 
7
  import { Menu } from 'lucide-react';
8
  import { useUIStore, useThemeStore } from '../../stores/appStore';
9
  import { useAIStore } from '../../stores/aiStore';
10
+ import { AnimatePresence, motion } from 'framer-motion';
11
  import { useKeyboardNav } from '../../hooks/useKeyboardNav';
12
 
13
  // Shell = NavDrawer bg per theme β€” seamless sidebar integration
14
  const SHELL_BG: Record<string, string> = {
15
+ dark: 'theme-dark-bg',
16
+ light: 'theme-light-bg',
17
+ classic: 'theme-classic-bg',
18
  };
19
 
20
  // Menu button bg per theme (matches RightToolbar bg for visual consistency)
 
35
  const shellBg = SHELL_BG[theme] ?? SHELL_BG.dark;
36
  const menuBtn = MENU_BTN[theme] ?? MENU_BTN.dark;
37
 
38
+ // ── Scroll Progress ────────────────────────────────────────
39
+ const [scrollProgress, setScrollProgress] = React.useState(0);
40
+ useEffect(() => {
41
+ const el = document.getElementById('reader-scroll-container');
42
+ if (!el) return;
43
+ const handleScroll = () => {
44
+ const total = el.scrollHeight - el.clientHeight;
45
+ if (total <= 0) { setScrollProgress(0); return; }
46
+ setScrollProgress((el.scrollTop / total) * 100);
47
+ };
48
+ el.addEventListener('scroll', handleScroll);
49
+ return () => el.removeEventListener('scroll', handleScroll);
50
+ }, []);
51
+
52
  return (
53
  <div className={`flex h-screen overflow-hidden ${shellBg}`}>
54
  {/* Left: NavDrawer */}
 
56
 
57
  {/* Centre: Reader β€” right-padded to clear RightToolbar (44px) on desktop */}
58
  <div className="flex-1 flex flex-col relative overflow-hidden lg:pr-11">
59
+ {/* Scroll Progress Bar */}
60
+ <div className="absolute top-0 left-0 right-0 h-0.5 z-40 bg-white/5">
61
+ <motion.div
62
+ className="h-full bg-[#c8860a]"
63
+ initial={{ width: 0 }}
64
+ animate={{ width: `${scrollProgress}%` }}
65
+ transition={{ type: 'spring', stiffness: 300, damping: 30 }}
66
+ />
67
+ </div>
68
+
69
  {/* Menu button β€” visible only when drawer is closed */}
70
  {!isNavOpen && (
71
  <button
webapp/tipitaka-web/src/components/layout/ReaderPanel.tsx CHANGED
@@ -3,6 +3,7 @@ import { useReaderStore, useThemeStore } from '../../stores/appStore';
3
  import api from '../../lib/api';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
  import SelectionPopup from '../reader/SelectionPopup';
 
6
  import { useSwipeNav } from '../../hooks/useSwipeNav';
7
 
8
  const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
@@ -12,20 +13,17 @@ const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
12
  };
13
 
14
  const SHELL_STYLES: Record<string, string> = {
15
- dark: 'bg-[#1a1a2e]',
16
- light: 'bg-[#f8f6f0]',
17
- classic: 'bg-[#f5edd8]',
18
  };
19
 
20
  const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
21
  return (
22
- <div
23
- style={{ fontSize: `${fontSize}px`, lineHeight: '2.1' }}
24
- className="reader-content text-center space-y-3"
25
- >
26
  {html ? (
27
  <div
28
- className="content-body leading-[2.1] text-justify"
29
  dangerouslySetInnerHTML={{ __html: html }}
30
  />
31
  ) : (
@@ -35,9 +33,13 @@ const renderContent = (html: string, fontSize: number, endMarkers: string[]) =>
35
  {endMarkers.length > 0 && (
36
  <div className="space-y-1 pt-2">
37
  {endMarkers.map((title, i) => (
38
- <p key={i} className="text-xs text-[#888] tracking-widest">
39
- --------- {title} ---------
40
- </p>
 
 
 
 
41
  ))}
42
  </div>
43
  )}
@@ -69,29 +71,30 @@ const renderFirstPage = (html: string, fontSize: number, volumeTitle: string) =>
69
 
70
  return (
71
  <div
72
- style={{ fontSize: `${fontSize}px`, lineHeight: '2.1' }}
73
- className="text-center space-y-3"
74
  >
75
- {/* Volume title β€” centered */}
76
- <p className="text-lg md:text-xl font-bold text-[#c8860a] leading-tight">
77
- {line1}
78
- </p>
79
- {line2 && (
80
- <p className="text-base md:text-lg text-[#c8860a] leading-tight">
81
- {line2}
82
  </p>
83
- )}
84
- <div className="border-b border-[#c8860a]/15 my-3" />
 
 
 
 
 
85
  {html ? (
86
  <>
87
  {/* Top: everything before/at homage β€” centered, clean */}
88
  <div
89
- className="first-page-top leading-[2.1]"
90
  dangerouslySetInnerHTML={{ __html: topPart }}
91
  />
92
  {/* Bottom: after homage β€” normal h4 styling, text-justify */}
93
  <div
94
- className="content-body leading-[2.1] text-justify"
95
  dangerouslySetInnerHTML={{ __html: bottomPart }}
96
  />
97
  </>
@@ -112,6 +115,7 @@ const ReaderPanel: React.FC = () => {
112
  const { theme, fontSize } = useThemeStore();
113
  const [content, setContent] = React.useState<any>(null);
114
  const [loading, setLoading] = React.useState(true);
 
115
  const prevPageRef = useRef(currentPage);
116
 
117
  const swipeRef = useSwipeNav<HTMLDivElement>();
@@ -136,9 +140,28 @@ const ReaderPanel: React.FC = () => {
136
  const { reader: readerCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
137
  const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  return (
140
  <div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
141
  <SelectionPopup />
 
142
  <div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
143
  <AnimatePresence mode="wait">
144
  {loading ? (
@@ -157,6 +180,7 @@ const ReaderPanel: React.FC = () => {
157
  initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
158
  transition={{ duration: 0.35, ease: 'easeOut' }}
159
  className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
 
160
  >
161
  {currentPage === 1 || content?.page_number === 1 ? (
162
  renderFirstPage(
 
3
  import api from '../../lib/api';
4
  import { motion, AnimatePresence } from 'framer-motion';
5
  import SelectionPopup from '../reader/SelectionPopup';
6
+ import ReferencePopup, { type RefPopupPos } from '../reader/ReferencePopup';
7
  import { useSwipeNav } from '../../hooks/useSwipeNav';
8
 
9
  const THEME_STYLES: Record<string, { reader: string; muted: string }> = {
 
13
  };
14
 
15
  const SHELL_STYLES: Record<string, string> = {
16
+ dark: 'theme-dark-bg',
17
+ light: 'theme-light-bg',
18
+ classic: 'theme-classic-bg',
19
  };
20
 
21
  const renderContent = (html: string, fontSize: number, endMarkers: string[]) => {
22
  return (
23
+ <div style={{ fontSize: `${fontSize}px` }}>
 
 
 
24
  {html ? (
25
  <div
26
+ className="content-body reader-content"
27
  dangerouslySetInnerHTML={{ __html: html }}
28
  />
29
  ) : (
 
33
  {endMarkers.length > 0 && (
34
  <div className="space-y-1 pt-2">
35
  {endMarkers.map((title, i) => (
36
+ <div key={i} className="flex items-center gap-4 py-2 opacity-40">
37
+ <div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
38
+ <p className="text-[10px] text-[#888] tracking-[0.2em] uppercase font-medium">
39
+ {title}
40
+ </p>
41
+ <div className="flex-1 h-px bg-gradient-to-r from-transparent via-[#888] to-transparent" />
42
+ </div>
43
  ))}
44
  </div>
45
  )}
 
71
 
72
  return (
73
  <div
74
+ style={{ fontSize: `${fontSize}px` }}
 
75
  >
76
+ {/* Volume title β€” centered explicitly */}
77
+ <div className="text-center">
78
+ <p className="text-lg md:text-xl font-bold text-[#c8860a] leading-tight">
79
+ {line1}
 
 
 
80
  </p>
81
+ {line2 && (
82
+ <p className="text-base md:text-lg text-[#c8860a] leading-tight">
83
+ {line2}
84
+ </p>
85
+ )}
86
+ <div className="border-b border-[#c8860a]/15 my-3" />
87
+ </div>
88
  {html ? (
89
  <>
90
  {/* Top: everything before/at homage β€” centered, clean */}
91
  <div
92
+ className="first-page-top text-center"
93
  dangerouslySetInnerHTML={{ __html: topPart }}
94
  />
95
  {/* Bottom: after homage β€” normal h4 styling, text-justify */}
96
  <div
97
+ className="content-body reader-content"
98
  dangerouslySetInnerHTML={{ __html: bottomPart }}
99
  />
100
  </>
 
115
  const { theme, fontSize } = useThemeStore();
116
  const [content, setContent] = React.useState<any>(null);
117
  const [loading, setLoading] = React.useState(true);
118
+ const [refPos, setRefPos] = React.useState<RefPopupPos | null>(null);
119
  const prevPageRef = useRef(currentPage);
120
 
121
  const swipeRef = useSwipeNav<HTMLDivElement>();
 
140
  const { reader: readerCls } = THEME_STYLES[theme] ?? THEME_STYLES.dark;
141
  const shellCls = SHELL_STYLES[theme] ?? SHELL_STYLES.dark;
142
 
143
+ const handleArticleClick = (e: React.MouseEvent) => {
144
+ const target = e.target as HTMLElement;
145
+ if (target.tagName.toLowerCase() === 'sup') {
146
+ const isAbbrev = target.classList.contains('abbrev-ref');
147
+ const isFootnote = target.classList.contains('footnote-ref');
148
+
149
+ if (isAbbrev || isFootnote) {
150
+ const rect = target.getBoundingClientRect();
151
+ setRefPos({
152
+ x: rect.left + rect.width / 2,
153
+ y: rect.bottom,
154
+ id: target.innerText,
155
+ type: isAbbrev ? 'abbrev' : 'footnote'
156
+ });
157
+ }
158
+ }
159
+ };
160
+
161
  return (
162
  <div ref={swipeRef} className={`flex-1 min-h-screen transition-colors duration-300 ${shellCls}`}>
163
  <SelectionPopup />
164
+ <ReferencePopup pos={refPos} onClose={() => setRefPos(null)} />
165
  <div className="max-w-3xl mx-auto px-4 py-10 lg:py-16">
166
  <AnimatePresence mode="wait">
167
  {loading ? (
 
180
  initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}
181
  transition={{ duration: 0.35, ease: 'easeOut' }}
182
  className={`rounded-xl p-8 lg:p-12 ${readerCls}`}
183
+ onClick={handleArticleClick}
184
  >
185
  {currentPage === 1 || content?.page_number === 1 ? (
186
  renderFirstPage(
webapp/tipitaka-web/src/components/reader/ReferencePopup.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef } from 'react';
2
+ import { motion, AnimatePresence } from 'framer-motion';
3
+
4
+ export interface RefPopupPos {
5
+ x: number;
6
+ y: number;
7
+ id: string;
8
+ type: 'abbrev' | 'footnote';
9
+ }
10
+
11
+ interface Props {
12
+ pos: RefPopupPos | null;
13
+ onClose: () => void;
14
+ }
15
+
16
+ const ReferencePopup: React.FC<Props> = ({ pos, onClose }) => {
17
+ const popupRef = useRef<HTMLDivElement>(null);
18
+
19
+ useEffect(() => {
20
+ // Click outside popup β†’ close
21
+ const onMouseDown = (e: MouseEvent | TouchEvent) => {
22
+ // Don't close if they clicked on another reference (it will update pos)
23
+ const target = e.target as HTMLElement;
24
+ if (target.tagName?.toLowerCase() === 'sup' && (target.classList.contains('abbrev-ref') || target.classList.contains('footnote-ref'))) {
25
+ return;
26
+ }
27
+ if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
28
+ onClose();
29
+ }
30
+ };
31
+
32
+ document.addEventListener('mousedown', onMouseDown);
33
+ document.addEventListener('touchstart', onMouseDown);
34
+ return () => {
35
+ document.removeEventListener('mousedown', onMouseDown);
36
+ document.removeEventListener('touchstart', onMouseDown);
37
+ };
38
+ }, [onClose]);
39
+
40
+ return (
41
+ <AnimatePresence>
42
+ {pos && (
43
+ <motion.div
44
+ ref={popupRef}
45
+ key="ref-popup"
46
+ initial={{ opacity: 0, scale: 0.95, y: 8 }}
47
+ animate={{ opacity: 1, scale: 1, y: 0 }}
48
+ exit={{ opacity: 0, scale: 0.95, y: 8 }}
49
+ transition={{ duration: 0.15, ease: 'easeOut' }}
50
+ style={{
51
+ position: 'fixed',
52
+ left: pos.x,
53
+ top: pos.y,
54
+ transform: 'translate(-50%, 8px)', // 8px gap below the target
55
+ zIndex: 9998,
56
+ }}
57
+ className="w-64 p-4 rounded-xl shadow-2xl bg-[#1a1a2e] border border-[#3a3a5e] text-[#e8e4da] text-sm leading-relaxed"
58
+ >
59
+ <div className="flex items-center gap-2 mb-2 pb-2 border-b border-[#3a3a5e]">
60
+ <span className="text-[#c8860a] font-bold">{pos.id}</span>
61
+ <span className="text-xs text-[#888]">
62
+ {pos.type === 'abbrev' ? 'ΰΉ€ΰΈ™ΰΈ·ΰΉ‰ΰΈ­ΰΈ«ΰΈ²ΰΈͺΰΉˆΰΈ§ΰΈ™ΰΈ—ΰΈ΅ΰΉˆΰΈ’ΰΉˆΰΈ­ΰΉ„ΰΈ§ΰΉ‰' : 'ΰΉ€ΰΈŠΰΈ΄ΰΈ‡ΰΈ­ΰΈ£ΰΈ£ΰΈ–'}
63
+ </span>
64
+ </div>
65
+ <p className="opacity-80 text-xs">
66
+ {pos.type === 'abbrev'
67
+ ? "กำΰΈ₯ΰΈ±ΰΈ‡ΰΈ­ΰΈ’ΰΈΉΰΉˆΰΉƒΰΈ™ΰΈ£ΰΈ°ΰΈ«ΰΈ§ΰΉˆΰΈ²ΰΈ‡ΰΈΰΈ²ΰΈ£ΰΈžΰΈ±ΰΈ’ΰΈ™ΰΈ² ΰΉ€ΰΈžΰΈ·ΰΉˆΰΈ­ΰΈ”ΰΈΆΰΈ‡ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΈͺΰΉˆΰΈ§ΰΈ™ΰΈ—ΰΈ΅ΰΉˆΰΈ–ΰΈΉΰΈΰΈ’ΰΉˆΰΈ­ΰΈ‘ΰΈ²ΰΉΰΈͺΰΈ”ΰΈ‡ΰΈœΰΈ₯..."
68
+ : "ΰΈ‚ΰΉ‰ΰΈ­ΰΈ‘ΰΈΉΰΈ₯ΰΉ€ΰΈŠΰΈ΄ΰΈ‡ΰΈ­ΰΈ£ΰΈ£ΰΈ–ΰΈΰΈ³ΰΈ₯ΰΈ±ΰΈ‡ΰΈ­ΰΈ’ΰΈΉΰΉˆΰΉƒΰΈ™ΰΈ£ΰΈ°ΰΈ«ΰΈ§ΰΉˆΰΈ²ΰΈ‡ΰΈΰΈ²ΰΈ£ΰΈžΰΈ±ΰΈ’ΰΈ™ΰΈ²..."}
69
+ </p>
70
+ {/* tail arrow pointing up */}
71
+ <div
72
+ className="pointer-events-none absolute left-1/2 -translate-x-1/2 -top-[6px]
73
+ w-3 h-3 bg-[#1a1a2e] border-l border-t border-[#3a3a5e] rotate-45"
74
+ />
75
+ </motion.div>
76
+ )}
77
+ </AnimatePresence>
78
+ );
79
+ };
80
+
81
+ export default ReferencePopup;
webapp/tipitaka-web/src/index.css CHANGED
@@ -12,7 +12,7 @@
12
 
13
  :root {
14
  font-family: var(--font-sarabun);
15
- line-height: 1.6;
16
  font-weight: 400;
17
 
18
  color: var(--color-pali-text);
@@ -38,22 +38,91 @@ body {
38
  /* ── Thai/Pali Typography ──────────────────────────────────────── */
39
 
40
  /* Font-kerning and Thai OpenType features for the body reading text */
41
- .reader-content p {
42
- /* Justification: inter-character adjusts spacing between Thai characters,
43
- producing cleaner rag than inter-word (which needs spaces Thai doesn't have) */
44
- text-justify: inter-character;
45
- /* Prevent inappropriate line breaks in Thai (no spaces between words) */
46
- word-break: keep-all;
47
- overflow-wrap: break-word;
48
- /* Enable font kerning */
 
 
 
 
49
  font-kerning: normal;
50
- /* OpenType features for Thai script rendering */
51
  font-feature-settings: "kern" 1, "liga" 1, "clig" 1;
52
- /* Balanced orphans/widows for paragraphs */
53
  orphans: 2;
54
  widows: 2;
55
  }
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  /* Remove gap when <br/> is at the start of a <p> (edge case) */
58
  .reader-content p:first-child {
59
  margin-top: 0;
@@ -70,10 +139,35 @@ body {
70
  border-image: linear-gradient(to bottom, #D4AF37, #F4E7A5, #D4AF37) 1;
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  .glass-panel {
74
- background: rgba(255, 255, 255, 0.7);
75
- backdrop-filter: blur(8px);
76
- border: 1px solid rgba(212, 175, 55, 0.2);
 
 
 
 
 
 
 
 
 
77
  }
78
 
79
  /* ── Inline Content Headings ── smaller than body text, highlight makes it stand out */
 
12
 
13
  :root {
14
  font-family: var(--font-sarabun);
15
+ line-height: 1.7;
16
  font-weight: 400;
17
 
18
  color: var(--color-pali-text);
 
38
  /* ── Thai/Pali Typography ──────────────────────────────────────── */
39
 
40
  /* Font-kerning and Thai OpenType features for the body reading text */
41
+ .reader-content p,
42
+ .content-body p {
43
+ /* !important overrides Tailwind typography plugin defaults and any inherited text-center */
44
+ text-align: justify !important;
45
+ text-align-last: left !important;
46
+ text-justify: auto !important;
47
+ text-indent: 2em;
48
+ margin-bottom: 0.6rem !important;
49
+ line-height: 1.75 !important;
50
+ word-break: normal;
51
+ line-break: loose;
52
+ overflow-wrap: anywhere;
53
  font-kerning: normal;
 
54
  font-feature-settings: "kern" 1, "liga" 1, "clig" 1;
 
55
  orphans: 2;
56
  widows: 2;
57
  }
58
 
59
+ .reader-content p.numbered-line,
60
+ .content-body p.numbered-line {
61
+ padding-left: 2.8em;
62
+ text-indent: -2.8em;
63
+ text-align: left !important;
64
+ text-justify: none;
65
+ }
66
+
67
+ /* Structured/tabular lines (e.g. Abhidhamma Patthana) β€” kept as standalone rows */
68
+ .reader-content p.structured-line,
69
+ .content-body p.structured-line {
70
+ text-align: left !important;
71
+ text-indent: 0;
72
+ text-justify: none;
73
+ margin-bottom: 0.1rem !important;
74
+ }
75
+
76
+
77
+
78
+ /* Continuation / repetition lines starting with "..." β€” no paragraph indent */
79
+ .reader-content p.continuation-line,
80
+ .content-body p.continuation-line {
81
+ text-indent: 0 !important;
82
+ }
83
+
84
+ /* All inline reference superscripts β€” same colour, same cursor for future click-to-expand */
85
+ sup.footnote-ref,
86
+ sup.abbrev-ref {
87
+ font-size: 0.7em;
88
+ color: #f59e0b;
89
+ opacity: 0.8;
90
+ vertical-align: super;
91
+ cursor: pointer;
92
+ letter-spacing: 0.03em;
93
+ transition: opacity 0.15s;
94
+ }
95
+ sup.footnote-ref:hover,
96
+ sup.abbrev-ref:hover {
97
+ opacity: 1;
98
+ text-decoration: underline;
99
+ }
100
+
101
+ /* Standalone fallback block β€” remove gaps so it reads like part of the paragraph above */
102
+ .reader-content p.footnote-ref {
103
+ display: inline; /* collapses to inline flow β€” no block gap */
104
+ font-size: 0.72em;
105
+ opacity: 0.55;
106
+ margin: 0;
107
+ padding: 0;
108
+ text-indent: 0;
109
+ }
110
+
111
+ /* Wrap each footnote-ref in a block-level container so "display:inline" still works inside prose */
112
+ .reader-content p.footnote-ref::before {
113
+ content: " "; /* space before the marker */
114
+ }
115
+
116
+ /* Paragraph immediately before a footnote-ref: kill its bottom margin */
117
+ .reader-content p:has(+ p.footnote-ref) {
118
+ margin-bottom: 0 !important;
119
+ }
120
+
121
+ /* Paragraph immediately after a footnote-ref: normal top gap resumes */
122
+ .reader-content p.footnote-ref + p {
123
+ margin-top: 0;
124
+ }
125
+
126
  /* Remove gap when <br/> is at the start of a <p> (edge case) */
127
  .reader-content p:first-child {
128
  margin-top: 0;
 
139
  border-image: linear-gradient(to bottom, #D4AF37, #F4E7A5, #D4AF37) 1;
140
  }
141
 
142
+ /* ── Theme Backgrounds with Textures ────────────────────────── */
143
+ .theme-light-bg {
144
+ background-color: #f8f6f0;
145
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.012'/%3E%3C/svg%3E");
146
+ }
147
+
148
+ .theme-classic-bg {
149
+ background-color: #f5edd8;
150
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.015'/%3E%3C/svg%3E");
151
+ }
152
+
153
+ .theme-dark-bg {
154
+ background-color: #1a1a2e;
155
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 400 400' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.45' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)' opacity='0.018'/%3E%3C/svg%3E");
156
+ }
157
+
158
  .glass-panel {
159
+ background: rgba(255, 255, 255, 0.03);
160
+ backdrop-filter: blur(12px);
161
+ -webkit-backdrop-filter: blur(12px);
162
+ border: 1px solid rgba(255, 255, 255, 0.1);
163
+ box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
164
+ }
165
+
166
+ .glass-panel-light {
167
+ background: rgba(255, 255, 255, 0.6);
168
+ backdrop-filter: blur(10px);
169
+ -webkit-backdrop-filter: blur(10px);
170
+ border: 1px solid rgba(200, 134, 10, 0.1);
171
  }
172
 
173
  /* ── Inline Content Headings ── smaller than body text, highlight makes it stand out */