Skip to content

Add comprehensive UI redesign with dark mode, pagination, and search functionality#278

Merged
myoshi2891 merged 19 commits into
mainfrom
dev-from-macmini
Feb 20, 2026
Merged

Add comprehensive UI redesign with dark mode, pagination, and search functionality#278
myoshi2891 merged 19 commits into
mainfrom
dev-from-macmini

Conversation

@myoshi2891
Copy link
Copy Markdown
Owner

@myoshi2891 myoshi2891 commented Feb 20, 2026

ページネーションの表示制御の微調整と、タブ切り替え時の検索フィルタ・クリアロジックの改善

@netlify
Copy link
Copy Markdown

netlify Bot commented Feb 20, 2026

Deploy Preview for algorithm-datastructures-math-studies ready!

Name Link
🔨 Latest commit 18cdc11
🔍 Latest deploy log https://app.netlify.com/projects/algorithm-datastructures-math-studies/deploys/69980b5c278e9e00091e74b0
😎 Deploy Preview https://deploy-preview-278--algorithm-datastructures-math-studies.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 20, 2026

Warning

Rate limit exceeded

@github-actions[bot] has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 18 minutes and 30 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

generate_index.py が静的インデックス生成を大幅に拡張し、ローカルベンダー資産のコピー/マッピング、カテゴリ別カウントとアイコン挿入、日本語テンプレート(Lab デザイン)への書き換え、ダーク/ライト切替、タブナビ、検索、クライアント側ページネーション、タイムスタンプ埋め込みを追加しました。

Changes

Cohort / File(s) Summary
コア生成ロジック
generate_index.py
index 生成フローを拡張:vendored asset マッピングと public/vendor へのコピー、カテゴリ別 total_count / domain_count 計算、タイトル取得の try/except フォールバックを追加。公開 API シグネチャは不変。
埋め込み HTML/CSS/JS テンプレート
generate_index.py(テンプレート内の HTML/CSS/JS 部分)
日本語(lang="ja")の新テンプレート導入。フォント、テーマ変数、カテゴリ別アイコン、ヘッダの合計数表示、UTC タイムスタンプ挿入を追加。スタイル(Outfit, JetBrains Mono)とカードベースのレスポンシブデザインを実装。
クライアント機能(インタラクション)
generate_index.py(スクリプト セクション)
タブ切替(data-category ベース)、デバウンス付き検索、タブ別絞り込みと found-count、クライアントページネーション(ITEMS_PER_PAGE、省略表示、Prev/Next)、ページネーション制御、アニメーション、ダーク/ライト切替(localStorage 永続化)を追加。
エラーハンドリング / セーフガード
generate_index.py
HTML タイトル取得の try/except でファイル名を代替使用する堅牢化と、HTML 生成時のエスケープを明示的に適用。
ドキュメント追加
CLAUDE.md
generate_index.py 実行スニペット/インデックス生成手順(「インデックスページ生成」)とブラウザテスト(Playwright MCP)に関する記述を追加。

Sequence Diagram(s)

sequenceDiagram
  participant Gen as generate_index.py
  participant FS as Filesystem (public/, repo)
  participant Browser as Browser (index.html)
  participant User as ユーザー

  Gen->>FS: vendored assets を public/vendor にコピー、index.html を生成
  FS->>Browser: 生成された index.html を配信(静的ファイル)
  User->>Browser: ページ読み込み
  Browser->>Browser: 初期レンダリング(タブ, カウント, タイムスタンプ)
  User->>Browser: タブ切替 / 検索入力 / ページ操作 / テーマ切替
  Browser->>Browser: フィルタリング・ページネーション・localStorage 保存
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 分

Possibly related PRs

🐰✨ インデックス畑でひと跳ね
タブが光り、アイコンが踊るよ
検索で見つけた小道しるべ
ページめくれば昼夜を選べる
わたしのコードに小さな春が咲いたよ 🌱

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Description check ❓ Inconclusive プルリクエストの説明が提供されていません。 プルリクエストの説明を追加して、変更の動機、実装の詳細、テスト方法などの重要な情報を記載してください。
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed タイトルはプルリクエストの主要な変更内容を正確に反映しており、ダークモード、ページネーション、検索機能の追加というUIの包括的なリデザインを明確に要約しています。
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
generate_index.py (1)

78-104: ⚠️ Potential issue | 🟠 Major

/vendor/... のルート相対パスは、ネストされたHTMLファイルの参照を壊します

