bruAristimunha commited on
Commit
0a148f6
·
1 Parent(s): 216b843

Redesign Space UI: hero, modality strip, HTML detail cards, Okabe-Ito palette

Browse files
Files changed (2) hide show
  1. app.py +363 -145
  2. style.css +523 -0
app.py CHANGED
@@ -1,15 +1,24 @@
1
  """EEGDash Dataset Catalog — Hugging Face Space.
2
 
3
- Mirrors the searchable table from https://eegdash.org and generates one-liner
4
- load snippets for EEGDash and braindecode. Rows whose slug matches an existing
5
- repo under the ``EEGDash`` org on the Hub are flagged as ``on 🤗`` and can be
6
- loaded via ``BaseConcatDataset.pull_from_hub``.
 
 
 
 
 
 
 
7
  """
8
 
9
  from __future__ import annotations
10
 
11
  import ast
 
12
  import json
 
13
  import os
14
  from functools import lru_cache
15
  from pathlib import Path
@@ -21,9 +30,27 @@ from huggingface_hub.utils import HfHubHTTPError
21
 
22
  HF_ORG = "EEGDash"
23
  CSV_PATH = Path(__file__).parent / "dataset_summary.csv"
 
24
  EEGDASH_URL = "https://eegdash.org"
25
  GITHUB_URL = "https://github.com/eegdash/EEGDash"
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  TABLE_COLUMNS = [
28
  "dataset",
29
  "author_year",
@@ -54,20 +81,19 @@ DISPLAY_HEADERS = {
54
  "n_records": "Records",
55
  "n_tasks": "Tasks",
56
  "nchans": "Channels",
57
- "sfreq": "Sampling rate (Hz)",
58
  "size": "Size",
59
  "license": "License",
60
- "on_hf": "on 🤗",
61
  }
62
 
 
63
 
64
- def _parse_mode_from_json_col(cell: object) -> str:
65
- """Return the most common value from a ``[{val, count}, ...]`` JSON cell.
66
 
67
- The summary CSV stores per-recording distributions of channel counts and
68
- sampling rates as a JSON list. The catalog UI wants a single
69
- representative value: the one with the highest ``count``.
70
- """
71
  if not isinstance(cell, str) or not cell.strip():
72
  return ""
73
  try:
@@ -88,11 +114,6 @@ def _parse_mode_from_json_col(cell: object) -> str:
88
 
89
  @lru_cache(maxsize=1)
90
  def _hf_repos() -> set[str]:
91
- """Slugs that exist as dataset repos under the EEGDash org.
92
-
93
- Cached for the lifetime of the process. Failures (no network, rate limit)
94
- degrade to an empty set rather than breaking the page load.
95
- """
96
  try:
97
  api = HfApi()
98
  repos = api.list_datasets(author=HF_ORG, limit=500)
@@ -109,7 +130,7 @@ def _load_catalog() -> pd.DataFrame:
109
  df["on_hf"] = df["dataset"].apply(lambda s: "✓" if s in on_hub else "")
110
  for col in ("n_subjects", "n_records", "n_tasks"):
111
  df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype(int)
112
- extra = ["dataset_title", "doi"]
113
  for col in TABLE_COLUMNS + extra:
114
  if col not in df.columns:
115
  df[col] = ""
@@ -117,6 +138,9 @@ def _load_catalog() -> pd.DataFrame:
117
  return df
118
 
119
 
 
 
 
120
  def _unique_sorted(series: pd.Series) -> list[str]:
121
  return sorted({str(v).strip() for v in series if str(v).strip()})
122
 
@@ -138,6 +162,8 @@ def _filter(
138
  out["dataset"].str.lower()
139
  + " "
140
  + out["author_year"].str.lower()
 
 
141
  )
142
  out = out[hay.str.contains(q, regex=False, na=False)]
143
  if modalities:
@@ -159,163 +185,341 @@ def _render_table(df: pd.DataFrame) -> pd.DataFrame:
159
  return df[TABLE_COLUMNS].rename(columns=DISPLAY_HEADERS)
160
 
161
 
162
- def _snippets(slug: str, on_hf: bool) -> str:
163
- native = f"""```python
164
- # EEGDash (streams from S3 / NEMAR, preserves BIDS)
165
- from eegdash import EEGDashDataset
166
-
167
- ds = EEGDashDataset(dataset="{slug}", cache_dir="./cache")
168
- print(len(ds), "recordings")
169
- ```"""
170
- hf_block = f"""```python
171
- # From Hugging Face (braindecode Zarr format, pre-windowed)
172
- from braindecode.datasets import BaseConcatDataset
173
-
174
- ds = BaseConcatDataset.pull_from_hub("{HF_ORG}/{slug}")
175
- ```"""
176
- if not on_hf:
177
- hf_block = (
178
- "> ℹ️ Not mirrored on Hugging Face yet. "
179
- "Open an issue on "
180
- f"[github.com/eegdash/EEGDash]({GITHUB_URL}/issues) to request it, "
181
- "or push it yourself:\n\n"
182
- "```python\n"
183
- "from eegdash import EEGDashDataset\n"
184
- f'ds = EEGDashDataset(dataset="{slug}", cache_dir="./cache")\n'
185
- f'ds.push_to_hub("{HF_ORG}/{slug}")\n'
186
- "```"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  )
188
- return native + "\n\n" + hf_block
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
 
190
 
191
- def _detail(df: pd.DataFrame, slug: str) -> str:
 
 
 
 
 
 
 
192
  if not slug:
193
- return "Pick a dataset row above to see details and load snippets."
194
  match = df[df["dataset"] == slug]
195
  if match.empty:
196
- return f"Dataset `{slug}` not found in the catalog."
197
  row = match.iloc[0]
198
  on_hf = row["on_hf"] == "✓"
199
- doi = row.get("doi", "")
200
- title = row.get("dataset_title", "") or slug
201
- lines = [f"## `{slug}` {title}"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  if on_hf:
203
- lines.append(
204
- f"[🤗 EEGDash/{slug}](https://huggingface.co/datasets/{HF_ORG}/{slug})"
 
 
 
 
 
 
 
 
205
  )
206
- if doi:
207
- lines.append(f"[DOI: {doi}](https://doi.org/{doi})")
208
- lines.append("")
209
- lines.append("| | |")
210
- lines.append("|--|--|")
211
- for key, label in [
212
- ("author_year", "Author (year)"),
213
- ("source", "Source"),
214
- ("record_modality", "Recording"),
215
- ("Type Subject", "Pathology"),
216
- ("modality of exp", "Modality"),
217
- ("type of exp", "Type"),
218
- ("n_subjects", "Subjects"),
219
- ("n_records", "Records"),
220
- ("n_tasks", "Tasks"),
221
- ("nchans", "Channels"),
222
- ("sfreq", "Sampling rate (Hz)"),
223
- ("size", "Size"),
224
- ("license", "License"),
225
- ]:
226
- val = row.get(key, "")
227
- if str(val).strip():
228
- lines.append(f"| **{label}** | {val} |")
229
- lines.append("")
230
- lines.append("### Load")
231
- lines.append(_snippets(slug, on_hf))
232
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
 
235
  CATALOG = _load_catalog()
 
236
  MODALITY_CHOICES = _unique_sorted(CATALOG["modality of exp"])
237
  SUBJECT_CHOICES = _unique_sorted(CATALOG["Type Subject"])
238
  SOURCE_CHOICES = _unique_sorted(CATALOG["source"])
239
  LICENSE_CHOICES = _unique_sorted(CATALOG["license"])
240
 
241
 
242
- CSS = """
243
- #detail { min-height: 320px; }
244
- .gradio-container { max-width: 1400px !important; }
245
- """
246
-
247
-
248
  def _on_select(evt: gr.SelectData, df) -> str:
249
- """Return the detail markdown for the clicked row.
250
-
251
- Bypasses ``gr.State`` so the lookup is a single hop: click → detail.
252
- Handles deselection and header clicks (``evt.index`` may be ``None``)
253
- and the three shapes gradio 5.x can pass the table value as (DataFrame,
254
- list-of-lists, or a dict with ``headers``/``data``).
255
- """
256
  if evt is None or evt.index is None:
257
- return "Pick a dataset row above to see details and load snippets."
258
  row_idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
259
  if df is None:
260
- return "Pick a dataset row above to see details and load snippets."
261
  if isinstance(df, pd.DataFrame):
262
  if df.empty or row_idx >= len(df):
263
- return "Pick a dataset row above to see details and load snippets."
264
  slug = str(df.iloc[row_idx, 0])
265
  elif isinstance(df, dict) and "data" in df:
266
  rows = df["data"]
267
  if not rows or row_idx >= len(rows):
268
- return "Pick a dataset row above to see details and load snippets."
269
  slug = str(rows[row_idx][0])
270
  else:
271
  try:
272
  slug = str(df[row_idx][0])
273
  except (IndexError, TypeError, KeyError):
274
- return "Pick a dataset row above to see details and load snippets."
275
- return _detail(CATALOG, slug)
276
 
277
 
278
  def _on_filter(
279
  query, modalities, subject_types, sources, licenses, min_subjects, only_on_hf
280
  ):
281
  filtered = _filter(
282
- CATALOG, query, modalities, subject_types, sources, licenses, min_subjects, only_on_hf
 
 
 
 
 
 
 
 
 
 
 
 
283
  )
284
- count_md = f"**{len(filtered)}** of {len(CATALOG)} datasets"
285
- return _render_table(filtered), count_md
286
-
287
 
288
- with gr.Blocks(title="EEGDash Dataset Catalog", css=CSS, theme=gr.themes.Soft()) as demo:
289
- gr.Markdown(
290
- f"""# 🧠 EEGDash Dataset Catalog
291
 
292
- Search {len(CATALOG)}+ EEG/MEG datasets and get copy-paste load snippets.
293
- Mirrored from [eegdash.org]({EEGDASH_URL}) · Code on [GitHub]({GITHUB_URL}) ·
294
- Library on [PyPI](https://pypi.org/project/eegdash/).
295
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  )
297
 
298
- with gr.Row():
299
- with gr.Column(scale=1):
300
- query = gr.Textbox(
301
- label="Search",
302
- placeholder="dataset id, author, year…",
303
- show_label=True,
304
- )
 
 
 
 
 
 
 
 
 
 
305
  modalities = gr.CheckboxGroup(
306
- label="Modality",
307
- choices=MODALITY_CHOICES,
308
- value=[],
309
  )
310
  subject_types = gr.CheckboxGroup(
311
- label="Subject type",
312
- choices=SUBJECT_CHOICES,
313
- value=[],
314
  )
 
315
  sources = gr.CheckboxGroup(
316
- label="Source",
317
- choices=SOURCE_CHOICES,
318
- value=[],
319
  )
320
  licenses = gr.Dropdown(
321
  label="License",
@@ -323,38 +527,52 @@ Library on [PyPI](https://pypi.org/project/eegdash/).
323
  multiselect=True,
324
  value=[],
325
  )
326
- min_subjects = gr.Slider(
327
- label="Min. subjects",
328
- minimum=0,
329
- maximum=500,
330
- step=10,
331
- value=0,
332
- )
333
- only_on_hf = gr.Checkbox(label="Only datasets mirrored on 🤗", value=False)
334
- count = gr.Markdown(f"**{len(CATALOG)}** of {len(CATALOG)} datasets")
335
 
336
- with gr.Column(scale=3):
 
337
  table = gr.Dataframe(
338
  value=_render_table(CATALOG),
339
  interactive=False,
340
- wrap=True,
341
  column_widths=[
342
- "130px", "140px", "90px", "90px", "120px", "110px",
343
- "150px", "90px", "90px", "70px", "90px", "120px",
344
- "90px", "130px", "70px",
345
  ],
346
- label="Catalog",
347
- )
348
- detail = gr.Markdown(
349
- "Pick a dataset row above to see details and load snippets.",
350
- elem_id="detail",
351
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
 
353
  filter_inputs = [
354
  query, modalities, subject_types, sources, licenses, min_subjects, only_on_hf,
355
  ]
356
  for w in filter_inputs:
357
- w.change(_on_filter, filter_inputs, [table, count])
358
 
359
  table.select(_on_select, [table], [detail])
360
 
 
1
  """EEGDash Dataset Catalog — Hugging Face Space.
2
 
3
+ Design system (kept in sync with ``style.css``):
4
+
5
+ * Typography: Inter for UI (14px base, 600 for headings), JetBrains Mono for
6
+ code snippets. Hierarchy: hero title > section titles > labels > meta.
7
+ * Palette: Okabe-Ito (colorblind-safe). Brand is #0072B2 (EEG-blue). One warm
8
+ accent #E69F00 reserved for the ``on 🤗`` flag — never decorative. Neutral
9
+ ramp is slate (#f8fafc → #0f172a).
10
+ * Encoding: categorical modality gets one Okabe-Ito hue per value. Continuous
11
+ (dataset size) is never encoded by color.
12
+ * Annotation: the hero, modality strip and detail panel each carry one
13
+ sentence of prose so the page reads as an argument, not a data dump.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
  import ast
19
+ import html as _html
20
  import json
21
+ import logging
22
  import os
23
  from functools import lru_cache
24
  from pathlib import Path
 
30
 
31
  HF_ORG = "EEGDash"
32
  CSV_PATH = Path(__file__).parent / "dataset_summary.csv"
33
+ CSS_PATH = Path(__file__).parent / "style.css"
34
  EEGDASH_URL = "https://eegdash.org"
35
  GITHUB_URL = "https://github.com/eegdash/EEGDash"
36
 
37
+ # Okabe-Ito categorical palette — one hue per modality, reused consistently
38
+ # across the modality strip and filter chips so the reader learns the mapping
39
+ # once.
40
+ MODALITY_HUES: dict[str, str] = {
41
+ "Visual": "#0072B2",
42
+ "Auditory": "#009E73",
43
+ "Motor": "#D55E00",
44
+ "Tactile": "#CC79A7",
45
+ "Multisensory": "#E69F00",
46
+ "Resting State": "#56B4E9",
47
+ "Sleep": "#F0E442",
48
+ "Anesthesia": "#999999",
49
+ "Other": "#555555",
50
+ "Unknown": "#cbd5e1",
51
+ }
52
+ DEFAULT_HUE = "#64748b"
53
+
54
  TABLE_COLUMNS = [
55
  "dataset",
56
  "author_year",
 
81
  "n_records": "Records",
82
  "n_tasks": "Tasks",
83
  "nchans": "Channels",
84
+ "sfreq": "Hz",
85
  "size": "Size",
86
  "license": "License",
87
+ "on_hf": "🤗",
88
  }
89
 
90
+ log = logging.getLogger(__name__)
91
 
 
 
92
 
93
+ # -------------------- Data loading --------------------
94
+
95
+
96
+ def _parse_mode_from_json_col(cell: object) -> str:
97
  if not isinstance(cell, str) or not cell.strip():
98
  return ""
99
  try:
 
114
 
115
  @lru_cache(maxsize=1)
116
  def _hf_repos() -> set[str]:
 
 
 
 
 
117
  try:
118
  api = HfApi()
119
  repos = api.list_datasets(author=HF_ORG, limit=500)
 
130
  df["on_hf"] = df["dataset"].apply(lambda s: "✓" if s in on_hub else "")
131
  for col in ("n_subjects", "n_records", "n_tasks"):
132
  df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype(int)
133
+ extra = ["dataset_title", "doi", "duration_hours_total"]
134
  for col in TABLE_COLUMNS + extra:
135
  if col not in df.columns:
136
  df[col] = ""
 
138
  return df
139
 
140
 
141
+ # -------------------- Filtering --------------------
142
+
143
+
144
  def _unique_sorted(series: pd.Series) -> list[str]:
145
  return sorted({str(v).strip() for v in series if str(v).strip()})
146
 
 
162
  out["dataset"].str.lower()
163
  + " "
164
  + out["author_year"].str.lower()
165
+ + " "
166
+ + out["dataset_title"].astype(str).str.lower()
167
  )
168
  out = out[hay.str.contains(q, regex=False, na=False)]
169
  if modalities:
 
185
  return df[TABLE_COLUMNS].rename(columns=DISPLAY_HEADERS)
186
 
187
 
188
+ # -------------------- Hero (stats + modality strip) --------------------
189
+
190
+
191
+ def _fmt_num(n: float) -> str:
192
+ if n >= 1_000_000:
193
+ return f"{n / 1_000_000:.1f}M"
194
+ if n >= 1_000:
195
+ return f"{n / 1_000:.1f}k"
196
+ return f"{int(n):,}"
197
+
198
+
199
+ def _hero_html(df: pd.DataFrame, total_all: int) -> str:
200
+ """Hero banner — the one thing a first-time visitor reads.
201
+
202
+ Four stat cards answer: "how big is this catalog?" at a glance. The
203
+ count is filter-aware so the banner tracks what's currently visible.
204
+ """
205
+ subjects = int(df["n_subjects"].sum())
206
+ records = int(df["n_records"].sum())
207
+ hours_series = pd.to_numeric(df["duration_hours_total"], errors="coerce").fillna(0)
208
+ hours = float(hours_series.sum())
209
+ on_hf = int((df["on_hf"] == "✓").sum())
210
+ viewing = len(df)
211
+
212
+ return f"""
213
+ <section class="eeg-hero">
214
+ <div class="eeg-hero__left">
215
+ <div class="eeg-hero__kicker">Open catalog</div>
216
+ <h1 class="eeg-hero__title">EEG / MEG datasets,<br/>one import away.</h1>
217
+ <p class="eeg-hero__lede">
218
+ Search {total_all} publicly shared recordings and load any of them with
219
+ a single line of Python — streamed from NEMAR or mirrored to
220
+ <a href="https://huggingface.co/{HF_ORG}">🤗 {HF_ORG}</a>.
221
+ </p>
222
+ <div class="eeg-hero__cta">
223
+ <a class="eeg-btn eeg-btn--primary" href="{EEGDASH_URL}">eegdash.org →</a>
224
+ <a class="eeg-btn" href="{GITHUB_URL}">GitHub</a>
225
+ <a class="eeg-btn" href="https://pypi.org/project/eegdash/">PyPI</a>
226
+ </div>
227
+ </div>
228
+ <div class="eeg-hero__stats" role="group" aria-label="Catalog totals">
229
+ <div class="eeg-stat"><div class="eeg-stat__n">{_fmt_num(viewing)}</div><div class="eeg-stat__l">datasets <span class="eeg-stat__meta">of {total_all}</span></div></div>
230
+ <div class="eeg-stat"><div class="eeg-stat__n">{_fmt_num(subjects)}</div><div class="eeg-stat__l">subjects</div></div>
231
+ <div class="eeg-stat"><div class="eeg-stat__n">{_fmt_num(records)}</div><div class="eeg-stat__l">recordings</div></div>
232
+ <div class="eeg-stat eeg-stat--accent"><div class="eeg-stat__n">{on_hf}</div><div class="eeg-stat__l">on <span aria-label="Hugging Face">🤗</span></div></div>
233
+ </div>
234
+ </section>
235
+ """
236
+
237
+
238
+ def _modality_strip_html(df: pd.DataFrame) -> str:
239
+ """Horizontal bar of dataset counts by modality — quick shape check.
240
+
241
+ Effectiveness via length (bars), expressiveness via categorical hue.
242
+ One stacked row is enough because we're answering a single question:
243
+ which experimental paradigms dominate the catalog?
244
+ """
245
+ counts = (
246
+ df["modality of exp"]
247
+ .replace("", "Unknown")
248
+ .value_counts()
249
+ .sort_values(ascending=False)
250
+ )
251
+ if counts.empty:
252
+ return ""
253
+ total = int(counts.sum())
254
+ segments = []
255
+ legend = []
256
+ for name, n in counts.items():
257
+ hue = MODALITY_HUES.get(str(name), DEFAULT_HUE)
258
+ pct = (n / total) * 100
259
+ if pct < 0.8:
260
+ continue
261
+ segments.append(
262
+ f'<span class="eeg-bar__seg" style="width:{pct:.2f}%;background:{hue}" '
263
+ f'title="{_html.escape(str(name))}: {n}"></span>'
264
+ )
265
+ legend.append(
266
+ f'<span class="eeg-legend__item">'
267
+ f'<span class="eeg-legend__swatch" style="background:{hue}"></span>'
268
+ f"{_html.escape(str(name))} <span class='eeg-legend__n'>{n}</span>"
269
+ f"</span>"
270
  )
271
+ return f"""
272
+ <section class="eeg-modality" aria-label="Datasets by experimental modality">
273
+ <div class="eeg-modality__head">
274
+ <span class="eeg-modality__title">By modality</span>
275
+ <span class="eeg-modality__meta">{total} datasets · {len(counts)} modalities</span>
276
+ </div>
277
+ <div class="eeg-bar" role="img" aria-label="Stacked breakdown of datasets by modality">{''.join(segments)}</div>
278
+ <div class="eeg-legend">{''.join(legend)}</div>
279
+ </section>
280
+ """
281
+
282
+
283
+ # -------------------- Detail card (HTML) --------------------
284
+
285
+
286
+ def _e(v: object) -> str:
287
+ return _html.escape(str(v)) if v is not None else ""
288
 
289
 
290
+ def _snippet_block(label: str, code: str) -> str:
291
+ return (
292
+ f'<div class="eeg-snippet"><div class="eeg-snippet__hd">{_e(label)}</div>'
293
+ f'<pre class="eeg-snippet__code">{_e(code)}</pre></div>'
294
+ )
295
+
296
+
297
+ def _detail_html(df: pd.DataFrame, slug: str) -> str:
298
  if not slug:
299
+ return _empty_detail()
300
  match = df[df["dataset"] == slug]
301
  if match.empty:
302
+ return _empty_detail()
303
  row = match.iloc[0]
304
  on_hf = row["on_hf"] == "✓"
305
+ title = str(row.get("dataset_title", "") or slug)
306
+ doi = str(row.get("doi", "") or "").strip()
307
+ author = str(row.get("author_year", "") or "").strip()
308
+ license_ = str(row.get("license", "") or "—").strip() or "—"
309
+ modality = str(row.get("modality of exp", "") or "").strip() or "—"
310
+ pathology = str(row.get("Type Subject", "") or "").strip() or "—"
311
+ modality_hue = MODALITY_HUES.get(modality, DEFAULT_HUE)
312
+
313
+ doi_link = (
314
+ f'<a class="eeg-tag" href="https://doi.org/{_e(doi)}" target="_blank" rel="noopener">doi:{_e(doi)}</a>'
315
+ if doi
316
+ else ""
317
+ )
318
+ hf_link = (
319
+ f'<a class="eeg-tag eeg-tag--accent" href="https://huggingface.co/datasets/{HF_ORG}/{_e(slug)}" target="_blank" rel="noopener">🤗 on Hub</a>'
320
+ if on_hf
321
+ else '<span class="eeg-tag eeg-tag--muted">not mirrored yet</span>'
322
+ )
323
+
324
+ stats = [
325
+ ("Subjects", _fmt_num(int(row.get("n_subjects", 0) or 0))),
326
+ ("Recordings", _fmt_num(int(row.get("n_records", 0) or 0))),
327
+ ("Tasks", _fmt_num(int(row.get("n_tasks", 0) or 0))),
328
+ ("Channels", str(row.get("nchans", "") or "—")),
329
+ ("Sampling", f"{row.get('sfreq', '') or '—'} Hz"),
330
+ ("Size", str(row.get("size", "") or "—")),
331
+ ]
332
+ stat_cards = "".join(
333
+ f'<div class="eeg-kv"><div class="eeg-kv__n">{_e(v)}</div><div class="eeg-kv__l">{_e(k)}</div></div>'
334
+ for k, v in stats
335
+ )
336
+
337
+ native_snippet = (
338
+ "from eegdash import EEGDashDataset\n\n"
339
+ f'ds = EEGDashDataset(dataset="{slug}", cache_dir="./cache")\n'
340
+ 'print(len(ds), "recordings")'
341
+ )
342
  if on_hf:
343
+ hub_snippet = (
344
+ "from braindecode.datasets import BaseConcatDataset\n\n"
345
+ f'ds = BaseConcatDataset.pull_from_hub("{HF_ORG}/{slug}")'
346
+ )
347
+ hub_block = _snippet_block("From 🤗 Hub (braindecode, Zarr)", hub_snippet)
348
+ else:
349
+ hub_snippet = (
350
+ "from eegdash import EEGDashDataset\n\n"
351
+ f'ds = EEGDashDataset(dataset="{slug}", cache_dir="./cache")\n'
352
+ f'ds.push_to_hub("{HF_ORG}/{slug}")'
353
  )
354
+ hub_block = (
355
+ '<div class="eeg-note">This dataset isn’t mirrored on 🤗 yet. '
356
+ f'<a href="{GITHUB_URL}/issues">Open an issue</a> to request it '
357
+ "or push it yourself:</div>"
358
+ + _snippet_block("Push to the Hub", hub_snippet)
359
+ )
360
+
361
+ return f"""
362
+ <article class="eeg-card" aria-labelledby="eeg-card-title">
363
+ <header class="eeg-card__hd">
364
+ <div class="eeg-card__id">
365
+ <span class="eeg-card__slug">{_e(slug)}</span>
366
+ <span class="eeg-card__modality" style="--hue:{modality_hue}">{_e(modality)}</span>
367
+ </div>
368
+ <h2 id="eeg-card-title" class="eeg-card__title">{_e(title)}</h2>
369
+ <div class="eeg-card__meta">
370
+ {f'<span class="eeg-tag">{_e(author)}</span>' if author else ''}
371
+ <span class="eeg-tag">{_e(license_)}</span>
372
+ <span class="eeg-tag">{_e(pathology)}</span>
373
+ {doi_link}
374
+ {hf_link}
375
+ </div>
376
+ </header>
377
+
378
+ <div class="eeg-card__kvs">{stat_cards}</div>
379
+
380
+ <section class="eeg-card__body">
381
+ <h3 class="eeg-card__h3">Load it</h3>
382
+ {_snippet_block("Native EEGDash (streams from S3 / NEMAR)", native_snippet)}
383
+ {hub_block}
384
+ </section>
385
+ </article>
386
+ """
387
+
388
+
389
+ def _empty_detail() -> str:
390
+ return """
391
+ <article class="eeg-card eeg-card--empty">
392
+ <div class="eeg-card__ghost">
393
+ <div class="eeg-card__ghost-title">Pick a dataset</div>
394
+ <p>Click any row in the table above to see its metadata, load snippet, and 🤗 status.</p>
395
+ </div>
396
+ </article>
397
+ """
398
+
399
+
400
+ # -------------------- Event handlers --------------------
401
 
402
 
403
  CATALOG = _load_catalog()
404
+ TOTAL_ALL = len(CATALOG)
405
  MODALITY_CHOICES = _unique_sorted(CATALOG["modality of exp"])
406
  SUBJECT_CHOICES = _unique_sorted(CATALOG["Type Subject"])
407
  SOURCE_CHOICES = _unique_sorted(CATALOG["source"])
408
  LICENSE_CHOICES = _unique_sorted(CATALOG["license"])
409
 
410
 
 
 
 
 
 
 
411
  def _on_select(evt: gr.SelectData, df) -> str:
 
 
 
 
 
 
 
412
  if evt is None or evt.index is None:
413
+ return _empty_detail()
414
  row_idx = evt.index[0] if isinstance(evt.index, (list, tuple)) else evt.index
415
  if df is None:
416
+ return _empty_detail()
417
  if isinstance(df, pd.DataFrame):
418
  if df.empty or row_idx >= len(df):
419
+ return _empty_detail()
420
  slug = str(df.iloc[row_idx, 0])
421
  elif isinstance(df, dict) and "data" in df:
422
  rows = df["data"]
423
  if not rows or row_idx >= len(rows):
424
+ return _empty_detail()
425
  slug = str(rows[row_idx][0])
426
  else:
427
  try:
428
  slug = str(df[row_idx][0])
429
  except (IndexError, TypeError, KeyError):
430
+ return _empty_detail()
431
+ return _detail_html(CATALOG, slug)
432
 
433
 
434
  def _on_filter(
435
  query, modalities, subject_types, sources, licenses, min_subjects, only_on_hf
436
  ):
437
  filtered = _filter(
438
+ CATALOG,
439
+ query,
440
+ modalities,
441
+ subject_types,
442
+ sources,
443
+ licenses,
444
+ min_subjects,
445
+ only_on_hf,
446
+ )
447
+ return (
448
+ _render_table(filtered),
449
+ _hero_html(filtered, TOTAL_ALL),
450
+ _modality_strip_html(filtered),
451
  )
 
 
 
452
 
 
 
 
453
 
454
+ # -------------------- UI assembly --------------------
455
+
456
+ CSS = CSS_PATH.read_text(encoding="utf-8") if CSS_PATH.exists() else ""
457
+
458
+ THEME = gr.themes.Base(
459
+ primary_hue=gr.themes.colors.blue,
460
+ secondary_hue=gr.themes.colors.slate,
461
+ neutral_hue=gr.themes.colors.slate,
462
+ font=(gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"),
463
+ font_mono=(
464
+ gr.themes.GoogleFont("JetBrains Mono"),
465
+ "ui-monospace",
466
+ "SFMono-Regular",
467
+ "monospace",
468
+ ),
469
+ ).set(
470
+ body_background_fill="#f8fafc",
471
+ body_background_fill_dark="#0b1220",
472
+ background_fill_primary="#ffffff",
473
+ background_fill_primary_dark="#111827",
474
+ border_color_primary="#e2e8f0",
475
+ border_color_primary_dark="#1f2937",
476
+ button_primary_background_fill="#0072B2",
477
+ button_primary_background_fill_hover="#005A8F",
478
+ button_primary_text_color="#ffffff",
479
+ block_radius="14px",
480
+ input_radius="10px",
481
+ body_text_color="#0f172a",
482
+ body_text_color_dark="#e2e8f0",
483
+ )
484
+
485
+
486
+ with gr.Blocks(
487
+ title="EEGDash — EEG/MEG dataset catalog",
488
+ css=CSS,
489
+ theme=THEME,
490
+ analytics_enabled=False,
491
+ ) as demo:
492
+ hero = gr.HTML(_hero_html(CATALOG, TOTAL_ALL), elem_classes=["eeg-hero-wrap"])
493
+ modality_strip = gr.HTML(
494
+ _modality_strip_html(CATALOG), elem_classes=["eeg-modality-wrap"]
495
  )
496
 
497
+ with gr.Row(elem_classes=["eeg-toolbar"]):
498
+ query = gr.Textbox(
499
+ label="Search",
500
+ placeholder="Type a dataset id, author, or keyword…",
501
+ show_label=False,
502
+ elem_classes=["eeg-search"],
503
+ scale=4,
504
+ )
505
+ only_on_hf = gr.Checkbox(
506
+ label="Only 🤗-mirrored",
507
+ value=False,
508
+ elem_classes=["eeg-toggle"],
509
+ scale=1,
510
+ )
511
+
512
+ with gr.Accordion("Filters", open=False, elem_classes=["eeg-filters"]):
513
+ with gr.Row():
514
  modalities = gr.CheckboxGroup(
515
+ label="Modality", choices=MODALITY_CHOICES, value=[]
 
 
516
  )
517
  subject_types = gr.CheckboxGroup(
518
+ label="Pathology / population", choices=SUBJECT_CHOICES, value=[]
 
 
519
  )
520
+ with gr.Row():
521
  sources = gr.CheckboxGroup(
522
+ label="Source", choices=SOURCE_CHOICES, value=[]
 
 
523
  )
524
  licenses = gr.Dropdown(
525
  label="License",
 
527
  multiselect=True,
528
  value=[],
529
  )
530
+ min_subjects = gr.Slider(
531
+ label="Minimum subjects",
532
+ minimum=0,
533
+ maximum=500,
534
+ step=10,
535
+ value=0,
536
+ )
 
 
537
 
538
+ with gr.Row(elem_classes=["eeg-main"]):
539
+ with gr.Column(scale=3, elem_classes=["eeg-main__table"]):
540
  table = gr.Dataframe(
541
  value=_render_table(CATALOG),
542
  interactive=False,
543
+ wrap=False,
544
  column_widths=[
545
+ "140px", "140px", "90px", "90px", "120px", "110px",
546
+ "140px", "85px", "85px", "60px", "80px", "70px",
547
+ "85px", "110px", "50px",
548
  ],
549
+ label=None,
550
+ show_label=False,
551
+ elem_classes=["eeg-table"],
552
+ max_height=640,
 
553
  )
554
+ with gr.Column(scale=2, elem_classes=["eeg-main__detail"]):
555
+ detail = gr.HTML(_empty_detail(), elem_classes=["eeg-detail"])
556
+
557
+ gr.HTML(
558
+ f"""
559
+ <footer class="eeg-foot">
560
+ <span>
561
+ EEGDash is open source · BSD-3-Clause · data licenses follow their origin.
562
+ <a href="{EEGDASH_URL}">eegdash.org</a> ·
563
+ <a href="{GITHUB_URL}">github</a> ·
564
+ <a href="https://huggingface.co/{HF_ORG}">🤗 {HF_ORG}</a>
565
+ </span>
566
+ </footer>
567
+ """,
568
+ elem_classes=["eeg-foot-wrap"],
569
+ )
570
 
571
  filter_inputs = [
572
  query, modalities, subject_types, sources, licenses, min_subjects, only_on_hf,
573
  ]
574
  for w in filter_inputs:
575
+ w.change(_on_filter, filter_inputs, [table, hero, modality_strip])
576
 
577
  table.select(_on_select, [table], [detail])
578
 
style.css ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ================================================================
2
+ EEGDash Space — visual design system
3
+ -----------------------------------------------------------------
4
+ Scale: 1 2 3 4 5 6 7 8 9 10
5
+ 4 8 12 16 20 24 32 40 56 72 (px)
6
+ Radii: 6 (sm) / 10 (md) / 14 (lg) / 20 (xl)
7
+ Motion: 150ms ease for hover, 220ms for layout
8
+ Palette (Okabe-Ito + slate neutrals):
9
+ brand #0072B2 (EEG waves; primary interaction)
10
+ accent #E69F00 (reserved for 🤗 flag, never decorative)
11
+ success #009E73
12
+ danger #D55E00
13
+ ink / text #0f172a / muted #64748b
14
+ surface #ffffff / subtle #f1f5f9 / outline #e2e8f0
15
+ ================================================================ */
16
+
17
+ :root {
18
+ --brand: #0072B2;
19
+ --brand-strong: #005A8F;
20
+ --brand-soft: #e6f1fa;
21
+ --accent: #E69F00;
22
+ --accent-soft: #fdf2dd;
23
+ --ok: #009E73;
24
+ --ink: #0f172a;
25
+ --muted: #64748b;
26
+ --outline: #e2e8f0;
27
+ --surface: #ffffff;
28
+ --subtle: #f1f5f9;
29
+ --code-bg: #0f172a;
30
+ --code-ink: #e2e8f0;
31
+ --shadow-sm: 0 1px 2px rgba(15,23,42,.06);
32
+ --shadow-md: 0 4px 14px rgba(15,23,42,.08);
33
+ }
34
+
35
+ .dark, .dark :root, html.dark {
36
+ --ink: #e2e8f0;
37
+ --muted: #94a3b8;
38
+ --outline: #1f2937;
39
+ --surface: #111827;
40
+ --subtle: #0b1220;
41
+ --brand-soft: #102a42;
42
+ --accent-soft: #2a1d00;
43
+ --code-bg: #020617;
44
+ --shadow-sm: 0 1px 2px rgba(0,0,0,.4);
45
+ --shadow-md: 0 6px 20px rgba(0,0,0,.5);
46
+ }
47
+
48
+ .gradio-container {
49
+ max-width: 1320px !important;
50
+ padding: 24px 20px 40px !important;
51
+ }
52
+
53
+ /* Kill gradio's default form chrome around our HTML blocks. */
54
+ .eeg-hero-wrap, .eeg-modality-wrap, .eeg-foot-wrap {
55
+ background: transparent !important;
56
+ border: 0 !important;
57
+ padding: 0 !important;
58
+ box-shadow: none !important;
59
+ }
60
+ .eeg-hero-wrap > .prose, .eeg-modality-wrap > .prose, .eeg-foot-wrap > .prose { max-width: none; }
61
+
62
+ /* ---------- Hero ---------- */
63
+
64
+ .eeg-hero {
65
+ display: grid;
66
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
67
+ gap: 32px;
68
+ align-items: center;
69
+ padding: 32px 28px;
70
+ border-radius: 20px;
71
+ background:
72
+ radial-gradient(1200px 320px at -10% -40%, rgba(0,114,178,.14), transparent 60%),
73
+ radial-gradient(900px 240px at 110% -30%, rgba(230,159,0,.10), transparent 60%),
74
+ linear-gradient(180deg, var(--surface), var(--subtle));
75
+ border: 1px solid var(--outline);
76
+ box-shadow: var(--shadow-sm);
77
+ margin-bottom: 16px;
78
+ }
79
+
80
+ .eeg-hero__kicker {
81
+ font-size: 11.5px;
82
+ letter-spacing: .12em;
83
+ text-transform: uppercase;
84
+ color: var(--brand);
85
+ font-weight: 600;
86
+ margin-bottom: 10px;
87
+ }
88
+
89
+ .eeg-hero__title {
90
+ font-size: clamp(26px, 3.2vw, 40px);
91
+ line-height: 1.08;
92
+ letter-spacing: -0.02em;
93
+ font-weight: 700;
94
+ color: var(--ink);
95
+ margin: 0 0 14px;
96
+ }
97
+
98
+ .eeg-hero__lede {
99
+ color: var(--muted);
100
+ font-size: 15px;
101
+ line-height: 1.55;
102
+ margin: 0 0 18px;
103
+ max-width: 58ch;
104
+ }
105
+
106
+ .eeg-hero__lede a {
107
+ color: var(--brand);
108
+ text-decoration: none;
109
+ border-bottom: 1px solid transparent;
110
+ transition: border-color 150ms ease;
111
+ }
112
+ .eeg-hero__lede a:hover { border-bottom-color: var(--brand); }
113
+
114
+ .eeg-hero__cta { display: flex; gap: 8px; flex-wrap: wrap; }
115
+
116
+ .eeg-btn {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: 6px;
120
+ padding: 8px 14px;
121
+ border-radius: 10px;
122
+ border: 1px solid var(--outline);
123
+ background: var(--surface);
124
+ color: var(--ink);
125
+ font-size: 13.5px;
126
+ font-weight: 500;
127
+ text-decoration: none;
128
+ transition: transform 150ms ease, border-color 150ms ease, background 150ms ease;
129
+ }
130
+ .eeg-btn:hover { transform: translateY(-1px); border-color: var(--brand); }
131
+ .eeg-btn--primary {
132
+ background: var(--brand);
133
+ border-color: var(--brand);
134
+ color: #fff;
135
+ }
136
+ .eeg-btn--primary:hover { background: var(--brand-strong); border-color: var(--brand-strong); }
137
+
138
+ .eeg-hero__stats {
139
+ display: grid;
140
+ grid-template-columns: repeat(2, 1fr);
141
+ gap: 12px;
142
+ }
143
+
144
+ .eeg-stat {
145
+ background: var(--surface);
146
+ border: 1px solid var(--outline);
147
+ border-radius: 14px;
148
+ padding: 16px 18px;
149
+ box-shadow: var(--shadow-sm);
150
+ }
151
+
152
+ .eeg-stat__n {
153
+ font-family: "Inter", ui-sans-serif, sans-serif;
154
+ font-size: 28px;
155
+ font-weight: 700;
156
+ letter-spacing: -0.02em;
157
+ color: var(--ink);
158
+ line-height: 1;
159
+ }
160
+ .eeg-stat__l {
161
+ color: var(--muted);
162
+ font-size: 12.5px;
163
+ margin-top: 6px;
164
+ text-transform: lowercase;
165
+ }
166
+ .eeg-stat__meta { opacity: .7; margin-left: 4px; }
167
+
168
+ .eeg-stat--accent {
169
+ background: linear-gradient(135deg, var(--accent-soft), var(--surface));
170
+ border-color: color-mix(in srgb, var(--accent) 35%, var(--outline));
171
+ }
172
+ .eeg-stat--accent .eeg-stat__n { color: var(--accent); }
173
+
174
+ @media (max-width: 860px) {
175
+ .eeg-hero { grid-template-columns: 1fr; padding: 24px 18px; }
176
+ .eeg-hero__stats { grid-template-columns: repeat(2, 1fr); }
177
+ }
178
+
179
+ /* ---------- Modality strip ---------- */
180
+
181
+ .eeg-modality {
182
+ padding: 16px 18px;
183
+ border-radius: 14px;
184
+ background: var(--surface);
185
+ border: 1px solid var(--outline);
186
+ box-shadow: var(--shadow-sm);
187
+ margin-bottom: 16px;
188
+ }
189
+
190
+ .eeg-modality__head {
191
+ display: flex;
192
+ justify-content: space-between;
193
+ align-items: baseline;
194
+ margin-bottom: 10px;
195
+ }
196
+ .eeg-modality__title {
197
+ font-size: 12px;
198
+ letter-spacing: .1em;
199
+ text-transform: uppercase;
200
+ color: var(--muted);
201
+ font-weight: 600;
202
+ }
203
+ .eeg-modality__meta {
204
+ color: var(--muted);
205
+ font-size: 12.5px;
206
+ }
207
+
208
+ .eeg-bar {
209
+ display: flex;
210
+ width: 100%;
211
+ height: 10px;
212
+ border-radius: 999px;
213
+ overflow: hidden;
214
+ background: var(--subtle);
215
+ box-shadow: inset 0 0 0 1px var(--outline);
216
+ }
217
+ .eeg-bar__seg { display: block; height: 100%; transition: filter 150ms ease; }
218
+ .eeg-bar__seg:hover { filter: brightness(1.08); }
219
+
220
+ .eeg-legend {
221
+ display: flex;
222
+ flex-wrap: wrap;
223
+ gap: 10px 16px;
224
+ margin-top: 12px;
225
+ }
226
+ .eeg-legend__item {
227
+ display: inline-flex;
228
+ align-items: center;
229
+ gap: 6px;
230
+ font-size: 12.5px;
231
+ color: var(--ink);
232
+ }
233
+ .eeg-legend__swatch {
234
+ width: 10px; height: 10px;
235
+ border-radius: 3px;
236
+ display: inline-block;
237
+ }
238
+ .eeg-legend__n {
239
+ color: var(--muted);
240
+ font-variant-numeric: tabular-nums;
241
+ margin-left: 2px;
242
+ }
243
+
244
+ /* ---------- Toolbar & filters ---------- */
245
+
246
+ .eeg-toolbar {
247
+ gap: 12px;
248
+ margin-bottom: 8px !important;
249
+ }
250
+
251
+ .eeg-search textarea, .eeg-search input {
252
+ font-size: 15px !important;
253
+ padding: 12px 14px !important;
254
+ border-radius: 12px !important;
255
+ }
256
+
257
+ .eeg-toggle label {
258
+ font-weight: 500;
259
+ color: var(--ink);
260
+ }
261
+
262
+ .eeg-filters { margin-top: 4px; }
263
+ .eeg-filters label span { color: var(--muted); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; font-weight: 600; }
264
+
265
+ /* CheckboxGroup → chip style */
266
+ .eeg-filters .wrap.svelte-1j5x5b > label,
267
+ .eeg-filters fieldset label {
268
+ border-radius: 999px !important;
269
+ padding: 6px 12px !important;
270
+ border: 1px solid var(--outline) !important;
271
+ background: var(--surface) !important;
272
+ transition: all 150ms ease;
273
+ }
274
+ .eeg-filters input[type="checkbox"]:checked + span {
275
+ color: var(--brand);
276
+ }
277
+
278
+ /* ---------- Main table + detail ---------- */
279
+
280
+ .eeg-main { gap: 16px !important; align-items: stretch; }
281
+
282
+ .eeg-main__table, .eeg-main__detail {
283
+ min-width: 0;
284
+ }
285
+
286
+ .eeg-table {
287
+ border-radius: 14px !important;
288
+ overflow: hidden !important;
289
+ border: 1px solid var(--outline) !important;
290
+ background: var(--surface) !important;
291
+ box-shadow: var(--shadow-sm);
292
+ }
293
+
294
+ .eeg-table table {
295
+ font-size: 13px !important;
296
+ font-variant-numeric: tabular-nums;
297
+ }
298
+
299
+ .eeg-table thead th {
300
+ background: var(--subtle) !important;
301
+ color: var(--muted) !important;
302
+ font-weight: 600 !important;
303
+ font-size: 11.5px !important;
304
+ text-transform: uppercase;
305
+ letter-spacing: .04em;
306
+ border-bottom: 1px solid var(--outline) !important;
307
+ padding: 10px 12px !important;
308
+ }
309
+
310
+ .eeg-table tbody td {
311
+ padding: 9px 12px !important;
312
+ border-bottom: 1px solid var(--outline) !important;
313
+ color: var(--ink);
314
+ }
315
+
316
+ .eeg-table tbody tr { transition: background 150ms ease, transform 150ms ease; }
317
+ .eeg-table tbody tr:hover { background: var(--brand-soft) !important; cursor: pointer; }
318
+ .eeg-table tbody tr.selected { background: var(--brand-soft) !important; }
319
+
320
+ /* First column = slug, emphasized */
321
+ .eeg-table tbody td:first-child {
322
+ font-weight: 600;
323
+ color: var(--brand);
324
+ font-family: "JetBrains Mono", ui-monospace, monospace;
325
+ font-size: 12px !important;
326
+ }
327
+
328
+ /* 🤗 column (last) */
329
+ .eeg-table tbody td:last-child {
330
+ text-align: center;
331
+ color: var(--accent);
332
+ font-weight: 700;
333
+ }
334
+
335
+ /* ---------- Detail card ---------- */
336
+
337
+ .eeg-detail { padding: 0 !important; background: transparent !important; border: 0 !important; }
338
+
339
+ .eeg-card {
340
+ background: var(--surface);
341
+ border: 1px solid var(--outline);
342
+ border-radius: 14px;
343
+ padding: 24px;
344
+ box-shadow: var(--shadow-md);
345
+ min-height: 520px;
346
+ display: flex;
347
+ flex-direction: column;
348
+ gap: 18px;
349
+ }
350
+
351
+ .eeg-card--empty {
352
+ background: linear-gradient(180deg, var(--subtle), var(--surface));
353
+ align-items: center;
354
+ justify-content: center;
355
+ text-align: center;
356
+ color: var(--muted);
357
+ }
358
+ .eeg-card__ghost-title {
359
+ font-size: 17px;
360
+ font-weight: 600;
361
+ color: var(--ink);
362
+ margin-bottom: 6px;
363
+ }
364
+ .eeg-card--empty p { max-width: 36ch; font-size: 14px; line-height: 1.5; }
365
+
366
+ .eeg-card__id {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 10px;
370
+ margin-bottom: 6px;
371
+ }
372
+ .eeg-card__slug {
373
+ font-family: "JetBrains Mono", ui-monospace, monospace;
374
+ font-size: 13px;
375
+ font-weight: 600;
376
+ color: var(--brand);
377
+ background: var(--brand-soft);
378
+ padding: 4px 10px;
379
+ border-radius: 8px;
380
+ }
381
+ .eeg-card__modality {
382
+ font-size: 11.5px;
383
+ font-weight: 600;
384
+ letter-spacing: .06em;
385
+ text-transform: uppercase;
386
+ color: var(--hue, var(--muted));
387
+ padding: 4px 10px;
388
+ border-radius: 8px;
389
+ background: color-mix(in srgb, var(--hue, var(--muted)) 10%, transparent);
390
+ border: 1px solid color-mix(in srgb, var(--hue, var(--muted)) 30%, transparent);
391
+ }
392
+
393
+ .eeg-card__title {
394
+ margin: 0;
395
+ font-size: 20px;
396
+ line-height: 1.25;
397
+ letter-spacing: -0.01em;
398
+ color: var(--ink);
399
+ font-weight: 700;
400
+ }
401
+
402
+ .eeg-card__meta {
403
+ display: flex;
404
+ flex-wrap: wrap;
405
+ gap: 6px;
406
+ margin-top: 12px;
407
+ }
408
+
409
+ .eeg-tag {
410
+ display: inline-flex;
411
+ align-items: center;
412
+ gap: 4px;
413
+ padding: 4px 10px;
414
+ border-radius: 999px;
415
+ background: var(--subtle);
416
+ border: 1px solid var(--outline);
417
+ color: var(--ink);
418
+ font-size: 11.5px;
419
+ font-weight: 500;
420
+ text-decoration: none;
421
+ transition: background 150ms ease, border-color 150ms ease;
422
+ }
423
+ .eeg-tag:hover { background: var(--brand-soft); border-color: var(--brand); }
424
+ .eeg-tag--accent {
425
+ background: var(--accent-soft);
426
+ border-color: color-mix(in srgb, var(--accent) 30%, var(--outline));
427
+ color: var(--accent);
428
+ }
429
+ .eeg-tag--accent:hover { border-color: var(--accent); }
430
+ .eeg-tag--muted { color: var(--muted); cursor: default; }
431
+ .eeg-tag--muted:hover { background: var(--subtle); border-color: var(--outline); }
432
+
433
+ .eeg-card__kvs {
434
+ display: grid;
435
+ grid-template-columns: repeat(6, minmax(0, 1fr));
436
+ gap: 10px;
437
+ padding: 14px;
438
+ background: var(--subtle);
439
+ border-radius: 12px;
440
+ border: 1px solid var(--outline);
441
+ }
442
+ .eeg-kv { text-align: center; }
443
+ .eeg-kv__n {
444
+ font-family: "Inter", ui-sans-serif, sans-serif;
445
+ font-size: 18px;
446
+ font-weight: 700;
447
+ color: var(--ink);
448
+ font-variant-numeric: tabular-nums;
449
+ line-height: 1;
450
+ }
451
+ .eeg-kv__l {
452
+ color: var(--muted);
453
+ font-size: 11px;
454
+ margin-top: 5px;
455
+ text-transform: lowercase;
456
+ letter-spacing: .02em;
457
+ }
458
+
459
+ @media (max-width: 620px) {
460
+ .eeg-card__kvs { grid-template-columns: repeat(3, 1fr); }
461
+ }
462
+
463
+ .eeg-card__h3 {
464
+ font-size: 12px;
465
+ text-transform: uppercase;
466
+ letter-spacing: .08em;
467
+ color: var(--muted);
468
+ font-weight: 600;
469
+ margin: 4px 0 10px;
470
+ }
471
+
472
+ .eeg-snippet + .eeg-snippet { margin-top: 10px; }
473
+
474
+ .eeg-snippet {
475
+ border-radius: 10px;
476
+ overflow: hidden;
477
+ border: 1px solid var(--outline);
478
+ }
479
+ .eeg-snippet__hd {
480
+ background: var(--subtle);
481
+ padding: 6px 12px;
482
+ font-size: 11.5px;
483
+ color: var(--muted);
484
+ border-bottom: 1px solid var(--outline);
485
+ font-weight: 500;
486
+ }
487
+ .eeg-snippet__code {
488
+ margin: 0;
489
+ padding: 14px 16px;
490
+ background: var(--code-bg);
491
+ color: var(--code-ink);
492
+ font-family: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
493
+ font-size: 12.5px;
494
+ line-height: 1.55;
495
+ overflow-x: auto;
496
+ }
497
+
498
+ .eeg-note {
499
+ margin: 10px 0 8px;
500
+ color: var(--muted);
501
+ font-size: 13px;
502
+ line-height: 1.5;
503
+ }
504
+ .eeg-note a { color: var(--brand); }
505
+
506
+ /* ---------- Footer ---------- */
507
+
508
+ .eeg-foot {
509
+ text-align: center;
510
+ color: var(--muted);
511
+ font-size: 12.5px;
512
+ padding: 24px 0 8px;
513
+ margin-top: 16px;
514
+ border-top: 1px solid var(--outline);
515
+ }
516
+ .eeg-foot a { color: var(--brand); text-decoration: none; margin: 0 4px; }
517
+ .eeg-foot a:hover { text-decoration: underline; }
518
+
519
+ /* ---------- Responsive ---------- */
520
+
521
+ @media (max-width: 960px) {
522
+ .eeg-main { flex-direction: column !important; }
523
+ }