rewrite_html_content() が生成する置換先 /vendor/... は、公開ディレクトリ内のすべてのHTMLファイル(154+のネストされたファイルを含む)に対して共通の絶対パスを使用しています。これにより以下のシナリオで参照が破損します:

  • GitHub Pages のサブパス配信(https://<user>.github.io/<repo>/
  • ファイルの深さが異なる場合の相対参照(例:public/Algorithm/Backtracking/leetcode/39. Combination Sum/Claude/README.html など)
  • file:// プロトコルでの直接開き(OSのルートを指してしまう)

各HTMLファイルの出力位置に応じて、vendorディレクトリへの相対パスを動的に計算する必要があります。

修正案(各HTMLファイルから vendor への相対パスを計算して置換)
-    def rewrite_html_content(self, content: str) -> str:
+    def rewrite_html_content(self, content: str, vendor_prefix: str) -> str:
         """Replaces CDN links with local vendor links."""
         replacements = [
             # React
-            ('https://unpkg.com/react@18/umd/react.development.js', '/vendor/react/react.development.js'),
+            ('https://unpkg.com/react@18/umd/react.development.js', f'{vendor_prefix}/react/react.development.js'),
             ...
         ]

呼び出し元(136-145行目付近):

-                        modified_content = self.rewrite_html_content(content)
+                        vendor_prefix = os.path.relpath(
+                            os.path.join(output_dir, "vendor"),
+                            os.path.dirname(dest_path),
+                        ).replace(os.sep, "/")
+                        modified_content = self.rewrite_html_content(content, vendor_prefix)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 78 - 104, The replace targets in
rewrite_html_content use absolute "/vendor/..." paths which break nested HTML
(GitHub Pages subpaths, file://, differing depths); change rewrite_html_content
to accept the HTML file's output path (e.g., output_path or html_relpath) or a
computed vendor_relative_base and compute a relative vendor prefix (like "../"
repeated based on output file depth) then replace vendor URLs with
vendor_relative_base + "vendor/..." instead of "/vendor/..."; update the
caller(s) that generate each HTML (where rewrite_html_content is invoked) to
pass the file's output location so the function can compute correct relative
links.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@generate_index.py`:
- Around line 182-851: The large html_template string in generate_index.py
(variable html_template) should be moved to an external template file (e.g.,
index_template.html) and loaded at runtime; replace the inlined html_template
with code that reads the file and performs safe placeholder substitution (use
Python's str.format_map with a dict or preferably use the standard library's
string.Template or Jinja2 to avoid accidental unescaped braces), update
references to {total_count}, {tabs}, {all_files}, {tab_contents}, {timestamp}
accordingly, remove the embedded doubled braces trick by letting the template
engine handle literal braces, and finally run ruff and black on the Python
changes before merge to satisfy formatting/lint guidelines.
- Around line 169-180: The subtitle currently hardcodes "across 6 domains" while
total_count is computed dynamically; compute domain_count from the categories
list you actually render (e.g., len(sorted_categories) or len([c for c in
sorted_categories if you include it]) ) and pass that domain_count into the
template instead of the fixed 6; update the code around
category_icons/total_count/current_time to compute domain_count from
sorted_categories (or structure keys if that better matches rendering) and
ensure the template rendering calls use this domain_count variable wherever
"across 6 domains" is generated (also replace other occurrences noted at lines
~595-602 and ~875-881).
- Around line 860-871: The HTML is inserting unescaped title and path (from
get_html_title()/title and path) into item_html and tabs_html, allowing markup
injection; fix by HTML-escaping display values before interpolation: call
html.escape on title and on the displayed path (but keep href using the
url-encoded encoded_path) and use the escaped variables in the f-strings that
build tabs_html/file_list_html; ensure html is imported and replace uses of raw
title/path in the string building (e.g., where item_html is constructed and the
tab label) with the escaped versions.
- Around line 739-763: openTab clears the search input but only calls
clearSearchFilter(categoryName) for the destination tab, leaving any
data-search-hidden flags on the previous tab and causing cards to remain hidden
when returning; fix by locating the previously active tab before hiding tabs
(e.g. via document.querySelector('.tab-content.active') or iterate all
'.tab-content' elements) and call clearSearchFilter with that previous tab's id
(or call clearSearchFilter for every tab) in addition to calling it for the
destination tab, making sure to perform this after resetting the input and
before renderPage(categoryName); reference openTab, clearSearchFilter,
currentPages, renderPage, and data-search-hidden.
- Around line 678-690: In renderPaginationControls, avoid leaving an empty
.pagination container when totalPages <= 1: before creating or after clearing
the container, if totalPages <= 1 remove any existing container (using the
container variable or tab.querySelector('.pagination')) and return immediately;
alternatively check totalPages <= 1 at the top and delete the existing
.pagination then return—update the logic in renderPaginationControls
(referencing tabId, container, totalPages, currentPage) to ensure no empty
container remains.

---

Outside diff comments:
In `@generate_index.py`:
- Around line 78-104: The replace targets in rewrite_html_content use absolute
"/vendor/..." paths which break nested HTML (GitHub Pages subpaths, file://,
differing depths); change rewrite_html_content to accept the HTML file's output
path (e.g., output_path or html_relpath) or a computed vendor_relative_base and
compute a relative vendor prefix (like "../" repeated based on output file
depth) then replace vendor URLs with vendor_relative_base + "vendor/..." instead
of "/vendor/..."; update the caller(s) that generate each HTML (where
rewrite_html_content is invoked) to pass the file's output location so the
function can compute correct relative links.

Comment thread generate_index.py
Comment thread generate_index.py
Comment on lines +182 to 851
# HTML Template — "Refined Lab" デザイン
html_template = """<!DOCTYPE html>
<html lang="en">
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Algorithm Study Index</title>
<title>Algorithm Study Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; line-height: 1.6; color: #333; max-width: 1200px; margin: 0 auto; padding: 20px; background-color: #f5f5f7; }}
h1 {{ text-align: center; color: #2c3e50; margin-bottom: 30px; }}
.tabs {{ display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-bottom: 20px; }}
.tab-button {{ padding: 10px 20px; border: none; background: #e0e0e0; cursor: pointer; border-radius: 5px; font-weight: 500; font-size: 16px; transition: all 0.3s ease; }}
.tab-button.active {{ background: #007bff; color: white; box-shadow: 0 2px 5px rgba(0,0,0,0.2); }}
.tab-button:hover:not(.active) {{ background: #d0d0d0; }}
.tab-content {{ display: none; animation: fadeIn 0.5s; }}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}

:root {{
--bg-primary: #faf8f5;
--bg-card: #ffffff;
--text-primary: #2d3436;
--text-secondary: #636e72;
--text-muted: #b2bec3;
--border-light: #e0dcd7;
--shadow-color: rgba(0, 0, 0, 0.06);
--color-all: #6c5ce7;
--color-algorithm: #0984e3;
--color-concurrency: #e17055;
--color-datastructures: #00b894;
--color-javascript: #f0932b;
--color-mathematics: #a29bfe;
--color-sql: #fd79a8;
--color-all-light: #ede9fe;
--color-algorithm-light: #dbeafe;
--color-concurrency-light: #fee2d5;
--color-datastructures-light: #d1fae5;
--color-javascript-light: #fef3c7;
--color-mathematics-light: #e8e5fe;
--color-sql-light: #fce7f3;
--radius-sm: 8px;
--radius-md: 14px;
--radius-lg: 50px;
--transition: 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}}

[data-theme="dark"] {{
--bg-primary: #13111a;
--bg-card: #1c1a27;
--text-primary: #e8e6f0;
--text-secondary: #9b99a9;
--text-muted: #5c5a6a;
--border-light: #2e2c3a;
--shadow-color: rgba(0, 0, 0, 0.4);
--color-all-light: #2d2654;
--color-algorithm-light: #0f2a4a;
--color-concurrency-light: #3a1a10;
--color-datastructures-light: #0a2e23;
--color-javascript-light: #3a2a10;
--color-mathematics-light: #2a2654;
--color-sql-light: #3a1a2e;
}}

body {{
font-family: 'Outfit', -apple-system, BlinkMacSystemFont, sans-serif;
line-height: 1.6;
color: var(--text-primary);
max-width: 1280px;
margin: 0 auto;
padding: 20px 24px;
background-color: var(--bg-primary);
background-image: radial-gradient(circle, var(--border-light) 1px, transparent 1px);
background-size: 28px 28px;
min-height: 100vh;
}}

/* Theme Toggle */
.theme-toggle {{
position: fixed;
top: 20px;
right: 20px;
width: 46px;
height: 46px;
border-radius: 50%;
border: 2px solid var(--border-light);
background: var(--bg-card);
cursor: pointer;
font-size: 1.3rem;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
z-index: 100;
box-shadow: 0 2px 12px var(--shadow-color);
}}
.theme-toggle:hover {{
transform: rotate(20deg) scale(1.1);
box-shadow: 0 4px 20px var(--shadow-color);
}}

/* Header */
.site-header {{
text-align: center;
padding: 48px 20px 32px;
position: relative;
}}
.header-badge {{
display: inline-block;
background: linear-gradient(135deg, var(--color-all), #a29bfe);
color: white;
padding: 4px 18px;
border-radius: 20px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 3px;
text-transform: uppercase;
margin-bottom: 14px;
}}
.site-title {{
font-family: 'Outfit', sans-serif;
font-weight: 700;
font-size: 2.8rem;
color: var(--text-primary);
line-height: 1.15;
}}
.title-accent {{
background: linear-gradient(135deg, var(--color-all), var(--color-algorithm));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}}
.site-subtitle {{
color: var(--text-secondary);
font-size: 1rem;
margin-top: 10px;
font-weight: 400;
}}

/* Search */
.search-container {{
max-width: 620px;
margin: 0 auto 28px;
position: relative;
display: flex;
align-items: center;
}}
.search-icon {{
position: absolute;
left: 18px;
font-size: 1.1rem;
opacity: 0.4;
pointer-events: none;
}}
.search-input {{
width: 100%;
padding: 14px 90px 14px 48px;
border: 2px solid var(--border-light);
border-radius: var(--radius-lg);
font-family: 'Outfit', sans-serif;
font-size: 0.95rem;
background: var(--bg-card);
color: var(--text-primary);
transition: border-color var(--transition), box-shadow var(--transition);
outline: none;
}}
.search-input::placeholder {{ color: var(--text-muted); }}
.search-input:focus {{
border-color: var(--color-all);
box-shadow: 0 0 0 4px var(--color-all-light);
}}
.search-count {{
position: absolute;
right: 48px;
font-size: 0.78rem;
color: var(--text-muted);
font-weight: 500;
}}
.search-clear {{
position: absolute;
right: 14px;
background: none;
border: none;
font-size: 1.4rem;
cursor: pointer;
color: var(--text-muted);
padding: 4px 8px;
border-radius: 50%;
transition: all 0.2s;
display: none;
}}
.search-clear:hover {{
color: var(--text-primary);
background: var(--border-light);
}}

/* Tabs */
.tabs {{
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
margin-bottom: 28px;
padding: 0 16px;
}}
.tab-button {{
padding: 10px 20px;
border: 2px solid var(--border-light);
background: var(--bg-card);
cursor: pointer;
border-radius: var(--radius-lg);
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 0.88rem;
color: var(--text-secondary);
transition: all var(--transition);
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}}
.tab-icon {{ font-size: 1.05em; }}
.tab-count {{
background: var(--border-light);
color: var(--text-muted);
padding: 1px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 700;
transition: all var(--transition);
}}
.tab-button:hover:not(.active) {{
border-color: var(--text-muted);
transform: translateY(-1px);
}}
.tab-button.active .tab-count {{
background: rgba(255,255,255,0.25);
color: white;
}}

/* カテゴリ別アクティブカラー */
.tab-button[data-category="all"].active {{ background: var(--color-all); color: white; border-color: var(--color-all); box-shadow: 0 4px 16px rgba(108,92,231,0.35); }}
.tab-button[data-category="algorithm"].active {{ background: var(--color-algorithm); color: white; border-color: var(--color-algorithm); box-shadow: 0 4px 16px rgba(9,132,227,0.35); }}
.tab-button[data-category="concurrency"].active {{ background: var(--color-concurrency); color: white; border-color: var(--color-concurrency); box-shadow: 0 4px 16px rgba(225,112,85,0.35); }}
.tab-button[data-category="datastructures"].active {{ background: var(--color-datastructures); color: white; border-color: var(--color-datastructures); box-shadow: 0 4px 16px rgba(0,184,148,0.35); }}
.tab-button[data-category="javascript"].active {{ background: var(--color-javascript); color: white; border-color: var(--color-javascript); box-shadow: 0 4px 16px rgba(240,147,43,0.35); }}
.tab-button[data-category="mathematics"].active {{ background: var(--color-mathematics); color: white; border-color: var(--color-mathematics); box-shadow: 0 4px 16px rgba(162,155,254,0.35); }}
.tab-button[data-category="sql"].active {{ background: var(--color-sql); color: white; border-color: var(--color-sql); box-shadow: 0 4px 16px rgba(253,121,168,0.35); }}

/* Tab Content */
.tab-content {{ display: none; }}
.tab-content.active {{ display: block; }}
@keyframes fadeIn {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}
.file-list {{ list-style: none; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px; }}
.file-item {{ background: white; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.05); transition: transform 0.2s; padding: 15px; border: 1px solid #eee; }}
.file-item:hover {{ transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); border-color: #007bff; }}
.file-link {{ text-decoration: none; color: #2c3e50; font-weight: 500; display: block; }}
.file-path {{ font-size: 0.8em; color: #7f8c8d; margin-top: 5px; display: block; word-break: break-all; }}
footer {{ margin-top: 50px; text-align: center; font-size: 0.9em; color: #777; border-top: 1px solid #ddd; padding-top: 20px; }}

/* Card Grid */
.file-list {{
list-style: none;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}}
.file-item {{
background: var(--bg-card);
border-radius: var(--radius-md);
box-shadow: 0 2px 8px var(--shadow-color);
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--border-light);
border-left: 4px solid var(--border-light);
overflow: hidden;
opacity: 0;
transform: translateY(16px);
animation: cardSlideIn 0.4s ease forwards;
}}
.file-item[data-category="algorithm"] {{ border-left-color: var(--color-algorithm); }}
.file-item[data-category="concurrency"] {{ border-left-color: var(--color-concurrency); }}
.file-item[data-category="datastructures"] {{ border-left-color: var(--color-datastructures); }}
.file-item[data-category="javascript"] {{ border-left-color: var(--color-javascript); }}
.file-item[data-category="mathematics"] {{ border-left-color: var(--color-mathematics); }}
.file-item[data-category="sql"] {{ border-left-color: var(--color-sql); }}

.file-item:hover {{
transform: translateY(-4px) scale(1.01);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}}
.file-item[data-category="algorithm"]:hover {{ background: linear-gradient(135deg, var(--color-algorithm-light), var(--bg-card)); }}
.file-item[data-category="concurrency"]:hover {{ background: linear-gradient(135deg, var(--color-concurrency-light), var(--bg-card)); }}
.file-item[data-category="datastructures"]:hover {{ background: linear-gradient(135deg, var(--color-datastructures-light), var(--bg-card)); }}
.file-item[data-category="javascript"]:hover {{ background: linear-gradient(135deg, var(--color-javascript-light), var(--bg-card)); }}
.file-item[data-category="mathematics"]:hover {{ background: linear-gradient(135deg, var(--color-mathematics-light), var(--bg-card)); }}
.file-item[data-category="sql"]:hover {{ background: linear-gradient(135deg, var(--color-sql-light), var(--bg-card)); }}

@keyframes cardSlideIn {{
from {{ opacity: 0; transform: translateY(16px); }}
to {{ opacity: 1; transform: translateY(0); }}
}}

.file-link {{
text-decoration: none;
color: var(--text-primary);
display: block;
padding: 16px 18px;
}}
.card-header {{
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}}
.card-icon {{
font-size: 1.25rem;
flex-shrink: 0;
line-height: 1.4;
}}
.card-title {{
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 0.92rem;
line-height: 1.45;
color: var(--text-primary);
}}
.file-path {{
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--text-muted);
margin-top: 4px;
display: block;
word-break: break-all;
line-height: 1.5;
}}

/* Pagination */
.pagination {{
display: flex;
justify-content: center;
align-items: center;
gap: 6px;
margin-top: 32px;
padding-bottom: 24px;
}}
.page-button {{
padding: 8px 14px;
border: 1px solid var(--border-light);
background: var(--bg-card);
cursor: pointer;
border-radius: 10px;
color: var(--text-secondary);
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 0.88rem;
transition: all 0.2s;
}}
.page-button.active {{
background: var(--color-all);
color: white;
border-color: var(--color-all);
box-shadow: 0 2px 8px rgba(108, 92, 231, 0.3);
}}
.page-button:hover:not(.active):not(:disabled) {{
background: var(--color-all-light);
border-color: var(--color-all);
color: var(--color-all);
}}
.page-button:disabled {{
opacity: 0.35;
cursor: not-allowed;
}}
.page-ellipsis {{
color: var(--text-muted);
font-weight: 600;
padding: 0 4px;
}}

.hidden-item {{ display: none !important; }}

/* Footer */
footer {{
margin-top: 60px;
text-align: center;
font-size: 0.82rem;
color: var(--text-muted);
border-top: 1px dashed var(--border-light);
padding: 24px 20px;
}}
.footer-icon {{ margin-right: 4px; }}

/* No Results */
.no-results {{
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
font-size: 1.1rem;
display: none;
}}
.no-results-icon {{ font-size: 2.5rem; display: block; margin-bottom: 12px; }}

/* Responsive */
@media (max-width: 768px) {{
.site-title {{ font-size: 2rem; }}
.file-list {{ grid-template-columns: 1fr; gap: 12px; }}
.tabs {{ gap: 6px; }}
.tab-button {{ padding: 8px 14px; font-size: 0.8rem; }}
.search-input {{ padding: 12px 80px 12px 42px; font-size: 0.9rem; }}
.theme-toggle {{ top: 12px; right: 12px; width: 40px; height: 40px; font-size: 1.1rem; }}
}}
@media (max-width: 480px) {{
body {{ padding: 12px; }}
.site-title {{ font-size: 1.6rem; }}
.site-header {{ padding: 36px 12px 24px; }}
.tab-button {{ padding: 6px 10px; font-size: 0.75rem; }}
.tab-count {{ display: none; }}
.file-list {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<h1>Algorithm Study Index</h1>
<button id="themeToggle" class="theme-toggle" aria-label="Toggle dark mode">
<span id="themeIcon">\u263E</span>
</button>

<header class="site-header">
<div class="header-badge">Lab</div>
<h1 class="site-title">
\U0001F9EA Algorithm Study
<span class="title-accent">Index</span>
</h1>
<p class="site-subtitle">{total_count} interactive lessons across 6 domains</p>
</header>

<div class="search-container">
<span class="search-icon">\U0001F50D</span>
<input type="text" id="searchInput" class="search-input"
placeholder="Search problems... (e.g. Binary Search, DP, LeetCode 91)"
autocomplete="off">
<span id="searchCount" class="search-count"></span>
<button id="searchClear" class="search-clear" aria-label="Clear search">\u00D7</button>
</div>

<div class="tabs" id="categoryTabs">
<button class="tab-button active" onclick="openTab(event, 'All')">All</button>
<button class="tab-button active" data-category="all" onclick="openTab(event, 'All')">
<span class="tab-icon">\U0001F30D</span> All <span class="tab-count">{total_count}</span>
</button>
{tabs}
</div>

<div id="All" class="tab-content active">
<ul class="file-list">
{all_files}
</ul>
<div class="no-results"><span class="no-results-icon">\U0001F50E</span>No results found</div>
</div>

{tab_contents}

<footer>
<span class="footer-icon">\U0001F9EA</span>
Generated on {timestamp}
</footer>

<script>
const ITEMS_PER_PAGE = 12;
let currentPages = {{}};

/* Pagination */
function initPagination() {{
document.querySelectorAll('.tab-content').forEach(tab => {{
currentPages[tab.id] = 1;
renderPage(tab.id);
}});
}}

function renderPage(tabId) {{
const tab = document.getElementById(tabId);
const allItems = Array.from(tab.querySelectorAll('.file-item'));
const items = allItems.filter(item => item.dataset.searchHidden !== 'true');
const hiddenBySearch = allItems.filter(item => item.dataset.searchHidden === 'true');

hiddenBySearch.forEach(item => item.classList.add('hidden-item'));

const totalItems = items.length;
const totalPages = Math.max(1, Math.ceil(totalItems / ITEMS_PER_PAGE));
const currentPage = Math.min(currentPages[tabId] || 1, totalPages);
currentPages[tabId] = currentPage;

items.forEach((item, index) => {{
const start = (currentPage - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
if (index >= start && index < end) {{
item.classList.remove('hidden-item');
}} else {{
item.classList.add('hidden-item');
}}
}});

const noResults = tab.querySelector('.no-results');
if (noResults) {{
noResults.style.display = totalItems === 0 ? 'block' : 'none';
}}

renderPaginationControls(tabId, totalPages, currentPage);
applyStaggerAnimation(tabId);
}}

function renderPaginationControls(tabId, totalPages, currentPage) {{
const tab = document.getElementById(tabId);
let container = tab.querySelector('.pagination');

if (!container) {{
container = document.createElement('div');
container.className = 'pagination';
tab.appendChild(container);
}}
while (container.firstChild) container.removeChild(container.firstChild);

if (totalPages <= 1) return;

const prevBtn = document.createElement('button');
prevBtn.className = 'page-button';
prevBtn.textContent = '\u00AB Prev';
prevBtn.disabled = currentPage === 1;
prevBtn.onclick = () => {{ currentPages[tabId]--; renderPage(tabId); window.scrollTo({{ top: 0, behavior: 'smooth' }}); }};
container.appendChild(prevBtn);

for (let i = 1; i <= totalPages; i++) {{
if (totalPages > 7) {{
if (i !== 1 && i !== totalPages && Math.abs(i - currentPage) > 1) {{
if (i === 2 || i === totalPages - 1) {{
const el = document.createElement('span');
el.className = 'page-ellipsis';
el.textContent = '...';
container.appendChild(el);
}}
continue;
}}
}}
const btn = document.createElement('button');
btn.className = 'page-button';
btn.textContent = i;
if (i === currentPage) btn.classList.add('active');
btn.onclick = () => {{ currentPages[tabId] = i; renderPage(tabId); window.scrollTo({{ top: 0, behavior: 'smooth' }}); }};
container.appendChild(btn);
}}

const nextBtn = document.createElement('button');
nextBtn.className = 'page-button';
nextBtn.textContent = 'Next \u00BB';
nextBtn.disabled = currentPage === totalPages;
nextBtn.onclick = () => {{ currentPages[tabId]++; renderPage(tabId); window.scrollTo({{ top: 0, behavior: 'smooth' }}); }};
container.appendChild(nextBtn);
}}

/* Stagger Animation */
function applyStaggerAnimation(tabId) {{
const tab = document.getElementById(tabId);
const items = tab.querySelectorAll('.file-item:not(.hidden-item)');
items.forEach((item, i) => {{
item.style.animation = 'none';
item.offsetHeight;
item.style.animation = '';
item.style.animationDelay = (i * 0.04) + 's';
}});
}}

/* Tab Navigation */
function openTab(evt, categoryName) {{
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) {{
tabcontent[i].style.display = "none";
tabcontent[i].classList.remove("active");
document.querySelectorAll('.tab-content').forEach(tc => {{
tc.style.display = 'none';
tc.classList.remove('active');
}});
document.querySelectorAll('.tab-button').forEach(tb => {{
tb.classList.remove('active');
}});

const target = document.getElementById(categoryName);
target.style.display = 'block';
target.classList.add('active');
evt.currentTarget.classList.add('active');

const input = document.getElementById('searchInput');
if (input.value) {{
input.value = '';
document.getElementById('searchClear').style.display = 'none';
document.getElementById('searchCount').textContent = '';
clearSearchFilter(categoryName);
}}

currentPages[categoryName] = currentPages[categoryName] || 1;
renderPage(categoryName);
}}

function clearSearchFilter(tabId) {{
const tab = document.getElementById(tabId);
tab.querySelectorAll('.file-item').forEach(item => {{
delete item.dataset.searchHidden;
}});
}}

/* Search */
function initSearch() {{
const input = document.getElementById('searchInput');
const clearBtn = document.getElementById('searchClear');
const countEl = document.getElementById('searchCount');
let debounceTimer;

input.addEventListener('input', () => {{
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => filterCards(input.value), 150);
clearBtn.style.display = input.value ? 'block' : 'none';
}});

clearBtn.addEventListener('click', () => {{
input.value = '';
clearBtn.style.display = 'none';
countEl.textContent = '';
const activeTab = document.querySelector('.tab-content.active');
if (activeTab) {{
clearSearchFilter(activeTab.id);
currentPages[activeTab.id] = 1;
renderPage(activeTab.id);
}}
}});

function filterCards(query) {{
const q = query.toLowerCase().trim();
const activeTab = document.querySelector('.tab-content.active');
if (!activeTab) return;

const items = activeTab.querySelectorAll('.file-item');
let visible = 0;

items.forEach(item => {{
const text = item.textContent.toLowerCase();
const match = !q || text.includes(q);
if (match) {{
delete item.dataset.searchHidden;
visible++;
}} else {{
item.dataset.searchHidden = 'true';
}}
}});

countEl.textContent = q ? visible + ' found' : '';
currentPages[activeTab.id] = 1;
renderPage(activeTab.id);
}}
tablinks = document.getElementsByClassName("tab-button");
for (i = 0; i < tablinks.length; i++) {{
tablinks[i].className = tablinks[i].className.replace(" active", "");
}}

/* Dark Mode */
function initTheme() {{
const toggle = document.getElementById('themeToggle');
const icon = document.getElementById('themeIcon');
const saved = localStorage.getItem('algo-study-theme');

if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {{
document.documentElement.setAttribute('data-theme', 'dark');
icon.textContent = '\u2600';
}}
document.getElementById(categoryName).style.display = "block";
document.getElementById(categoryName).classList.add("active");
evt.currentTarget.className += " active";

toggle.addEventListener('click', () => {{
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const next = isDark ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
icon.textContent = next === 'dark' ? '\u2600' : '\u263E';
localStorage.setItem('algo-study-theme', next);
}});
}}

/* Init */
window.addEventListener('DOMContentLoaded', () => {{
initTheme();
initPagination();
initSearch();
}});
</script>
</body>
</html>"""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

巨大な html_template を Python 文字列に直書きしており、保守と差分レビューが難しい

この規模のHTML/CSS/JSを .format() 前提で {{ }} だらけにする構造は、将来的にテンプレ修正時の事故(未エスケープ { による KeyError 等)を誘発しやすいです。テンプレートを別ファイルへ分離し、Python 側は「読み込み+プレースホルダ置換」に寄せるのを推奨します。

また、Python 部分はマージ前に ruff/black を通して整形・lint しておくと安全です。As per coding guidelines, "Use Python's ruff and black for code formatting and linting".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 182 - 851, The large html_template string in
generate_index.py (variable html_template) should be moved to an external
template file (e.g., index_template.html) and loaded at runtime; replace the
inlined html_template with code that reads the file and performs safe
placeholder substitution (use Python's str.format_map with a dict or preferably
use the standard library's string.Template or Jinja2 to avoid accidental
unescaped braces), update references to {total_count}, {tabs}, {all_files},
{tab_contents}, {timestamp} accordingly, remove the embedded doubled braces
trick by letting the template engine handle literal braces, and finally run ruff
and black on the Python changes before merge to satisfy formatting/lint
guidelines.

Comment thread generate_index.py Outdated
Comment thread generate_index.py Outdated
Comment thread generate_index.py
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 20, 2026

Note

Docstrings generation - SUCCESS
Generated docstrings for this pull request at #280

Docstrings generation was requested by @myoshi2891.

* #278 (comment)

The following files were modified:

* `generate_index.py`
@myoshi2891 myoshi2891 changed the title Dev from macmini Add comprehensive UI redesign with dark mode, pagination, and search functionality Feb 20, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
generate_index.py (3)

55-58: 🧹 Nitpick | 🔵 Trivial

インデントが不揃いです(ruff / black で検出される問題)。

Line 56-58 はインデントにスペースが1つ余分に入っています(Line 40, 97 の # コメントも同様)。コーディングガイドラインに従い、マージ前に ruff / black を実行してください。

As per coding guidelines, "Use Python's ruff and black for code formatting and linting".

♻️ 修正案
         for src in prism_plugins:
-             if os.path.exists(src):
-                 rel_path = os.path.relpath(src, "node_modules/prismjs")
-                 file_map[src] = f"prismjs/{rel_path}"
+            if os.path.exists(src):
+                rel_path = os.path.relpath(src, "node_modules/prismjs")
+                file_map[src] = f"prismjs/{rel_path}"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 55 - 58, Fix inconsistent indentation around
the prism_plugins loop: align the for-statement block so the lines using
os.path.exists(src), rel_path = os.path.relpath(...), and file_map[src] =
f"prismjs/{rel_path}" have the same indentation level as the for line; also
correct the mismatched leading space on the nearby inline '#' comments (the ones
above and below this block) to match project style, then run ruff/black to
autoformat and ensure no remaining spacing issues.

151-167: ⚠️ Potential issue | 🟡 Minor

get_html_titletry-except ブロックの外にあり、エンコーディングエラー時にスクリプト全体がクラッシュします。

Line 143-154 の try-except はファイル読み書きのエラーをキャッチしますが、Line 166 の get_html_title(filepath) はその外にあります。Line 151 でエンコーディングエラーが発生しフォールバックコピーされた場合、同じファイルに対する get_html_title も同様に失敗し、ループ全体が中断します。

🛡️ 修正案
                 except Exception as e:
                     print(f"Error processing {filepath}: {e}")
                     # Fallback copy if encoding/read fails (unlikely given check)
                     shutil.copy2(filepath, dest_path)

                 # Indexing Logic
                 parts = rel_path.split(os.sep)
                 if len(parts) > 1:
                     category = parts[0]
                 else:
                     category = "Uncategorized"

                 if category.startswith('.'):
                     continue

-                title = self.get_html_title(filepath)
+                try:
+                    title = self.get_html_title(filepath)
+                except Exception:
+                    title = os.path.basename(filepath)
                 structure[category].append((title, rel_path))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 151 - 167, get_html_title(filepath) is called
outside the try-except that handles read/encoding failures, so an encoding or
parse error there can crash the loop; wrap the title extraction in its own safe
block (or move it inside the existing except/try) so failures are handled
gracefully: call get_html_title(filepath) inside the try that reads the file or
add a small try-except around get_html_title in the loop, on failure set a
sensible fallback title (e.g., filename or "Untitled") and then append (title,
rel_path) to structure[category]; ensure the code still respects the existing
category skip (category.startswith('.')) and that structure is updated only
after a successful or fallback title is determined.

11-17: 🧹 Nitpick | 🔵 Trivial

ファイルの二重読み込みを回避可能です。

generate_index 内(Line 143-144)で既にファイル内容を読み込んでいますが、get_html_title (Line 12) で同じファイルを再度読み込んでいます。content を引数として渡せば不要な I/O を削減できます。

♻️ リファクタ案
-    def get_html_title(self, filepath: str) -> str:
-        with open(filepath, 'r', encoding='utf-8') as f:
-            content = f.read()
-            match = re.search(r'<title>(.*?)</title>', content, re.IGNORECASE | re.DOTALL)
-            if match:
-                return html.unescape(match.group(1).strip())
-            return os.path.basename(filepath)
+    def get_html_title(self, filepath: str, content: str = "") -> str:
+        if not content:
+            with open(filepath, 'r', encoding='utf-8') as f:
+                content = f.read()
+        match = re.search(r'<title>(.*?)</title>', content, re.IGNORECASE | re.DOTALL)
+        if match:
+            return html.unescape(match.group(1).strip())
+        return os.path.basename(filepath)

呼び出し側(generate_index 内):

title = self.get_html_title(filepath, content=content)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 11 - 17, get_html_title is re-opening files
already read in generate_index; modify get_html_title to accept an optional
content: str parameter (e.g., def get_html_title(self, filepath: str, content:
Optional[str]=None)) and only open/read filepath when content is None, then
update the generate_index call to pass the already-read content (title =
self.get_html_title(filepath, content=content)); ensure other callers of
get_html_title are updated or still work with the new optional parameter and
keep the existing return behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@generate_index.py`:
- Around line 55-58: Fix inconsistent indentation around the prism_plugins loop:
align the for-statement block so the lines using os.path.exists(src), rel_path =
os.path.relpath(...), and file_map[src] = f"prismjs/{rel_path}" have the same
indentation level as the for line; also correct the mismatched leading space on
the nearby inline '#' comments (the ones above and below this block) to match
project style, then run ruff/black to autoformat and ensure no remaining spacing
issues.
- Around line 151-167: get_html_title(filepath) is called outside the try-except
that handles read/encoding failures, so an encoding or parse error there can
crash the loop; wrap the title extraction in its own safe block (or move it
inside the existing except/try) so failures are handled gracefully: call
get_html_title(filepath) inside the try that reads the file or add a small
try-except around get_html_title in the loop, on failure set a sensible fallback
title (e.g., filename or "Untitled") and then append (title, rel_path) to
structure[category]; ensure the code still respects the existing category skip
(category.startswith('.')) and that structure is updated only after a successful
or fallback title is determined.
- Around line 11-17: get_html_title is re-opening files already read in
generate_index; modify get_html_title to accept an optional content: str
parameter (e.g., def get_html_title(self, filepath: str, content:
Optional[str]=None)) and only open/read filepath when content is None, then
update the generate_index call to pass the already-read content (title =
self.get_html_title(filepath, content=content)); ensure other callers of
get_html_title are updated or still work with the new optional parameter and
keep the existing return behavior.

---

Duplicate comments:
In `@generate_index.py`:
- Around line 865-878: The category value is being interpolated directly into
the onclick JS string and title/path are not HTML-escaped; fix by importing json
at the top and using json.dumps(category) (or an equivalent JS-string escaper)
when building the onclick argument for openTab to ensure a JS-safe string, and
wrap title and path with html.escape before inserting into item_html and
file_list_html; update the string building locations that produce tabs_html,
item_html, all_files_html and tab_contents_html to use the escaped values
(reference symbols: category, title, path, tabs_html, file_list_html,
tab_contents_html, openTab).
- Around line 187-188: The large HTML string assigned to html_template in
generate_index.py should be moved into an external template file and loaded at
runtime to improve maintainability; replace the inline html_template with code
that reads a separate .html (or .j2) file and render it via a template engine
(e.g., Jinja2) using safe lookup (e.g., Jinja2's default values or the get
filter) to avoid KeyError from heavy use of {{ }} placeholders; update the
variable name/reference (html_template) to call the loader/renderer and ensure
tests or callers (where html_template is used) now receive the rendered string
instead of the inline literal.
- Around line 758-764: The current code only calls
clearSearchFilter(categoryName) for the destination tab, leaving the source
tab's data-search-hidden flags set; update the logic so that when clearing the
search input you also clear any data-search-hidden state on the other tab(s).
Concretely, after clearing the input and UI bits (the code that touches
'searchInput', 'searchClear', 'searchCount'), either invoke clearSearchFilter
for the previous/other category in addition to the current categoryName or query
and remove the data-search-hidden attribute from all card elements (selector
matching the per-category cards) so no tab retains hidden cards; reference
clearSearchFilter, categoryName and the data-search-hidden attribute to locate
where to change the DOM-clearing behavior.
- Line 606: The subtitle currently hardcodes "6 domains" in the template string
("{total_count} interactive lessons across 6 domains"); change it to use a
dynamic domain count (e.g., a variable like domain_count or
len(domains)/len(categories) computed where total_count is computed) so the
rendered string becomes "{total_count} interactive lessons across {domain_count}
domains" (and ensure the variable used is in scope where the template is built
and pluralization remains correct).
- Around line 683-694: renderPaginationControls currently creates (and clears) a
.pagination container before checking totalPages <= 1, leaving an empty
container and layout gap; change the logic in renderPaginationControls(tabId,
totalPages, currentPage) so that you either (a) check totalPages <= 1 before
creating/appending the container, or (b) if you must create it first, remove the
container from the tab (tab.removeChild(container) or container.remove()) and
return when totalPages <= 1; target the container variable and the tab element
in that function to ensure no empty .pagination element remains.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
generate_index.py (1)

142-167: ⚠️ Potential issue | 🟠 Major

get_html_title が try-except の外にあり、エンコードエラーでスクリプト全体がクラッシュする

except ブランチで shutil.copy2 によるフォールバックコピーが完了した後、同じファイルを encoding='utf-8' で再オープンする get_html_title(Line 166)が呼ばれます。非 UTF-8 の HTML ファイルがあると同じ UnicodeDecodeError が発生し、以降のすべての処理が中断されます。

🐛 修正案 — フォールバック時は basename を使用
             except Exception as e:
                 print(f"Error processing {filepath}: {e}")
                 shutil.copy2(filepath, dest_path)

             # Indexing Logic
             parts = rel_path.split(os.sep)
             if len(parts) > 1:
                 category = parts[0]
             else:
                 category = "Uncategorized"

             if category.startswith('.'):
                 continue

-            title = self.get_html_title(filepath)
+            try:
+                title = self.get_html_title(filepath)
+            except Exception:
+                title = os.path.basename(filepath)
             structure[category].append((title, rel_path))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 142 - 167, The code calls
get_html_title(filepath) outside the file-read try/except so a
UnicodeDecodeError in non-UTF8 files can crash the script; change the flow to
determine title after the read/fallback and protect it: call get_html_title on
the successfully written file (dest_path) inside the try/except or wrap
get_html_title(...) in its own try/except and fall back to
os.path.basename(filepath) (or similar) when title extraction fails, ensuring
structure[category].append((title, rel_path)) always receives a safe title.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@generate_index.py`:
- Around line 612-614: The search <input> element with id "searchInput" (class
"search-input") lacks an accessible name; add an aria-label attribute (e.g.,
aria-label="Search problems") to that <input> so screen readers can announce its
purpose (do not rely on placeholder text); update the element definition where
id="searchInput" is defined to include the aria-label string.
- Around line 739-744: applyStaggerAnimation currently forces a synchronous
reflow per item because each iteration reads item.offsetHeight; change the
implementation to avoid per-item layout reads by batching the layout read and
using requestAnimationFrame or toggling a CSS class: first loop to set/reset
animation styles without reading layout, perform a single layout read (e.g.,
read container.offsetHeight) inside requestAnimationFrame or before the second
pass, then in a second loop apply item.style.animationDelay (or add a stagger
CSS class) so you only trigger at most one reflow rather than one per item;
target symbols: applyStaggerAnimation, item.offsetHeight,
item.style.animationDelay.
- Around line 195-197: HTML currently includes Google Fonts <link> tags which
leaves font loading CDN-dependent; copy Outfit and JetBrains Mono font files
into vendor/fonts/, add `@font-face` declarations in your main CSS (or a new
vendor/fonts.css) pointing to those local files, update generate_index.py to
inject a local <link rel="stylesheet" href="vendor/fonts.css"> instead of the
Google Fonts URLs (replace the three <link> lines), and if CDN usage was
intentional, document that decision in CLAUDE.md noting the trade-offs for
offline/CI environments.
- Around line 874-886: The category string is injected into HTML and JS without
escaping (used in tabs_html onclick, tab_contents_html id, and displayed text),
so escape it before embedding: call html.escape(category, quote=True) (use a
variable like safe_category) when inserting into id attributes and into the
visible text, and avoid breaking the onclick JS by either using a data-category
attribute (e.g., data-category="{safe_category}") and letting openTab read
event.currentTarget.dataset.category, or if keeping inline onclick, pass an
already JS-escaped value (escape single quotes/backslashes) so openTab(event,
...) cannot be broken; update uses in tabs_html, tab_contents_html, and any
onclick occurrences accordingly.

---

Outside diff comments:
In `@generate_index.py`:
- Around line 142-167: The code calls get_html_title(filepath) outside the
file-read try/except so a UnicodeDecodeError in non-UTF8 files can crash the
script; change the flow to determine title after the read/fallback and protect
it: call get_html_title on the successfully written file (dest_path) inside the
try/except or wrap get_html_title(...) in its own try/except and fall back to
os.path.basename(filepath) (or similar) when title extraction fails, ensuring
structure[category].append((title, rel_path)) always receives a safe title.

Comment thread generate_index.py
Comment on lines +195 to +197
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=JetBrains+Mono:wght@400&display=swap" rel="stylesheet">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Google Fonts のみ CDN 依存が残り、ローカルベンダー方針と不整合

React/Babel/Tailwind/PrismJS/FontAwesome はすべてローカルベンダーへコピーしているのに、Outfit と JetBrains Mono だけ Google Fonts CDN に依存しています。ネットワーク接続がない環境(CI や GitHub Pages のオフラインプレビュー等)では fonts が読み込まれず、フォールバックフォントで表示されます。フォントファイルも vendor/fonts/ にコピーし、@font-face でセルフホストするか、または意図的な CDN 依存として CLAUDE.md に明記することを推奨します。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@generate_index.py` around lines 195 - 197, HTML currently includes Google
Fonts <link> tags which leaves font loading CDN-dependent; copy Outfit and
JetBrains Mono font files into vendor/fonts/, add `@font-face` declarations in
your main CSS (or a new vendor/fonts.css) pointing to those local files, update
generate_index.py to inject a local <link rel="stylesheet"
href="vendor/fonts.css"> instead of the Google Fonts URLs (replace the three
<link> lines), and if CDN usage was intentional, document that decision in
CLAUDE.md noting the trade-offs for offline/CI environments.

Comment thread generate_index.py
Comment thread generate_index.py
Comment thread generate_index.py Outdated
@myoshi2891 myoshi2891 merged commit dd0c7b6 into main Feb 20, 2026
1 check was pending
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant