ngoan Claude Opus 4.8 (1M context) commited on
Commit
d70f132
·
1 Parent(s): 73d498e

MedASR Bench: full platform + Hugging Face Docker Space packaging

Browse files

Multi-dataset leaderboard UI (Next.js 16, Tailwind v4) plus R0 release
packaging: output:'standalone', Dockerfile (node:22-alpine, uid 1000,
port 3000), .dockerignore, and HF Space README front-matter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +17 -0
  2. Dockerfile +39 -0
  3. README.md +36 -26
  4. SKILL.md +231 -0
  5. next.config.ts +4 -1
  6. package-lock.json +457 -4
  7. package.json +7 -2
  8. scripts/sync-results.mjs +142 -0
  9. src/app/compare/loading.tsx +100 -0
  10. src/app/compare/page.tsx +101 -0
  11. src/app/datasets/[slug]/page.tsx +548 -0
  12. src/app/datasets/loading.tsx +43 -0
  13. src/app/datasets/page.tsx +274 -0
  14. src/app/favicon.ico +0 -0
  15. src/app/globals.css +811 -14
  16. src/app/icon.svg +12 -0
  17. src/app/layout.tsx +116 -9
  18. src/app/loading.tsx +59 -0
  19. src/app/methodology/page.tsx +304 -0
  20. src/app/models/[slug]/loading.tsx +83 -0
  21. src/app/models/[slug]/page.tsx +230 -0
  22. src/app/page.tsx +174 -56
  23. src/app/template.tsx +16 -0
  24. src/components/SiteNav.tsx +137 -0
  25. src/components/compare/EfficiencySection.tsx +244 -0
  26. src/components/compare/HeadToHead.tsx +439 -0
  27. src/components/compare/ParetoChart.tsx +350 -0
  28. src/components/compare/ParetoSection.tsx +126 -0
  29. src/components/compare/compare-utils.ts +268 -0
  30. src/components/docs/CautionCallout.tsx +45 -0
  31. src/components/docs/HourBar.tsx +43 -0
  32. src/components/docs/ManifestCard.tsx +79 -0
  33. src/components/docs/MetricDefCard.tsx +77 -0
  34. src/components/docs/OnboardingCriteria.tsx +76 -0
  35. src/components/docs/PipelineSteps.tsx +48 -0
  36. src/components/docs/ProvenanceLadder.tsx +82 -0
  37. src/components/docs/RoadmapStrip.tsx +81 -0
  38. src/components/docs/SplitMiniBar.tsx +74 -0
  39. src/components/docs/SplitsTable.tsx +138 -0
  40. src/components/docs/StatTile.tsx +42 -0
  41. src/components/docs/TopicBars.tsx +47 -0
  42. src/components/docs/TracksTable.tsx +140 -0
  43. src/components/leaderboard/HeroStats.tsx +127 -0
  44. src/components/leaderboard/LeaderboardTable.tsx +1013 -0
  45. src/components/model/DatasetScope.tsx +53 -0
  46. src/components/model/FlagNotes.tsx +79 -0
  47. src/components/model/FrontierThumb.tsx +163 -0
  48. src/components/model/GeneralizationStrip.tsx +136 -0
  49. src/components/model/MetricPanel.tsx +160 -0
  50. src/components/model/ModelHeader.tsx +66 -0
.dockerignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .next
3
+ out
4
+ build
5
+ .git
6
+ .gitignore
7
+ Dockerfile
8
+ .dockerignore
9
+ README.md
10
+ .DS_Store
11
+ *.pem
12
+ .env*
13
+ npm-debug.log*
14
+ .vercel
15
+ coverage
16
+ .idea
17
+ .vscode
Dockerfile ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1
2
+ # MedASR Bench — Hugging Face Docker Space (CPU). Next.js 16 standalone output.
3
+ # The Space runtime requires the container to run as uid 1000 and listen on
4
+ # app_port (3000, set in README.md front-matter).
5
+
6
+ # ---- deps: install full deps for the build ----
7
+ FROM node:22-alpine AS deps
8
+ RUN apk add --no-cache libc6-compat
9
+ WORKDIR /app
10
+ COPY package.json package-lock.json ./
11
+ RUN npm ci
12
+
13
+ # ---- builder: produce .next/standalone ----
14
+ FROM node:22-alpine AS builder
15
+ WORKDIR /app
16
+ COPY --from=deps /app/node_modules ./node_modules
17
+ COPY . .
18
+ ENV NEXT_TELEMETRY_DISABLED=1
19
+ RUN npm run build
20
+
21
+ # ---- runner: minimal self-contained image ----
22
+ FROM node:22-alpine AS runner
23
+ WORKDIR /app
24
+ ENV NODE_ENV=production \
25
+ NEXT_TELEMETRY_DISABLED=1 \
26
+ PORT=3000 \
27
+ HOSTNAME=0.0.0.0
28
+
29
+ # node:alpine already ships a `node` user at uid 1000 — exactly what HF Spaces
30
+ # expect — so no custom user is created (uid 1000 is already taken).
31
+ # server.js lives at the root of .next/standalone; public/ and .next/static
32
+ # are NOT bundled into standalone and must be copied in explicitly.
33
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
34
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
35
+ COPY --from=builder --chown=node:node /app/public ./public
36
+
37
+ USER node
38
+ EXPOSE 3000
39
+ CMD ["node", "server.js"]
README.md CHANGED
@@ -1,36 +1,46 @@
1
- This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
 
 
 
 
 
 
 
 
 
 
2
 
3
- ## Getting Started
4
 
5
- First, run the development server:
 
 
6
 
7
- ```bash
8
- npm run dev
9
- # or
10
- yarn dev
11
- # or
12
- pnpm dev
13
- # or
14
- bun dev
15
- ```
16
-
17
- Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18
-
19
- You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20
-
21
- This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22
 
23
- ## Learn More
24
 
25
- To learn more about Next.js, take a look at the following resources:
 
26
 
27
- - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28
- - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29
 
30
- You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
 
 
 
31
 
32
- ## Deploy on Vercel
33
 
34
- The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
 
 
35
 
36
- Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
 
 
 
 
1
+ ---
2
+ title: MedASR Bench
3
+ emoji: 🩺
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 3000
8
+ pinned: false
9
+ license: mit
10
+ short_description: Vietnamese medical ASR benchmark, code-switching aware
11
+ ---
12
 
13
+ # MedASR Bench
14
 
15
+ A public benchmark for **Vietnamese medical speech recognition with English
16
+ code-switching**, anchored on the [ViMedCSS](https://huggingface.co/datasets/tensorxt/ViMedCSS)
17
+ dataset. Part of the **Clinical Scribe** program.
18
 
19
+ This Space is the **display layer** only — a stateless Next.js app that renders
20
+ the leaderboard, per-model pages, and methodology. It runs on free CPU; no audio
21
+ and no model inference ever happen here. Benchmark numbers are produced by the
22
+ offline harness (`benchmark/medasr_eval.py`) and published as versioned,
23
+ auditable run manifests.
 
 
 
 
 
 
 
 
 
 
24
 
25
+ ## Metrics
26
 
27
+ WER · CER · CS-WER (code-switched-term WER) · N-WER · MTR (medical-term
28
+ recall), each with bootstrap 95% CIs under a pinned text normalizer.
29
 
30
+ ## Running locally
 
31
 
32
+ ```bash
33
+ npm install
34
+ npm run dev # http://localhost:3000
35
+ ```
36
 
37
+ ## Build / deploy
38
 
39
+ This Space is a Docker SDK Space. The image builds the Next.js
40
+ [`standalone`](https://nextjs.org/docs/app/api-reference/config/next-config-js/output)
41
+ output and serves it with `node server.js` as uid 1000 on port 3000.
42
 
43
+ ```bash
44
+ docker build -t medasr-bench .
45
+ docker run --rm -p 3000:3000 medasr-bench
46
+ ```
SKILL.md ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: clinicalscribe-taste
3
+ description: Anti-slop frontend skill for ClinicalScribe Medical ASR Benchmark. Premium clinical instrument design with ECG phosphor accents, hairline borders, and orchestrated entrance animations.
4
+ ---
5
+
6
+ # ClinicalScribe Taste Skill
7
+
8
+ Anti-slop frontend skill for the ClinicalScribe Medical ASR Benchmark platform.
9
+
10
+ ---
11
+
12
+ ## Design Read
13
+
14
+ **"Reading this as:** B2B medical/scientific benchmark platform for Vietnamese Medical ASR evaluation, with a light "precision instrument" language — paper-white surfaces, ink text, deep violet accents, hairline borders, and IBM Plex Mono numerals. The aesthetic should feel like a premium lab report / modern dev-tool dashboard (Linear/Stripe register) — precise, data-dense, trustworthy.
15
+
16
+ ---
17
+
18
+ ## Dial Settings
19
+
20
+ Based on the design read:
21
+
22
+ - **`DESIGN_VARIANCE: 5`** — Scientific/professional environment, moderate asymmetry is acceptable
23
+ - **`MOTION_INTENSITY: 6`** — Orchestrated entrance animations, micro-interactions for interactivity
24
+ - **`VISUAL_DENSITY: 7`** — Data-dense benchmark platform, information-rich layouts
25
+
26
+ ---
27
+
28
+ ## Design System Foundation
29
+
30
+ ### Typography
31
+
32
+ - **Display:** `Instrument Serif` — Editorial headings with medical gravitas
33
+ - **Body:** `Be Vietnam Pro` — UI text with Vietnamese language support
34
+ - **Mono:** `IBM Plex Mono` — Data/numerals with tabular figures
35
+
36
+ **Upgrades applied:**
37
+ - Reduce all-caps subheaders where possible
38
+ - Ensure italic descender clearance with `leading-[1.1]` minimum
39
+ - Tighten letter-spacing on large headers
40
+
41
+ ### Color Calibration
42
+
43
+ ClinicalScribe already has excellent color discipline (theme flipped to
44
+ LIGHT 2026-06-11 at user request — "light theme, tech savvy, professional";
45
+ palette built in OKLCH, all small-text pairings ≥ 4.5:1):
46
+
47
+ | Token | Value | Purpose |
48
+ |-------|-------|---------|
49
+ | `--bg` | `#f9f9fb` | Cool paper-white background |
50
+ | `--surface` | `#ffffff` | Card surfaces |
51
+ | `--surface-2` | `#eeeef2` | Recessed panels / code blocks |
52
+ | `--text` | `#24242f` | Ink text |
53
+ | `--accent` | `#654abb` | Deep violet — oklch(0.50 0.17 290) |
54
+ | `--warn` | `#a15e00` | Caution states |
55
+ | `--crit` | `#c53637` | Critical/error states |
56
+
57
+ **No green anywhere** — the user rejected phosphor green, desaturated teal,
58
+ and soft blue; the accent is user-picked violet. Don't drift it back toward
59
+ the green–blue band. The theme is LIGHT — don't reintroduce dark surfaces.
60
+
61
+ **Preserve:** Single accent color (deep violet), hairline borders, soft
62
+ low-alpha shadows (rgba(20,20,45,…) ≤ 0.10). All tints derive from tokens
63
+ via `color-mix` — never hardcode accent rgba values.
64
+ **Enhance:** Add subtle grain texture, improve hover spotlight effects.
65
+
66
+ ---
67
+
68
+ ## Enhancement Priorities
69
+
70
+ ### 1. Grain & Texture (High Impact)
71
+
72
+ Add subtle noise overlay for depth:
73
+
74
+ ```css
75
+ /* Subtle film grain for tactile quality */
76
+ .grain-overlay::after {
77
+ content: "";
78
+ position: fixed;
79
+ inset: 0;
80
+ z-index: 31;
81
+ pointer-events: none;
82
+ opacity: 0.03;
83
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
84
+ }
85
+ ```
86
+
87
+ ### 2. Spotlight Border Cards
88
+
89
+ Interactive cards that illuminate under cursor:
90
+
91
+ ```css
92
+ .spotlight-card {
93
+ position: relative;
94
+ overflow: hidden;
95
+ transition: border-color 0.3s ease;
96
+ }
97
+
98
+ .spotlight-card::before {
99
+ content: "";
100
+ position: absolute;
101
+ inset: 0;
102
+ border-radius: inherit;
103
+ padding: 1px;
104
+ background: radial-gradient(
105
+ 600px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
106
+ rgba(52, 245, 197, 0.15),
107
+ transparent 40%
108
+ );
109
+ -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
110
+ mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
111
+ -webkit-mask-composite: xor;
112
+ mask-composite: exclude;
113
+ opacity: 0;
114
+ transition: opacity 0.3s ease;
115
+ }
116
+
117
+ .spotlight-card:hover::before {
118
+ opacity: 1;
119
+ }
120
+ ```
121
+
122
+ ### 3. Tactile Button States
123
+
124
+ ```css
125
+ .tactile-button:active {
126
+ transform: scale(0.98) translateY(1px);
127
+ transition: transform 0.1s ease;
128
+ }
129
+
130
+ .tactile-button:hover {
131
+ background-color: var(--surface-2);
132
+ }
133
+ ```
134
+
135
+ ### 4. Spring Physics for Animations
136
+
137
+ Replace linear easing with spring curves:
138
+
139
+ ```css
140
+ .spring-in {
141
+ animation: spring-in 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
142
+ }
143
+
144
+ @keyframes spring-in {
145
+ from {
146
+ opacity: 0;
147
+ transform: scale(0.95) translateY(10px);
148
+ }
149
+ to {
150
+ opacity: 1;
151
+ transform: scale(1) translateY(0);
152
+ }
153
+ }
154
+ ```
155
+
156
+ ### 5. Stagger Refinements
157
+
158
+ ```css
159
+ /* Improved stagger with spring feel */
160
+ .stagger-spring > * {
161
+ animation: stagger-spring 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
162
+ }
163
+
164
+ @keyframes stagger-spring {
165
+ from {
166
+ opacity: 0;
167
+ transform: translateY(16px);
168
+ }
169
+ to {
170
+ opacity: 1;
171
+ transform: translateY(0);
172
+ }
173
+ }
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Layout Discipline
179
+
180
+ ### Preserved Elements
181
+ - Graph-paper micro-grid background (unique, on-brand)
182
+ - Scanline overlay effect
183
+ - Hairline borders (signature element)
184
+ - Editorial hero with Instrument Serif
185
+ - ECG phosphor accents (used sparingly)
186
+
187
+ ### Enhancements
188
+ - Increase section breathing room
189
+ - Better vertical rhythm with `gap-y-12` or `gap-y-16`
190
+ - Asymmetric hero layout with offset stats
191
+ - Refined table row hover states
192
+
193
+ ---
194
+
195
+ ## Component Upgrades
196
+
197
+ ### Card Component
198
+ - Add spotlight border on hover
199
+ - Subtle inner glow effect
200
+ - Refined border-radius (keep `rounded-sm`)
201
+
202
+ ### Badge Component
203
+ - Add subtle shimmer on accent badges
204
+ - Refine spacing and typography
205
+ - Consistent hover states
206
+
207
+ ### Leaderboard Table
208
+ - Improve row hover transitions
209
+ - Add spotlight row highlighting
210
+ - Better column breathing room
211
+ - Refine sort indicator animations
212
+
213
+ ---
214
+
215
+ ## Accessibility Requirements
216
+
217
+ - Honor `prefers-reduced-motion` for all animations
218
+ - Maintain WCAG AA contrast ratios
219
+ - Keep focus-visible outlines
220
+ - Screen reader support for all interactive elements
221
+
222
+ ---
223
+
224
+ ## Pre-Flight Checklist
225
+
226
+ - [ ] All animations respect `prefers-reduced-motion`
227
+ - [ ] Spotlight effects work on interactive cards
228
+ - [ ] Grain texture is subtle (3-5% opacity max)
229
+ - [ ] No broken interactions
230
+ - [ ] Typography hierarchy is clear
231
+ - [ ] Light theme is consistent throughout (no stray dark surfaces)
next.config.ts CHANGED
@@ -1,7 +1,10 @@
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
- /* config options here */
 
 
 
5
  };
6
 
7
  export default nextConfig;
 
1
  import type { NextConfig } from "next";
2
 
3
  const nextConfig: NextConfig = {
4
+ // Emit .next/standalone (server.js + traced node_modules) for a small
5
+ // self-contained Docker image — used by the Hugging Face Space runtime.
6
+ // Ref: node_modules/next/dist/docs/.../next-config-js/output.md
7
+ output: "standalone",
8
  };
9
 
10
  export default nextConfig;
package-lock.json CHANGED
@@ -8,9 +8,13 @@
8
  "name": "platform",
9
  "version": "0.1.0",
10
  "dependencies": {
 
 
 
11
  "next": "16.2.9",
12
  "react": "19.2.4",
13
- "react-dom": "19.2.4"
 
14
  },
15
  "devDependencies": {
16
  "@tailwindcss/postcss": "^4",
@@ -1246,6 +1250,42 @@
1246
  "node": ">=12.4.0"
1247
  }
1248
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1249
  "node_modules/@rtsao/scc": {
1250
  "version": "1.1.0",
1251
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1253,6 +1293,18 @@
1253
  "dev": true,
1254
  "license": "MIT"
1255
  },
 
 
 
 
 
 
 
 
 
 
 
 
1256
  "node_modules/@swc/helpers": {
1257
  "version": "0.5.15",
1258
  "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1544,6 +1596,69 @@
1544
  "tslib": "^2.4.0"
1545
  }
1546
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1547
  "node_modules/@types/estree": {
1548
  "version": "1.0.9",
1549
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -1579,7 +1694,7 @@
1579
  "version": "19.2.17",
1580
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
1581
  "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
1582
- "dev": true,
1583
  "license": "MIT",
1584
  "dependencies": {
1585
  "csstype": "^3.2.2"
@@ -1595,6 +1710,12 @@
1595
  "@types/react": "^19.2.0"
1596
  }
1597
  },
 
 
 
 
 
 
1598
  "node_modules/@typescript-eslint/eslint-plugin": {
1599
  "version": "8.61.0",
1600
  "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
@@ -2680,6 +2801,15 @@
2680
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2681
  "license": "MIT"
2682
  },
 
 
 
 
 
 
 
 
 
2683
  "node_modules/color-convert": {
2684
  "version": "2.0.1",
2685
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -2733,9 +2863,130 @@
2733
  "version": "3.2.3",
2734
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2735
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2736
- "dev": true,
2737
  "license": "MIT"
2738
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2739
  "node_modules/damerau-levenshtein": {
2740
  "version": "1.0.8",
2741
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2815,6 +3066,12 @@
2815
  }
2816
  }
2817
  },
 
 
 
 
 
 
2818
  "node_modules/deep-is": {
2819
  "version": "0.1.4",
2820
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3101,6 +3358,16 @@
3101
  "url": "https://github.com/sponsors/ljharb"
3102
  }
3103
  },
 
 
 
 
 
 
 
 
 
 
3104
  "node_modules/escalade": {
3105
  "version": "3.2.0",
3106
  "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -3530,6 +3797,12 @@
3530
  "node": ">=0.10.0"
3531
  }
3532
  },
 
 
 
 
 
 
3533
  "node_modules/fast-deep-equal": {
3534
  "version": "3.1.3",
3535
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3671,6 +3944,33 @@
3671
  "url": "https://github.com/sponsors/ljharb"
3672
  }
3673
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3674
  "node_modules/function-bind": {
3675
  "version": "1.1.2",
3676
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -3986,6 +4286,16 @@
3986
  "node": ">= 4"
3987
  }
3988
  },
 
 
 
 
 
 
 
 
 
 
3989
  "node_modules/import-fresh": {
3990
  "version": "3.3.1",
3991
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4028,6 +4338,15 @@
4028
  "node": ">= 0.4"
4029
  }
4030
  },
 
 
 
 
 
 
 
 
 
4031
  "node_modules/is-array-buffer": {
4032
  "version": "3.0.5",
4033
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4929,6 +5248,15 @@
4929
  "yallist": "^3.0.2"
4930
  }
4931
  },
 
 
 
 
 
 
 
 
 
4932
  "node_modules/magic-string": {
4933
  "version": "0.30.21",
4934
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4996,6 +5324,21 @@
4996
  "url": "https://github.com/sponsors/ljharb"
4997
  }
4998
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4999
  "node_modules/ms": {
5000
  "version": "2.1.3",
5001
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5521,9 +5864,76 @@
5521
  "version": "16.13.1",
5522
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
5523
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
5524
- "dev": true,
5525
  "license": "MIT"
5526
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5527
  "node_modules/reflect.getprototypeof": {
5528
  "version": "1.0.10",
5529
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5568,6 +5978,12 @@
5568
  "url": "https://github.com/sponsors/ljharb"
5569
  }
5570
  },
 
 
 
 
 
 
5571
  "node_modules/resolve": {
5572
  "version": "2.0.0-next.7",
5573
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -6161,6 +6577,12 @@
6161
  "url": "https://opencollective.com/webpack"
6162
  }
6163
  },
 
 
 
 
 
 
6164
  "node_modules/tinyglobby": {
6165
  "version": "0.2.17",
6166
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -6501,6 +6923,37 @@
6501
  "punycode": "^2.1.0"
6502
  }
6503
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6504
  "node_modules/which": {
6505
  "version": "2.0.2",
6506
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
 
8
  "name": "platform",
9
  "version": "0.1.0",
10
  "dependencies": {
11
+ "clsx": "^2.1.1",
12
+ "framer-motion": "^12.40.0",
13
+ "lucide-react": "^1.17.0",
14
  "next": "16.2.9",
15
  "react": "19.2.4",
16
+ "react-dom": "19.2.4",
17
+ "recharts": "^3.8.1"
18
  },
19
  "devDependencies": {
20
  "@tailwindcss/postcss": "^4",
 
1250
  "node": ">=12.4.0"
1251
  }
1252
  },
1253
+ "node_modules/@reduxjs/toolkit": {
1254
+ "version": "2.12.0",
1255
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
1256
+ "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
1257
+ "license": "MIT",
1258
+ "dependencies": {
1259
+ "@standard-schema/spec": "^1.0.0",
1260
+ "@standard-schema/utils": "^0.3.0",
1261
+ "immer": "^11.0.0",
1262
+ "redux": "^5.0.1",
1263
+ "redux-thunk": "^3.1.0",
1264
+ "reselect": "^5.1.0"
1265
+ },
1266
+ "peerDependencies": {
1267
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
1268
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
1269
+ },
1270
+ "peerDependenciesMeta": {
1271
+ "react": {
1272
+ "optional": true
1273
+ },
1274
+ "react-redux": {
1275
+ "optional": true
1276
+ }
1277
+ }
1278
+ },
1279
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
1280
+ "version": "11.1.8",
1281
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
1282
+ "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
1283
+ "license": "MIT",
1284
+ "funding": {
1285
+ "type": "opencollective",
1286
+ "url": "https://opencollective.com/immer"
1287
+ }
1288
+ },
1289
  "node_modules/@rtsao/scc": {
1290
  "version": "1.1.0",
1291
  "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
 
1293
  "dev": true,
1294
  "license": "MIT"
1295
  },
1296
+ "node_modules/@standard-schema/spec": {
1297
+ "version": "1.1.0",
1298
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
1299
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
1300
+ "license": "MIT"
1301
+ },
1302
+ "node_modules/@standard-schema/utils": {
1303
+ "version": "0.3.0",
1304
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
1305
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
1306
+ "license": "MIT"
1307
+ },
1308
  "node_modules/@swc/helpers": {
1309
  "version": "0.5.15",
1310
  "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
 
1596
  "tslib": "^2.4.0"
1597
  }
1598
  },
1599
+ "node_modules/@types/d3-array": {
1600
+ "version": "3.2.2",
1601
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
1602
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
1603
+ "license": "MIT"
1604
+ },
1605
+ "node_modules/@types/d3-color": {
1606
+ "version": "3.1.3",
1607
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
1608
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
1609
+ "license": "MIT"
1610
+ },
1611
+ "node_modules/@types/d3-ease": {
1612
+ "version": "3.0.2",
1613
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
1614
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
1615
+ "license": "MIT"
1616
+ },
1617
+ "node_modules/@types/d3-interpolate": {
1618
+ "version": "3.0.4",
1619
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
1620
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
1621
+ "license": "MIT",
1622
+ "dependencies": {
1623
+ "@types/d3-color": "*"
1624
+ }
1625
+ },
1626
+ "node_modules/@types/d3-path": {
1627
+ "version": "3.1.1",
1628
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
1629
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
1630
+ "license": "MIT"
1631
+ },
1632
+ "node_modules/@types/d3-scale": {
1633
+ "version": "4.0.9",
1634
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
1635
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
1636
+ "license": "MIT",
1637
+ "dependencies": {
1638
+ "@types/d3-time": "*"
1639
+ }
1640
+ },
1641
+ "node_modules/@types/d3-shape": {
1642
+ "version": "3.1.8",
1643
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
1644
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
1645
+ "license": "MIT",
1646
+ "dependencies": {
1647
+ "@types/d3-path": "*"
1648
+ }
1649
+ },
1650
+ "node_modules/@types/d3-time": {
1651
+ "version": "3.0.4",
1652
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
1653
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
1654
+ "license": "MIT"
1655
+ },
1656
+ "node_modules/@types/d3-timer": {
1657
+ "version": "3.0.2",
1658
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
1659
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
1660
+ "license": "MIT"
1661
+ },
1662
  "node_modules/@types/estree": {
1663
  "version": "1.0.9",
1664
  "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
 
1694
  "version": "19.2.17",
1695
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
1696
  "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
1697
+ "devOptional": true,
1698
  "license": "MIT",
1699
  "dependencies": {
1700
  "csstype": "^3.2.2"
 
1710
  "@types/react": "^19.2.0"
1711
  }
1712
  },
1713
+ "node_modules/@types/use-sync-external-store": {
1714
+ "version": "0.0.6",
1715
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
1716
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
1717
+ "license": "MIT"
1718
+ },
1719
  "node_modules/@typescript-eslint/eslint-plugin": {
1720
  "version": "8.61.0",
1721
  "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
 
2801
  "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
2802
  "license": "MIT"
2803
  },
2804
+ "node_modules/clsx": {
2805
+ "version": "2.1.1",
2806
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
2807
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
2808
+ "license": "MIT",
2809
+ "engines": {
2810
+ "node": ">=6"
2811
+ }
2812
+ },
2813
  "node_modules/color-convert": {
2814
  "version": "2.0.1",
2815
  "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
 
2863
  "version": "3.2.3",
2864
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
2865
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2866
+ "devOptional": true,
2867
  "license": "MIT"
2868
  },
2869
+ "node_modules/d3-array": {
2870
+ "version": "3.2.4",
2871
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
2872
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
2873
+ "license": "ISC",
2874
+ "dependencies": {
2875
+ "internmap": "1 - 2"
2876
+ },
2877
+ "engines": {
2878
+ "node": ">=12"
2879
+ }
2880
+ },
2881
+ "node_modules/d3-color": {
2882
+ "version": "3.1.0",
2883
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
2884
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
2885
+ "license": "ISC",
2886
+ "engines": {
2887
+ "node": ">=12"
2888
+ }
2889
+ },
2890
+ "node_modules/d3-ease": {
2891
+ "version": "3.0.1",
2892
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
2893
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
2894
+ "license": "BSD-3-Clause",
2895
+ "engines": {
2896
+ "node": ">=12"
2897
+ }
2898
+ },
2899
+ "node_modules/d3-format": {
2900
+ "version": "3.1.2",
2901
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
2902
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
2903
+ "license": "ISC",
2904
+ "engines": {
2905
+ "node": ">=12"
2906
+ }
2907
+ },
2908
+ "node_modules/d3-interpolate": {
2909
+ "version": "3.0.1",
2910
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
2911
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
2912
+ "license": "ISC",
2913
+ "dependencies": {
2914
+ "d3-color": "1 - 3"
2915
+ },
2916
+ "engines": {
2917
+ "node": ">=12"
2918
+ }
2919
+ },
2920
+ "node_modules/d3-path": {
2921
+ "version": "3.1.0",
2922
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
2923
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
2924
+ "license": "ISC",
2925
+ "engines": {
2926
+ "node": ">=12"
2927
+ }
2928
+ },
2929
+ "node_modules/d3-scale": {
2930
+ "version": "4.0.2",
2931
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
2932
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
2933
+ "license": "ISC",
2934
+ "dependencies": {
2935
+ "d3-array": "2.10.0 - 3",
2936
+ "d3-format": "1 - 3",
2937
+ "d3-interpolate": "1.2.0 - 3",
2938
+ "d3-time": "2.1.1 - 3",
2939
+ "d3-time-format": "2 - 4"
2940
+ },
2941
+ "engines": {
2942
+ "node": ">=12"
2943
+ }
2944
+ },
2945
+ "node_modules/d3-shape": {
2946
+ "version": "3.2.0",
2947
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
2948
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
2949
+ "license": "ISC",
2950
+ "dependencies": {
2951
+ "d3-path": "^3.1.0"
2952
+ },
2953
+ "engines": {
2954
+ "node": ">=12"
2955
+ }
2956
+ },
2957
+ "node_modules/d3-time": {
2958
+ "version": "3.1.0",
2959
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
2960
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
2961
+ "license": "ISC",
2962
+ "dependencies": {
2963
+ "d3-array": "2 - 3"
2964
+ },
2965
+ "engines": {
2966
+ "node": ">=12"
2967
+ }
2968
+ },
2969
+ "node_modules/d3-time-format": {
2970
+ "version": "4.1.0",
2971
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
2972
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
2973
+ "license": "ISC",
2974
+ "dependencies": {
2975
+ "d3-time": "1 - 3"
2976
+ },
2977
+ "engines": {
2978
+ "node": ">=12"
2979
+ }
2980
+ },
2981
+ "node_modules/d3-timer": {
2982
+ "version": "3.0.1",
2983
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
2984
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
2985
+ "license": "ISC",
2986
+ "engines": {
2987
+ "node": ">=12"
2988
+ }
2989
+ },
2990
  "node_modules/damerau-levenshtein": {
2991
  "version": "1.0.8",
2992
  "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
 
3066
  }
3067
  }
3068
  },
3069
+ "node_modules/decimal.js-light": {
3070
+ "version": "2.5.1",
3071
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
3072
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
3073
+ "license": "MIT"
3074
+ },
3075
  "node_modules/deep-is": {
3076
  "version": "0.1.4",
3077
  "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
 
3358
  "url": "https://github.com/sponsors/ljharb"
3359
  }
3360
  },
3361
+ "node_modules/es-toolkit": {
3362
+ "version": "1.47.0",
3363
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
3364
+ "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
3365
+ "license": "MIT",
3366
+ "workspaces": [
3367
+ "docs",
3368
+ "benchmarks"
3369
+ ]
3370
+ },
3371
  "node_modules/escalade": {
3372
  "version": "3.2.0",
3373
  "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
 
3797
  "node": ">=0.10.0"
3798
  }
3799
  },
3800
+ "node_modules/eventemitter3": {
3801
+ "version": "5.0.4",
3802
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
3803
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
3804
+ "license": "MIT"
3805
+ },
3806
  "node_modules/fast-deep-equal": {
3807
  "version": "3.1.3",
3808
  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
 
3944
  "url": "https://github.com/sponsors/ljharb"
3945
  }
3946
  },
3947
+ "node_modules/framer-motion": {
3948
+ "version": "12.40.0",
3949
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.40.0.tgz",
3950
+ "integrity": "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==",
3951
+ "license": "MIT",
3952
+ "dependencies": {
3953
+ "motion-dom": "^12.40.0",
3954
+ "motion-utils": "^12.39.0",
3955
+ "tslib": "^2.4.0"
3956
+ },
3957
+ "peerDependencies": {
3958
+ "@emotion/is-prop-valid": "*",
3959
+ "react": "^18.0.0 || ^19.0.0",
3960
+ "react-dom": "^18.0.0 || ^19.0.0"
3961
+ },
3962
+ "peerDependenciesMeta": {
3963
+ "@emotion/is-prop-valid": {
3964
+ "optional": true
3965
+ },
3966
+ "react": {
3967
+ "optional": true
3968
+ },
3969
+ "react-dom": {
3970
+ "optional": true
3971
+ }
3972
+ }
3973
+ },
3974
  "node_modules/function-bind": {
3975
  "version": "1.1.2",
3976
  "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
 
4286
  "node": ">= 4"
4287
  }
4288
  },
4289
+ "node_modules/immer": {
4290
+ "version": "10.2.0",
4291
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
4292
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
4293
+ "license": "MIT",
4294
+ "funding": {
4295
+ "type": "opencollective",
4296
+ "url": "https://opencollective.com/immer"
4297
+ }
4298
+ },
4299
  "node_modules/import-fresh": {
4300
  "version": "3.3.1",
4301
  "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
 
4338
  "node": ">= 0.4"
4339
  }
4340
  },
4341
+ "node_modules/internmap": {
4342
+ "version": "2.0.3",
4343
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
4344
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
4345
+ "license": "ISC",
4346
+ "engines": {
4347
+ "node": ">=12"
4348
+ }
4349
+ },
4350
  "node_modules/is-array-buffer": {
4351
  "version": "3.0.5",
4352
  "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
 
5248
  "yallist": "^3.0.2"
5249
  }
5250
  },
5251
+ "node_modules/lucide-react": {
5252
+ "version": "1.17.0",
5253
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz",
5254
+ "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==",
5255
+ "license": "ISC",
5256
+ "peerDependencies": {
5257
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5258
+ }
5259
+ },
5260
  "node_modules/magic-string": {
5261
  "version": "0.30.21",
5262
  "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
 
5324
  "url": "https://github.com/sponsors/ljharb"
5325
  }
5326
  },
5327
+ "node_modules/motion-dom": {
5328
+ "version": "12.40.0",
5329
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.40.0.tgz",
5330
+ "integrity": "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==",
5331
+ "license": "MIT",
5332
+ "dependencies": {
5333
+ "motion-utils": "^12.39.0"
5334
+ }
5335
+ },
5336
+ "node_modules/motion-utils": {
5337
+ "version": "12.39.0",
5338
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
5339
+ "integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
5340
+ "license": "MIT"
5341
+ },
5342
  "node_modules/ms": {
5343
  "version": "2.1.3",
5344
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
 
5864
  "version": "16.13.1",
5865
  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
5866
  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
 
5867
  "license": "MIT"
5868
  },
5869
+ "node_modules/react-redux": {
5870
+ "version": "9.3.0",
5871
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
5872
+ "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
5873
+ "license": "MIT",
5874
+ "dependencies": {
5875
+ "@types/use-sync-external-store": "^0.0.6",
5876
+ "use-sync-external-store": "^1.4.0"
5877
+ },
5878
+ "peerDependencies": {
5879
+ "@types/react": "^18.2.25 || ^19",
5880
+ "react": "^18.0 || ^19",
5881
+ "redux": "^5.0.0"
5882
+ },
5883
+ "peerDependenciesMeta": {
5884
+ "@types/react": {
5885
+ "optional": true
5886
+ },
5887
+ "redux": {
5888
+ "optional": true
5889
+ }
5890
+ }
5891
+ },
5892
+ "node_modules/recharts": {
5893
+ "version": "3.8.1",
5894
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
5895
+ "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
5896
+ "license": "MIT",
5897
+ "workspaces": [
5898
+ "www"
5899
+ ],
5900
+ "dependencies": {
5901
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
5902
+ "clsx": "^2.1.1",
5903
+ "decimal.js-light": "^2.5.1",
5904
+ "es-toolkit": "^1.39.3",
5905
+ "eventemitter3": "^5.0.1",
5906
+ "immer": "^10.1.1",
5907
+ "react-redux": "8.x.x || 9.x.x",
5908
+ "reselect": "5.1.1",
5909
+ "tiny-invariant": "^1.3.3",
5910
+ "use-sync-external-store": "^1.2.2",
5911
+ "victory-vendor": "^37.0.2"
5912
+ },
5913
+ "engines": {
5914
+ "node": ">=18"
5915
+ },
5916
+ "peerDependencies": {
5917
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
5918
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
5919
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
5920
+ }
5921
+ },
5922
+ "node_modules/redux": {
5923
+ "version": "5.0.1",
5924
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
5925
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
5926
+ "license": "MIT"
5927
+ },
5928
+ "node_modules/redux-thunk": {
5929
+ "version": "3.1.0",
5930
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
5931
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
5932
+ "license": "MIT",
5933
+ "peerDependencies": {
5934
+ "redux": "^5.0.0"
5935
+ }
5936
+ },
5937
  "node_modules/reflect.getprototypeof": {
5938
  "version": "1.0.10",
5939
  "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
 
5978
  "url": "https://github.com/sponsors/ljharb"
5979
  }
5980
  },
5981
+ "node_modules/reselect": {
5982
+ "version": "5.1.1",
5983
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
5984
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
5985
+ "license": "MIT"
5986
+ },
5987
  "node_modules/resolve": {
5988
  "version": "2.0.0-next.7",
5989
  "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
 
6577
  "url": "https://opencollective.com/webpack"
6578
  }
6579
  },
6580
+ "node_modules/tiny-invariant": {
6581
+ "version": "1.3.3",
6582
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
6583
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
6584
+ "license": "MIT"
6585
+ },
6586
  "node_modules/tinyglobby": {
6587
  "version": "0.2.17",
6588
  "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
 
6923
  "punycode": "^2.1.0"
6924
  }
6925
  },
6926
+ "node_modules/use-sync-external-store": {
6927
+ "version": "1.6.0",
6928
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
6929
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
6930
+ "license": "MIT",
6931
+ "peerDependencies": {
6932
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
6933
+ }
6934
+ },
6935
+ "node_modules/victory-vendor": {
6936
+ "version": "37.3.6",
6937
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
6938
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
6939
+ "license": "MIT AND ISC",
6940
+ "dependencies": {
6941
+ "@types/d3-array": "^3.0.3",
6942
+ "@types/d3-ease": "^3.0.0",
6943
+ "@types/d3-interpolate": "^3.0.1",
6944
+ "@types/d3-scale": "^4.0.2",
6945
+ "@types/d3-shape": "^3.1.0",
6946
+ "@types/d3-time": "^3.0.0",
6947
+ "@types/d3-timer": "^3.0.0",
6948
+ "d3-array": "^3.1.6",
6949
+ "d3-ease": "^3.0.1",
6950
+ "d3-interpolate": "^3.0.1",
6951
+ "d3-scale": "^4.0.2",
6952
+ "d3-shape": "^3.1.0",
6953
+ "d3-time": "^3.0.0",
6954
+ "d3-timer": "^3.0.1"
6955
+ }
6956
+ },
6957
  "node_modules/which": {
6958
  "version": "2.0.2",
6959
  "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
package.json CHANGED
@@ -6,12 +6,17 @@
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
- "lint": "eslint"
 
10
  },
11
  "dependencies": {
 
 
 
12
  "next": "16.2.9",
13
  "react": "19.2.4",
14
- "react-dom": "19.2.4"
 
15
  },
16
  "devDependencies": {
17
  "@tailwindcss/postcss": "^4",
 
6
  "dev": "next dev",
7
  "build": "next build",
8
  "start": "next start",
9
+ "lint": "eslint",
10
+ "sync-results": "node scripts/sync-results.mjs"
11
  },
12
  "dependencies": {
13
+ "clsx": "^2.1.1",
14
+ "framer-motion": "^12.40.0",
15
+ "lucide-react": "^1.17.0",
16
  "next": "16.2.9",
17
  "react": "19.2.4",
18
+ "react-dom": "19.2.4",
19
+ "recharts": "^3.8.1"
20
  },
21
  "devDependencies": {
22
  "@tailwindcss/postcss": "^4",
scripts/sync-results.mjs ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sync-results.mjs — pull platform run outputs into src/data/results.json.
4
+ *
5
+ * Reads, for each known model and each split (test|hard):
6
+ * ../benchmark/results/<model>/<split>/metrics.json
7
+ * ../benchmark/results/<model>/<split>/run_manifest.json (optional, logged)
8
+ *
9
+ * metrics.json keys: wer, wer_ci95 [lo,hi], cer, cs_wer, cs_wer_ci95,
10
+ * n_wer, mtr_exact, mtr_fuzzy, rtfx_infer.
11
+ *
12
+ * Upserts the matching slug's metrics into the vimedcss entry of
13
+ * src/data/results.json (shape: { datasets: [{ info, models }] }) and sets
14
+ * provenance to "platform-verified". All other rows and fields are preserved.
15
+ *
16
+ * Usage: npm run sync-results
17
+ */
18
+
19
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const PLATFORM_ROOT = resolve(__dirname, "..");
25
+ const RESULTS_ROOT = resolve(PLATFORM_ROOT, "..", "benchmark", "results");
26
+ const DATA_FILE = join(PLATFORM_ROOT, "src", "data", "results.json");
27
+
28
+ /** benchmark results dir name → leaderboard slug */
29
+ const MODEL_MAP = {
30
+ zipformer: "zipformer-30m",
31
+ qwen3asr: "qwen3-asr-0.6b",
32
+ gemini: "gemini-3.5-flash",
33
+ deepgram: "deepgram-nova-3",
34
+ };
35
+
36
+ const SPLITS = ["test", "hard"];
37
+
38
+ /** Platform runs land on the anchor dataset's leaderboard. */
39
+ const TARGET_DATASET_ID = "vimedcss";
40
+
41
+ /** metrics.json key → SplitMetrics field */
42
+ function toSplitMetrics(raw) {
43
+ const out = {};
44
+ if (typeof raw.wer === "number") out.wer = raw.wer;
45
+ if (Array.isArray(raw.wer_ci95)) out.werCi = raw.wer_ci95;
46
+ if (typeof raw.cer === "number") out.cer = raw.cer;
47
+ if (typeof raw.cs_wer === "number") out.csWer = raw.cs_wer;
48
+ if (Array.isArray(raw.cs_wer_ci95)) out.csWerCi = raw.cs_wer_ci95;
49
+ if (typeof raw.n_wer === "number") out.nWer = raw.n_wer;
50
+ if (typeof raw.mtr_exact === "number") out.mtrExact = raw.mtr_exact;
51
+ if (typeof raw.mtr_fuzzy === "number") out.mtrFuzzy = raw.mtr_fuzzy;
52
+ if (typeof raw.rtfx_infer === "number") out.rtfx = raw.rtfx_infer;
53
+ return out;
54
+ }
55
+
56
+ function readJson(path) {
57
+ return JSON.parse(readFileSync(path, "utf8"));
58
+ }
59
+
60
+ function main() {
61
+ if (!existsSync(RESULTS_ROOT)) {
62
+ console.log(`[sync-results] no results dir at ${RESULTS_ROOT} — nothing to do`);
63
+ return;
64
+ }
65
+
66
+ const data = readJson(DATA_FILE);
67
+ const entry = (data.datasets ?? []).find(
68
+ (d) => d.info?.id === TARGET_DATASET_ID,
69
+ );
70
+ if (!entry) {
71
+ console.error(
72
+ `[sync-results] dataset "${TARGET_DATASET_ID}" not found in ${DATA_FILE} — aborting`,
73
+ );
74
+ process.exitCode = 1;
75
+ return;
76
+ }
77
+ let updates = 0;
78
+
79
+ for (const [dir, slug] of Object.entries(MODEL_MAP)) {
80
+ const row = entry.models.find((m) => m.slug === slug);
81
+ if (!row) {
82
+ console.warn(`[sync-results] slug ${slug} not found in results.json — skipped`);
83
+ continue;
84
+ }
85
+
86
+ for (const split of SPLITS) {
87
+ const metricsPath = join(RESULTS_ROOT, dir, split, "metrics.json");
88
+ if (!existsSync(metricsPath)) continue;
89
+
90
+ let raw;
91
+ try {
92
+ raw = readJson(metricsPath);
93
+ } catch (err) {
94
+ console.warn(`[sync-results] unreadable ${metricsPath}: ${err.message} — skipped`);
95
+ continue;
96
+ }
97
+
98
+ const manifestPath = join(RESULTS_ROOT, dir, split, "run_manifest.json");
99
+ let manifest;
100
+ if (existsSync(manifestPath)) {
101
+ try {
102
+ manifest = readJson(manifestPath);
103
+ console.log(
104
+ `[sync-results] ${slug}/${split} manifest:`,
105
+ JSON.stringify(manifest),
106
+ );
107
+ } catch {
108
+ console.warn(`[sync-results] unreadable manifest at ${manifestPath}`);
109
+ }
110
+ }
111
+
112
+ row.metrics = row.metrics ?? {};
113
+ row.metrics[split] = toSplitMetrics(raw);
114
+ /* RTFx: the manifest computes audio_s / infer_s at run time and is the
115
+ authoritative timing record; metrics.json only snapshots it at score
116
+ time and can go stale across re-runs (observed on qwen3asr). API
117
+ runners report infer_total=0 -> manifest rtfx_infer null, so RTFx
118
+ stays absent (network latency ≠ local-hardware throughput). */
119
+ if (typeof manifest?.rtfx_infer === "number") {
120
+ row.metrics[split].rtfx = manifest.rtfx_infer;
121
+ }
122
+ /* Provenance: locally-run models are platform-verified (pinned
123
+ revision, our hardware, SOTA-eligible). Commercial APIs run through
124
+ the same harness but on the vendor's service with no pinned revision,
125
+ so they are recorded as a date-stamped "api-snapshot" instead. */
126
+ const isApi = row.category === "api-intl" || row.category === "api-vn";
127
+ row.provenance = isApi ? "api-snapshot" : "platform-verified";
128
+ updates += 1;
129
+ console.log(`[sync-results] upserted ${slug}/${split}`);
130
+ }
131
+ }
132
+
133
+ if (updates === 0) {
134
+ console.log("[sync-results] no metrics found — results.json untouched");
135
+ return;
136
+ }
137
+
138
+ writeFileSync(DATA_FILE, `${JSON.stringify(data, null, 2)}\n`, "utf8");
139
+ console.log(`[sync-results] wrote ${updates} split result(s) to ${DATA_FILE}`);
140
+ }
141
+
142
+ main();
src/app/compare/loading.tsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Skeleton,
3
+ SkeletonTable,
4
+ SkeletonText,
5
+ } from "@/components/ui/LoadingSkeleton";
6
+
7
+ /* Route-level loading state for /compare — mirrors the page's structure
8
+ (heading + dataset chips, Pareto canvas, head-to-head card, efficiency
9
+ canvas) so content streams in without layout shift. Server-safe: all
10
+ shimmer comes from the .skeleton class, which collapses to a static
11
+ block under prefers-reduced-motion. */
12
+
13
+ function HeadingSkeleton({ titleWidth = 320 }: { titleWidth?: number }) {
14
+ return (
15
+ <div className="hairline-b flex flex-col gap-3 pb-6">
16
+ <Skeleton className="h-3" width={130} />
17
+ <Skeleton className="h-9" width={titleWidth} />
18
+ <SkeletonText className="max-w-2xl" lines={2} lastLineWidth="45%" />
19
+ </div>
20
+ );
21
+ }
22
+
23
+ function ChartCardSkeleton({ height }: { height: number }) {
24
+ return (
25
+ <div className="glass depth-2 rounded-sm p-5">
26
+ {/* control row: legend chips left, toggle right */}
27
+ <div className="flex flex-wrap items-center justify-between gap-3">
28
+ <div className="flex flex-wrap items-center gap-4">
29
+ {[72, 96, 64, 88].map((w, i) => (
30
+ <Skeleton key={i} className="h-3" width={w} />
31
+ ))}
32
+ </div>
33
+ <Skeleton className="h-7" width={140} />
34
+ </div>
35
+ {/* chart canvas */}
36
+ <Skeleton className="mt-4 w-full" height={height} />
37
+ {/* caption */}
38
+ <div className="hairline-t mt-4 pt-3">
39
+ <Skeleton className="h-3.5" width="70%" />
40
+ </div>
41
+ </div>
42
+ );
43
+ }
44
+
45
+ export default function CompareLoading() {
46
+ return (
47
+ <div
48
+ aria-busy="true"
49
+ aria-label="Loading model comparison"
50
+ className="flex flex-col gap-12 md:gap-16"
51
+ >
52
+ {/* 02 · Compare heading + dataset-scope chips */}
53
+ <div className="flex flex-col gap-5">
54
+ <HeadingSkeleton titleWidth={340} />
55
+ <div className="flex flex-wrap items-center gap-2">
56
+ <Skeleton className="h-3" width={96} />
57
+ {[88, 76, 112].map((w, i) => (
58
+ <Skeleton key={i} className="h-6 rounded-sm" width={w} />
59
+ ))}
60
+ </div>
61
+ </div>
62
+
63
+ {/* pulse line strip */}
64
+ <Skeleton className="h-6 w-full" />
65
+
66
+ {/* Pareto frontier card */}
67
+ <ChartCardSkeleton height={440} />
68
+
69
+ {/* 02.1 · Head-to-head */}
70
+ <HeadingSkeleton titleWidth={300} />
71
+ <div className="glass depth-2 rounded-sm p-5">
72
+ {/* three model selectors */}
73
+ <div className="flex flex-wrap items-end gap-4">
74
+ {[0, 1, 2].map((i) => (
75
+ <div key={i} className="flex min-w-52 flex-1 flex-col gap-1.5">
76
+ <Skeleton className="h-3" width="45%" />
77
+ <Skeleton className="h-9 w-full" />
78
+ </div>
79
+ ))}
80
+ </div>
81
+ {/* delta table */}
82
+ <SkeletonTable className="mt-5" rows={6} columns={4} />
83
+ {/* profile bars */}
84
+ <div className="mt-5 grid gap-x-10 gap-y-5 sm:grid-cols-2">
85
+ {[0, 1, 2, 3].map((i) => (
86
+ <div key={i} className="flex flex-col gap-2">
87
+ <Skeleton className="h-3" width="35%" />
88
+ <Skeleton className="h-1.5 w-full" />
89
+ <Skeleton className="h-1.5 w-4/5" />
90
+ </div>
91
+ ))}
92
+ </div>
93
+ </div>
94
+
95
+ {/* 02.2 · Efficiency */}
96
+ <HeadingSkeleton titleWidth={360} />
97
+ <ChartCardSkeleton height={360} />
98
+ </div>
99
+ );
100
+ }
src/app/compare/page.tsx ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import { Suspense } from "react";
3
+ import DatasetChips from "@/components/ui/DatasetChips";
4
+ import { Skeleton, SkeletonTable } from "@/components/ui/LoadingSkeleton";
5
+ import PulseLine from "@/components/ui/PulseLine";
6
+ import ScrollReveal from "@/components/ui/ScrollReveal";
7
+ import SectionHeading from "@/components/ui/SectionHeading";
8
+ import EfficiencySection from "@/components/compare/EfficiencySection";
9
+ import HeadToHead from "@/components/compare/HeadToHead";
10
+ import ParetoSection from "@/components/compare/ParetoSection";
11
+ import {
12
+ getDataset,
13
+ getDatasets,
14
+ getModels,
15
+ NORMALIZER_VERSION,
16
+ } from "@/lib/data";
17
+
18
+ export const metadata: Metadata = { title: "Compare" };
19
+
20
+ function HeadToHeadFallback() {
21
+ return (
22
+ <div
23
+ aria-busy="true"
24
+ aria-label="Loading head-to-head comparison"
25
+ className="glass depth-2 rounded-sm p-5"
26
+ >
27
+ <div className="flex flex-wrap items-end gap-4">
28
+ {[0, 1, 2].map((i) => (
29
+ <div key={i} className="flex min-w-52 flex-1 flex-col gap-1.5">
30
+ <Skeleton className="h-3" width="45%" />
31
+ <Skeleton className="h-9 w-full" />
32
+ </div>
33
+ ))}
34
+ </div>
35
+ <SkeletonTable className="mt-5" rows={6} columns={4} />
36
+ </div>
37
+ );
38
+ }
39
+
40
+ export default function ComparePage() {
41
+ const dataset = getDataset(); // anchor: ViMedCSS, the first live dataset
42
+ const datasets = getDatasets();
43
+ const models = getModels(dataset.id);
44
+
45
+ return (
46
+ <div className="flex flex-col gap-12 md:gap-16">
47
+ {/* above the fold: spring-staggered stack */}
48
+ <div className="stagger-spring flex flex-col gap-12 md:gap-16">
49
+ <div className="flex flex-col gap-5">
50
+ <SectionHeading
51
+ as="h1"
52
+ eyebrow="02 · Compare"
53
+ title="The Pareto frontier"
54
+ sub={
55
+ <>
56
+ Overall WER against CS-WER — errors inside code-switched spans
57
+ — scoped to {dataset.name}, the hub&rsquo;s first live dataset.
58
+ Lower-left wins. Bootstrap <span className="num">95%</span>{" "}
59
+ CIs; normalizer{" "}
60
+ <span className="num">{NORMALIZER_VERSION}</span>; WER is only
61
+ comparable within a normalizer version.
62
+ </>
63
+ }
64
+ />
65
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
66
+ <span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
67
+ Dataset scope
68
+ </span>
69
+ <DatasetChips datasets={datasets} activeId={dataset.id} />
70
+ </div>
71
+ </div>
72
+ <PulseLine compact />
73
+ <ParetoSection models={models} datasetName={dataset.name} />
74
+ </div>
75
+
76
+ <ScrollReveal>
77
+ <SectionHeading
78
+ eyebrow="02.1 · Head-to-head"
79
+ title="Two models, read closely"
80
+ sub={`Pick a baseline and up to two challengers — all measured on ${dataset.name}. Deltas are challenger minus baseline — green improves on the baseline, red regresses. Models without measurements stay pickable and show their evidence gap.`}
81
+ />
82
+ </ScrollReveal>
83
+ <ScrollReveal delay={0.08}>
84
+ <Suspense fallback={<HeadToHeadFallback />}>
85
+ <HeadToHead models={models} datasetName={dataset.name} />
86
+ </Suspense>
87
+ </ScrollReveal>
88
+
89
+ <ScrollReveal>
90
+ <SectionHeading
91
+ eyebrow="02.2 · Efficiency"
92
+ title="Accuracy against throughput"
93
+ sub={`RTFx — audio seconds per wall second on platform hardware — against WER on ${dataset.name}, with bubble area scaling by parameter count. Track E (sub-1B, CPU, under 8 GB VRAM) is config-ready.`}
94
+ />
95
+ </ScrollReveal>
96
+ <ScrollReveal delay={0.08}>
97
+ <EfficiencySection models={models} datasetName={dataset.name} />
98
+ </ScrollReveal>
99
+ </div>
100
+ );
101
+ }
src/app/datasets/[slug]/page.tsx ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { notFound } from "next/navigation";
4
+ import { ArrowLeft, ArrowUpRight, ExternalLink } from "lucide-react";
5
+ import Badge from "@/components/ui/Badge";
6
+ import DatasetChips from "@/components/ui/DatasetChips";
7
+ import ScrollReveal from "@/components/ui/ScrollReveal";
8
+ import SectionHeading from "@/components/ui/SectionHeading";
9
+ import CautionCallout from "@/components/docs/CautionCallout";
10
+ import OnboardingCriteria from "@/components/docs/OnboardingCriteria";
11
+ import PipelineSteps from "@/components/docs/PipelineSteps";
12
+ import RoadmapStrip, { type RoadmapItem } from "@/components/docs/RoadmapStrip";
13
+ import SplitsTable from "@/components/docs/SplitsTable";
14
+ import StatTile from "@/components/docs/StatTile";
15
+ import TopicBars from "@/components/docs/TopicBars";
16
+ import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data";
17
+ import type { DatasetInfo } from "@/lib/types";
18
+
19
+ interface Props {
20
+ params: Promise<{ slug: string }>;
21
+ }
22
+
23
+ export function generateStaticParams() {
24
+ return getDatasets().map((d) => ({ slug: d.id }));
25
+ }
26
+
27
+ /* The registry is closed (static JSON) — unknown slugs must be a real
28
+ HTTP 404, not a streamed 200 with not-found content. */
29
+ export const dynamicParams = false;
30
+
31
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
32
+ const { slug } = await params;
33
+ const dataset = getDatasets().find((d) => d.id === slug);
34
+ if (!dataset) return { title: "Dataset" };
35
+ return {
36
+ title:
37
+ dataset.status === "live"
38
+ ? `${dataset.name} dataset`
39
+ : `${dataset.name} — onboarding`,
40
+ description: dataset.tagline,
41
+ };
42
+ }
43
+
44
+ /* ------------------------------------------------------------------ */
45
+ /* ViMedCSS editorial material — corpus-specific scholarship that has */
46
+ /* no home in the data API. Scoped to the vimedcss id; other live */
47
+ /* datasets simply skip these sections. */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ const VIMEDCSS_TOPICS = [
51
+ { label: "Medical Sciences", vi: "Khoa học y khoa", hours: 16.3 },
52
+ { label: "Pathology & Pathogens", vi: "Bệnh học & tác nhân gây bệnh", hours: 10.3 },
53
+ { label: "Treatments", vi: "Điều trị", hours: 3.8 },
54
+ { label: "Nutrition", vi: "Dinh dưỡng", hours: 2.1 },
55
+ { label: "Diagnostics", vi: "Chẩn đoán", hours: 2.0 },
56
+ ] as const;
57
+
58
+ const VIMEDCSS_PIPELINE = [
59
+ {
60
+ title: "YouTube crawl",
61
+ stat: "700+ hours",
62
+ detail:
63
+ "Vietnamese medical channels crawled and filtered down to clean lecture-style audio, then segmented into 2–21 second utterances.",
64
+ },
65
+ {
66
+ title: "Meddict lexicon match",
67
+ stat: "64,232 entries",
68
+ detail:
69
+ "Candidate English medical terms located against the 64,232-entry Meddict bilingual medical lexicon to anchor code-switched spans.",
70
+ },
71
+ {
72
+ title: "LLM label seeding",
73
+ stat: "Gemini 2.5 Pro",
74
+ detail:
75
+ "First-pass transcripts and span labels drafted by Gemini 2.5 Pro — the origin of the permanent contamination flag on Gemini-family rows.",
76
+ },
77
+ {
78
+ title: "Levenshtein canonicalization",
79
+ stat: "889 canonical terms",
80
+ detail:
81
+ "Surface variants of the same term collapsed to canonical forms by edit-distance matching, yielding 889 distinct code-switched medical terms.",
82
+ },
83
+ {
84
+ title: "Human verification",
85
+ stat: "κ = 0.65",
86
+ detail:
87
+ "Annotators verified every label; inter-annotator agreement κ = 0.65. Verified — but Gemini-seeded. See the caution below.",
88
+ },
89
+ ];
90
+
91
+ /** Accent-tinted chip marking a code-switched English medical term. */
92
+ function CS({ children }: { children: React.ReactNode }) {
93
+ return (
94
+ <span
95
+ lang="en"
96
+ className="rounded-sm border border-accent/25 bg-accent-dim px-1 py-px text-accent"
97
+ >
98
+ {children}
99
+ </span>
100
+ );
101
+ }
102
+
103
+ /* ------------------------------------------------------------------ */
104
+ /* Shared bits */
105
+ /* ------------------------------------------------------------------ */
106
+
107
+ const chipLink =
108
+ "num inline-flex items-center gap-1.5 rounded-sm border border-line px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
109
+
110
+ const actionLink =
111
+ "num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
112
+
113
+ const ROADMAP_ROLE: Record<string, RoadmapItem["status"]> = {
114
+ [ANCHOR_DATASET_ID]: "live",
115
+ vietmed: "next",
116
+ "private-clinic": "north-star",
117
+ };
118
+
119
+ function buildRoadmap(): RoadmapItem[] {
120
+ return getDatasets().map((d) => ({
121
+ name: d.name,
122
+ status:
123
+ d.status === "live" ? "live" : (ROADMAP_ROLE[d.id] ?? "next"),
124
+ stat:
125
+ d.status === "live" && d.hours !== undefined
126
+ ? `${fmt(d.hours)} h`
127
+ : undefined,
128
+ detail: d.tagline,
129
+ }));
130
+ }
131
+
132
+ function RegistryStrip({ activeId }: { activeId: string }) {
133
+ return (
134
+ <div className="flex flex-wrap items-center justify-between gap-3">
135
+ <Link
136
+ href="/datasets"
137
+ className="num inline-flex items-center gap-1.5 rounded-sm text-[12px] tracking-wide text-muted transition-colors duration-200 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
138
+ >
139
+ <ArrowLeft size={13} aria-hidden />
140
+ All datasets
141
+ </Link>
142
+ <DatasetChips datasets={getDatasets()} activeId={activeId} />
143
+ </div>
144
+ );
145
+ }
146
+
147
+ /* ------------------------------------------------------------------ */
148
+ /* Live dataset profile */
149
+ /* ------------------------------------------------------------------ */
150
+
151
+ function LiveProfile({ dataset }: { dataset: DatasetInfo }) {
152
+ const isViMedCSS = dataset.id === "vimedcss";
153
+ const systems = getModels(dataset.id).length;
154
+
155
+ /* sub-section numbering stays dense even when editorial sections are
156
+ absent on future live datasets */
157
+ let n = 0;
158
+ const sec = () => `03.${++n}`;
159
+
160
+ return (
161
+ <div className="spring-up flex flex-col gap-12 md:gap-16">
162
+ {/* 1 · Header */}
163
+ <header className="hairline-b flex flex-col gap-6 pb-6">
164
+ <RegistryStrip activeId={dataset.id} />
165
+ <div>
166
+ <p className="num mb-2 text-[11px] uppercase tracking-[0.22em] text-muted">
167
+ 03 · Dataset profile
168
+ </p>
169
+ <h1 className="font-display text-4xl leading-tight text-text md:text-5xl">
170
+ {dataset.name}
171
+ {dataset.fullName && (
172
+ <span className="mt-1 block font-display text-xl italic text-muted md:text-2xl">
173
+ {dataset.fullName}
174
+ </span>
175
+ )}
176
+ </h1>
177
+ <p className="mt-3 max-w-2xl text-sm leading-relaxed text-muted md:text-[15px]">
178
+ {dataset.tagline}
179
+ {dataset.id === ANCHOR_DATASET_ID && (
180
+ <> The anchor benchmark of MedASR Bench — its first live dataset.</>
181
+ )}
182
+ </p>
183
+ <div className="mt-4 flex flex-wrap items-center gap-2">
184
+ <Badge variant="accent">
185
+ <span
186
+ aria-hidden
187
+ className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
188
+ />
189
+ Live · {systems} systems
190
+ </Badge>
191
+ {dataset.license && (
192
+ <Badge variant="outline" title="License">
193
+ {dataset.license}
194
+ </Badge>
195
+ )}
196
+ {dataset.arxiv && (
197
+ <a
198
+ href={`https://arxiv.org/abs/${dataset.arxiv}`}
199
+ target="_blank"
200
+ rel="noreferrer"
201
+ className={chipLink}
202
+ >
203
+ arXiv:{dataset.arxiv}
204
+ <ExternalLink size={12} aria-hidden />
205
+ </a>
206
+ )}
207
+ {dataset.hf && (
208
+ <a
209
+ href={`https://huggingface.co/datasets/${dataset.hf}`}
210
+ target="_blank"
211
+ rel="noreferrer"
212
+ className={chipLink}
213
+ >
214
+ HF · {dataset.hf}
215
+ <ExternalLink size={12} aria-hidden />
216
+ </a>
217
+ )}
218
+ </div>
219
+ </div>
220
+ </header>
221
+
222
+ {/* 2 · Stat strip */}
223
+ <section
224
+ aria-label="Corpus at a glance"
225
+ className="grid grid-cols-2 gap-3 md:grid-cols-4 stagger-spring"
226
+ >
227
+ {dataset.hours !== undefined && (
228
+ <StatTile label="Audio" value={fmt(dataset.hours)} suffix="h" />
229
+ )}
230
+ {dataset.utterances !== undefined && (
231
+ <StatTile
232
+ label="Utterances"
233
+ value={dataset.utterances.toLocaleString("en-US")}
234
+ />
235
+ )}
236
+ {dataset.csTerms !== undefined && (
237
+ <StatTile
238
+ label="CS medical terms"
239
+ value={dataset.csTerms.toLocaleString("en-US")}
240
+ sub="distinct English terms"
241
+ />
242
+ )}
243
+ {isViMedCSS && <StatTile label="Clip length" value="2–21" suffix="s" />}
244
+ </section>
245
+
246
+ {/* Specimen — ViMedCSS editorial */}
247
+ {isViMedCSS && (
248
+ <section className="flex flex-col gap-5">
249
+ <SectionHeading
250
+ eyebrow={`${sec()} · Specimen`}
251
+ title="What an utterance looks like"
252
+ sub="Two transcript lines from the corpus — Vietnamese clinical speech with the code-switched English terms highlighted, exactly the spans CS-WER scores."
253
+ />
254
+ <figure className="hairline depth-1 rounded-sm bg-surface px-5 py-6 md:px-6">
255
+ <blockquote
256
+ lang="vi"
257
+ className="flex flex-col gap-5 text-lg leading-[1.8] text-text md:text-xl"
258
+ >
259
+ <p>
260
+ Bệnh nhân có tiền sử tăng huyết áp, chỉ định siêu âm{" "}
261
+ <CS>Doppler</CS> động mạch cảnh.
262
+ </p>
263
+ <p>
264
+ Kết quả <CS>MRI</CS> cho thấy tổn thương chất trắng lan tỏa, cần
265
+ theo dõi <CS>multiple sclerosis</CS>.
266
+ </p>
267
+ </blockquote>
268
+ <figcaption className="num mt-5 border-t border-line pt-3 text-[11px] tracking-wide text-muted uppercase">
269
+ Lecture-style utterances, 2–21 s ·{" "}
270
+ <span className="text-accent">highlighted</span> = code-switched
271
+ English medical term
272
+ {dataset.csTerms !== undefined && <> ({dataset.csTerms} distinct)</>} ·
273
+ scored by CS-WER
274
+ </figcaption>
275
+ </figure>
276
+ </section>
277
+ )}
278
+
279
+ {/* Splits — any live dataset with frozen splits */}
280
+ {dataset.splits && (
281
+ <section className="flex flex-col gap-5">
282
+ <SectionHeading
283
+ eyebrow={`${sec()} · Splits`}
284
+ title="Four splits, two leaderboards"
285
+ sub="Test is the primary leaderboard. Hard isolates rare and unseen terms — when a model ranks well on Test but slides on Hard, it memorized the vocabulary instead of learning it."
286
+ />
287
+ <SplitsTable splits={dataset.splits} datasetName={dataset.name} />
288
+ </section>
289
+ )}
290
+
291
+ {/* Topics — ViMedCSS editorial */}
292
+ {isViMedCSS && (
293
+ <ScrollReveal>
294
+ <section className="flex flex-col gap-5">
295
+ <SectionHeading
296
+ eyebrow={`${sec()} · Topic distribution`}
297
+ title="Where the hours live"
298
+ sub="Five clinical topic areas; bar lengths are relative to the largest topic."
299
+ />
300
+ <TopicBars items={VIMEDCSS_TOPICS} />
301
+ </section>
302
+ </ScrollReveal>
303
+ )}
304
+
305
+ {/* Construction — ViMedCSS editorial */}
306
+ {isViMedCSS && (
307
+ <ScrollReveal>
308
+ <section className="flex flex-col gap-6">
309
+ <SectionHeading
310
+ eyebrow={`${sec()} · Construction`}
311
+ title="How the corpus was built"
312
+ sub="Five stages from raw crawl to verified labels. Stage three is why the contamination policy exists."
313
+ />
314
+ <PipelineSteps steps={VIMEDCSS_PIPELINE} />
315
+ </section>
316
+ </ScrollReveal>
317
+ )}
318
+
319
+ {/* Validity — ViMedCSS editorial */}
320
+ {isViMedCSS && (
321
+ <ScrollReveal>
322
+ <section className="flex flex-col gap-5">
323
+ <SectionHeading
324
+ eyebrow={`${sec()} · Validity`}
325
+ title="Read these before citing a number"
326
+ sub="Two caveats bound what ViMedCSS results can and cannot claim."
327
+ />
328
+ <div className="grid gap-4 lg:grid-cols-2">
329
+ <CautionCallout
330
+ eyebrow="Caution · Label provenance"
331
+ title="Gemini seeded these labels"
332
+ >
333
+ <p>
334
+ Transcripts and code-switch spans were first drafted by Gemini
335
+ 2.5 Pro, then human-verified at{" "}
336
+ <span className="num text-text">κ = 0.65</span>. Gemini-family
337
+ models are therefore structurally advantaged on this benchmark
338
+ and carry a{" "}
339
+ <strong className="font-medium text-warn">
340
+ permanent contamination flag
341
+ </strong>{" "}
342
+ on every ViMedCSS leaderboard, regardless of score.
343
+ </p>
344
+ <p>
345
+ Test labels are public, so provenance declarations are
346
+ honor-system today. A private canary subset is on the roadmap
347
+ to detect test-set training.
348
+ </p>
349
+ </CautionCallout>
350
+ <CautionCallout
351
+ eyebrow="Caution · Domain mismatch"
352
+ title="Lecture audio is not a clinic"
353
+ >
354
+ <p>
355
+ ViMedCSS is lecture-style YouTube audio: clean microphones,
356
+ composed delivery, one speaker. The production setting —
357
+ noisy, two-speaker clinic conversation — is materially harder.
358
+ </p>
359
+ <p>
360
+ Never read a ViMedCSS score as progress against the production{" "}
361
+ <span className="num text-text">WER &lt; 15%</span> deployment
362
+ gate. That gate is scored on conversational clinic data (Track
363
+ F, roadmap).
364
+ </p>
365
+ </CautionCallout>
366
+ </div>
367
+ </section>
368
+ </ScrollReveal>
369
+ )}
370
+
371
+ {/* Hub roadmap — generic, derived from the registry */}
372
+ <ScrollReveal>
373
+ <section className="flex flex-col gap-5">
374
+ <SectionHeading
375
+ eyebrow={`${sec()} · The hub`}
376
+ title="One dataset of several"
377
+ sub="MedASR Bench is multi-dataset by construction — every corpus mounts into the same harness, normalizer, and provenance rules."
378
+ />
379
+ <RoadmapStrip items={buildRoadmap()} />
380
+ <div className="flex flex-wrap items-center gap-2">
381
+ <Link href="/datasets" className={actionLink}>
382
+ <ArrowLeft size={13} aria-hidden />
383
+ All datasets
384
+ </Link>
385
+ <Link href="/methodology#onboarding" className={actionLink}>
386
+ Onboarding criteria
387
+ <ArrowUpRight size={13} aria-hidden />
388
+ </Link>
389
+ </div>
390
+ </section>
391
+ </ScrollReveal>
392
+ </div>
393
+ );
394
+ }
395
+
396
+ /* ------------------------------------------------------------------ */
397
+ /* Coming-soon teaser */
398
+ /* ------------------------------------------------------------------ */
399
+
400
+ const LAUNCH_STEPS = [
401
+ {
402
+ title: "Profile published",
403
+ detail:
404
+ "Provenance, license, and split inventory go live on this page — frozen before any system is scored.",
405
+ },
406
+ {
407
+ title: "Baselines re-run",
408
+ detail:
409
+ "The platform harness runs the existing leaderboard systems against the new corpus, manifests and all.",
410
+ },
411
+ {
412
+ title: "Leaderboard opens",
413
+ detail:
414
+ "Six metrics with bootstrap CIs and provenance chips from day one — comparable with every other dataset on the hub.",
415
+ },
416
+ ];
417
+
418
+ function ComingSoonProfile({ dataset }: { dataset: DatasetInfo }) {
419
+ return (
420
+ <div className="spring-up flex flex-col gap-12 md:gap-14">
421
+ {/* Header */}
422
+ <header className="hairline-b flex flex-col gap-6 pb-6">
423
+ <RegistryStrip activeId={dataset.id} />
424
+ <div>
425
+ <p className="num mb-2 text-[11px] uppercase tracking-[0.22em] text-muted">
426
+ 03 · Dataset profile
427
+ </p>
428
+ <h1 className="font-display text-4xl leading-tight text-text md:text-5xl">
429
+ {dataset.name}
430
+ </h1>
431
+ <p className="mt-3 max-w-2xl text-[15px] leading-relaxed text-muted">
432
+ {dataset.tagline}
433
+ </p>
434
+ <div className="mt-4 flex flex-wrap items-center gap-2">
435
+ <Badge variant="dashed">Onboarding</Badge>
436
+ {dataset.eta && (
437
+ <Badge variant="outline" title="Expected availability">
438
+ ETA {dataset.eta}
439
+ </Badge>
440
+ )}
441
+ {dataset.license && (
442
+ <Badge variant="outline" title="License">
443
+ {dataset.license}
444
+ </Badge>
445
+ )}
446
+ </div>
447
+ </div>
448
+ </header>
449
+
450
+ {/* What's known today */}
451
+ <section className="flex flex-col gap-5">
452
+ <SectionHeading
453
+ eyebrow="03.1 · Status"
454
+ title="What is known today"
455
+ sub="No numbers are published before the corpus is frozen — this page carries identity only, never preliminary stats."
456
+ />
457
+ <dl className="hairline depth-1 grid gap-px overflow-hidden rounded-sm bg-line sm:grid-cols-3">
458
+ {[
459
+ { dt: "Status", dd: "Onboarding — not yet runnable" },
460
+ { dt: "Source", dd: dataset.source ?? "To be announced" },
461
+ {
462
+ dt: "Leaderboard",
463
+ dd: "Opens after the first platform-verified run",
464
+ },
465
+ ].map((row) => (
466
+ <div key={row.dt} className="flex flex-col gap-1 bg-surface px-4 py-3.5">
467
+ <dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
468
+ {row.dt}
469
+ </dt>
470
+ <dd className="text-[13px] leading-relaxed text-text">{row.dd}</dd>
471
+ </div>
472
+ ))}
473
+ </dl>
474
+ </section>
475
+
476
+ {/* Onboarding criteria */}
477
+ <ScrollReveal>
478
+ <section className="flex flex-col gap-5">
479
+ <SectionHeading
480
+ eyebrow="03.2 · Onboarding"
481
+ title="What onboarding involves"
482
+ sub="Six criteria every dataset clears before its first leaderboard — the same bar ViMedCSS cleared."
483
+ />
484
+ <OnboardingCriteria />
485
+ <Link
486
+ href="/methodology#onboarding"
487
+ className={`${actionLink} self-start`}
488
+ >
489
+ Full criteria &amp; versioning policy
490
+ <ArrowUpRight size={13} aria-hidden />
491
+ </Link>
492
+ </section>
493
+ </ScrollReveal>
494
+
495
+ {/* Launch sequence */}
496
+ <ScrollReveal>
497
+ <section className="flex flex-col gap-5">
498
+ <SectionHeading
499
+ eyebrow="03.3 · At launch"
500
+ title="What happens when it goes live"
501
+ sub="The same sequence every dataset follows — nothing is scored before the freeze."
502
+ />
503
+ <ol className="grid gap-3 md:grid-cols-3 stagger-spring">
504
+ {LAUNCH_STEPS.map((step, i) => (
505
+ <li
506
+ key={step.title}
507
+ className="hairline depth-1 flex flex-col gap-2 rounded-sm bg-surface p-5"
508
+ >
509
+ <span aria-hidden className="num text-[12px] text-muted">
510
+ {String(i + 1).padStart(2, "0")}
511
+ </span>
512
+ <h3 className="text-sm font-medium text-text">{step.title}</h3>
513
+ <p className="text-[13px] leading-relaxed text-muted">
514
+ {step.detail}
515
+ </p>
516
+ </li>
517
+ ))}
518
+ </ol>
519
+ <div className="flex flex-wrap items-center gap-2">
520
+ <Link href="/datasets" className={actionLink}>
521
+ <ArrowLeft size={13} aria-hidden />
522
+ All datasets
523
+ </Link>
524
+ <Link href={`/datasets/${ANCHOR_DATASET_ID}`} className={actionLink}>
525
+ See the live anchor dataset
526
+ </Link>
527
+ </div>
528
+ </section>
529
+ </ScrollReveal>
530
+ </div>
531
+ );
532
+ }
533
+
534
+ /* ------------------------------------------------------------------ */
535
+ /* Route */
536
+ /* ------------------------------------------------------------------ */
537
+
538
+ export default async function DatasetPage({ params }: Props) {
539
+ const { slug } = await params;
540
+ const dataset = getDatasets().find((d) => d.id === slug);
541
+ if (!dataset) notFound();
542
+
543
+ return dataset.status === "live" ? (
544
+ <LiveProfile dataset={dataset} />
545
+ ) : (
546
+ <ComingSoonProfile dataset={dataset} />
547
+ );
548
+ }
src/app/datasets/loading.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Skeleton,
3
+ SkeletonCard,
4
+ SkeletonStats,
5
+ } from "@/components/ui/LoadingSkeleton";
6
+
7
+ /** Route-level skeleton for /datasets and its nested dataset profiles. */
8
+ export default function Loading() {
9
+ return (
10
+ <div className="flex flex-col gap-10 md:gap-12" aria-busy="true">
11
+ {/* header */}
12
+ <div className="hairline-b flex flex-col gap-4 pb-6">
13
+ <Skeleton width={140} height={12} />
14
+ <Skeleton width="min(28rem, 80%)" height={36} />
15
+ <Skeleton width="min(40rem, 100%)" height={14} />
16
+ <div className="flex gap-2 pt-2">
17
+ <Skeleton width={88} height={26} />
18
+ <Skeleton width={88} height={26} />
19
+ <Skeleton width={120} height={26} />
20
+ </div>
21
+ </div>
22
+
23
+ {/* registry grid */}
24
+ <div className="grid gap-4 md:grid-cols-2">
25
+ <div className="hairline depth-1 flex flex-col gap-5 rounded-sm bg-surface p-6 md:col-span-2 md:p-7">
26
+ <div className="flex gap-2">
27
+ <Skeleton width={56} height={20} />
28
+ <Skeleton width={104} height={20} />
29
+ </div>
30
+ <Skeleton width="min(20rem, 70%)" height={30} />
31
+ <Skeleton width="min(34rem, 95%)" height={14} />
32
+ <SkeletonStats count={3} className="grid-cols-3 max-w-xl" />
33
+ <Skeleton width="100%" height={10} />
34
+ </div>
35
+ <SkeletonCard lines={3} />
36
+ <SkeletonCard lines={3} />
37
+ <div className="md:col-span-2">
38
+ <SkeletonCard lines={2} />
39
+ </div>
40
+ </div>
41
+ </div>
42
+ );
43
+ }
src/app/datasets/page.tsx ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { ArrowRight, ArrowUpRight, ExternalLink } from "lucide-react";
4
+ import Badge from "@/components/ui/Badge";
5
+ import Card from "@/components/ui/Card";
6
+ import DatasetChips from "@/components/ui/DatasetChips";
7
+ import SectionHeading from "@/components/ui/SectionHeading";
8
+ import { ONBOARDING_CRITERIA } from "@/components/docs/OnboardingCriteria";
9
+ import SplitMiniBar from "@/components/docs/SplitMiniBar";
10
+ import { ANCHOR_DATASET_ID, fmt, getDatasets, getModels } from "@/lib/data";
11
+ import type { DatasetInfo } from "@/lib/types";
12
+
13
+ export const metadata: Metadata = {
14
+ title: "Datasets",
15
+ description:
16
+ "The dataset registry of MedASR Bench — live Vietnamese medical ASR corpora, onboarding corpora, and the criteria a dataset must meet to mount into the harness.",
17
+ };
18
+
19
+ const chipLink =
20
+ "num inline-flex items-center gap-1.5 rounded-sm border border-line px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
21
+
22
+ const actionPrimary =
23
+ "num inline-flex items-center gap-1.5 rounded-sm border border-accent/40 bg-accent-dim px-3 py-1.5 text-[12px] tracking-wide text-accent transition-colors duration-200 hover:bg-accent/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
24
+
25
+ const actionSecondary =
26
+ "num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
27
+
28
+ /** Rich profile card for a live dataset — stats, split bars, links. */
29
+ function LiveDatasetCard({ dataset }: { dataset: DatasetInfo }) {
30
+ const systems = getModels(dataset.id).length;
31
+
32
+ return (
33
+ <Card depth={2} className="h-full">
34
+ <div className="flex h-full flex-col gap-6 p-6 md:p-7">
35
+ <div className="flex flex-wrap items-center gap-2">
36
+ <Badge variant="accent">
37
+ <span
38
+ aria-hidden
39
+ className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
40
+ />
41
+ Live
42
+ </Badge>
43
+ {dataset.id === ANCHOR_DATASET_ID && (
44
+ <Badge variant="outline">Anchor dataset</Badge>
45
+ )}
46
+ {dataset.license && (
47
+ <Badge variant="outline" title="License">
48
+ {dataset.license}
49
+ </Badge>
50
+ )}
51
+ </div>
52
+
53
+ <div className="grid gap-6 md:grid-cols-[1.15fr_1fr] md:gap-10">
54
+ {/* identity */}
55
+ <div className="flex flex-col gap-4">
56
+ <h2 className="font-display text-3xl leading-tight text-text md:text-4xl">
57
+ {dataset.name}
58
+ {dataset.fullName && (
59
+ <span className="mt-1 block font-display text-lg italic text-muted md:text-xl">
60
+ {dataset.fullName}
61
+ </span>
62
+ )}
63
+ </h2>
64
+ <p className="max-w-xl text-sm leading-relaxed text-muted md:text-[15px]">
65
+ {dataset.tagline}
66
+ </p>
67
+ {dataset.source && (
68
+ <p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
69
+ Source · {dataset.source}
70
+ </p>
71
+ )}
72
+ <div className="flex flex-wrap items-center gap-2">
73
+ {dataset.arxiv && (
74
+ <a
75
+ href={`https://arxiv.org/abs/${dataset.arxiv}`}
76
+ target="_blank"
77
+ rel="noreferrer"
78
+ className={chipLink}
79
+ >
80
+ arXiv:{dataset.arxiv}
81
+ <ExternalLink size={12} aria-hidden />
82
+ </a>
83
+ )}
84
+ {dataset.hf && (
85
+ <a
86
+ href={`https://huggingface.co/datasets/${dataset.hf}`}
87
+ target="_blank"
88
+ rel="noreferrer"
89
+ className={chipLink}
90
+ >
91
+ HF · {dataset.hf}
92
+ <ExternalLink size={12} aria-hidden />
93
+ </a>
94
+ )}
95
+ </div>
96
+ <div className="mt-auto flex flex-wrap items-center gap-2 pt-2">
97
+ <Link href={`/datasets/${dataset.id}`} className={actionPrimary}>
98
+ Dataset profile
99
+ <ArrowRight size={13} aria-hidden />
100
+ </Link>
101
+ <Link href="/" className={actionSecondary}>
102
+ Leaderboard · {systems} systems
103
+ <ArrowRight size={13} aria-hidden />
104
+ </Link>
105
+ </div>
106
+ </div>
107
+
108
+ {/* instrument readout */}
109
+ <div className="flex flex-col gap-5 border-line md:border-l md:pl-8">
110
+ <dl className="grid grid-cols-3 gap-4">
111
+ {dataset.hours !== undefined && (
112
+ <div>
113
+ <dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
114
+ Audio
115
+ </dt>
116
+ <dd className="num mt-1 text-2xl leading-none text-text">
117
+ {fmt(dataset.hours)}
118
+ <span className="ml-0.5 text-base text-muted">h</span>
119
+ </dd>
120
+ </div>
121
+ )}
122
+ {dataset.utterances !== undefined && (
123
+ <div>
124
+ <dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
125
+ Utterances
126
+ </dt>
127
+ <dd className="num mt-1 text-2xl leading-none text-text">
128
+ {dataset.utterances.toLocaleString("en-US")}
129
+ </dd>
130
+ </div>
131
+ )}
132
+ {dataset.csTerms !== undefined && (
133
+ <div>
134
+ <dt className="text-[11px] uppercase tracking-[0.14em] text-muted">
135
+ CS terms
136
+ </dt>
137
+ <dd className="num mt-1 text-2xl leading-none text-text">
138
+ {dataset.csTerms.toLocaleString("en-US")}
139
+ </dd>
140
+ </div>
141
+ )}
142
+ </dl>
143
+ {dataset.splits && (
144
+ <div className="flex flex-col gap-2">
145
+ <p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
146
+ Frozen splits · share of audio
147
+ </p>
148
+ <SplitMiniBar splits={dataset.splits} />
149
+ </div>
150
+ )}
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </Card>
155
+ );
156
+ }
157
+
158
+ /** Muted teaser card for an onboarding dataset. */
159
+ function ComingSoonCard({ dataset }: { dataset: DatasetInfo }) {
160
+ return (
161
+ <div className="depth-1 flex h-full flex-col gap-3 rounded-sm border border-dashed border-line bg-surface/40 p-6 transition-colors duration-200 hover:border-line hover:bg-surface/60">
162
+ <div className="flex flex-wrap items-center justify-between gap-2">
163
+ <Badge variant="dashed">Onboarding</Badge>
164
+ <span className="num text-[11px] uppercase tracking-[0.16em] text-muted">
165
+ {dataset.eta ? `ETA ${dataset.eta}` : "soon"}
166
+ </span>
167
+ </div>
168
+ <h2 className="font-display text-2xl leading-tight text-text">
169
+ {dataset.name}
170
+ </h2>
171
+ <p className="text-[13px] leading-relaxed text-muted">{dataset.tagline}</p>
172
+ {dataset.source && (
173
+ <p className="num text-[11px] uppercase tracking-[0.16em] text-muted">
174
+ Source · {dataset.source}
175
+ </p>
176
+ )}
177
+ <Link
178
+ href={`/datasets/${dataset.id}`}
179
+ className="num mt-auto inline-flex items-center gap-1.5 self-start rounded-sm pt-2 text-[12px] tracking-wide text-muted transition-colors duration-200 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
180
+ >
181
+ What onboarding involves
182
+ <ArrowRight size={13} aria-hidden />
183
+ </Link>
184
+ </div>
185
+ );
186
+ }
187
+
188
+ export default function DatasetsPage() {
189
+ const datasets = getDatasets();
190
+ const live = datasets.filter((d) => d.status === "live");
191
+ const comingSoon = datasets.filter((d) => d.status === "coming-soon");
192
+
193
+ return (
194
+ <div className="flex flex-col gap-10 md:gap-12">
195
+ {/* 1 · Header */}
196
+ <div className="spring-up">
197
+ <SectionHeading
198
+ as="h1"
199
+ eyebrow="03 · Datasets"
200
+ title="One instrument, many corpora"
201
+ sub={
202
+ <>
203
+ MedASR Bench is a benchmark hub for Vietnamese medical ASR. Every
204
+ dataset mounts into the same harness — same normalizer, same six
205
+ metrics, same provenance rules — so scores stay comparable across
206
+ corpora. <span className="num text-text">{live.length}</span> live
207
+ today, <span className="num text-text">{comingSoon.length}</span>{" "}
208
+ onboarding.
209
+ </>
210
+ }
211
+ />
212
+ <DatasetChips datasets={datasets} className="mt-5" />
213
+ </div>
214
+
215
+ {/* 2 · Bento registry */}
216
+ <section aria-label="Dataset registry" className="grid gap-4 md:grid-cols-2">
217
+ {live.map((dataset) => (
218
+ <div key={dataset.id} className="spring-up delay-1 md:col-span-2">
219
+ <LiveDatasetCard dataset={dataset} />
220
+ </div>
221
+ ))}
222
+ {comingSoon.map((dataset, i) => (
223
+ <div
224
+ key={dataset.id}
225
+ className={`spring-up ${i % 2 === 0 ? "delay-2" : "delay-3"}`}
226
+ >
227
+ <ComingSoonCard dataset={dataset} />
228
+ </div>
229
+ ))}
230
+
231
+ {/* 3 · Onboarding CTA — glass treatment */}
232
+ <div className="spring-up delay-4 md:col-span-2">
233
+ <aside
234
+ aria-label="Onboard a dataset"
235
+ className="glass depth-2 rounded-sm p-6 md:p-7"
236
+ >
237
+ <div className="grid gap-6 md:grid-cols-[1fr_auto] md:items-end md:gap-10">
238
+ <div className="flex flex-col gap-3">
239
+ <p className="num text-[11px] uppercase tracking-[0.22em] text-muted">
240
+ Onboard a dataset
241
+ </p>
242
+ <h2 className="font-display text-2xl leading-tight text-text md:text-3xl">
243
+ Run your corpus on the same instrument
244
+ </h2>
245
+ <p className="max-w-2xl text-sm leading-relaxed text-muted">
246
+ Any Vietnamese medical speech corpus that meets six criteria
247
+ can mount into the harness, and its scores are comparable from
248
+ the first run. Datasets are frozen, versioned snapshots —
249
+ never moving targets.
250
+ </p>
251
+ <ul className="mt-1 flex flex-wrap gap-2" aria-label="Onboarding criteria">
252
+ {ONBOARDING_CRITERIA.map((c) => (
253
+ <li key={c.title}>
254
+ <span
255
+ title={c.detail}
256
+ className="num inline-flex items-center rounded-sm border border-line bg-surface/60 px-2 py-0.5 text-[11px] leading-4 tracking-wide text-text"
257
+ >
258
+ {c.title}
259
+ </span>
260
+ </li>
261
+ ))}
262
+ </ul>
263
+ </div>
264
+ <Link href="/methodology#onboarding" className={actionPrimary}>
265
+ Criteria &amp; versioning policy
266
+ <ArrowUpRight size={13} aria-hidden />
267
+ </Link>
268
+ </div>
269
+ </aside>
270
+ </div>
271
+ </section>
272
+ </div>
273
+ );
274
+ }
src/app/favicon.ico DELETED
Binary file (25.9 kB)
 
src/app/globals.css CHANGED
@@ -1,26 +1,823 @@
1
  @import "tailwindcss";
2
 
 
 
 
 
3
  :root {
4
- --background: #ffffff;
5
- --foreground: #171717;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
7
 
8
  @theme inline {
9
- --color-background: var(--background);
10
- --color-foreground: var(--foreground);
11
- --font-sans: var(--font-geist-sans);
12
- --font-mono: var(--font-geist-mono);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  }
14
 
15
- @media (prefers-color-scheme: dark) {
16
- :root {
17
- --background: #0a0a0a;
18
- --foreground: #ededed;
19
- }
 
20
  }
21
 
22
  body {
23
- background: var(--background);
24
- color: var(--foreground);
25
- font-family: Arial, Helvetica, sans-serif;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  }
 
1
  @import "tailwindcss";
2
 
3
+ /* ------------------------------------------------------------------ */
4
+ /* MedASR Bench — "Clinical Instrument" token system */
5
+ /* ------------------------------------------------------------------ */
6
+
7
  :root {
8
+ color-scheme: light;
9
+
10
+ /* Palette — light "precision instrument": cool paper-white surfaces, ink
11
+ text, deep violet accent (user-picked hue; deepened for light-mode
12
+ contrast: oklch(0.50 0.17 290) = 6.1:1 on --bg). Built in OKLCH; all
13
+ small-text pairings clear WCAG 4.5:1. Saturated hues are reserved for
14
+ meaning (accent/warn/crit). NO green — repeatedly rejected by the user. */
15
+ --bg: #f9f9fb;
16
+ --surface: #ffffff;
17
+ --surface-2: #eeeef2;
18
+ --line: rgba(36, 36, 60, 0.13);
19
+ --text: #24242f;
20
+ --muted: #62626f;
21
+ --accent: #654abb; /* deep violet — sparingly */
22
+ --warn: #a15e00; /* contamination / caution only */
23
+ --crit: #c53637; /* critical / error only */
24
+
25
+ /* Derived tints — single-source: everything mixes from the tokens above */
26
+ --accent-dim: color-mix(in srgb, var(--accent) 12%, transparent);
27
+ --warn-dim: color-mix(in srgb, var(--warn) 12%, transparent);
28
+ --crit-dim: color-mix(in srgb, var(--crit) 10%, transparent);
29
+ --grid-ink: rgba(36, 36, 80, 0.045);
30
+ --grid-ink-major: rgba(36, 36, 80, 0.065);
31
  }
32
 
33
  @theme inline {
34
+ /* Colors → Tailwind utilities (bg-bg, text-muted, border-line, …) */
35
+ --color-bg: var(--bg);
36
+ --color-surface: var(--surface);
37
+ --color-surface-2: var(--surface-2);
38
+ --color-line: var(--line);
39
+ --color-text: var(--text);
40
+ --color-muted: var(--muted);
41
+ --color-accent: var(--accent);
42
+ --color-warn: var(--warn);
43
+ --color-crit: var(--crit);
44
+ --color-accent-dim: var(--accent-dim);
45
+ --color-warn-dim: var(--warn-dim);
46
+ --color-crit-dim: var(--crit-dim);
47
+
48
+ /* Fonts → font-display / font-body / font-mono utilities.
49
+ The var() chain resolves to next/font variables set on <html>. */
50
+ --font-display: var(--font-display), "Instrument Serif", Georgia, serif;
51
+ --font-body: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
52
+ --font-sans: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
53
+ --font-mono: var(--font-mono), "IBM Plex Mono", ui-monospace, SFMono-Regular,
54
+ monospace;
55
  }
56
 
57
+ /* ------------------------------------------------------------------ */
58
+ /* Base + atmosphere: graph-paper micro-grid + scanline layer */
59
+ /* ------------------------------------------------------------------ */
60
+
61
+ html {
62
+ background-color: var(--bg);
63
  }
64
 
65
  body {
66
+ /* solid color lives on <html>; body stays transparent so the fixed
67
+ negative-z ambient glow layers (AmbientEffects) shine through the grid */
68
+ background-color: transparent;
69
+ color: var(--text);
70
+ font-family: var(--font-body), "Be Vietnam Pro", system-ui, sans-serif;
71
+ /* graph-paper grid: 140px major rhythm over the 28px minor micro-grid */
72
+ background-image: linear-gradient(var(--grid-ink-major) 1px, transparent 1px),
73
+ linear-gradient(90deg, var(--grid-ink-major) 1px, transparent 1px),
74
+ linear-gradient(var(--grid-ink) 1px, transparent 1px),
75
+ linear-gradient(90deg, var(--grid-ink) 1px, transparent 1px);
76
+ background-size:
77
+ 140px 140px,
78
+ 140px 140px,
79
+ 28px 28px,
80
+ 28px 28px;
81
+ -webkit-font-smoothing: antialiased;
82
+ }
83
+
84
+ /* ------------------------------------------------------------------ */
85
+ /* Grain texture — subtle film noise for tactile depth */
86
+ /* ------------------------------------------------------------------ */
87
+
88
+ body::before {
89
+ content: "";
90
+ position: fixed;
91
+ inset: 0;
92
+ z-index: 29;
93
+ pointer-events: none;
94
+ opacity: 0.02;
95
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
96
+ }
97
+
98
+ /* scanline veil — fixed, non-interactive, layered above grain */
99
+ body::after {
100
+ content: "";
101
+ position: fixed;
102
+ inset: 0;
103
+ z-index: 30;
104
+ pointer-events: none;
105
+ background: repeating-linear-gradient(
106
+ to bottom,
107
+ color-mix(in srgb, var(--text) 1.2%, transparent) 0px,
108
+ color-mix(in srgb, var(--text) 1.2%, transparent) 1px,
109
+ transparent 1px,
110
+ transparent 3px
111
+ );
112
+ }
113
+
114
+ ::selection {
115
+ background: var(--accent-dim);
116
+ color: var(--text);
117
+ }
118
+
119
+ /* ------------------------------------------------------------------ */
120
+ /* Ambient light — fixed phosphor glow washes behind all content */
121
+ /* (rendered by components/ui/AmbientEffects; GPU-cheap transform drift) */
122
+ /* ------------------------------------------------------------------ */
123
+
124
+ @keyframes ambient-drift-a {
125
+ 0%,
126
+ 100% {
127
+ transform: translate3d(0, 0, 0) scale(1);
128
+ }
129
+ 50% {
130
+ transform: translate3d(4%, 3%, 0) scale(1.08);
131
+ }
132
+ }
133
+
134
+ @keyframes ambient-drift-b {
135
+ 0%,
136
+ 100% {
137
+ transform: translate3d(0, 0, 0) scale(1);
138
+ }
139
+ 50% {
140
+ transform: translate3d(-3%, -4%, 0) scale(1.06);
141
+ }
142
+ }
143
+
144
+ .ambient-glow {
145
+ position: absolute;
146
+ pointer-events: none;
147
+ will-change: transform;
148
+ }
149
+
150
+ /* top-center wash — the room's key light */
151
+ .ambient-glow-top {
152
+ top: -32vh;
153
+ left: -12vw;
154
+ right: -12vw;
155
+ height: 78vh;
156
+ background: radial-gradient(
157
+ ellipse 55% 55% at 50% 45%,
158
+ color-mix(in srgb, var(--accent) 9%, transparent),
159
+ transparent 70%
160
+ );
161
+ animation: ambient-drift-a 70s ease-in-out infinite;
162
+ }
163
+
164
+ /* lower-right fill — faint counter-glow */
165
+ .ambient-glow-corner {
166
+ right: -22vw;
167
+ bottom: -30vh;
168
+ width: 72vw;
169
+ height: 75vh;
170
+ background: radial-gradient(
171
+ ellipse 50% 50% at 55% 55%,
172
+ color-mix(in srgb, var(--accent) 5.5%, transparent),
173
+ transparent 70%
174
+ );
175
+ animation: ambient-drift-b 90s ease-in-out infinite;
176
+ }
177
+
178
+ /* ------------------------------------------------------------------ */
179
+ /* Glass morphism — translucent surfaces tuned to the lifted palette */
180
+ /* ------------------------------------------------------------------ */
181
+
182
+ /* layered as components so Tailwind utilities (bg-surface/55 …) can
183
+ override the surface opacity per-instance */
184
+ @layer components {
185
+ /* veil only: translucent surface + blur, no border (compose freely) */
186
+ .glass-veil {
187
+ background: color-mix(in srgb, var(--surface) 66%, transparent);
188
+ -webkit-backdrop-filter: blur(16px) saturate(1.2);
189
+ backdrop-filter: blur(16px) saturate(1.2);
190
+ }
191
+
192
+ /* full glass panel: veil + hairline + machined top-edge relief */
193
+ .glass {
194
+ background: color-mix(in srgb, var(--surface) 72%, transparent);
195
+ -webkit-backdrop-filter: blur(14px) saturate(1.2);
196
+ backdrop-filter: blur(14px) saturate(1.2);
197
+ border: 1px solid var(--line);
198
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 6%, transparent);
199
+ }
200
+ }
201
+
202
+ /* ------------------------------------------------------------------ */
203
+ /* Hairline utilities (1px --line rules, the only "shadows" we use) */
204
+ /* ------------------------------------------------------------------ */
205
+
206
+ .hairline {
207
+ border: 1px solid var(--line);
208
+ /* machined top-edge relief — the only "shadow" beyond hairlines */
209
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent);
210
+ }
211
+ .hairline-t {
212
+ border-top: 1px solid var(--line);
213
+ }
214
+ .hairline-b {
215
+ border-bottom: 1px solid var(--line);
216
+ }
217
+ .hairline-l {
218
+ border-left: 1px solid var(--line);
219
+ }
220
+ .hairline-r {
221
+ border-right: 1px solid var(--line);
222
+ }
223
+
224
+ /* ------------------------------------------------------------------ */
225
+ /* Spring animations — physics-based motion */
226
+ /* ------------------------------------------------------------------ */
227
+
228
+ @keyframes spring-in {
229
+ from {
230
+ opacity: 0;
231
+ transform: scale(0.95) translateY(12px);
232
+ }
233
+ to {
234
+ opacity: 1;
235
+ transform: scale(1) translateY(0);
236
+ }
237
+ }
238
+
239
+ @keyframes spring-up {
240
+ from {
241
+ opacity: 0;
242
+ transform: translateY(20px);
243
+ }
244
+ to {
245
+ opacity: 1;
246
+ transform: translateY(0);
247
+ }
248
+ }
249
+
250
+ @keyframes soft-appear {
251
+ from {
252
+ opacity: 0;
253
+ transform: translateY(8px);
254
+ }
255
+ to {
256
+ opacity: 1;
257
+ transform: translateY(0);
258
+ }
259
+ }
260
+
261
+ /* Spring-based entrance animations */
262
+ .spring-in {
263
+ animation: spring-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
264
+ }
265
+
266
+ .spring-up {
267
+ animation: spring-up 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
268
+ }
269
+
270
+ .soft-appear {
271
+ animation: soft-appear 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
272
+ }
273
+
274
+ /* Improved stagger with spring feel */
275
+ .stagger-spring > * {
276
+ animation: spring-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
277
+ }
278
+ .stagger-spring > :nth-child(1) { animation-delay: 0ms; }
279
+ .stagger-spring > :nth-child(2) { animation-delay: 60ms; }
280
+ .stagger-spring > :nth-child(3) { animation-delay: 120ms; }
281
+ .stagger-spring > :nth-child(4) { animation-delay: 180ms; }
282
+ .stagger-spring > :nth-child(5) { animation-delay: 240ms; }
283
+ .stagger-spring > :nth-child(6) { animation-delay: 300ms; }
284
+ .stagger-spring > :nth-child(7) { animation-delay: 360ms; }
285
+ .stagger-spring > :nth-child(8) { animation-delay: 420ms; }
286
+
287
+ /* ------------------------------------------------------------------ */
288
+ /* Spotlight border effect — card illumination on hover */
289
+ /* ------------------------------------------------------------------ */
290
+
291
+ .spotlight-card {
292
+ position: relative;
293
+ isolation: isolate;
294
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
295
+ }
296
+
297
+ .spotlight-card::before {
298
+ content: "";
299
+ position: absolute;
300
+ inset: 0;
301
+ border-radius: inherit;
302
+ background: radial-gradient(
303
+ 500px circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
304
+ color-mix(in srgb, var(--accent) 12%, transparent),
305
+ transparent 40%
306
+ );
307
+ opacity: 0;
308
+ transition: opacity 0.4s ease;
309
+ z-index: -1;
310
+ pointer-events: none;
311
+ }
312
+
313
+ .spotlight-card:hover {
314
+ border-color: color-mix(in srgb, var(--accent) 25%, transparent);
315
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--accent) 8%, transparent);
316
+ }
317
+
318
+ .spotlight-card:hover::before {
319
+ opacity: 1;
320
+ }
321
+
322
+ /* Spotlight row for tables.
323
+ IMPORTANT: no ::before/::after on this class — it sits on <tr>, and a
324
+ generated box on a table row gets wrapped in an ANONYMOUS CELL, silently
325
+ shifting every real cell one column right of the headers. The hover
326
+ sweep is painted as the row's own background instead. */
327
+ .spotlight-row {
328
+ transition: background-color 0.2s ease;
329
+ }
330
+
331
+ .spotlight-row:hover {
332
+ background-color: var(--surface-2);
333
+ background-image: linear-gradient(
334
+ 90deg,
335
+ transparent 0%,
336
+ color-mix(in srgb, var(--accent) 4%, transparent) 50%,
337
+ transparent 100%
338
+ );
339
+ }
340
+
341
+ /* ------------------------------------------------------------------ */
342
+ /* Tactile button states */
343
+ /* ------------------------------------------------------------------ */
344
+
345
+ .tactile {
346
+ transition: transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1),
347
+ background-color 0.2s ease,
348
+ border-color 0.2s ease;
349
+ }
350
+
351
+ .tactile:active {
352
+ transform: scale(0.975) translateY(1px);
353
+ }
354
+
355
+ .tactile:hover:not(:disabled) {
356
+ background-color: var(--surface-2);
357
+ }
358
+
359
+ /* Interactive elements with subtle lift */
360
+ .hover-lift {
361
+ transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1),
362
+ box-shadow 0.2s ease;
363
+ }
364
+
365
+ .hover-lift:hover {
366
+ transform: translateY(-2px);
367
+ box-shadow:
368
+ 0 4px 14px rgba(20, 20, 45, 0.1),
369
+ inset 0 1px 0 color-mix(in srgb, var(--text) 4%, transparent);
370
+ }
371
+
372
+ /* ------------------------------------------------------------------ */
373
+ /* Accent glow effects */
374
+ /* ------------------------------------------------------------------ */
375
+
376
+ .glow-accent-subtle {
377
+ box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 10%, transparent);
378
+ }
379
+
380
+ /* ------------------------------------------------------------------ */
381
+ /* Edge accent bar — vertical phosphor line */
382
+ /* ------------------------------------------------------------------ */
383
+
384
+ .edge-accent {
385
+ position: relative;
386
+ }
387
+
388
+ .edge-accent::before {
389
+ content: "";
390
+ position: absolute;
391
+ left: 0;
392
+ top: 0;
393
+ bottom: 0;
394
+ width: 2px;
395
+ background: linear-gradient(
396
+ to bottom,
397
+ transparent,
398
+ var(--accent) 20%,
399
+ var(--accent) 80%,
400
+ transparent
401
+ );
402
+ opacity: 0.6;
403
+ }
404
+
405
+ /* Mono numerals everywhere data lives */
406
+ .num {
407
+ font-family: var(--font-mono), "IBM Plex Mono", ui-monospace, monospace;
408
+ font-variant-numeric: tabular-nums;
409
+ }
410
+ .tabular {
411
+ font-variant-numeric: tabular-nums;
412
+ }
413
+
414
+ /* ------------------------------------------------------------------ */
415
+ /* Keyframes */
416
+ /* ------------------------------------------------------------------ */
417
+
418
+ @keyframes rise {
419
+ from {
420
+ opacity: 0;
421
+ transform: translateY(14px);
422
+ }
423
+ to {
424
+ opacity: 1;
425
+ transform: translateY(0);
426
+ }
427
+ }
428
+
429
+ /* pair with pathLength={1} + stroke-dasharray: 1 on an SVG path */
430
+ @keyframes drawLine {
431
+ from {
432
+ stroke-dashoffset: 1;
433
+ }
434
+ to {
435
+ stroke-dashoffset: 0;
436
+ }
437
+ }
438
+
439
+ /* ambient ECG sweep: a short bright tracer cycling over the dim base path */
440
+ @keyframes sweep {
441
+ from {
442
+ stroke-dashoffset: 1;
443
+ }
444
+ to {
445
+ stroke-dashoffset: 0;
446
+ }
447
+ }
448
+
449
+ @keyframes shimmer {
450
+ from {
451
+ background-position: -200% 0;
452
+ }
453
+ to {
454
+ background-position: 200% 0;
455
+ }
456
+ }
457
+
458
+ @keyframes pulseDot {
459
+ 0%,
460
+ 100% {
461
+ opacity: 1;
462
+ transform: scale(1);
463
+ }
464
+ 50% {
465
+ opacity: 0.35;
466
+ transform: scale(0.78);
467
+ }
468
+ }
469
+
470
+ @keyframes scaleInX {
471
+ from {
472
+ transform: scaleX(0);
473
+ }
474
+ to {
475
+ transform: scaleX(1);
476
+ }
477
+ }
478
+
479
+ /* ------------------------------------------------------------------ */
480
+ /* Motion helpers — one orchestrated load per route */
481
+ /* ------------------------------------------------------------------ */
482
+
483
+ /* stagger container: children rise with 55ms steps */
484
+ .stagger > * {
485
+ animation: rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
486
+ }
487
+ .stagger > :nth-child(1) {
488
+ animation-delay: 0ms;
489
+ }
490
+ .stagger > :nth-child(2) {
491
+ animation-delay: 55ms;
492
+ }
493
+ .stagger > :nth-child(3) {
494
+ animation-delay: 110ms;
495
+ }
496
+ .stagger > :nth-child(4) {
497
+ animation-delay: 165ms;
498
+ }
499
+ .stagger > :nth-child(5) {
500
+ animation-delay: 220ms;
501
+ }
502
+ .stagger > :nth-child(6) {
503
+ animation-delay: 275ms;
504
+ }
505
+ .stagger > :nth-child(7) {
506
+ animation-delay: 330ms;
507
+ }
508
+ .stagger > :nth-child(8) {
509
+ animation-delay: 385ms;
510
+ }
511
+ .stagger > :nth-child(9) {
512
+ animation-delay: 440ms;
513
+ }
514
+ .stagger > :nth-child(10) {
515
+ animation-delay: 495ms;
516
+ }
517
+ .stagger > :nth-child(11) {
518
+ animation-delay: 550ms;
519
+ }
520
+ .stagger > :nth-child(12) {
521
+ animation-delay: 605ms;
522
+ }
523
+
524
+ /* manual stagger steps for non-sibling layouts */
525
+ .delay-1 {
526
+ animation-delay: 55ms;
527
+ }
528
+ .delay-2 {
529
+ animation-delay: 110ms;
530
+ }
531
+ .delay-3 {
532
+ animation-delay: 165ms;
533
+ }
534
+ .delay-4 {
535
+ animation-delay: 220ms;
536
+ }
537
+
538
+ /* skeleton block: surface placeholder swept by the shimmer keyframes */
539
+ .skeleton {
540
+ position: relative;
541
+ overflow: hidden;
542
+ border-radius: 2px;
543
+ background-color: var(--surface-2);
544
+ background-image: linear-gradient(
545
+ 100deg,
546
+ transparent 40%,
547
+ color-mix(in srgb, var(--text) 7%, transparent) 50%,
548
+ transparent 60%
549
+ );
550
+ background-size: 200% 100%;
551
+ animation: shimmer 1.8s linear infinite;
552
+ }
553
+
554
+ /* shimmering surface for status=running chips */
555
+ .chip-shimmer {
556
+ background-image: linear-gradient(
557
+ 100deg,
558
+ color-mix(in srgb, var(--accent) 7%, transparent) 40%,
559
+ color-mix(in srgb, var(--accent) 22%, transparent) 50%,
560
+ color-mix(in srgb, var(--accent) 7%, transparent) 60%
561
+ );
562
+ background-size: 200% 100%;
563
+ animation: shimmer 2.4s linear infinite;
564
+ }
565
+
566
+ .pulse-dot {
567
+ animation: pulseDot 1.4s ease-in-out infinite;
568
+ }
569
+
570
+ .draw-line {
571
+ stroke-dasharray: 1;
572
+ animation: drawLine 1.4s cubic-bezier(0.4, 0, 0.2, 1) 0.15s both;
573
+ }
574
+
575
+ /* pair with pathLength={1}: 6%-long phosphor tracer, ~7s per pass */
576
+ .pulse-sweep {
577
+ stroke-dasharray: 0.06 0.94;
578
+ animation: sweep 7s linear infinite;
579
+ }
580
+
581
+ /* CI whisker draw-in: the micro-bar sweeps open left → right once */
582
+ .ci-draw {
583
+ transform-origin: left center;
584
+ animation: scaleInX 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
585
+ }
586
+
587
+ /* NOTE: scroll-driven entrances (animation-timeline: view()) were tried
588
+ and removed — with fill-mode both, content whose timeline binds to the
589
+ wrong scroll container (e.g. rows inside overflow-x-auto) can be stuck
590
+ at opacity 0 forever. Entrances are load-orchestrated only. */
591
+
592
+ /* ------------------------------------------------------------------ */
593
+ /* Scroll-triggered animations (Intersection Observer based) */
594
+ /* ------------------------------------------------------------------ */
595
+
596
+ /* Base scroll animation classes */
597
+ .scroll-animate {
598
+ opacity: 0;
599
+ transform: translateY(24px);
600
+ transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1),
601
+ transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
602
+ }
603
+
604
+ .scroll-animate.in-view {
605
+ opacity: 1;
606
+ transform: translateY(0);
607
+ }
608
+
609
+ /* Scroll scale animation */
610
+ .scroll-scale {
611
+ opacity: 0;
612
+ transform: scale(0.95);
613
+ transition: opacity 0.5s cubic-bezier(0.22, 1, 0.36, 1),
614
+ transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
615
+ }
616
+
617
+ .scroll-scale.in-view {
618
+ opacity: 1;
619
+ transform: scale(1);
620
+ }
621
+
622
+ /* Scroll slide animations */
623
+ .scroll-slide-left {
624
+ opacity: 0;
625
+ transform: translateX(-24px);
626
+ transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
627
+ transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
628
+ }
629
+
630
+ .scroll-slide-left.in-view {
631
+ opacity: 1;
632
+ transform: translateX(0);
633
+ }
634
+
635
+ .scroll-slide-right {
636
+ opacity: 0;
637
+ transform: translateX(24px);
638
+ transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
639
+ transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
640
+ }
641
+
642
+ .scroll-slide-right.in-view {
643
+ opacity: 1;
644
+ transform: translateX(0);
645
+ }
646
+
647
+ .scroll-slide-down {
648
+ opacity: 0;
649
+ transform: translateY(-24px);
650
+ transition: opacity 0.4s cubic-bezier(0.22, 1, 0.36, 1),
651
+ transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
652
+ }
653
+
654
+ .scroll-slide-down.in-view {
655
+ opacity: 1;
656
+ transform: translateY(0);
657
+ }
658
+
659
+ /* Scroll fade animation */
660
+ .scroll-fade {
661
+ opacity: 0;
662
+ transition: opacity 0.4s ease-out;
663
+ }
664
+
665
+ .scroll-fade.in-view {
666
+ opacity: 1;
667
+ }
668
+
669
+ /* Stagger children on scroll */
670
+ .scroll-stagger > * {
671
+ opacity: 0;
672
+ transform: translateY(16px);
673
+ transition: opacity 0.4s ease-out,
674
+ transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
675
+ }
676
+
677
+ .scroll-stagger.in-view > *:nth-child(1) { opacity: 1; transform: translateY(0); transition-delay: 0ms; }
678
+ .scroll-stagger.in-view > *:nth-child(2) { opacity: 1; transform: translateY(0); transition-delay: 60ms; }
679
+ .scroll-stagger.in-view > *:nth-child(3) { opacity: 1; transform: translateY(0); transition-delay: 120ms; }
680
+ .scroll-stagger.in-view > *:nth-child(4) { opacity: 1; transform: translateY(0); transition-delay: 180ms; }
681
+ .scroll-stagger.in-view > *:nth-child(5) { opacity: 1; transform: translateY(0); transition-delay: 240ms; }
682
+ .scroll-stagger.in-view > *:nth-child(6) { opacity: 1; transform: translateY(0); transition-delay: 300ms; }
683
+ .scroll-stagger.in-view > *:nth-child(7) { opacity: 1; transform: translateY(0); transition-delay: 360ms; }
684
+ .scroll-stagger.in-view > *:nth-child(8) { opacity: 1; transform: translateY(0); transition-delay: 420ms; }
685
+
686
+ /* ------------------------------------------------------------------ */
687
+ /* Micro-interactions */
688
+ /* ------------------------------------------------------------------ */
689
+
690
+ /* ------------------------------------------------------------------ */
691
+ /* Card depth variations */
692
+ /* ------------------------------------------------------------------ */
693
+
694
+ .depth-1 {
695
+ box-shadow: 0 1px 3px rgba(20, 20, 45, 0.06);
696
+ }
697
+
698
+ .depth-2 {
699
+ box-shadow: 0 2px 10px rgba(20, 20, 45, 0.07);
700
+ }
701
+
702
+ .depth-3 {
703
+ box-shadow:
704
+ 0 8px 28px rgba(20, 20, 45, 0.09),
705
+ 0 2px 6px rgba(20, 20, 45, 0.05);
706
+ }
707
+
708
+ /* ------------------------------------------------------------------ */
709
+ /* Full-bleed breakout — a block wider than the page container, */
710
+ /* centered on the viewport (parent must be a centered max-w column) */
711
+ /* ------------------------------------------------------------------ */
712
+
713
+ @media (min-width: 80rem) {
714
+ .breakout-wide {
715
+ --breakout-w: min(100vw - 5rem, 85rem);
716
+ width: var(--breakout-w);
717
+ margin-left: calc(50% - var(--breakout-w) / 2);
718
+ }
719
+ }
720
+
721
+ /* ------------------------------------------------------------------ */
722
+ /* Nav sweep — a phosphor tracer riding the sticky nav's bottom */
723
+ /* hairline; the instrument is always on */
724
+ /* ------------------------------------------------------------------ */
725
+
726
+ .nav-sweep {
727
+ position: absolute;
728
+ inset-inline: 0;
729
+ bottom: -1px;
730
+ height: 1px;
731
+ overflow: hidden;
732
+ pointer-events: none;
733
+ }
734
+
735
+ .nav-sweep::before {
736
+ content: "";
737
+ position: absolute;
738
+ top: 0;
739
+ left: 0;
740
+ height: 100%;
741
+ width: 140px;
742
+ background: linear-gradient(
743
+ 90deg,
744
+ transparent,
745
+ color-mix(in srgb, var(--accent) 55%, transparent),
746
+ transparent
747
+ );
748
+ animation: nav-sweep 9s linear infinite;
749
+ }
750
+
751
+ @keyframes nav-sweep {
752
+ from {
753
+ transform: translateX(-140px);
754
+ }
755
+ to {
756
+ transform: translateX(100vw);
757
+ }
758
+ }
759
+
760
+ /* ------------------------------------------------------------------ */
761
+ /* Reduced motion: everything settles instantly */
762
+ /* ------------------------------------------------------------------ */
763
+
764
+ @media (prefers-reduced-motion: reduce) {
765
+ *,
766
+ *::before,
767
+ *::after {
768
+ animation-duration: 0.001ms !important;
769
+ animation-delay: 0ms !important;
770
+ animation-iteration-count: 1 !important;
771
+ animation-timeline: auto !important;
772
+ transition-duration: 0.001ms !important;
773
+ scroll-behavior: auto !important;
774
+ }
775
+ /* ambient effects are pure ambience — remove them when motion is reduced */
776
+ .pulse-sweep {
777
+ visibility: hidden;
778
+ }
779
+ .nav-sweep {
780
+ display: none;
781
+ }
782
+ /* ambient glow drift stops; the static light wash may remain */
783
+ .ambient-glow {
784
+ animation: none;
785
+ will-change: auto;
786
+ }
787
+ /* skeletons settle to a static placeholder */
788
+ .skeleton {
789
+ animation: none;
790
+ background-image: none;
791
+ }
792
+ /* grain and scanline remain as they don't animate */
793
+ body::before,
794
+ body::after {
795
+ display: none;
796
+ }
797
+ /* spotlight effects collapse to static */
798
+ .spotlight-card::before {
799
+ display: none;
800
+ }
801
+ /* hover lifts settle */
802
+ .hover-lift:hover {
803
+ transform: none;
804
+ }
805
+ /* glows collapse */
806
+ .glow-accent-subtle {
807
+ box-shadow: none;
808
+ }
809
+ /* scroll animations resolve immediately */
810
+ .scroll-animate,
811
+ .scroll-scale,
812
+ .scroll-slide-left,
813
+ .scroll-slide-right,
814
+ .scroll-slide-down,
815
+ .scroll-fade {
816
+ opacity: 1;
817
+ transform: none;
818
+ }
819
+ .scroll-stagger > * {
820
+ opacity: 1;
821
+ transform: none;
822
+ }
823
  }
src/app/icon.svg ADDED
src/app/layout.tsx CHANGED
@@ -1,33 +1,140 @@
1
  import type { Metadata } from "next";
2
- import { Geist, Geist_Mono } from "next/font/google";
 
 
 
 
3
  import "./globals.css";
4
 
5
- const geistSans = Geist({
6
- variable: "--font-geist-sans",
 
7
  subsets: ["latin"],
 
8
  });
9
 
10
- const geistMono = Geist_Mono({
11
- variable: "--font-geist-mono",
 
 
 
 
 
 
12
  subsets: ["latin"],
 
13
  });
14
 
15
  export const metadata: Metadata = {
16
- title: "Create Next App",
17
- description: "Generated by create next app",
 
 
 
 
18
  };
19
 
 
 
 
20
  export default function RootLayout({
21
  children,
22
  }: Readonly<{
23
  children: React.ReactNode;
24
  }>) {
 
25
  return (
26
  <html
27
  lang="en"
28
- className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
  >
30
- <body className="min-h-full flex flex-col">{children}</body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </html>
32
  );
33
  }
 
1
  import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { Be_Vietnam_Pro, IBM_Plex_Mono, Instrument_Serif } from "next/font/google";
4
+ import SiteNav from "@/components/SiteNav";
5
+ import AmbientEffects from "@/components/ui/AmbientEffects";
6
+ import { getDataset, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data";
7
  import "./globals.css";
8
 
9
+ const display = Instrument_Serif({
10
+ weight: "400",
11
+ style: ["normal", "italic"],
12
  subsets: ["latin"],
13
+ variable: "--font-display",
14
  });
15
 
16
+ const body = Be_Vietnam_Pro({
17
+ weight: ["400", "500", "600", "700"],
18
+ subsets: ["vietnamese", "latin"],
19
+ variable: "--font-body",
20
+ });
21
+
22
+ const mono = IBM_Plex_Mono({
23
+ weight: ["400", "500", "600"],
24
  subsets: ["latin"],
25
+ variable: "--font-mono",
26
  });
27
 
28
  export const metadata: Metadata = {
29
+ title: {
30
+ default: "MedASR Bench Vietnamese Medical ASR Benchmark",
31
+ template: "%s · MedASR Bench",
32
+ },
33
+ description:
34
+ "Public benchmark for Vietnamese medical speech recognition with English code-switching, anchored on ViMedCSS. Part of the Clinical Scribe program.",
35
  };
36
 
37
+ const footerLink =
38
+ "transition-colors duration-200 ease-out hover:text-text focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70";
39
+
40
  export default function RootLayout({
41
  children,
42
  }: Readonly<{
43
  children: React.ReactNode;
44
  }>) {
45
+ const dataset = getDataset();
46
  return (
47
  <html
48
  lang="en"
49
+ className={`${display.variable} ${body.variable} ${mono.variable} h-full antialiased`}
50
  >
51
+ <body className="flex min-h-full flex-col scroll-smooth">
52
+ <AmbientEffects />
53
+ <SiteNav />
54
+ <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-10 md:px-6">
55
+ {children}
56
+ </main>
57
+ <footer className="edge-accent hairline-t mt-16">
58
+ <div className="mx-auto grid max-w-6xl gap-8 px-4 py-7 md:grid-cols-3 md:px-6">
59
+ {/* wordmark + program */}
60
+ <div className="flex flex-col gap-1.5">
61
+ <p className="font-display text-lg leading-none text-text">
62
+ <em className="italic">Med</em>ASR&nbsp;Bench
63
+ </p>
64
+ <p className="text-[12px] leading-relaxed text-muted">
65
+ Part of the <span className="text-text">Clinical Scribe</span>{" "}
66
+ program — Vietnamese medical ASR, measured in the open.
67
+ </p>
68
+ </div>
69
+
70
+ {/* navigation echo + citation */}
71
+ <nav aria-label="Footer" className="text-[12px] text-muted">
72
+ <ul className="grid grid-cols-2 gap-x-6 gap-y-1.5">
73
+ <li>
74
+ <Link href="/" className={footerLink}>
75
+ Leaderboard
76
+ </Link>
77
+ </li>
78
+ <li>
79
+ <Link href="/compare" className={footerLink}>
80
+ Compare
81
+ </Link>
82
+ </li>
83
+ <li>
84
+ <Link href="/datasets" className={footerLink}>
85
+ Datasets
86
+ </Link>
87
+ </li>
88
+ <li>
89
+ <Link href="/methodology" className={footerLink}>
90
+ Methodology
91
+ </Link>
92
+ </li>
93
+ <li>
94
+ <a
95
+ href={`https://arxiv.org/abs/${dataset.arxiv}`}
96
+ target="_blank"
97
+ rel="noreferrer"
98
+ className={`num ${footerLink}`}
99
+ >
100
+ Cite · arXiv:{dataset.arxiv}
101
+ </a>
102
+ </li>
103
+ <li>
104
+ <a
105
+ href={`https://huggingface.co/datasets/${dataset.hf}`}
106
+ target="_blank"
107
+ rel="noreferrer"
108
+ className={`num ${footerLink}`}
109
+ >
110
+ HF · {dataset.hf}
111
+ </a>
112
+ </li>
113
+ </ul>
114
+ </nav>
115
+
116
+ {/* mono colophon */}
117
+ <dl className="num flex flex-col gap-1.5 text-[11px] tracking-wide text-muted/80 md:text-right">
118
+ <div>
119
+ <dt className="sr-only">Normalizer</dt>
120
+ <dd>normalizer {NORMALIZER_VERSION}</dd>
121
+ </div>
122
+ <div>
123
+ <dt className="sr-only">Harness</dt>
124
+ <dd>{HARNESS_VERSION}</dd>
125
+ </div>
126
+ <div>
127
+ <dt className="sr-only">License</dt>
128
+ <dd>benchmark data {dataset.license}</dd>
129
+ </div>
130
+ <div>
131
+ <dt className="sr-only">API snapshot policy</dt>
132
+ <dd>API results are date-stamped snapshots</dd>
133
+ </div>
134
+ </dl>
135
+ </div>
136
+ </footer>
137
+ </body>
138
  </html>
139
  );
140
  }
src/app/loading.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Skeleton,
3
+ SkeletonStats,
4
+ SkeletonTable,
5
+ SkeletonText,
6
+ } from "@/components/ui/LoadingSkeleton";
7
+
8
+ /**
9
+ * Route-level loading state for the leaderboard home. Mirrors the page
10
+ * skeleton-for-skeleton: kicker + headline + lede, dataset chip strip,
11
+ * anchor-dataset stats band, then filter controls and the table. All shimmer
12
+ * comes from the `.skeleton` class, which the global prefers-reduced-motion
13
+ * override settles to a static placeholder.
14
+ */
15
+ export default function Loading() {
16
+ return (
17
+ <div aria-busy="true" className="flex flex-col gap-12 md:gap-16">
18
+ <p role="status" className="sr-only">
19
+ Loading the leaderboard…
20
+ </p>
21
+
22
+ {/* ---------- hero ---------- */}
23
+ <section aria-hidden>
24
+ <Skeleton className="h-3" width={220} />
25
+ <Skeleton className="mt-5 h-12 md:h-14" width="min(38rem, 95%)" />
26
+ <Skeleton className="mt-3 h-12 md:h-14" width="min(22rem, 65%)" />
27
+ <SkeletonText className="mt-6 max-w-xl" lines={2} lastLineWidth="45%" />
28
+ {/* dataset chip strip */}
29
+ <div className="mt-8 flex flex-wrap items-center gap-2">
30
+ <Skeleton className="h-3.5" width={64} />
31
+ <Skeleton className="h-[26px]" width={92} />
32
+ <Skeleton className="h-[26px]" width={108} />
33
+ <Skeleton className="h-[26px]" width={132} />
34
+ </div>
35
+ </section>
36
+
37
+ {/* ---------- anchor-dataset stats band ---------- */}
38
+ <section aria-hidden className="flex flex-col gap-px">
39
+ <Skeleton className="h-9 rounded-b-none" />
40
+ <SkeletonStats
41
+ className="grid-cols-2 rounded-t-none md:grid-cols-4"
42
+ count={4}
43
+ />
44
+ </section>
45
+
46
+ {/* ---------- leaderboard: title + controls + table ---------- */}
47
+ <section aria-hidden className="flex flex-col gap-4">
48
+ <Skeleton className="h-8" width="min(24rem, 80%)" />
49
+ <div className="flex flex-wrap items-center gap-2">
50
+ <Skeleton className="h-7" width={140} />
51
+ <Skeleton className="h-7" width={176} />
52
+ <Skeleton className="h-7" width={232} />
53
+ <Skeleton className="ml-auto h-8 w-full max-w-52" />
54
+ </div>
55
+ <SkeletonTable rows={10} columns={8} />
56
+ </section>
57
+ </div>
58
+ );
59
+ }
src/app/methodology/page.tsx ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { AlertTriangle, ArrowRight } from "lucide-react";
4
+ import Badge from "@/components/ui/Badge";
5
+ import CIBar from "@/components/ui/CIBar";
6
+ import Card from "@/components/ui/Card";
7
+ import ScrollReveal, { Stagger } from "@/components/ui/ScrollReveal";
8
+ import SectionHeading from "@/components/ui/SectionHeading";
9
+ import ManifestCard from "@/components/docs/ManifestCard";
10
+ import MetricDefCard from "@/components/docs/MetricDefCard";
11
+ import OnboardingCriteria from "@/components/docs/OnboardingCriteria";
12
+ import ProvenanceLadder from "@/components/docs/ProvenanceLadder";
13
+ import TracksTable from "@/components/docs/TracksTable";
14
+
15
+ export const metadata: Metadata = {
16
+ title: "Methodology",
17
+ description:
18
+ "Metric definitions, evaluation tracks, provenance classes, reproducibility policy, and dataset onboarding criteria for MedASR Bench.",
19
+ };
20
+
21
+ const NORMALIZER_STEPS = [
22
+ "Unicode NFC normalization",
23
+ "Lowercase",
24
+ "Punctuation → space",
25
+ "Collapse repeated whitespace",
26
+ "Digits kept as-is",
27
+ ];
28
+
29
+ export default function MethodologyPage() {
30
+ return (
31
+ <div className="spring-up flex flex-col gap-12 md:gap-16">
32
+ {/* 1 · Header */}
33
+ <SectionHeading
34
+ as="h1"
35
+ eyebrow="04 · Methodology"
36
+ title="The instrument’s calibration sheet"
37
+ sub="What every metric means, what each track allows, how much to trust a row, how to reproduce it — and what a dataset must clear to join the hub. Every number on this platform is a measurement; this page defines the measurement, identically for every dataset."
38
+ />
39
+
40
+ {/* 2 · Metrics */}
41
+ <section className="flex flex-col gap-5">
42
+ <SectionHeading
43
+ eyebrow="04.1 · Metrics"
44
+ title="Six numbers per run"
45
+ sub="All metrics are computed by the platform harness from raw transcripts after normalization — WER-family values are percentages, lower is better unless marked otherwise."
46
+ />
47
+ <Stagger className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
48
+ <MetricDefCard
49
+ abbr="WER"
50
+ name="Word error rate"
51
+ formula={"WER = (S + D + I) / N × 100"}
52
+ direction="lower is better"
53
+ >
54
+ The headline metric: word-level edit distance against the
55
+ human-verified reference, after normalizer v0.1. S, D, I are
56
+ substitutions, deletions, insertions; N is reference words.
57
+ </MetricDefCard>
58
+ <MetricDefCard
59
+ abbr="CER"
60
+ name="Character error rate"
61
+ formula={"CER = (S + D + I) / N_chars × 100"}
62
+ direction="lower is better"
63
+ >
64
+ The same edit distance at character level — robust to tokenization
65
+ choices and a fairer lens on Vietnamese diacritics.
66
+ </MetricDefCard>
67
+ <MetricDefCard
68
+ abbr="CS-WER"
69
+ name="Code-switch word error rate"
70
+ formula={"WER over tokens ∈ code-switched spans"}
71
+ direction="lower is better"
72
+ >
73
+ WER restricted to code-switched English spans — the clinical
74
+ failure mode this benchmark exists to expose. Vietnamese-specialized
75
+ models win overall WER yet lose here.
76
+ </MetricDefCard>
77
+ <MetricDefCard
78
+ abbr="N-WER"
79
+ name="Native word error rate"
80
+ formula={"WER over Vietnamese-only tokens"}
81
+ direction="lower is better"
82
+ >
83
+ The mirror image of CS-WER: WER over Vietnamese-only tokens with
84
+ code-switched spans excluded. Multilingual models invert the
85
+ pattern — strong here is rare when CS-WER is strong too.
86
+ </MetricDefCard>
87
+ <MetricDefCard
88
+ abbr="MTR"
89
+ name="Medical term recall"
90
+ formula={"MTR = |terms recovered| / |terms in reference|"}
91
+ direction="higher is better"
92
+ >
93
+ Share of reference medical terms recovered in the hypothesis. The
94
+ exact tier requires verbatim recovery; the fuzzy tier tolerates
95
+ minor surface variation.
96
+ </MetricDefCard>
97
+ <MetricDefCard
98
+ abbr="RTFx"
99
+ name="Real-time factor (inverted)"
100
+ formula={"RTFx = audio seconds / wall-clock seconds"}
101
+ direction="higher is better"
102
+ >
103
+ Throughput during the platform run on the manifest’s declared
104
+ hardware. Above 1.0 means faster than real time.
105
+ </MetricDefCard>
106
+ </Stagger>
107
+
108
+ <div className="grid gap-4 lg:grid-cols-2">
109
+ <Card className="flex flex-col gap-3">
110
+ <h3 className="font-display text-xl text-text">
111
+ Confidence intervals &amp; the SOTA rule
112
+ </h3>
113
+ <p className="text-[13px] leading-relaxed text-muted">
114
+ Headline metrics carry bootstrap 95% confidence intervals from
115
+ utterance-level resampling, rendered as whisker micro-bars beside
116
+ every value. The policy is strict:{" "}
117
+ <strong className="font-medium text-text">
118
+ no SOTA badge without significance
119
+ </strong>{" "}
120
+ — a model is never promoted while its CI overlaps the
121
+ incumbent’s.
122
+ </p>
123
+ <div className="hairline flex flex-col gap-2 rounded-sm bg-surface-2 px-4 py-3">
124
+ <p className="num text-[10px] uppercase tracking-[0.16em] text-muted">
125
+ Illustration — overlapping CIs, no badge awarded
126
+ </p>
127
+ <div className="flex items-center gap-3">
128
+ <span className="num w-24 text-[12px] text-muted">incumbent</span>
129
+ <span className="num text-[13px] text-text">24.80</span>
130
+ <CIBar value={24.8} ci={[23.9, 25.7]} />
131
+ </div>
132
+ <div className="flex items-center gap-3">
133
+ <span className="num w-24 text-[12px] text-muted">candidate</span>
134
+ <span className="num text-[13px] text-text">23.90</span>
135
+ <CIBar value={23.9} ci={[23.0, 24.8]} />
136
+ </div>
137
+ </div>
138
+ </Card>
139
+
140
+ <Card className="flex flex-col gap-3">
141
+ <h3 className="font-display text-xl text-text">Normalizer v0.1</h3>
142
+ <p className="text-[13px] leading-relaxed text-muted">
143
+ Both reference and hypothesis pass through the same normalizer
144
+ before scoring. The version is recorded in every run manifest.
145
+ </p>
146
+ <ol className="num flex flex-col gap-1.5 text-[12px] text-text">
147
+ {NORMALIZER_STEPS.map((step, i) => (
148
+ <li key={step} className="flex items-baseline gap-3">
149
+ <span className="text-muted">{String(i + 1).padStart(2, "0")}</span>
150
+ {step}
151
+ </li>
152
+ ))}
153
+ </ol>
154
+ <p className="mt-auto flex items-start gap-2 border-t border-warn/20 pt-3 text-[12px] leading-relaxed text-warn">
155
+ <AlertTriangle size={14} aria-hidden className="mt-0.5 shrink-0" />
156
+ WER is only comparable within a normalizer version. Cross-version
157
+ numbers are never ranked against each other.
158
+ </p>
159
+ </Card>
160
+ </div>
161
+
162
+ <Stagger className="grid gap-4 md:grid-cols-3">
163
+ <MetricDefCard abbr="MTR-fb" name="Frequency-bucketed MTR" roadmap>
164
+ Term recall split by training-frequency buckets — separates
165
+ memorized vocabulary from genuine generalization.
166
+ </MetricDefCard>
167
+ <MetricDefCard abbr="CTER" name="Critical term error rate" roadmap>
168
+ Error rate over confusable drug-name pairs, where a substitution is
169
+ a patient-safety event rather than a typo.
170
+ </MetricDefCard>
171
+ <MetricDefCard abbr="LAT / $" name="API latency & cost" roadmap>
172
+ p50/p90 latency and dollars per audio-hour for commercial APIs,
173
+ captured during date-stamped snapshot runs.
174
+ </MetricDefCard>
175
+ </Stagger>
176
+ </section>
177
+
178
+ {/* 3 · Tracks */}
179
+ <ScrollReveal>
180
+ <section className="flex flex-col gap-5">
181
+ <SectionHeading
182
+ eyebrow="04.2 · Tracks"
183
+ title="Six tracks, one axis: allowed signal"
184
+ sub="A track defines exactly what a system may know about a dataset's audio and labels before it transcribes a single clip. Rows only compete within their track, on the same dataset."
185
+ />
186
+ <TracksTable />
187
+ </section>
188
+ </ScrollReveal>
189
+
190
+ {/* 4 · Provenance */}
191
+ <ScrollReveal>
192
+ <section className="flex flex-col gap-5">
193
+ <SectionHeading
194
+ eyebrow="04.3 · Provenance"
195
+ title="Trust is a ladder, not a boolean"
196
+ sub="Every row declares where its numbers came from, and the chip follows the number everywhere it appears."
197
+ />
198
+ <ProvenanceLadder />
199
+ <Card className="flex flex-col gap-2">
200
+ <div className="flex flex-wrap items-center gap-2">
201
+ <h3 className="font-display text-xl text-text">
202
+ Commercial APIs are snapshots
203
+ </h3>
204
+ <Badge variant="outline">date-stamped</Badge>
205
+ <Badge variant="ghost">quarterly drift re-runs · roadmap</Badge>
206
+ </div>
207
+ <p className="text-[13px] leading-relaxed text-muted">
208
+ API systems change behind stable names, so their rows are
209
+ date-stamped snapshots of the service on the day of the run.
210
+ Quarterly drift re-runs are on the roadmap; superseded snapshots
211
+ stay visible with their dates instead of being overwritten.
212
+ </p>
213
+ </Card>
214
+ </section>
215
+ </ScrollReveal>
216
+
217
+ {/* 5 · Reproducibility */}
218
+ <ScrollReveal>
219
+ <section className="flex flex-col gap-5">
220
+ <SectionHeading
221
+ eyebrow="04.4 · Reproducibility"
222
+ title="Every number carries its manifest"
223
+ sub="A result you cannot reproduce is an anecdote. Platform-verified runs publish the complete manifest below."
224
+ />
225
+ <ManifestCard />
226
+ <Card className="flex flex-col gap-3">
227
+ <h3 className="font-display text-xl text-text">Anti-gaming policy</h3>
228
+ <ul className="flex flex-col gap-2.5 text-[13px] leading-relaxed text-muted">
229
+ <li className="flex gap-3">
230
+ <span aria-hidden className="num text-muted/60">01</span>
231
+ Provenance is declared per row and rendered beside the number
232
+ everywhere it appears — it never washes out in aggregation.
233
+ </li>
234
+ <li className="flex gap-3">
235
+ <span aria-hidden className="num text-muted/60">02</span>
236
+ Contamination flags are permanent and scoped per dataset. On
237
+ ViMedCSS, Gemini-family models seeded the labels and carry the
238
+ amber flag forever, regardless of later scores.
239
+ </li>
240
+ <li className="flex gap-3">
241
+ <span aria-hidden className="num text-muted/60">03</span>
242
+ Where a dataset’s test labels are public — as ViMedCSS’s are —
243
+ self-reported numbers are honor-system today. A private canary
244
+ subset is on the roadmap to detect test-set training.
245
+ </li>
246
+ <li className="flex gap-3">
247
+ <span aria-hidden className="num text-muted/60">04</span>
248
+ Commercial APIs are evaluated as date-stamped snapshots; history
249
+ is preserved, never overwritten.
250
+ </li>
251
+ </ul>
252
+ </Card>
253
+ </section>
254
+ </ScrollReveal>
255
+
256
+ {/* 6 · Dataset onboarding */}
257
+ <ScrollReveal>
258
+ <section id="onboarding" className="flex scroll-mt-28 flex-col gap-5">
259
+ <SectionHeading
260
+ eyebrow="04.5 · Onboarding"
261
+ title="What it takes to join the hub"
262
+ sub="MedASR Bench is a multi-dataset hub: any Vietnamese medical speech corpus clearing six criteria mounts into the same harness and becomes directly comparable. ViMedCSS cleared this bar first; VietMed and the private clinic set are next."
263
+ />
264
+ <OnboardingCriteria />
265
+ <Card className="flex flex-col gap-3">
266
+ <h3 className="font-display text-xl text-text">
267
+ Snapshots &amp; versioning
268
+ </h3>
269
+ <ul className="flex flex-col gap-2.5 text-[13px] leading-relaxed text-muted">
270
+ <li className="flex gap-3">
271
+ <span aria-hidden className="num text-muted/60">01</span>
272
+ <span>
273
+ Datasets mount as frozen, versioned snapshots (e.g.{" "}
274
+ <span className="num text-text">vimedcss v1</span>). Split
275
+ membership never changes within a version.
276
+ </span>
277
+ </li>
278
+ <li className="flex gap-3">
279
+ <span aria-hidden className="num text-muted/60">02</span>
280
+ Any revision — new clips, relabeled spans, re-frozen splits —
281
+ creates a new dataset version and re-runs the affected
282
+ leaderboards. Old numbers stay visible, scoped to their version,
283
+ never silently edited.
284
+ </li>
285
+ <li className="flex gap-3">
286
+ <span aria-hidden className="num text-muted/60">03</span>
287
+ Every run manifest records the dataset version alongside the
288
+ normalizer and harness versions; numbers are only ranked within
289
+ matching versions.
290
+ </li>
291
+ </ul>
292
+ <Link
293
+ href="/datasets"
294
+ className="num mt-1 inline-flex items-center gap-1.5 self-start rounded-sm border border-line px-3 py-1.5 text-[12px] tracking-wide text-text transition-colors duration-200 hover:border-accent/40 hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
295
+ >
296
+ See the dataset registry
297
+ <ArrowRight size={13} aria-hidden />
298
+ </Link>
299
+ </Card>
300
+ </section>
301
+ </ScrollReveal>
302
+ </div>
303
+ );
304
+ }
src/app/models/[slug]/loading.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ Skeleton,
3
+ SkeletonCard,
4
+ SkeletonStats,
5
+ SkeletonText,
6
+ } from "@/components/ui/LoadingSkeleton";
7
+
8
+ /**
9
+ * Route-level skeleton for the model-detail page. Mirrors the real layout:
10
+ * identity header, dataset scope bar, the Results — <dataset> metric grid
11
+ * (with the frontier thumb column on lg), then generalization + manifest
12
+ * cards. Server-safe — all shimmer comes from the .skeleton CSS class, so
13
+ * prefers-reduced-motion collapses it to static blocks.
14
+ */
15
+ export default function Loading() {
16
+ return (
17
+ <div
18
+ aria-busy="true"
19
+ aria-label="Loading model profile"
20
+ className="flex flex-col gap-10"
21
+ >
22
+ {/* 01 — model identity header */}
23
+ <div className="hairline-b pb-6">
24
+ <Skeleton className="mb-3 h-3" width={210} />
25
+ <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
26
+ <Skeleton className="h-10 md:h-12" width="48%" />
27
+ <Skeleton className="h-6" width={132} />
28
+ </div>
29
+ <div className="mt-4 flex flex-wrap items-center gap-3">
30
+ <Skeleton className="h-4" width={96} />
31
+ <Skeleton className="h-5" width={72} />
32
+ <Skeleton className="h-5" width={150} />
33
+ </div>
34
+ <SkeletonText className="mt-4 max-w-2xl" lines={2} lastLineWidth="40%" />
35
+ </div>
36
+
37
+ {/* dataset scope bar */}
38
+ <div className="hairline flex items-center justify-between gap-6 rounded-sm px-4 py-3">
39
+ <div className="flex items-center gap-3">
40
+ <Skeleton className="h-3" width={72} />
41
+ <Skeleton className="h-5" width={110} />
42
+ <Skeleton className="hidden h-3 sm:block" width={220} />
43
+ </div>
44
+ <Skeleton className="h-3" width={96} />
45
+ </div>
46
+
47
+ {/* 02 — results grid + frontier thumb column */}
48
+ <div className="grid gap-8 lg:grid-cols-[1fr_272px] lg:items-start">
49
+ <div className="flex flex-col gap-4">
50
+ <div className="flex flex-wrap items-end justify-between gap-3">
51
+ <Skeleton className="h-6" width={230} />
52
+ <Skeleton className="h-7" width={140} />
53
+ </div>
54
+ <SkeletonStats
55
+ count={6}
56
+ className="grid-cols-2 md:grid-cols-3"
57
+ />
58
+ <Skeleton className="h-3" width="70%" />
59
+ </div>
60
+ <div className="flex flex-col gap-4">
61
+ <Skeleton className="h-3" width={170} />
62
+ <div className="hairline rounded-sm bg-surface p-4">
63
+ <Skeleton className="h-44 w-full" />
64
+ <Skeleton className="mt-3 h-3" width="85%" />
65
+ </div>
66
+ </div>
67
+ </div>
68
+
69
+ {/* 03 — generalization strip */}
70
+ <div className="flex flex-col gap-4">
71
+ <Skeleton className="h-6" width={190} />
72
+ <div className="flex flex-wrap gap-3">
73
+ <SkeletonCard className="min-w-[160px] flex-none" lines={1} />
74
+ <SkeletonCard className="min-w-[160px] flex-none" lines={1} />
75
+ </div>
76
+ </div>
77
+
78
+ {/* 04/05 — per-topic + run manifest */}
79
+ <SkeletonCard lines={3} />
80
+ <SkeletonCard lines={4} />
81
+ </div>
82
+ );
83
+ }
src/app/models/[slug]/page.tsx ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { notFound } from "next/navigation";
4
+ import { ArrowLeft, ArrowUpRight } from "lucide-react";
5
+ import DatasetScope from "@/components/model/DatasetScope";
6
+ import FrontierThumb from "@/components/model/FrontierThumb";
7
+ import GeneralizationStrip from "@/components/model/GeneralizationStrip";
8
+ import MetricPanel from "@/components/model/MetricPanel";
9
+ import ModelHeader from "@/components/model/ModelHeader";
10
+ import RunManifest from "@/components/model/RunManifest";
11
+ import StatusNotice from "@/components/model/StatusNotice";
12
+ import TopicBreakdown from "@/components/model/TopicBreakdown";
13
+ import PulseLine from "@/components/ui/PulseLine";
14
+ import ScrollReveal from "@/components/ui/ScrollReveal";
15
+ import {
16
+ ANCHOR_DATASET_ID,
17
+ getDataset,
18
+ getModel,
19
+ getModels,
20
+ } from "@/lib/data";
21
+ import type { ModelRow } from "@/lib/types";
22
+
23
+ interface Props {
24
+ params: Promise<{ slug: string }>;
25
+ }
26
+
27
+ /* Model pages are generated over the anchor dataset's leaderboard. */
28
+ export function generateStaticParams() {
29
+ return getModels(ANCHOR_DATASET_ID).map((m) => ({ slug: m.slug }));
30
+ }
31
+
32
+ /* The leaderboard is closed (static JSON) — unknown slugs must be a real
33
+ HTTP 404, not a streamed 200 with not-found content. */
34
+ export const dynamicParams = false;
35
+
36
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
37
+ const { slug } = await params;
38
+ const model = getModel(slug);
39
+ if (!model) return { title: "Model" };
40
+ const dataset = getDataset();
41
+ return {
42
+ title: model.name,
43
+ description: `${model.name} (${model.org}) — results on the ${dataset.name} benchmark, MedASR Bench.`,
44
+ };
45
+ }
46
+
47
+ const footerLink =
48
+ "num inline-flex items-center gap-1.5 rounded-sm border border-line px-3 py-1.5 text-[13px] tracking-wide transition-colors duration-200 ease-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
49
+
50
+ /** Everything a first platform run writes — listed in the awaiting panel. */
51
+ const PENDING_CHIPS = [
52
+ "WER",
53
+ "CS-WER",
54
+ "N-WER",
55
+ "CER",
56
+ "MTR",
57
+ "RTFx",
58
+ "Test → Hard Δ",
59
+ "per-topic WER",
60
+ ] as const;
61
+
62
+ function hasAnyMeasurement(model: ModelRow): boolean {
63
+ return (["test", "hard"] as const).some((s) =>
64
+ Object.values(model.metrics?.[s] ?? {}).some((v) => typeof v === "number"),
65
+ );
66
+ }
67
+
68
+ function PageFooter({ slug }: { slug: string }) {
69
+ return (
70
+ <footer className="hairline-t flex flex-wrap items-center justify-between gap-4 pt-6">
71
+ <Link
72
+ href="/"
73
+ className={`${footerLink} text-muted hover:border-line hover:text-text`}
74
+ >
75
+ <ArrowLeft size={14} aria-hidden />
76
+ Back to leaderboard
77
+ </Link>
78
+ <Link
79
+ href={`/compare?m=${slug}`}
80
+ className={`${footerLink} text-text hover:border-accent/40 hover:text-accent`}
81
+ >
82
+ Compare this model
83
+ <ArrowUpRight size={14} aria-hidden />
84
+ </Link>
85
+ </footer>
86
+ );
87
+ }
88
+
89
+ export default async function ModelPage({ params }: Props) {
90
+ const { slug } = await params;
91
+ const model = getModel(slug);
92
+ if (!model) notFound();
93
+
94
+ /* Anchor dataset (ViMedCSS) — the dataset every number on this page is
95
+ scoped to. It is live, so split statistics are guaranteed present. */
96
+ const dataset = getDataset();
97
+ const splits = dataset.splits;
98
+ if (!splits) {
99
+ throw new Error(
100
+ `Dataset "${dataset.id}" has no split statistics — model pages require a live dataset.`,
101
+ );
102
+ }
103
+ const models = getModels();
104
+
105
+ const showStatus =
106
+ model.provenance === "running" || model.provenance === "planned";
107
+ const hasParetoPoint =
108
+ typeof model.metrics?.test?.wer === "number" &&
109
+ typeof model.metrics?.test?.csWer === "number";
110
+ const measured = hasAnyMeasurement(model);
111
+
112
+ /* unmeasured pages collapse sections 02–04 into one composed panel and
113
+ promote the run manifest — the only real data — under the header */
114
+ if (!measured) {
115
+ const queue = models.filter((m) => !hasAnyMeasurement(m));
116
+ const queuePos = queue.findIndex((m) => m.slug === model.slug) + 1;
117
+
118
+ return (
119
+ <div className="flex flex-col gap-10">
120
+ <div className="stagger flex flex-col gap-10">
121
+ <ModelHeader model={model} />
122
+
123
+ <DatasetScope dataset={dataset} />
124
+
125
+ <RunManifest model={model} index="02" />
126
+
127
+ <section
128
+ aria-label="Awaiting first platform run"
129
+ className="flex flex-col gap-4"
130
+ >
131
+ {showStatus && (
132
+ <StatusNotice model={model} datasetName={dataset.name} />
133
+ )}
134
+ <div className="hairline depth-1 rounded-sm bg-surface px-5 py-6">
135
+ <div aria-hidden className="flex items-center">
136
+ <span className="h-px flex-1 bg-accent/20" />
137
+ <span className="pulse-dot size-2 shrink-0 rounded-full bg-accent" />
138
+ </div>
139
+ <p className="mt-4 font-display text-lg italic text-text">
140
+ awaiting first platform run
141
+ </p>
142
+ <p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
143
+ No trace exists for this model on {dataset.name} yet —
144
+ measurements, Test&nbsp;→&nbsp;Hard generalization and the
145
+ per-topic breakdown all land with its first run on{" "}
146
+ <span className="num">Test</span> +{" "}
147
+ <span className="num">Hard</span>.
148
+ </p>
149
+ <p className="num mt-3 text-[11px] tracking-[0.14em] text-muted uppercase">
150
+ Queue position {queuePos} of {queue.length} unmeasured systems
151
+ </p>
152
+ <ul
153
+ className="mt-4 flex flex-wrap gap-1.5"
154
+ aria-label="What the first run will populate"
155
+ >
156
+ {PENDING_CHIPS.map((c) => (
157
+ <li
158
+ key={c}
159
+ className="num rounded-sm border border-line/70 px-1.5 py-0.5 text-[11px] tracking-wide text-muted"
160
+ >
161
+ {c}
162
+ </li>
163
+ ))}
164
+ </ul>
165
+ </div>
166
+ </section>
167
+ </div>
168
+
169
+ <PageFooter slug={model.slug} />
170
+ </div>
171
+ );
172
+ }
173
+
174
+ return (
175
+ <div className="flex flex-col gap-10">
176
+ {/* above the fold: load-orchestrated — header first, then panels */}
177
+ <div className="stagger flex flex-col gap-10">
178
+ <ModelHeader model={model} />
179
+
180
+ <DatasetScope dataset={dataset} />
181
+
182
+ {showStatus && (
183
+ <StatusNotice model={model} datasetName={dataset.name} />
184
+ )}
185
+
186
+ {hasParetoPoint ? (
187
+ <div className="grid gap-8 lg:grid-cols-[1fr_272px] lg:items-start">
188
+ <MetricPanel
189
+ datasetName={dataset.name}
190
+ metrics={model.metrics}
191
+ splits={{ test: splits.test, hard: splits.hard }}
192
+ provenance={model.provenance}
193
+ />
194
+ <FrontierThumb
195
+ models={models}
196
+ slug={model.slug}
197
+ datasetName={dataset.name}
198
+ />
199
+ </div>
200
+ ) : (
201
+ <MetricPanel
202
+ datasetName={dataset.name}
203
+ metrics={model.metrics}
204
+ splits={{ test: splits.test, hard: splits.hard }}
205
+ provenance={model.provenance}
206
+ />
207
+ )}
208
+ </div>
209
+
210
+ {/* below the fold: scroll-revealed sections */}
211
+ <ScrollReveal direction="fade">
212
+ <PulseLine compact />
213
+ </ScrollReveal>
214
+
215
+ <ScrollReveal>
216
+ <GeneralizationStrip model={model} hardInfo={splits.hard} />
217
+ </ScrollReveal>
218
+
219
+ <ScrollReveal>
220
+ <TopicBreakdown model={model} />
221
+ </ScrollReveal>
222
+
223
+ <ScrollReveal>
224
+ <RunManifest model={model} />
225
+ </ScrollReveal>
226
+
227
+ <PageFooter slug={model.slug} />
228
+ </div>
229
+ );
230
+ }
src/app/page.tsx CHANGED
@@ -1,65 +1,183 @@
1
- import Image from "next/image";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- export default function Home() {
4
  return (
5
- <div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
6
- <main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
7
- <Image
8
- className="dark:invert"
9
- src="/next.svg"
10
- alt="Next.js logo"
11
- width={100}
12
- height={20}
13
- priority
14
- />
15
- <div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
16
- <h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
17
- To get started, edit the page.tsx file.
18
- </h1>
19
- <p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
20
- Looking for a starting point or more instructions? Head over to{" "}
21
- <a
22
- href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
23
- className="font-medium text-zinc-950 dark:text-zinc-50"
24
  >
25
- Templates
26
- </a>{" "}
27
- or the{" "}
28
- <a
29
- href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
30
- className="font-medium text-zinc-950 dark:text-zinc-50"
31
  >
32
- Learning
33
- </a>{" "}
34
- center.
35
- </p>
36
- </div>
37
- <div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
38
- <a
39
- className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
40
- href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
41
- target="_blank"
42
- rel="noopener noreferrer"
43
- >
44
- <Image
45
- className="dark:invert"
46
- src="/vercel.svg"
47
- alt="Vercel logomark"
48
- width={16}
49
- height={16}
50
- />
51
- Deploy Now
52
- </a>
53
- <a
54
- className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
55
- href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
56
- target="_blank"
57
- rel="noopener noreferrer"
58
  >
59
- Documentation
60
- </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </div>
62
- </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  </div>
64
  );
65
  }
 
1
+ import type { Metadata } from "next";
2
+ import PulseLine from "@/components/ui/PulseLine";
3
+ import DatasetChips from "@/components/ui/DatasetChips";
4
+ import ScrollReveal from "@/components/ui/ScrollReveal";
5
+ import HeroStats from "@/components/leaderboard/HeroStats";
6
+ import LeaderboardTable from "@/components/leaderboard/LeaderboardTable";
7
+ import {
8
+ ANCHOR_DATASET_ID,
9
+ getDataset,
10
+ getDatasets,
11
+ getLiveDatasets,
12
+ getModels,
13
+ HARNESS_VERSION,
14
+ NORMALIZER_VERSION,
15
+ } from "@/lib/data";
16
+
17
+ export const metadata: Metadata = {
18
+ title: "Leaderboard",
19
+ };
20
+
21
+ export default function LeaderboardPage() {
22
+ const datasets = getDatasets();
23
+ const liveCount = getLiveDatasets().length;
24
+ /* The home leaderboard reads the anchor dataset. vimedcss is live, so its
25
+ measured fields are always present — hence the non-null assertions below. */
26
+ const dataset = getDataset(ANCHOR_DATASET_ID);
27
+ const models = getModels(ANCHOR_DATASET_ID);
28
 
 
29
  return (
30
+ <div className="spring-up flex flex-col gap-12 md:gap-16">
31
+ {/* ---------- hero ---------- */}
32
+ <section aria-labelledby="hero-title" className="stagger-spring">
33
+ <div className="md:grid md:grid-cols-[1fr_auto] md:items-start md:gap-12">
34
+ <div>
35
+ <p className="num mb-4 text-[11px] tracking-[0.22em] text-muted uppercase spring-in">
36
+ MedASR Bench · Clinical Scribe Program
37
+ </p>
38
+ <h1
39
+ id="hero-title"
40
+ className="font-display max-w-3xl text-[clamp(2.6rem,11.5vw,3.75rem)] leading-[1.04] tracking-tight text-text spring-in"
41
+ style={{ animationDelay: "60ms" }}
 
 
 
 
 
 
 
42
  >
43
+ The Vietnamese Medical <em className="italic">ASR</em> Benchmark
44
+ </h1>
45
+ <p
46
+ className="mt-5 max-w-2xl text-[15px] leading-relaxed text-muted md:text-base spring-in"
47
+ style={{ animationDelay: "120ms" }}
 
48
  >
49
+ One harness measuring how speech recognition survives real
50
+ Vietnamese medical speech — and the English clinical terms
51
+ code-switched through it — across a growing registry of
52
+ datasets. Six metrics, bootstrap{" "}
53
+ <span className="num text-text">95%</span> CIs, transparent
54
+ provenance on every number.
55
+ </p>
56
+ </div>
57
+
58
+ {/* instrument readout — quiet counterweight to the headline */}
59
+ <dl
60
+ aria-label="Platform readout"
61
+ className="num hidden flex-col gap-3 border-l border-line pl-6 text-[11px] tracking-[0.14em] text-muted md:flex spring-in"
62
+ style={{ animationDelay: "180ms" }}
 
 
 
 
 
 
 
 
 
 
 
 
63
  >
64
+ <div className="flex items-baseline justify-between gap-8">
65
+ <dt className="uppercase text-muted/70">Datasets</dt>
66
+ <dd className="text-text">
67
+ {liveCount} live · {datasets.length - liveCount} onboarding
68
+ </dd>
69
+ </div>
70
+ <div className="flex items-baseline justify-between gap-8">
71
+ <dt className="uppercase text-muted/70">Anchor set</dt>
72
+ <dd className="text-text">{dataset.name} v1</dd>
73
+ </div>
74
+ <div className="flex items-baseline justify-between gap-8">
75
+ <dt className="uppercase text-muted/70">Normalizer</dt>
76
+ <dd className="text-text">{NORMALIZER_VERSION}</dd>
77
+ </div>
78
+ <div className="flex items-baseline justify-between gap-8">
79
+ <dt className="uppercase text-muted/70">Harness</dt>
80
+ <dd className="text-text">{HARNESS_VERSION}</dd>
81
+ </div>
82
+ <div className="flex items-baseline justify-between gap-8">
83
+ <dt className="uppercase text-muted/70">Systems on anchor</dt>
84
+ <dd className="text-text">{models.length}</dd>
85
+ </div>
86
+ </dl>
87
+ </div>
88
+
89
+ {/* dataset registry */}
90
+ <div
91
+ className="mt-8 flex flex-wrap items-center gap-3 spring-in"
92
+ style={{ animationDelay: "240ms" }}
93
+ >
94
+ <span className="num text-[11px] tracking-[0.18em] text-muted uppercase">
95
+ Datasets
96
+ </span>
97
+ <DatasetChips datasets={datasets} activeId={ANCHOR_DATASET_ID} />
98
  </div>
99
+ </section>
100
+
101
+ {/* ---------- anchor-dataset stats band ---------- */}
102
+ <HeroStats
103
+ hours={dataset.hours!}
104
+ utterances={dataset.utterances!}
105
+ csTerms={dataset.csTerms!}
106
+ datasetName={dataset.name}
107
+ datasetHref={`/datasets/${dataset.id}`}
108
+ />
109
+
110
+ {/* ---------- ECG divider ---------- */}
111
+ <PulseLine />
112
+
113
+ {/* ---------- editorial insight — scoped to the anchor dataset ---------- */}
114
+ <p className="font-display max-w-3xl text-xl leading-snug text-muted italic md:text-2xl spring-in">
115
+ On {dataset.name}, Vietnamese-specialized models win the overall word
116
+ error rate but lose the code-switched spans; multilingual models invert
117
+ the trade — <span className="text-text">adaptation closes both.</span>
118
+ </p>
119
+
120
+ {/* ---------- leaderboard ---------- */}
121
+ <LeaderboardTable
122
+ models={models}
123
+ datasetName={dataset.name}
124
+ hardSplit={dataset.splits!.hard}
125
+ />
126
+
127
+ {/* ---------- footnotes ---------- */}
128
+ <ScrollReveal direction="fade">
129
+ <section aria-label="Methodology notes" className="hairline-t pt-6">
130
+ <ol className="flex max-w-[72ch] flex-col gap-3 text-[12px] leading-relaxed text-muted">
131
+ <li className="flex gap-3">
132
+ <span className="num shrink-0 text-muted/80">[1]</span>
133
+ <span>
134
+ All WER-family numbers were computed under text normalizer{" "}
135
+ <span className="num text-text">v0.1</span>. WER is only
136
+ comparable within a normalizer version; the version is recorded
137
+ per run.
138
+ </span>
139
+ </li>
140
+ <li className="flex gap-3">
141
+ <span className="num shrink-0 text-muted/80">[2]</span>
142
+ <span>
143
+ Paper-imported rows (dashed chips) reproduce numbers from{" "}
144
+ <span className="num">arXiv:{dataset.arxiv}</span> as published
145
+ — not yet re-verified on this platform.
146
+ </span>
147
+ </li>
148
+ <li className="flex gap-3">
149
+ <span className="num shrink-0 text-muted/80">[3]</span>
150
+ <span>
151
+ Contamination flag: {dataset.name} labels were seeded by Gemini
152
+ 2.5 Pro before human verification (κ ={" "}
153
+ <span className="num">0.65</span>), so Gemini-family models
154
+ carry a permanent contamination flag. With public labels,
155
+ provenance declarations are honor-system for now; a private
156
+ canary subset is on the roadmap.
157
+ </span>
158
+ </li>
159
+ <li className="flex gap-3">
160
+ <span className="num shrink-0 text-muted/80">[4]</span>
161
+ <span>
162
+ Domain caveat: {dataset.name}{" "}
163
+ is lecture-style YouTube audio, not noisy two-speaker clinic
164
+ conversation. Scores here do not
165
+ certify the production deployment gate (WER &lt;{" "}
166
+ <span className="num">15%</span> on clinic speech).
167
+ </span>
168
+ </li>
169
+ <li className="flex gap-3">
170
+ <span className="num shrink-0 text-muted/80">[5]</span>
171
+ <span>
172
+ Bars under WER and CS-WER place the value on a fixed{" "}
173
+ <span className="num">15–65</span> scale; whiskers appear when
174
+ bootstrap <span className="num">95%</span> CIs are available —
175
+ platform runs record them, paper imports rarely publish them.
176
+ </span>
177
+ </li>
178
+ </ol>
179
+ </section>
180
+ </ScrollReveal>
181
  </div>
182
  );
183
  }
src/app/template.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import PageTransition from "@/components/ui/PageTransition";
4
+
5
+ /**
6
+ * Root template — wraps every route's content in a fade + rise entrance.
7
+ * PageTransition keys itself by pathname, so the animation also fires on
8
+ * deep navigations that don't remount this template (e.g. between models).
9
+ */
10
+ export default function Template({
11
+ children,
12
+ }: {
13
+ children: React.ReactNode;
14
+ }) {
15
+ return <PageTransition>{children}</PageTransition>;
16
+ }
src/components/SiteNav.tsx ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import { motion } from "framer-motion";
7
+ import Badge from "@/components/ui/Badge";
8
+ import { NORMALIZER_VERSION } from "@/lib/data";
9
+
10
+ const LINKS = [
11
+ { href: "/", label: "Leaderboard" },
12
+ { href: "/compare", label: "Compare" },
13
+ { href: "/datasets", label: "Datasets" },
14
+ { href: "/methodology", label: "Methodology" },
15
+ ] as const;
16
+
17
+ function isActive(pathname: string, href: string): boolean {
18
+ if (href === "/") return pathname === "/" || pathname.startsWith("/models");
19
+ return pathname === href || pathname.startsWith(`${href}/`);
20
+ }
21
+
22
+ /**
23
+ * Top navigation: wordmark + section links with animated active-route styling +
24
+ * normalizer chip. Features smooth animated indicator for active state.
25
+ */
26
+ export default function SiteNav() {
27
+ const pathname = usePathname();
28
+
29
+ return (
30
+ <nav className="glass-veil hairline-b sticky top-0 z-40">
31
+ {/* phosphor tracer riding the bottom hairline — the instrument is on */}
32
+ <span aria-hidden className="nav-sweep" />
33
+ <div className="mx-auto max-w-6xl px-4 md:px-6">
34
+ <div className="flex flex-col md:h-14 md:flex-row md:items-center md:gap-6">
35
+ {/* row 1 on mobile · the whole bar on md+ */}
36
+ <div className="flex h-12 items-center justify-between gap-4 md:h-auto md:flex-1 md:justify-start md:gap-6">
37
+ <Link
38
+ href="/"
39
+ className="group flex shrink-0 items-center gap-2.5"
40
+ >
41
+ <span className="font-display text-xl leading-none text-text transition-all duration-200 group-hover:brightness-110">
42
+ <em className="italic">Med</em>
43
+ <span className="not-italic">ASR&nbsp;Bench</span>
44
+ </span>
45
+ <span className="hidden min-[420px]:inline-flex">
46
+ <Badge variant="outline">ViMedCSS v1</Badge>
47
+ </span>
48
+ </Link>
49
+
50
+ <div className="hidden min-w-0 flex-1 items-center gap-1 md:flex">
51
+ {LINKS.map(({ href, label }) => {
52
+ const active = isActive(pathname, href);
53
+ return (
54
+ <Link
55
+ key={href}
56
+ href={href}
57
+ aria-current={active ? "page" : undefined}
58
+ className={clsx(
59
+ "relative shrink-0 whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[13px]",
60
+ "transition-colors duration-200 ease-out",
61
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
62
+ active
63
+ ? "text-text"
64
+ : "text-muted hover:text-text hover:bg-surface/50",
65
+ )}
66
+ >
67
+ {label}
68
+ {/* Animated underline for active state */}
69
+ {active && (
70
+ <motion.span
71
+ layoutId="nav-indicator"
72
+ className="absolute inset-x-2 bottom-1 h-px bg-accent"
73
+ initial={false}
74
+ transition={{
75
+ type: "spring",
76
+ stiffness: 500,
77
+ damping: 35,
78
+ }}
79
+ />
80
+ )}
81
+ </Link>
82
+ );
83
+ })}
84
+ </div>
85
+
86
+ <span className="hidden shrink-0 sm:inline-flex">
87
+ <Badge variant="outline">normalizer {NORMALIZER_VERSION}</Badge>
88
+ </span>
89
+ </div>
90
+
91
+ {/* row 2, mobile only: scrollable link strip with edge fade */}
92
+ <div className="relative -mx-4 md:hidden">
93
+ <div className="flex items-center gap-1 overflow-x-auto px-4 pb-2.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
94
+ {LINKS.map(({ href, label }) => {
95
+ const active = isActive(pathname, href);
96
+ return (
97
+ <Link
98
+ key={href}
99
+ href={href}
100
+ aria-current={active ? "page" : undefined}
101
+ className={clsx(
102
+ "relative shrink-0 whitespace-nowrap rounded-sm px-2.5 py-1.5 text-[13px]",
103
+ "transition-colors duration-200 ease-out",
104
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
105
+ active
106
+ ? "hairline bg-surface text-text"
107
+ : "text-muted hover:text-text hover:bg-surface/50",
108
+ )}
109
+ >
110
+ {label}
111
+ {/* Animated dot indicator for mobile */}
112
+ {active && (
113
+ <motion.span
114
+ layoutId="nav-dot"
115
+ className="absolute bottom-0.5 left-1/2 h-1 w-1 -translate-x-1/2 rounded-full bg-accent"
116
+ initial={false}
117
+ transition={{
118
+ type: "spring",
119
+ stiffness: 500,
120
+ damping: 35,
121
+ }}
122
+ />
123
+ )}
124
+ </Link>
125
+ );
126
+ })}
127
+ </div>
128
+ <span
129
+ aria-hidden
130
+ className="pointer-events-none absolute inset-y-0 right-0 w-10 bg-gradient-to-l from-surface/80 to-transparent"
131
+ />
132
+ </div>
133
+ </div>
134
+ </div>
135
+ </nav>
136
+ );
137
+ }
src/components/compare/EfficiencySection.tsx ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import type { ReactElement } from "react";
4
+ import {
5
+ CartesianGrid,
6
+ ResponsiveContainer,
7
+ Scatter,
8
+ ScatterChart,
9
+ Tooltip,
10
+ XAxis,
11
+ YAxis,
12
+ ZAxis,
13
+ } from "recharts";
14
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
15
+ import { categoryLabels, fmt } from "@/lib/data";
16
+ import type { ModelRow } from "@/lib/types";
17
+ import {
18
+ CHART_AXIS_LABEL,
19
+ CHART_AXIS_TICK,
20
+ CHART_LABEL_FILL,
21
+ CHART_LABEL_KNOCKOUT,
22
+ categoryColor,
23
+ isApiCategory,
24
+ shortName,
25
+ } from "./compare-utils";
26
+
27
+ interface EffDatum {
28
+ rtfx: number;
29
+ wer: number;
30
+ paramsB: number;
31
+ model: ModelRow;
32
+ }
33
+
34
+ function renderBubble(props: unknown): ReactElement {
35
+ const { cx, cy, size, payload } = props as {
36
+ cx?: number;
37
+ cy?: number;
38
+ size?: number;
39
+ payload?: EffDatum;
40
+ };
41
+ if (typeof cx !== "number" || typeof cy !== "number" || !payload) {
42
+ return <g />;
43
+ }
44
+ const color = categoryColor[payload.model.category];
45
+ const r = Math.max(Math.sqrt((size ?? 64) / Math.PI), 4);
46
+ const side = r * Math.SQRT2; // diamond with the same half-diagonal
47
+ return (
48
+ <g>
49
+ {isApiCategory(payload.model.category) ? (
50
+ <rect
51
+ x={cx - side / 2}
52
+ y={cy - side / 2}
53
+ width={side}
54
+ height={side}
55
+ transform={`rotate(45 ${cx} ${cy})`}
56
+ style={{ fill: color }}
57
+ fillOpacity={0.14}
58
+ stroke={color}
59
+ strokeWidth={1.25}
60
+ />
61
+ ) : (
62
+ <circle
63
+ cx={cx}
64
+ cy={cy}
65
+ r={r}
66
+ style={{ fill: color }}
67
+ fillOpacity={0.14}
68
+ stroke={color}
69
+ strokeWidth={1.25}
70
+ />
71
+ )}
72
+ <circle cx={cx} cy={cy} r={1.6} style={{ fill: color }} />
73
+ <text
74
+ x={cx}
75
+ y={cy - r - 6}
76
+ textAnchor="middle"
77
+ style={{
78
+ fill: CHART_LABEL_FILL,
79
+ fontSize: 11,
80
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
81
+ ...CHART_LABEL_KNOCKOUT,
82
+ }}
83
+ >
84
+ {shortName(payload.model)}
85
+ </text>
86
+ </g>
87
+ );
88
+ }
89
+
90
+ function EffTooltip(props: unknown) {
91
+ const { active, payload } = props as {
92
+ active?: boolean;
93
+ payload?: ReadonlyArray<{ payload?: EffDatum }>;
94
+ };
95
+ const d = payload?.[0]?.payload;
96
+ if (!active || !d) return null;
97
+ return (
98
+ <div className="glass depth-2 min-w-48 rounded-sm bg-surface-2/85 px-3 py-2.5">
99
+ <p className="text-sm leading-snug text-text">{d.model.name}</p>
100
+ <p className="num mt-0.5 text-[11px] tracking-wide text-muted">
101
+ {d.model.org} · {categoryLabels[d.model.category]}
102
+ </p>
103
+ <dl className="num mt-2 space-y-1 text-[12px]">
104
+ <div className="flex items-baseline justify-between gap-6">
105
+ <dt className="text-muted">RTFx</dt>
106
+ <dd className="text-text">{fmt(d.rtfx)}×</dd>
107
+ </div>
108
+ <div className="flex items-baseline justify-between gap-6">
109
+ <dt className="text-muted">WER</dt>
110
+ <dd className="text-text">{fmt(d.wer)}</dd>
111
+ </div>
112
+ <div className="flex items-baseline justify-between gap-6">
113
+ <dt className="text-muted">Params</dt>
114
+ <dd className="text-text">{fmt(d.paramsB, 3)}B</dd>
115
+ </div>
116
+ </dl>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ export interface EfficiencySectionProps {
122
+ models: ModelRow[];
123
+ /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
124
+ datasetName?: string;
125
+ }
126
+
127
+ export default function EfficiencySection({
128
+ models,
129
+ datasetName = "ViMedCSS",
130
+ }: EfficiencySectionProps) {
131
+ const data: EffDatum[] = models.flatMap((m) => {
132
+ const s = m.metrics?.test;
133
+ if (typeof s?.rtfx !== "number" || typeof s?.wer !== "number") return [];
134
+ return [{ rtfx: s.rtfx, wer: s.wer, paramsB: m.paramsB ?? 0.1, model: m }];
135
+ });
136
+ const running = models.filter((m) => m.provenance === "running");
137
+ /* a sparse plot reads as broken unless the gap is named: when most
138
+ systems are unmeasured, say so inside the plot itself */
139
+ const unmeasured = models.length - data.length;
140
+ const sparse = data.length > 0 && unmeasured > models.length / 2;
141
+
142
+ return (
143
+ <section aria-label="Efficiency: throughput against accuracy">
144
+ <div className="glass depth-2 spotlight-card rounded-sm p-5">
145
+ {data.length > 0 ? (
146
+ <figure>
147
+ <div
148
+ className="relative h-[360px] w-full"
149
+ role="img"
150
+ aria-label={`Bubble chart of RTFx throughput against WER on the ${datasetName} Test split; bubble area scales with parameter count. ${data.length} platform-measured models plotted.`}
151
+ >
152
+ {sparse && (
153
+ <p className="num absolute top-10 left-16 z-10 max-w-[34ch] rounded-sm border border-dashed border-line bg-surface/70 px-2.5 py-1.5 text-[11px] leading-relaxed tracking-wide text-muted">
154
+ {data.length} of {models.length} systems measured — RTFx only
155
+ ships with platform-verified runs; the rest of this plane is
156
+ the evidence gap, not the field.
157
+ </p>
158
+ )}
159
+ <ResponsiveContainer width="100%" height="100%">
160
+ <ScatterChart
161
+ margin={{ top: 28, right: 36, bottom: 16, left: 8 }}
162
+ >
163
+ <CartesianGrid stroke="var(--line)" strokeWidth={1} />
164
+ <XAxis
165
+ type="number"
166
+ dataKey="rtfx"
167
+ name="RTFx"
168
+ domain={[0, "auto"]}
169
+ tick={CHART_AXIS_TICK}
170
+ tickLine={{ stroke: "var(--line)" }}
171
+ axisLine={{ stroke: "var(--line)" }}
172
+ label={{
173
+ value: "RTFX × · BETTER →",
174
+ position: "insideBottomRight",
175
+ offset: -8,
176
+ style: CHART_AXIS_LABEL,
177
+ }}
178
+ />
179
+ <YAxis
180
+ type="number"
181
+ dataKey="wer"
182
+ name="WER"
183
+ domain={[15, 65]}
184
+ tick={CHART_AXIS_TICK}
185
+ tickLine={{ stroke: "var(--line)" }}
186
+ axisLine={{ stroke: "var(--line)" }}
187
+ width={44}
188
+ label={{
189
+ value: "WER % · ↓ BETTER",
190
+ angle: -90,
191
+ position: "insideLeft",
192
+ offset: 8,
193
+ style: { ...CHART_AXIS_LABEL, textAnchor: "middle" },
194
+ }}
195
+ />
196
+ <ZAxis
197
+ type="number"
198
+ dataKey="paramsB"
199
+ range={[80, 640]}
200
+ name="Params (B)"
201
+ />
202
+ <Tooltip
203
+ cursor={{ stroke: "var(--line)", strokeDasharray: "3 3" }}
204
+ content={EffTooltip}
205
+ />
206
+ <Scatter data={data} shape={renderBubble} />
207
+ </ScatterChart>
208
+ </ResponsiveContainer>
209
+ </div>
210
+ <figcaption className="hairline-t mt-4 pt-3 text-[13px] text-muted">
211
+ RTFx = audio seconds ÷ wall seconds, measured on platform
212
+ hardware. Bubble area scales with parameter count. {datasetName}{" "}
213
+ test split · normalizer v0.1.
214
+ </figcaption>
215
+ </figure>
216
+ ) : (
217
+ /* slim status banner — the tall canvas is reserved for real data */
218
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-sm border border-dashed border-line bg-surface-2/40 px-4 py-3">
219
+ <span
220
+ aria-hidden
221
+ className="pulse-dot inline-block size-2 shrink-0 rounded-full bg-accent"
222
+ />
223
+ <p
224
+ className="num text-[12px] tracking-[0.18em] text-muted"
225
+ title="Throughput is only recorded when this platform executes the model itself; paper-imported rows never carry RTFx."
226
+ >
227
+ AWAITING PILOT DATA — RTFX REQUIRES A PLATFORM-VERIFIED RUN
228
+ </p>
229
+ {running.length > 0 && (
230
+ <ul className="flex flex-wrap items-center gap-x-4 gap-y-2">
231
+ {running.map((m) => (
232
+ <li key={m.slug} className="flex items-center gap-2">
233
+ <span className="num text-[12px] text-text">{m.name}</span>
234
+ <ProvenanceChip provenance={m.provenance} />
235
+ </li>
236
+ ))}
237
+ </ul>
238
+ )}
239
+ </div>
240
+ )}
241
+ </div>
242
+ </section>
243
+ );
244
+ }
src/components/compare/HeadToHead.tsx ADDED
@@ -0,0 +1,439 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import { motion, MotionConfig } from "framer-motion";
5
+ import { useSearchParams } from "next/navigation";
6
+ import { useState } from "react";
7
+ import FlagBadges from "@/components/ui/FlagBadges";
8
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
9
+ import {
10
+ categoryLabels,
11
+ fmt,
12
+ metricLabels,
13
+ provenanceLabels,
14
+ } from "@/lib/data";
15
+ import type { Category, MetricKey, ModelRow } from "@/lib/types";
16
+ import {
17
+ H2H_METRICS,
18
+ METRIC_UNIT,
19
+ deltaTone,
20
+ fmtDelta,
21
+ hasEvidenceGap,
22
+ seriesColor,
23
+ type DeltaTone,
24
+ } from "./compare-utils";
25
+
26
+ const DEFAULT_A = "phowhisper-large";
27
+ const DEFAULT_B = "whisper-large-v3";
28
+
29
+ const toneClass: Record<DeltaTone, string> = {
30
+ better: "text-accent",
31
+ worse: "text-crit/90",
32
+ flat: "text-muted",
33
+ };
34
+
35
+ /* Entrance for freshly-selected column values: a short fade with a 2px
36
+ rise. MotionConfig reducedMotion="user" strips the transform for
37
+ reduced-motion users, leaving an instant-feeling opacity settle. */
38
+ const VALUE_FADE = {
39
+ initial: { opacity: 0, y: 2 },
40
+ animate: { opacity: 1, y: 0 },
41
+ transition: { duration: 0.3, ease: "easeOut" },
42
+ } as const;
43
+
44
+ /* custom select caret: 1.5px-stroke chevron in the --muted token, painted
45
+ as a background so the native OS glyph never appears */
46
+ const SELECT_CARET: React.CSSProperties = {
47
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5l3 3 3-3' fill='none' stroke='%23b9c9c2' stroke-width='1.5'/%3E%3C/svg%3E")`,
48
+ backgroundPosition: "right 10px center",
49
+ backgroundRepeat: "no-repeat",
50
+ };
51
+
52
+ /** [A, B, C] slugs; C may be "" (off). Honors ?m=slug,slug(,slug). */
53
+ function parseM(raw: string | null, models: ModelRow[]): [string, string, string] {
54
+ const valid =
55
+ raw
56
+ ?.split(",")
57
+ .map((s) => s.trim())
58
+ .filter((s) => models.some((m) => m.slug === s)) ?? [];
59
+ const uniq = [...new Set(valid)];
60
+ const a = uniq[0] ?? DEFAULT_A;
61
+ let b = uniq[1] ?? DEFAULT_B;
62
+ if (b === a) b = a === DEFAULT_B ? DEFAULT_A : DEFAULT_B;
63
+ const c = uniq[2] && uniq[2] !== a && uniq[2] !== b ? uniq[2] : "";
64
+ return [a, b, c];
65
+ }
66
+
67
+ function syncUrl(sel: [string, string, string]) {
68
+ if (typeof window === "undefined") return;
69
+ const url = new URL(window.location.href);
70
+ url.searchParams.set("m", sel.filter(Boolean).join(","));
71
+ window.history.replaceState(null, "", url);
72
+ }
73
+
74
+ function ModelSelect({
75
+ id,
76
+ label,
77
+ value,
78
+ models,
79
+ allowNone,
80
+ onChange,
81
+ }: {
82
+ id: string;
83
+ label: string;
84
+ value: string;
85
+ models: ModelRow[];
86
+ allowNone?: boolean;
87
+ onChange: (slug: string) => void;
88
+ }) {
89
+ const groups = (Object.keys(categoryLabels) as Category[])
90
+ .map((c) => ({ c, rows: models.filter((m) => m.category === c) }))
91
+ .filter((g) => g.rows.length > 0);
92
+ return (
93
+ <div className="flex min-w-52 flex-1 flex-col gap-1.5">
94
+ <label
95
+ htmlFor={id}
96
+ className="num text-[11px] uppercase tracking-[0.18em] text-muted"
97
+ >
98
+ {label}
99
+ </label>
100
+ <select
101
+ id={id}
102
+ value={value}
103
+ onChange={(e) => onChange(e.target.value)}
104
+ className="hairline num tactile w-full cursor-pointer appearance-none rounded-sm bg-surface-2 py-2 pr-8 pl-2.5 text-[12px] tracking-wide text-text outline-none transition-colors duration-200 ease-out hover:border-accent/40 focus-visible:border-accent/60 focus-visible:ring-1 focus-visible:ring-accent/40"
105
+ style={SELECT_CARET}
106
+ >
107
+ {allowNone && <option value="">— none —</option>}
108
+ {groups.map((g) => (
109
+ <optgroup key={g.c} label={categoryLabels[g.c]}>
110
+ {g.rows.map((m) => (
111
+ <option key={m.slug} value={m.slug}>
112
+ {m.name}
113
+ {hasEvidenceGap(m, "test")
114
+ ? ` (${provenanceLabels[m.provenance].toLowerCase()})`
115
+ : ""}
116
+ </option>
117
+ ))}
118
+ </optgroup>
119
+ ))}
120
+ </select>
121
+ </div>
122
+ );
123
+ }
124
+
125
+ export interface HeadToHeadProps {
126
+ models: ModelRow[];
127
+ /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
128
+ datasetName?: string;
129
+ }
130
+
131
+ export default function HeadToHead({
132
+ models,
133
+ datasetName = "ViMedCSS",
134
+ }: HeadToHeadProps) {
135
+ const searchParams = useSearchParams();
136
+ const [sel, setSel] = useState<[string, string, string]>(() =>
137
+ parseM(searchParams.get("m"), models),
138
+ );
139
+ const [showUnmeasured, setShowUnmeasured] = useState(false);
140
+
141
+ const pick = (i: 0 | 1 | 2) => (slug: string) => {
142
+ setSel((prev) => {
143
+ const next: [string, string, string] = [...prev];
144
+ next[i] = slug;
145
+ syncUrl(next);
146
+ return next;
147
+ });
148
+ };
149
+
150
+ const cols = sel
151
+ .filter(Boolean)
152
+ .map((s) => models.find((m) => m.slug === s))
153
+ .filter((m): m is ModelRow => Boolean(m));
154
+ const baseline = cols[0];
155
+ const gapModels = cols.filter((m) => hasEvidenceGap(m, "test"));
156
+ /* metrics at least one selected model has measured drive the table rows
157
+ and the profile bars; the rest collapse into one quiet expandable row */
158
+ const measuredMetrics = H2H_METRICS.filter((k) =>
159
+ cols.some((m) => typeof m.metrics?.test?.[k] === "number"),
160
+ );
161
+ const unmeasuredMetrics = H2H_METRICS.filter(
162
+ (k) => !cols.some((m) => typeof m.metrics?.test?.[k] === "number"),
163
+ );
164
+ const barMetrics = measuredMetrics;
165
+
166
+ return (
167
+ <MotionConfig reducedMotion="user">
168
+ <section aria-label="Head-to-head model comparison">
169
+ <div className="glass depth-2 spotlight-card rounded-sm p-5">
170
+ {/* selectors */}
171
+ <div className="flex flex-wrap items-end gap-4">
172
+ <ModelSelect
173
+ id="h2h-a"
174
+ label="Model A · baseline"
175
+ value={sel[0]}
176
+ models={models}
177
+ onChange={pick(0)}
178
+ />
179
+ <ModelSelect
180
+ id="h2h-b"
181
+ label="Model B"
182
+ value={sel[1]}
183
+ models={models}
184
+ onChange={pick(1)}
185
+ />
186
+ <ModelSelect
187
+ id="h2h-c"
188
+ label="Model C · optional"
189
+ value={sel[2]}
190
+ models={models}
191
+ allowNone
192
+ onChange={pick(2)}
193
+ />
194
+ </div>
195
+
196
+ {/* evidence-gap notices */}
197
+ {gapModels.length > 0 && (
198
+ <div className="mt-4 flex flex-col gap-2">
199
+ {gapModels.map((m) => (
200
+ <motion.p
201
+ key={m.slug}
202
+ {...VALUE_FADE}
203
+ className="flex flex-wrap items-center gap-2 rounded-sm border border-dashed border-line bg-surface-2/40 px-3 py-2 text-[12px] text-muted"
204
+ >
205
+ <span className="num tracking-wide">
206
+ EVIDENCE GAP — {m.name} has no measurements on Test yet.
207
+ </span>
208
+ <ProvenanceChip provenance={m.provenance} />
209
+ </motion.p>
210
+ ))}
211
+ </div>
212
+ )}
213
+
214
+ {/* delta table */}
215
+ <div className="mt-5 overflow-x-auto">
216
+ <table className="w-full min-w-[560px] border-collapse text-sm">
217
+ <caption className="sr-only">
218
+ Metric comparison on the {datasetName} Test split. Deltas are
219
+ challenger minus baseline; green improves on the baseline, red
220
+ regresses.
221
+ </caption>
222
+ <thead>
223
+ <tr className="hairline-b text-left align-bottom">
224
+ <th
225
+ scope="col"
226
+ className="num py-2.5 pr-3 text-[11px] font-normal uppercase tracking-[0.18em] text-muted"
227
+ >
228
+ Metric · Test
229
+ </th>
230
+ {cols.map((m, i) => (
231
+ <th
232
+ key={m.slug}
233
+ scope="col"
234
+ className="py-2.5 pr-3 font-normal"
235
+ >
236
+ <motion.span
237
+ {...VALUE_FADE}
238
+ className="flex flex-wrap items-center gap-2"
239
+ >
240
+ <span
241
+ aria-hidden
242
+ className="inline-block size-2 rounded-full"
243
+ style={{ background: seriesColor[i] }}
244
+ />
245
+ <span className="text-text">{m.name}</span>
246
+ <FlagBadges flags={m.flags} />
247
+ {i === 0 && (
248
+ <span className="num rounded-sm border border-line px-1.5 py-0.5 text-[11px] tracking-wide text-muted">
249
+ BASELINE
250
+ </span>
251
+ )}
252
+ </motion.span>
253
+ </th>
254
+ ))}
255
+ </tr>
256
+ </thead>
257
+ <tbody>
258
+ {(showUnmeasured
259
+ ? [...measuredMetrics, ...unmeasuredMetrics]
260
+ : measuredMetrics
261
+ ).map((k: MetricKey) => {
262
+ const base = baseline?.metrics?.test?.[k];
263
+ return (
264
+ <tr key={k} className="hairline-b">
265
+ <th
266
+ scope="row"
267
+ className="num py-2.5 pr-3 text-left text-[12px] font-normal text-muted"
268
+ >
269
+ {metricLabels[k]}{" "}
270
+ <span className="text-muted/60">{METRIC_UNIT[k]}</span>
271
+ </th>
272
+ {cols.map((m, i) => {
273
+ const v = m.metrics?.test?.[k];
274
+ const showDelta =
275
+ i > 0 &&
276
+ typeof v === "number" &&
277
+ typeof base === "number";
278
+ const d = showDelta ? v - base : 0;
279
+ return (
280
+ /* keyed by slug: swapping a model remounts the
281
+ cell, so the fresh value fades in */
282
+ <td key={m.slug} className="num py-2.5 pr-3">
283
+ <motion.span
284
+ {...VALUE_FADE}
285
+ className="inline-block"
286
+ >
287
+ <span
288
+ className={
289
+ typeof v === "number"
290
+ ? "text-text"
291
+ : "text-muted/60"
292
+ }
293
+ >
294
+ {fmt(v)}
295
+ </span>
296
+ {showDelta && (
297
+ <span
298
+ className={clsx(
299
+ "ml-2 text-[12px]",
300
+ toneClass[deltaTone(d, k)],
301
+ )}
302
+ >
303
+ {fmtDelta(d)}
304
+ </span>
305
+ )}
306
+ </motion.span>
307
+ </td>
308
+ );
309
+ })}
310
+ </tr>
311
+ );
312
+ })}
313
+ {unmeasuredMetrics.length > 0 && (
314
+ <tr className="hairline-b">
315
+ <td colSpan={cols.length + 1} className="py-2">
316
+ <button
317
+ type="button"
318
+ aria-expanded={showUnmeasured}
319
+ onClick={() => setShowUnmeasured((v) => !v)}
320
+ className="num tactile inline-flex items-center gap-1.5 rounded-sm px-1 py-1 text-[11px] tracking-wide text-muted transition-colors duration-150 ease-out hover:text-text focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70"
321
+ >
322
+ <span aria-hidden>{showUnmeasured ? "▴" : "▸"}</span>
323
+ {unmeasuredMetrics.length}{" "}
324
+ {unmeasuredMetrics.length === 1 ? "metric" : "metrics"}{" "}
325
+ · no platform trace yet for the selected models
326
+ </button>
327
+ </td>
328
+ </tr>
329
+ )}
330
+ </tbody>
331
+ </table>
332
+ </div>
333
+
334
+ {/* grouped horizontal bars */}
335
+ {barMetrics.length > 0 && (
336
+ <div className="hairline-t mt-5 pt-5">
337
+ <h3 className="num text-[11px] uppercase tracking-[0.18em] text-muted">
338
+ Profile — grouped bars, Test split
339
+ </h3>
340
+ <div className="mt-4 grid gap-x-10 gap-y-5 sm:grid-cols-2">
341
+ {barMetrics.map((k) => {
342
+ const values = cols.map((m) => m.metrics?.test?.[k]);
343
+ const max = Math.max(
344
+ ...values.map((v) => (typeof v === "number" ? v : 0)),
345
+ );
346
+ /* %-unit metrics share one fixed 0–100 domain so equal bar
347
+ lengths always encode equal values; RTFx (open-ended)
348
+ keeps local-max scaling */
349
+ const isPct = METRIC_UNIT[k] === "%";
350
+ const widthPct = (v: number) =>
351
+ isPct
352
+ ? Math.min(v, 100)
353
+ : max > 0
354
+ ? Math.max((v / (max * 1.08)) * 100, 1.5)
355
+ : 0;
356
+ return (
357
+ <div key={k}>
358
+ <p className="num text-[11px] tracking-wide text-muted">
359
+ {metricLabels[k]}{" "}
360
+ <span className="text-muted/60">
361
+ {METRIC_UNIT[k]}
362
+ {isPct && " · 0–100"}
363
+ </span>
364
+ </p>
365
+ <div className="mt-1.5 flex flex-col gap-1.5">
366
+ {/* rows are keyed by SLOT, not slug — the bar element
367
+ survives a model swap, so its width tweens to the
368
+ new value (CSS transition; reduced-motion safe) */}
369
+ {cols.map((m, i) => {
370
+ const v = values[i];
371
+ return (
372
+ <div
373
+ key={`slot-${i}`}
374
+ className="flex items-center gap-2"
375
+ >
376
+ <div
377
+ aria-hidden
378
+ className="relative h-1.5 flex-1 rounded-sm bg-surface-2"
379
+ >
380
+ {/* scale ticks at 25 / 50 / 75 */}
381
+ {isPct &&
382
+ [25, 50, 75].map((t) => (
383
+ <span
384
+ key={t}
385
+ className="absolute inset-y-0 w-px bg-line"
386
+ style={{ left: `${t}%` }}
387
+ />
388
+ ))}
389
+ {typeof v === "number" &&
390
+ (i === 2 ? (
391
+ /* series 3: hairline-outlined, transparent
392
+ fill — distinct without more accent */
393
+ <span
394
+ className="absolute inset-y-0 left-0 rounded-sm border border-muted/70 bg-transparent transition-[width] duration-500 ease-out"
395
+ style={{
396
+ width: `${widthPct(v).toFixed(2)}%`,
397
+ }}
398
+ />
399
+ ) : (
400
+ <span
401
+ className="absolute inset-y-0 left-0 rounded-sm transition-[width] duration-500 ease-out"
402
+ style={{
403
+ width: `${widthPct(v).toFixed(2)}%`,
404
+ background: seriesColor[i],
405
+ opacity: 0.85,
406
+ }}
407
+ />
408
+ ))}
409
+ </div>
410
+ <span
411
+ className={clsx(
412
+ "num w-14 text-right text-[11px]",
413
+ typeof v === "number"
414
+ ? "text-text"
415
+ : "text-muted/60",
416
+ )}
417
+ >
418
+ {fmt(v)}
419
+ </span>
420
+ </div>
421
+ );
422
+ })}
423
+ </div>
424
+ </div>
425
+ );
426
+ })}
427
+ </div>
428
+ </div>
429
+ )}
430
+
431
+ <p className="num mt-5 text-[11px] tracking-wide text-muted">
432
+ ALL VALUES: {datasetName.toUpperCase()} TEST SPLIT · NORMALIZER v0.1
433
+ — WER IS ONLY COMPARABLE WITHIN A NORMALIZER VERSION.
434
+ </p>
435
+ </div>
436
+ </section>
437
+ </MotionConfig>
438
+ );
439
+ }
src/components/compare/ParetoChart.tsx ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import type { ReactElement } from "react";
4
+ import {
5
+ CartesianGrid,
6
+ ReferenceArea,
7
+ ReferenceDot,
8
+ ReferenceLine,
9
+ ResponsiveContainer,
10
+ Scatter,
11
+ ScatterChart,
12
+ Tooltip,
13
+ XAxis,
14
+ YAxis,
15
+ } from "recharts";
16
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
17
+ import { categoryLabels, fmt, fmtCI } from "@/lib/data";
18
+ import {
19
+ CHART_AXIS_LABEL,
20
+ CHART_AXIS_TICK,
21
+ CHART_LABEL_FILL,
22
+ CHART_LABEL_KNOCKOUT,
23
+ categoryColor,
24
+ dominatedBands,
25
+ domainTicks,
26
+ frontierSegments,
27
+ isApiCategory,
28
+ niceDomain,
29
+ paretoFrontier,
30
+ shortName,
31
+ type ParetoDatum,
32
+ } from "./compare-utils";
33
+
34
+ /* Datum enriched with chart-level placement hints. `annotated` marks the
35
+ frontier point the serif "adapted frontier" annotation anchors to — its
36
+ FRONTIER tag is suppressed so captions never pile onto one point. */
37
+ type PlottedDatum = ParetoDatum & {
38
+ onFrontier: boolean;
39
+ flip: boolean;
40
+ annotated: boolean;
41
+ };
42
+
43
+ /* ------------------------------------------------------------------ */
44
+ /* Point glyph: category-colored dot (diamond for API systems) · */
45
+ /* dashed ring = paper-imported · amber ring = contamination · */
46
+ /* concentric accent ring + FRONTIER tag for frontier members · */
47
+ /* tiny mono label */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ function renderPoint(props: unknown): ReactElement {
51
+ const { cx, cy, payload } = props as {
52
+ cx?: number;
53
+ cy?: number;
54
+ payload?: PlottedDatum;
55
+ };
56
+ if (typeof cx !== "number" || typeof cy !== "number" || !payload) {
57
+ return <g />;
58
+ }
59
+ const m = payload.model;
60
+ const color = categoryColor[m.category];
61
+ const dashed = m.provenance === "paper-imported";
62
+ const contaminated = Boolean(m.flags?.contamination);
63
+ const frontier = payload.onFrontier;
64
+ const flip = payload.flip;
65
+ const annotated = payload.annotated;
66
+ return (
67
+ <g>
68
+ {contaminated && (
69
+ <circle
70
+ cx={cx}
71
+ cy={cy}
72
+ r={9.5}
73
+ fill="none"
74
+ stroke="var(--warn)"
75
+ strokeWidth={1}
76
+ opacity={0.85}
77
+ />
78
+ )}
79
+ {frontier && (
80
+ <circle
81
+ cx={cx}
82
+ cy={cy}
83
+ r={8.5}
84
+ fill="none"
85
+ stroke="var(--accent)"
86
+ strokeOpacity={0.6}
87
+ strokeWidth={1}
88
+ />
89
+ )}
90
+ {isApiCategory(m.category) ? (
91
+ <rect
92
+ x={cx - 4.4}
93
+ y={cy - 4.4}
94
+ width={8.8}
95
+ height={8.8}
96
+ transform={`rotate(45 ${cx} ${cy})`}
97
+ style={{ fill: color }}
98
+ fillOpacity={0.3}
99
+ stroke={color}
100
+ strokeWidth={1.25}
101
+ strokeDasharray={dashed ? "2.5 2.2" : undefined}
102
+ />
103
+ ) : (
104
+ <circle
105
+ cx={cx}
106
+ cy={cy}
107
+ r={5.5}
108
+ style={{ fill: color }}
109
+ fillOpacity={0.3}
110
+ stroke={color}
111
+ strokeWidth={1.25}
112
+ strokeDasharray={dashed ? "2.5 2.2" : undefined}
113
+ />
114
+ )}
115
+ <circle cx={cx} cy={cy} r={1.6} style={{ fill: color }} />
116
+ {/* frontier members: name lifted clear above the dot + mono tag below */}
117
+ <text
118
+ x={frontier ? cx : flip ? cx - 11 : cx + 11}
119
+ y={frontier ? cy - 14 : cy + 3.5}
120
+ textAnchor={frontier ? "middle" : flip ? "end" : "start"}
121
+ style={{
122
+ fill: CHART_LABEL_FILL,
123
+ fontSize: 11,
124
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
125
+ ...CHART_LABEL_KNOCKOUT,
126
+ }}
127
+ >
128
+ {shortName(m)}
129
+ </text>
130
+ {frontier && !annotated && (
131
+ <text
132
+ x={cx}
133
+ y={cy + 21}
134
+ textAnchor="middle"
135
+ style={{
136
+ fill: "var(--accent)",
137
+ fillOpacity: 0.9,
138
+ fontSize: 9,
139
+ letterSpacing: "0.18em",
140
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
141
+ ...CHART_LABEL_KNOCKOUT,
142
+ }}
143
+ >
144
+ FRONTIER
145
+ </text>
146
+ )}
147
+ </g>
148
+ );
149
+ }
150
+
151
+ /* ------------------------------------------------------------------ */
152
+ /* Instrument tooltip — glass over the chart, never opaque black */
153
+ /* ------------------------------------------------------------------ */
154
+
155
+ function ParetoTooltip(props: unknown) {
156
+ const { active, payload } = props as {
157
+ active?: boolean;
158
+ payload?: ReadonlyArray<{ payload?: ParetoDatum }>;
159
+ };
160
+ const d = payload?.[0]?.payload;
161
+ if (!active || !d) return null;
162
+ const m = d.model;
163
+ return (
164
+ <div className="glass depth-2 min-w-52 rounded-sm bg-surface-2/85 px-3 py-2.5">
165
+ <p className="text-sm leading-snug text-text">{m.name}</p>
166
+ <p className="num mt-0.5 text-[11px] tracking-wide text-muted">
167
+ {m.org} · {categoryLabels[m.category]}
168
+ </p>
169
+ <dl className="num mt-2 space-y-1 text-[12px]">
170
+ <div className="flex items-baseline justify-between gap-6">
171
+ <dt className="text-muted">WER</dt>
172
+ <dd className="text-right text-text">
173
+ {fmt(d.wer)}
174
+ {d.werCi && (
175
+ <span className="ml-1.5 text-[11px] text-muted">
176
+ {fmtCI(d.werCi)}
177
+ </span>
178
+ )}
179
+ </dd>
180
+ </div>
181
+ <div className="flex items-baseline justify-between gap-6">
182
+ <dt className="text-muted">CS-WER</dt>
183
+ <dd className="text-right text-text">
184
+ {fmt(d.csWer)}
185
+ {d.csWerCi && (
186
+ <span className="ml-1.5 text-[11px] text-muted">
187
+ {fmtCI(d.csWerCi)}
188
+ </span>
189
+ )}
190
+ </dd>
191
+ </div>
192
+ </dl>
193
+ <div className="mt-2">
194
+ <ProvenanceChip provenance={m.provenance} />
195
+ </div>
196
+ </div>
197
+ );
198
+ }
199
+
200
+ /* ------------------------------------------------------------------ */
201
+ /* Chart */
202
+ /* ------------------------------------------------------------------ */
203
+
204
+ export interface ParetoChartProps {
205
+ data: ParetoDatum[];
206
+ splitLabel: string;
207
+ }
208
+
209
+ export default function ParetoChart({ data, splitLabel }: ParetoChartProps) {
210
+ /* axis domains track the data extent (padded, snapped to 5s) so the
211
+ points command the canvas instead of huddling in a corner */
212
+ const xDomain = niceDomain(data.map((d) => d.wer));
213
+ const yDomain = niceDomain(data.map((d) => d.csWer));
214
+ const xTicks = domainTicks(xDomain);
215
+ const yTicks = domainTicks(yDomain);
216
+
217
+ const frontier = paretoFrontier(data);
218
+ const frontierSet = new Set(frontier.map((d) => d.model.slug));
219
+ const segments = frontierSegments(frontier, xDomain[1], yDomain[1]);
220
+ const bands = dominatedBands(frontier, xDomain[1], yDomain[1]);
221
+ const last = frontier[frontier.length - 1];
222
+
223
+ const plotted: PlottedDatum[] = data.map((d) => ({
224
+ ...d,
225
+ onFrontier: frontierSet.has(d.model.slug),
226
+ flip: d.wer > (xDomain[0] + xDomain[1]) / 2,
227
+ annotated: Boolean(last) && d.model.slug === last.model.slug,
228
+ }));
229
+
230
+ return (
231
+ <div
232
+ className="relative h-[440px] w-full"
233
+ role="img"
234
+ aria-label={`Scatter plot of overall WER against code-switch WER on the ${splitLabel} split, with the Pareto frontier drawn as a stepped line and the dominated region lightly shaded. ${data.length} models plotted; lower-left is better.`}
235
+ >
236
+ <ResponsiveContainer width="100%" height="100%">
237
+ <ScatterChart margin={{ top: 24, right: 36, bottom: 16, left: 8 }}>
238
+ <CartesianGrid stroke="var(--line)" strokeWidth={1} />
239
+ <XAxis
240
+ type="number"
241
+ dataKey="wer"
242
+ name="WER"
243
+ domain={xDomain}
244
+ ticks={xTicks}
245
+ tick={CHART_AXIS_TICK}
246
+ tickLine={{ stroke: "var(--line)" }}
247
+ axisLine={{ stroke: "var(--line)" }}
248
+ label={{
249
+ value: "WER % · ← BETTER",
250
+ position: "insideBottomRight",
251
+ offset: -8,
252
+ style: CHART_AXIS_LABEL,
253
+ }}
254
+ />
255
+ <YAxis
256
+ type="number"
257
+ dataKey="csWer"
258
+ name="CS-WER"
259
+ domain={yDomain}
260
+ ticks={yTicks}
261
+ tick={CHART_AXIS_TICK}
262
+ tickLine={{ stroke: "var(--line)" }}
263
+ axisLine={{ stroke: "var(--line)" }}
264
+ width={44}
265
+ label={{
266
+ value: "CS-WER % · ↓ BETTER",
267
+ angle: -90,
268
+ position: "insideLeft",
269
+ offset: 8,
270
+ style: { ...CHART_AXIS_LABEL, textAnchor: "middle" },
271
+ }}
272
+ />
273
+ {/* dominated region: the staircase boundary reads as a region edge */}
274
+ {bands.map((b, i) => (
275
+ <ReferenceArea
276
+ key={`dominated-${i}`}
277
+ x1={b.x1}
278
+ x2={b.x2}
279
+ y1={b.y1}
280
+ y2={b.y2}
281
+ fill="var(--accent)"
282
+ fillOpacity={0.06}
283
+ stroke="none"
284
+ ifOverflow="hidden"
285
+ />
286
+ ))}
287
+ {segments.map(([a, b], i) => (
288
+ <ReferenceLine
289
+ key={`frontier-${i}`}
290
+ segment={[a, b]}
291
+ stroke="var(--accent)"
292
+ strokeWidth={1}
293
+ strokeDasharray="4 3"
294
+ strokeOpacity={0.6}
295
+ ifOverflow="hidden"
296
+ />
297
+ ))}
298
+ {/* data-anchored frontier annotation — floats above the frontier's
299
+ horizontal run-out, clear of the frontier point's own label */}
300
+ {last && (
301
+ <ReferenceDot
302
+ x={last.wer + (xDomain[1] - xDomain[0]) * 0.18}
303
+ y={last.csWer + (yDomain[1] - yDomain[0]) * 0.06}
304
+ r={0}
305
+ fill="none"
306
+ stroke="none"
307
+ ifOverflow="hidden"
308
+ label={{
309
+ value: "adapted frontier",
310
+ position: "right",
311
+ offset: 0,
312
+ style: {
313
+ textAnchor: "start",
314
+ fill: "var(--accent)",
315
+ fillOpacity: 0.85,
316
+ fontSize: 16,
317
+ fontStyle: "italic",
318
+ fontFamily:
319
+ "var(--font-display), 'Instrument Serif', Georgia, serif",
320
+ ...CHART_LABEL_KNOCKOUT,
321
+ },
322
+ }}
323
+ />
324
+ )}
325
+ <Tooltip
326
+ cursor={{ stroke: "var(--line)", strokeDasharray: "3 3" }}
327
+ content={ParetoTooltip}
328
+ />
329
+ <Scatter data={plotted} shape={renderPoint} />
330
+ </ScatterChart>
331
+ </ResponsiveContainer>
332
+
333
+ {/* Quadrant captions — editorial, pinned at fixed insets from the
334
+ plot corners (plot edges: left 52px, top 24px, right 36px,
335
+ bottom ≈46px) so they frame the data */}
336
+ <p
337
+ aria-hidden
338
+ className="pointer-events-none absolute left-[68px] top-28 max-w-56 font-display text-[16px] italic leading-snug text-muted [text-shadow:0_0_6px_var(--surface),0_0_6px_var(--surface),0_0_6px_var(--surface)]"
339
+ >
340
+ Vietnamese-specialized: strong overall, weak CS
341
+ </p>
342
+ <p
343
+ aria-hidden
344
+ className="pointer-events-none absolute bottom-[58px] right-[52px] whitespace-nowrap text-right font-display text-[16px] italic leading-snug text-muted [text-shadow:0_0_6px_var(--surface),0_0_6px_var(--surface),0_0_6px_var(--surface)]"
345
+ >
346
+ multilingual: strong CS, weak overall
347
+ </p>
348
+ </div>
349
+ );
350
+ }
src/components/compare/ParetoSection.tsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useMemo, useState } from "react";
4
+ import SplitToggle from "@/components/ui/SplitToggle";
5
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
6
+ import { categoryLabels, splitLabels } from "@/lib/data";
7
+ import type { Category, ModelRow, Split } from "@/lib/types";
8
+ import { categoryColor, isApiCategory, paretoData } from "./compare-utils";
9
+ import ParetoChart from "./ParetoChart";
10
+
11
+ export interface ParetoSectionProps {
12
+ models: ModelRow[];
13
+ /** Name of the dataset the comparison is scoped to (e.g. "ViMedCSS"). */
14
+ datasetName?: string;
15
+ }
16
+
17
+ export default function ParetoSection({
18
+ models,
19
+ datasetName = "ViMedCSS",
20
+ }: ParetoSectionProps) {
21
+ const [split, setSplit] = useState<Split>("test");
22
+ const data = useMemo(() => paretoData(models, split), [models, split]);
23
+ const running = models.filter((m) => m.provenance === "running");
24
+
25
+ const categories = useMemo(() => {
26
+ const present = new Set<Category>(data.map((d) => d.model.category));
27
+ return (Object.keys(categoryLabels) as Category[]).filter((c) =>
28
+ present.has(c),
29
+ );
30
+ }, [data]);
31
+
32
+ return (
33
+ <section aria-label="WER versus CS-WER Pareto frontier">
34
+ <div className="glass depth-2 spotlight-card rounded-sm p-5">
35
+ {/* control row: legend chips left, split toggle right */}
36
+ <div className="flex flex-wrap items-center justify-between gap-3">
37
+ <ul className="num flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[11px] tracking-wide text-muted">
38
+ {categories.map((c) => (
39
+ <li key={c} className="flex items-center gap-1.5">
40
+ <span
41
+ aria-hidden
42
+ className={
43
+ isApiCategory(c)
44
+ ? "inline-block size-2 rotate-45"
45
+ : "inline-block size-2 rounded-full"
46
+ }
47
+ style={{ background: categoryColor[c] }}
48
+ />
49
+ {categoryLabels[c]}
50
+ </li>
51
+ ))}
52
+ <li className="flex items-center gap-1.5">
53
+ <span
54
+ aria-hidden
55
+ className="inline-block size-2.5 rounded-full border border-dashed border-muted/70"
56
+ />
57
+ paper-imported
58
+ </li>
59
+ <li className="flex items-center gap-1.5">
60
+ <span
61
+ aria-hidden
62
+ className="inline-block size-2.5 rounded-full border border-warn/80"
63
+ />
64
+ contamination
65
+ </li>
66
+ <li className="flex items-center gap-1.5">
67
+ <span
68
+ aria-hidden
69
+ className="inline-block h-px w-4 border-t border-dashed border-accent/60"
70
+ />
71
+ Pareto frontier
72
+ </li>
73
+ </ul>
74
+ <SplitToggle value={split} onChange={setSplit} />
75
+ </div>
76
+
77
+ {/* keyed on split so toggling cross-fades the whole canvas in;
78
+ soft-appear collapses under the global reduced-motion override */}
79
+ <figure key={split} className="soft-appear mt-4">
80
+ {data.length > 0 ? (
81
+ <ParetoChart data={data} splitLabel={splitLabels[split]} />
82
+ ) : (
83
+ <div className="flex h-[440px] flex-col items-center justify-center gap-4 rounded-sm border border-dashed border-line bg-surface-2/40 px-6 text-center">
84
+ <span
85
+ aria-hidden
86
+ className="pulse-dot inline-block size-2 rounded-full bg-accent"
87
+ />
88
+ <p className="num text-[12px] tracking-[0.18em] text-muted">
89
+ NO PAIRED WER / CS-WER RESULTS ON THE {datasetName.toUpperCase()}{" "}
90
+ {splitLabels[split].toUpperCase()} SPLIT YET
91
+ </p>
92
+ <p className="max-w-md text-[13px] leading-relaxed text-muted">
93
+ Published baselines did not report this split. The first
94
+ platform-verified points will land here when the pilot runs
95
+ complete.
96
+ </p>
97
+ {running.length > 0 && (
98
+ <ul className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2">
99
+ {running.map((m) => (
100
+ <li key={m.slug} className="flex items-center gap-2">
101
+ <span className="num text-[12px] text-text">
102
+ {m.name}
103
+ </span>
104
+ <ProvenanceChip provenance={m.provenance} />
105
+ </li>
106
+ ))}
107
+ </ul>
108
+ )}
109
+ </div>
110
+ )}
111
+ <figcaption className="hairline-t mt-4 pt-3 text-[13px] leading-relaxed text-muted">
112
+ <span className="font-display italic text-text/90">
113
+ The trade-off, in one plot:
114
+ </span>{" "}
115
+ on {datasetName}, Vietnamese-specialized models hold the left wall
116
+ but drift high on code-switched spans, multilingual models sink
117
+ lower on the right — and only adaptation reaches the lower-left,
118
+ where PhoWhisper-small + Attention-Guide sets the frontier at{" "}
119
+ <span className="num text-text">23.67</span> WER /{" "}
120
+ <span className="num text-text">19.50</span> CS-WER.
121
+ </figcaption>
122
+ </figure>
123
+ </div>
124
+ </section>
125
+ );
126
+ }
src/components/compare/compare-utils.ts ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Category, MetricKey, ModelRow, Split } from "@/lib/types";
2
+ import { HIGHER_IS_BETTER } from "@/lib/data";
3
+
4
+ /* ------------------------------------------------------------------ */
5
+ /* Category color mapping — derived ONLY from globals.css tokens. */
6
+ /* vn-open = phosphor accent · multilingual = cool ice (accent→text) */
7
+ /* medical = sage · api-* = cool/neutral mixes drawn as DIAMONDS, so */
8
+ /* the warm --warn channel belongs exclusively to contamination. */
9
+ /* ------------------------------------------------------------------ */
10
+
11
+ export const categoryColor: Record<Category, string> = {
12
+ "vn-open": "var(--accent)",
13
+ "multilingual-open": "color-mix(in srgb, var(--accent) 65%, var(--text) 35%)",
14
+ medical: "var(--text)",
15
+ "api-intl": "color-mix(in srgb, var(--text) 55%, var(--muted) 45%)",
16
+ "api-vn": "var(--muted)",
17
+ };
18
+
19
+ /** API points render as 45°-rotated squares instead of dots. */
20
+ export function isApiCategory(c: Category): boolean {
21
+ return c === "api-intl" || c === "api-vn";
22
+ }
23
+
24
+ /* ------------------------------------------------------------------ */
25
+ /* Shared Recharts text styles — single source for both compare */
26
+ /* charts. 11px floor and token-driven fills keep SVG text legible on */
27
+ /* the lighter glass panels. */
28
+ /* ------------------------------------------------------------------ */
29
+
30
+ export const CHART_AXIS_TICK = {
31
+ fill: "var(--muted)",
32
+ fontSize: 11,
33
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
34
+ } as const;
35
+
36
+ export const CHART_AXIS_LABEL = {
37
+ fill: "var(--muted)",
38
+ fontSize: 11,
39
+ letterSpacing: "0.14em",
40
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
41
+ } as const;
42
+
43
+ /** Point labels knock out lines beneath them with a panel-toned halo —
44
+ charts now sit on glass surfaces, so the halo matches --surface,
45
+ not the page background. */
46
+ export const CHART_LABEL_KNOCKOUT = {
47
+ paintOrder: "stroke",
48
+ stroke: "var(--surface)",
49
+ strokeWidth: 4,
50
+ } as const;
51
+
52
+ /** Near-text fill for chart point labels (brighter than --muted). */
53
+ export const CHART_LABEL_FILL =
54
+ "color-mix(in srgb, var(--text) 86%, var(--muted) 14%)";
55
+
56
+ /** Selection-order palette for head-to-head columns and profile bars.
57
+ Series 2 reads as bone-white with a teal cast so it never doubles the
58
+ accent; series 3 bars render hairline-outlined with transparent fill. */
59
+ export const seriesColor: readonly string[] = [
60
+ "var(--accent)",
61
+ "color-mix(in srgb, var(--accent) 15%, var(--text) 85%)",
62
+ "var(--muted)",
63
+ ];
64
+
65
+ /* ------------------------------------------------------------------ */
66
+ /* Compact display names for chart point labels */
67
+ /* ------------------------------------------------------------------ */
68
+
69
+ const SHORT_NAMES: Record<string, string> = {
70
+ "phowhisper-small-ag": "PhoWhisper-S ·AG",
71
+ "phowhisper-small-lora": "PhoWhisper-S ·LoRA",
72
+ vietasr: "VietASR 68M",
73
+ "phowhisper-large": "PhoWhisper-L",
74
+ "whisper-large-v3": "Whisper-L v3",
75
+ "phowhisper-small": "PhoWhisper-S",
76
+ "wav2vec2-base-vi": "w2v2-base-vi",
77
+ "whisper-small": "Whisper-S",
78
+ "mms-1b": "MMS-1B",
79
+ "zipformer-30m": "Zipformer-30M",
80
+ "qwen3-asr-0.6b": "Qwen3-ASR-0.6B",
81
+ "gemini-2.5-pro": "Gemini 2.5 Pro",
82
+ "gpt-4o-transcribe": "gpt-4o-transcribe",
83
+ "elevenlabs-scribe-v2": "Scribe v2",
84
+ "fpt-ai-stt": "FPT.AI STT",
85
+ };
86
+
87
+ export function shortName(m: ModelRow): string {
88
+ return SHORT_NAMES[m.slug] ?? m.name;
89
+ }
90
+
91
+ /* ------------------------------------------------------------------ */
92
+ /* Pareto math (minimize both axes; lower-left is better) */
93
+ /* ------------------------------------------------------------------ */
94
+
95
+ export interface ParetoDatum {
96
+ wer: number;
97
+ csWer: number;
98
+ werCi?: [number, number];
99
+ csWerCi?: [number, number];
100
+ model: ModelRow;
101
+ }
102
+
103
+ /** One datum per model holding BOTH wer and csWer on the split. */
104
+ export function paretoData(models: ModelRow[], split: Split): ParetoDatum[] {
105
+ return models.flatMap((m) => {
106
+ const s = m.metrics?.[split];
107
+ if (typeof s?.wer !== "number" || typeof s?.csWer !== "number") return [];
108
+ return [
109
+ {
110
+ wer: s.wer,
111
+ csWer: s.csWer,
112
+ werCi: s.werCi,
113
+ csWerCi: s.csWerCi,
114
+ model: m,
115
+ },
116
+ ];
117
+ });
118
+ }
119
+
120
+ /** Non-dominated subset, sorted by WER ascending (csWer strictly falls). */
121
+ export function paretoFrontier(points: ParetoDatum[]): ParetoDatum[] {
122
+ const sorted = [...points].sort((a, b) => a.wer - b.wer || a.csWer - b.csWer);
123
+ const out: ParetoDatum[] = [];
124
+ let best = Infinity;
125
+ for (const p of sorted) {
126
+ if (p.csWer < best) {
127
+ out.push(p);
128
+ best = p.csWer;
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ export type Segment = readonly [
135
+ { x: number; y: number },
136
+ { x: number; y: number },
137
+ ];
138
+
139
+ /**
140
+ * Staircase boundary of the region dominated by the frontier,
141
+ * extended to the chart domain edges. Steps go right, then down.
142
+ */
143
+ export function frontierSegments(
144
+ frontier: ParetoDatum[],
145
+ xMax: number,
146
+ yMax: number,
147
+ ): Segment[] {
148
+ if (frontier.length === 0) return [];
149
+ const segs: Segment[] = [];
150
+ const first = frontier[0];
151
+ const last = frontier[frontier.length - 1];
152
+ // drop in from the top edge to the best-WER point
153
+ segs.push([
154
+ { x: first.wer, y: yMax },
155
+ { x: first.wer, y: first.csWer },
156
+ ]);
157
+ for (let i = 0; i < frontier.length - 1; i += 1) {
158
+ const a = frontier[i];
159
+ const b = frontier[i + 1];
160
+ segs.push([
161
+ { x: a.wer, y: a.csWer },
162
+ { x: b.wer, y: a.csWer },
163
+ ]);
164
+ segs.push([
165
+ { x: b.wer, y: a.csWer },
166
+ { x: b.wer, y: b.csWer },
167
+ ]);
168
+ }
169
+ // run out to the right edge at the best CS-WER level
170
+ segs.push([
171
+ { x: last.wer, y: last.csWer },
172
+ { x: xMax, y: last.csWer },
173
+ ]);
174
+ return segs;
175
+ }
176
+
177
+ /**
178
+ * Non-overlapping vertical bands covering the dominated region (up-right
179
+ * of the staircase): band i spans x from frontier[i] to frontier[i+1]
180
+ * (last band runs to xMax), y from frontier[i].csWer to yMax.
181
+ */
182
+ export function dominatedBands(
183
+ frontier: ParetoDatum[],
184
+ xMax: number,
185
+ yMax: number,
186
+ ): Array<{ x1: number; x2: number; y1: number; y2: number }> {
187
+ return frontier.map((p, i) => ({
188
+ x1: p.wer,
189
+ x2: frontier[i + 1]?.wer ?? xMax,
190
+ y1: p.csWer,
191
+ y2: yMax,
192
+ }));
193
+ }
194
+
195
+ /* ------------------------------------------------------------------ */
196
+ /* Axis domain helpers (data-driven, padded, rounded to 2s or 5s) */
197
+ /* ------------------------------------------------------------------ */
198
+
199
+ /**
200
+ * Data extent padded ~10–15% and snapped outward — to multiples of 2 when
201
+ * the span is tight (< 15 units) so the points command the canvas, else 5.
202
+ */
203
+ export function niceDomain(values: number[]): [number, number] {
204
+ if (values.length === 0) return [0, 100];
205
+ const min = Math.min(...values);
206
+ const max = Math.max(...values);
207
+ const pad = Math.max((max - min) * 0.1, 1.5);
208
+ const snap = max - min < 15 ? 2 : 5;
209
+ return [
210
+ Math.floor((min - pad) / snap) * snap,
211
+ Math.ceil((max + pad) / snap) * snap,
212
+ ];
213
+ }
214
+
215
+ /** Tick positions for a niceDomain: every 2 / 5 / 10 by span width. */
216
+ export function domainTicks([lo, hi]: [number, number]): number[] {
217
+ const span = hi - lo;
218
+ const step = span <= 16 ? 2 : span <= 30 ? 5 : 10;
219
+ const out: number[] = [];
220
+ for (let t = lo; t <= hi; t += step) out.push(t);
221
+ return out;
222
+ }
223
+
224
+ /* ------------------------------------------------------------------ */
225
+ /* Head-to-head helpers */
226
+ /* ------------------------------------------------------------------ */
227
+
228
+ export const H2H_METRICS: readonly MetricKey[] = [
229
+ "wer",
230
+ "csWer",
231
+ "nWer",
232
+ "cer",
233
+ "mtrExact",
234
+ "mtrFuzzy",
235
+ "rtfx",
236
+ ];
237
+
238
+ export const METRIC_UNIT: Record<MetricKey, string> = {
239
+ wer: "%",
240
+ cer: "%",
241
+ csWer: "%",
242
+ nWer: "%",
243
+ mtrExact: "%",
244
+ mtrFuzzy: "%",
245
+ rtfx: "×",
246
+ };
247
+
248
+ export type DeltaTone = "better" | "worse" | "flat";
249
+
250
+ /** Sign-aware quality of a challenger-minus-baseline delta. */
251
+ export function deltaTone(delta: number, metric: MetricKey): DeltaTone {
252
+ if (delta === 0) return "flat";
253
+ const improved = HIGHER_IS_BETTER.has(metric) ? delta > 0 : delta < 0;
254
+ return improved ? "better" : "worse";
255
+ }
256
+
257
+ /** "+3.23" / "−1.40" / "±0.00" (true minus sign for mono alignment). */
258
+ export function fmtDelta(delta: number, digits = 2): string {
259
+ const sign = delta > 0 ? "+" : delta < 0 ? "−" : "±";
260
+ return `${sign}${Math.abs(delta).toFixed(digits)}`;
261
+ }
262
+
263
+ /** True when the model has no numeric metric at all on the split. */
264
+ export function hasEvidenceGap(m: ModelRow, split: Split): boolean {
265
+ const s = m.metrics?.[split];
266
+ if (!s) return true;
267
+ return !H2H_METRICS.some((k) => typeof s[k] === "number");
268
+ }
src/components/docs/CautionCallout.tsx ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import { AlertTriangle } from "lucide-react";
3
+ import type { ReactNode } from "react";
4
+
5
+ export interface CautionCalloutProps {
6
+ /** mono kicker, e.g. "Caution · Label provenance" */
7
+ eyebrow?: string;
8
+ title: string;
9
+ children: ReactNode;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * Prominent amber caution block — reserved for contamination and
15
+ * domain-validity warnings (--warn semantics only).
16
+ */
17
+ export default function CautionCallout({
18
+ eyebrow = "Caution",
19
+ title,
20
+ children,
21
+ className,
22
+ }: CautionCalloutProps) {
23
+ return (
24
+ <aside
25
+ role="note"
26
+ className={clsx(
27
+ "depth-1 rounded-sm border border-warn/30 bg-warn-dim p-5",
28
+ className,
29
+ )}
30
+ >
31
+ <div className="flex items-start gap-3">
32
+ <AlertTriangle size={18} aria-hidden className="mt-0.5 shrink-0 text-warn" />
33
+ <div>
34
+ <p className="num text-[11px] uppercase tracking-[0.2em] text-warn">
35
+ {eyebrow}
36
+ </p>
37
+ <h3 className="mt-1 font-display text-xl text-text">{title}</h3>
38
+ <div className="mt-2 flex flex-col gap-2 text-[13px] leading-relaxed text-muted">
39
+ {children}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </aside>
44
+ );
45
+ }
src/components/docs/HourBar.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+
3
+ export interface HourBarProps {
4
+ /** 0..1 portion of the track to fill */
5
+ share: number;
6
+ /** accent = primary leaderboard data · accent-soft = secondary live data · muted = everything else */
7
+ tone?: "muted" | "accent" | "accent-soft";
8
+ className?: string;
9
+ }
10
+
11
+ const tones: Record<NonNullable<HourBarProps["tone"]>, string> = {
12
+ /* token-derived teal-tinted fill so even "everything else" bars carry
13
+ a quiet phosphor cast instead of grey-on-grey */
14
+ muted: "bg-[color-mix(in_srgb,var(--accent)_18%,var(--muted)_50%)]",
15
+ accent: "bg-accent/75",
16
+ "accent-soft": "bg-accent/40",
17
+ };
18
+
19
+ /**
20
+ * Proportional hour bar. Purely decorative — always pair with the printed
21
+ * number; the bar itself is aria-hidden.
22
+ */
23
+ export default function HourBar({
24
+ share,
25
+ tone = "muted",
26
+ className,
27
+ }: HourBarProps) {
28
+ const clamped = Math.max(0, Math.min(1, share));
29
+ return (
30
+ <div
31
+ aria-hidden
32
+ className={clsx(
33
+ "hairline h-2 w-full overflow-hidden rounded-sm bg-surface-2",
34
+ className,
35
+ )}
36
+ >
37
+ <div
38
+ className={clsx("h-full", tones[tone])}
39
+ style={{ width: `${clamped * 100}%` }}
40
+ />
41
+ </div>
42
+ );
43
+ }
src/components/docs/ManifestCard.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ANCHOR_DATASET_ID, HARNESS_VERSION, NORMALIZER_VERSION } from "@/lib/data";
2
+
3
+ export interface ManifestCardProps {
4
+ /** Registry id rendered in the exemplar's dataset field. */
5
+ datasetId?: string;
6
+ className?: string;
7
+ }
8
+
9
+ const manifestFor = (datasetId: string) => `{
10
+ "run_id": "2026-06-09T21:14:02Z-a41f",
11
+ "model": { "id": "vinai/PhoWhisper-small", "revision": "84f3c1d" },
12
+ "harness": "${HARNESS_VERSION}",
13
+ "normalizer": "${NORMALIZER_VERSION}",
14
+ "dataset": { "id": "${datasetId}", "split": "test" },
15
+ "hardware": "1x RTX 4090 24GB / CUDA 12.4",
16
+ "seeds": { "global": 1337 },
17
+ "decode": { "beam_size": 5, "temperature": 0.0, "bias_list": null }
18
+ }`;
19
+
20
+ const FIELDS: ReadonlyArray<{ key: string; gloss: string }> = [
21
+ {
22
+ key: "model.revision",
23
+ gloss: "Pins exact weights — a commit hash, never a moving tag.",
24
+ },
25
+ {
26
+ key: "harness",
27
+ gloss: "Version of the scoring code that produced every number in the row.",
28
+ },
29
+ {
30
+ key: "normalizer",
31
+ gloss: "Text-normalization version. WER is only comparable within it.",
32
+ },
33
+ {
34
+ key: "dataset",
35
+ gloss:
36
+ "Registry id and split the run scored — every manifest is scoped to exactly one dataset.",
37
+ },
38
+ {
39
+ key: "hardware",
40
+ gloss: "The machine behind RTFx — published so speed claims have context.",
41
+ },
42
+ {
43
+ key: "seeds",
44
+ gloss: "Decode determinism: same manifest in, same transcript out.",
45
+ },
46
+ {
47
+ key: "decode",
48
+ gloss: "Beam, temperature, bias list — Track B records the bias-list hash here.",
49
+ },
50
+ ];
51
+
52
+ /** Anatomy of a run manifest: schematic mono block plus field glosses. */
53
+ export default function ManifestCard({
54
+ datasetId = ANCHOR_DATASET_ID,
55
+ className,
56
+ }: ManifestCardProps) {
57
+ return (
58
+ <div
59
+ className={`hairline spotlight-card depth-1 flex flex-col gap-4 rounded-sm bg-surface p-5${className ? ` ${className}` : ""}`}
60
+ >
61
+ <p className="num text-[11px] uppercase tracking-[0.18em] text-muted">
62
+ run_manifest.json — published with every platform-verified run
63
+ </p>
64
+ <pre className="num hairline overflow-x-auto rounded-sm bg-surface-2 p-4 text-[12px] leading-relaxed text-text">
65
+ {manifestFor(datasetId)}
66
+ </pre>
67
+ <dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
68
+ {FIELDS.map((f) => (
69
+ <div key={f.key}>
70
+ <dt className="num text-[12px] text-text">{f.key}</dt>
71
+ <dd className="mt-0.5 text-[12px] leading-relaxed text-muted">
72
+ {f.gloss}
73
+ </dd>
74
+ </div>
75
+ ))}
76
+ </dl>
77
+ </div>
78
+ );
79
+ }
src/components/docs/MetricDefCard.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import type { ReactNode } from "react";
3
+ import Badge from "@/components/ui/Badge";
4
+
5
+ export interface MetricDefCardProps {
6
+ /** short symbol, e.g. "CS-WER" */
7
+ abbr: string;
8
+ /** spelled-out name, e.g. "Code-switch word error rate" */
9
+ name: string;
10
+ /** mono formula line; omit on roadmap ghosts without a fixed definition */
11
+ formula?: string;
12
+ /** "lower is better" / "higher is better" */
13
+ direction?: string;
14
+ /** ghost treatment for roadmap metrics */
15
+ roadmap?: boolean;
16
+ children: ReactNode;
17
+ className?: string;
18
+ }
19
+
20
+ /** Typeset metric definition card: serif symbol, mono formula, prose gloss. */
21
+ export default function MetricDefCard({
22
+ abbr,
23
+ name,
24
+ formula,
25
+ direction,
26
+ roadmap = false,
27
+ children,
28
+ className,
29
+ }: MetricDefCardProps) {
30
+ return (
31
+ <div
32
+ className={clsx(
33
+ "flex flex-col gap-2.5 rounded-sm p-5 spotlight-card",
34
+ roadmap
35
+ ? "border border-dashed border-line bg-transparent"
36
+ : "hairline depth-1 bg-surface",
37
+ className,
38
+ )}
39
+ >
40
+ <div className="flex items-baseline justify-between gap-3">
41
+ <h3
42
+ className={clsx(
43
+ "font-display text-2xl leading-none",
44
+ roadmap ? "text-muted" : "text-text",
45
+ )}
46
+ >
47
+ {abbr}
48
+ </h3>
49
+ {roadmap ? (
50
+ <Badge variant="ghost">Roadmap</Badge>
51
+ ) : (
52
+ direction && (
53
+ <span className="num whitespace-nowrap text-[11px] uppercase tracking-[0.14em] text-muted">
54
+ {direction}
55
+ </span>
56
+ )
57
+ )}
58
+ </div>
59
+ <p className="text-[11px] uppercase tracking-[0.14em] text-muted">
60
+ {name}
61
+ </p>
62
+ {formula && (
63
+ <code
64
+ className={clsx(
65
+ "num block whitespace-pre-wrap rounded-sm px-3 py-2 text-[12px]",
66
+ roadmap
67
+ ? "border border-dashed border-line text-muted"
68
+ : "hairline bg-surface-2 text-text",
69
+ )}
70
+ >
71
+ {formula}
72
+ </code>
73
+ )}
74
+ <p className="text-[13px] leading-relaxed text-muted">{children}</p>
75
+ </div>
76
+ );
77
+ }
src/components/docs/OnboardingCriteria.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+
3
+ export interface OnboardingCriterion {
4
+ title: string;
5
+ detail: string;
6
+ }
7
+
8
+ /**
9
+ * The six onboarding criteria — single source of truth, consumed by the
10
+ * methodology #onboarding section, the datasets index CTA card, and the
11
+ * coming-soon dataset teasers.
12
+ */
13
+ export const ONBOARDING_CRITERIA: ReadonlyArray<OnboardingCriterion> = [
14
+ {
15
+ title: "Open or contracted license",
16
+ detail:
17
+ "Redistribution rights documented up front — an open license, or a signed contract for held-out corpora like the private clinic set.",
18
+ },
19
+ {
20
+ title: "Transparent provenance",
21
+ detail:
22
+ "Where every clip came from, how transcripts were produced, and who verified them — published as part of the dataset profile.",
23
+ },
24
+ {
25
+ title: "Fixed frozen splits",
26
+ detail:
27
+ "Train, Valid, Test (and Hard where defined) frozen before the first run. Any revision creates a new dataset version — never a silent edit.",
28
+ },
29
+ {
30
+ title: "Contamination policy",
31
+ detail:
32
+ "Systems that touched the labels — for example LLM-seeded transcripts — are declared and flagged permanently on that dataset's leaderboards.",
33
+ },
34
+ {
35
+ title: "Normalizer compatibility",
36
+ detail:
37
+ "References must score cleanly under the platform normalizer; orthographic quirks are documented and resolved before the first run.",
38
+ },
39
+ {
40
+ title: "Six-metric coverage",
41
+ detail:
42
+ "WER, CER, CS-WER, N-WER, MTR, and RTFx must be computable from the references — or the gaps declared on the dataset profile.",
43
+ },
44
+ ];
45
+
46
+ export interface OnboardingCriteriaProps {
47
+ items?: ReadonlyArray<OnboardingCriterion>;
48
+ className?: string;
49
+ }
50
+
51
+ /** Numbered grid of dataset-onboarding criteria. */
52
+ export default function OnboardingCriteria({
53
+ items = ONBOARDING_CRITERIA,
54
+ className,
55
+ }: OnboardingCriteriaProps) {
56
+ return (
57
+ <ol className={clsx("grid gap-3 sm:grid-cols-2 lg:grid-cols-3", className)}>
58
+ {items.map((item, i) => (
59
+ <li
60
+ key={item.title}
61
+ className="hairline spotlight-card depth-1 flex flex-col gap-1.5 rounded-sm bg-surface p-4"
62
+ >
63
+ <div className="flex items-baseline gap-2.5">
64
+ <span aria-hidden className="num text-[12px] text-muted">
65
+ {String(i + 1).padStart(2, "0")}
66
+ </span>
67
+ <h3 className="text-[14px] font-medium text-text">{item.title}</h3>
68
+ </div>
69
+ <p className="pl-[1.85rem] text-[13px] leading-relaxed text-muted">
70
+ {item.detail}
71
+ </p>
72
+ </li>
73
+ ))}
74
+ </ol>
75
+ );
76
+ }
src/components/docs/PipelineSteps.tsx ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface PipelineStep {
2
+ title: string;
3
+ detail: string;
4
+ /** mono stat shown beside the title, e.g. "64,232 entries" */
5
+ stat?: string;
6
+ }
7
+
8
+ export interface PipelineStepsProps {
9
+ steps: PipelineStep[];
10
+ }
11
+
12
+ /** Numbered construction-pipeline steps with a hairline connector spine. */
13
+ export default function PipelineSteps({ steps }: PipelineStepsProps) {
14
+ return (
15
+ <ol className="flex flex-col stagger-spring">
16
+ {steps.map((step, i) => (
17
+ <li
18
+ key={step.title}
19
+ className="relative grid grid-cols-[2.5rem_1fr] gap-x-4 pb-7 last:pb-0"
20
+ >
21
+ {i < steps.length - 1 && (
22
+ <span
23
+ aria-hidden
24
+ className="absolute bottom-0 left-5 top-11 w-px bg-line"
25
+ />
26
+ )}
27
+ <span
28
+ aria-hidden
29
+ className="num hairline flex size-10 items-center justify-center rounded-sm bg-surface-2 text-[13px] text-text"
30
+ >
31
+ {String(i + 1).padStart(2, "0")}
32
+ </span>
33
+ <div className="pt-1.5">
34
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5">
35
+ <h3 className="text-sm font-medium text-text">{step.title}</h3>
36
+ {step.stat && (
37
+ <span className="num text-[12px] text-muted">{step.stat}</span>
38
+ )}
39
+ </div>
40
+ <p className="mt-1 max-w-xl text-[13px] leading-relaxed text-muted">
41
+ {step.detail}
42
+ </p>
43
+ </div>
44
+ </li>
45
+ ))}
46
+ </ol>
47
+ );
48
+ }
src/components/docs/ProvenanceLadder.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
2
+ import type { Provenance } from "@/lib/types";
3
+
4
+ const LADDER: ReadonlyArray<{ p: Provenance; desc: string }> = [
5
+ {
6
+ p: "platform-verified",
7
+ desc: "Run by the platform harness, on platform hardware, from a pinned model revision. The full run manifest is published beside the number. The only class eligible for a SOTA badge.",
8
+ },
9
+ {
10
+ p: "self-reported",
11
+ desc: "Submitted by the system’s authors with a manifest, reproduced on their hardware rather than ours. Honor-system until the private canary subset ships.",
12
+ },
13
+ {
14
+ p: "api-snapshot",
15
+ desc: "Run through the platform harness against the live commercial API on a given date. We hold the hypotheses and bootstrap CIs, but the service has no pinned revision and runs on the vendor’s hardware — so RTFx is omitted and the row is never SOTA-eligible. Re-run on a quarterly drift schedule.",
16
+ },
17
+ {
18
+ p: "paper-imported",
19
+ desc: "Transcribed from a published paper. Normalizer and decode settings may differ from ours, so these rows wear a dashed outline everywhere they appear.",
20
+ },
21
+ ];
22
+
23
+ const STATES: ReadonlyArray<{ p: Provenance; desc: string }> = [
24
+ {
25
+ p: "running",
26
+ desc: "A platform run is executing right now. Metrics appear the moment the run completes and is verified.",
27
+ },
28
+ {
29
+ p: "planned",
30
+ desc: "Queued for a platform run. No numbers yet — the row exists so the roadmap is legible.",
31
+ },
32
+ ];
33
+
34
+ /** Trust ladder: three provenance classes ranked, plus the two row states. */
35
+ export default function ProvenanceLadder() {
36
+ return (
37
+ <div className="flex flex-col gap-4 stagger-spring">
38
+ <ol className="hairline spotlight-card depth-1 flex flex-col rounded-sm bg-surface px-5 py-1">
39
+ {LADDER.map((item, i) => (
40
+ <li
41
+ key={item.p}
42
+ className={
43
+ "grid grid-cols-[2rem_minmax(9.5rem,11rem)_1fr] items-start gap-x-4 py-4 " +
44
+ (i < LADDER.length - 1 ? "hairline-b" : "")
45
+ }
46
+ >
47
+ <span className="num pt-0.5 text-[12px] text-muted">
48
+ {String(i + 1).padStart(2, "0")}
49
+ </span>
50
+ <ProvenanceChip provenance={item.p} className="justify-self-start" />
51
+ <p className="text-[13px] leading-relaxed text-muted">{item.desc}</p>
52
+ </li>
53
+ ))}
54
+ </ol>
55
+
56
+ <div className="hairline spotlight-card depth-1 flex flex-col rounded-sm bg-surface px-5 py-1">
57
+ <p className="num hairline-b py-3 text-[11px] uppercase tracking-[0.18em] text-muted">
58
+ Row states — not trust classes
59
+ </p>
60
+ <ul className="flex flex-col">
61
+ {STATES.map((item, i) => (
62
+ <li
63
+ key={item.p}
64
+ className={
65
+ "grid grid-cols-[2rem_minmax(9.5rem,11rem)_1fr] items-start gap-x-4 py-4 " +
66
+ (i < STATES.length - 1 ? "hairline-b" : "")
67
+ }
68
+ >
69
+ <span aria-hidden className="num pt-0.5 text-[12px] text-muted/50">
70
+
71
+ </span>
72
+ <ProvenanceChip provenance={item.p} className="justify-self-start" />
73
+ <p className="text-[13px] leading-relaxed text-muted">
74
+ {item.desc}
75
+ </p>
76
+ </li>
77
+ ))}
78
+ </ul>
79
+ </div>
80
+ </div>
81
+ );
82
+ }
src/components/docs/RoadmapStrip.tsx ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import Badge from "@/components/ui/Badge";
3
+
4
+ export interface RoadmapItem {
5
+ name: string;
6
+ status: "live" | "next" | "north-star";
7
+ /** leading data figure, typeset in mono (e.g. "34.57 h") */
8
+ stat?: string;
9
+ detail: string;
10
+ }
11
+
12
+ export interface RoadmapStripProps {
13
+ items: RoadmapItem[];
14
+ }
15
+
16
+ const statusMeta: Record<
17
+ RoadmapItem["status"],
18
+ { label: string; variant: "accent" | "outline" | "ghost"; card: string }
19
+ > = {
20
+ live: {
21
+ label: "Live",
22
+ variant: "accent",
23
+ card: "hairline depth-1 bg-surface",
24
+ },
25
+ next: {
26
+ label: "Next",
27
+ variant: "outline",
28
+ card: "hairline depth-1 bg-surface",
29
+ },
30
+ "north-star": {
31
+ label: "North star",
32
+ variant: "ghost",
33
+ card: "border border-dashed border-line bg-transparent",
34
+ },
35
+ };
36
+
37
+ /** Multi-dataset roadmap: live anchor → next corpus → north-star evaluation. */
38
+ export default function RoadmapStrip({ items }: RoadmapStripProps) {
39
+ return (
40
+ <ol className="grid gap-3 md:grid-cols-3 stagger-spring">
41
+ {items.map((item, i) => {
42
+ const meta = statusMeta[item.status];
43
+ return (
44
+ <li
45
+ key={item.name}
46
+ className={clsx("flex flex-col gap-2 rounded-sm p-5", meta.card)}
47
+ >
48
+ <div className="flex items-center justify-between gap-2">
49
+ <span className="num text-[11px] tracking-[0.18em] text-muted">
50
+ {String(i + 1).padStart(2, "0")}
51
+ </span>
52
+ <Badge variant={meta.variant}>
53
+ {item.status === "live" && (
54
+ <span
55
+ aria-hidden
56
+ className="pulse-dot inline-block size-1.5 rounded-full bg-accent"
57
+ />
58
+ )}
59
+ {meta.label}
60
+ </Badge>
61
+ </div>
62
+ <h3
63
+ className={clsx(
64
+ "font-display text-xl",
65
+ item.status === "north-star" ? "text-muted" : "text-text",
66
+ )}
67
+ >
68
+ {item.name}
69
+ </h3>
70
+ <p className="text-[13px] leading-relaxed text-muted">
71
+ {item.stat && (
72
+ <span className="num text-text">{item.stat} </span>
73
+ )}
74
+ {item.detail}
75
+ </p>
76
+ </li>
77
+ );
78
+ })}
79
+ </ol>
80
+ );
81
+ }
src/components/docs/SplitMiniBar.tsx ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import { fmt } from "@/lib/data";
3
+ import type { DatasetInfo } from "@/lib/types";
4
+
5
+ type Splits = NonNullable<DatasetInfo["splits"]>;
6
+
7
+ export interface SplitMiniBarProps {
8
+ splits: Splits;
9
+ className?: string;
10
+ }
11
+
12
+ /* Segment palette mirrors HourBar tones: Test carries the phosphor note,
13
+ Hard a softer echo, Train/Valid stay quiet teal-greys. */
14
+ const SEGMENTS: ReadonlyArray<{
15
+ key: keyof Splits;
16
+ label: string;
17
+ fill: string;
18
+ }> = [
19
+ {
20
+ key: "train",
21
+ label: "Train",
22
+ fill: "bg-[color-mix(in_srgb,var(--accent)_16%,var(--muted)_46%)]",
23
+ },
24
+ {
25
+ key: "valid",
26
+ label: "Valid",
27
+ fill: "bg-[color-mix(in_srgb,var(--accent)_10%,var(--muted)_30%)]",
28
+ },
29
+ { key: "test", label: "Test", fill: "bg-accent/75" },
30
+ { key: "hard", label: "Hard", fill: "bg-accent/40" },
31
+ ];
32
+
33
+ /**
34
+ * Compact split-distribution readout for dataset cards: one segmented
35
+ * hour bar plus a mono legend. The bar is decorative; the legend carries
36
+ * the numbers.
37
+ */
38
+ export default function SplitMiniBar({ splits, className }: SplitMiniBarProps) {
39
+ const total = SEGMENTS.reduce((sum, s) => sum + splits[s.key].hours, 0);
40
+
41
+ return (
42
+ <div className={clsx("flex flex-col gap-2.5", className)}>
43
+ <div
44
+ aria-hidden
45
+ className="hairline flex h-2.5 w-full gap-px overflow-hidden rounded-sm bg-surface-2"
46
+ >
47
+ {SEGMENTS.map((s) => (
48
+ <div
49
+ key={s.key}
50
+ className={clsx("h-full", s.fill)}
51
+ style={{ width: `${(splits[s.key].hours / total) * 100}%` }}
52
+ />
53
+ ))}
54
+ </div>
55
+ {/* 2×2 legend — four columns crowd the card's narrow readout column
56
+ and wrap the unit onto its own line */}
57
+ <dl className="grid grid-cols-2 gap-x-8 gap-y-1.5">
58
+ {SEGMENTS.map((s) => (
59
+ <div key={s.key} className="flex items-baseline gap-1.5">
60
+ <span
61
+ aria-hidden
62
+ className={clsx("size-2 shrink-0 self-center rounded-[1px]", s.fill)}
63
+ />
64
+ <dt className="text-[12px] text-muted">{s.label}</dt>
65
+ <dd className="num ml-auto text-[12px] whitespace-nowrap text-text">
66
+ {fmt(splits[s.key].hours)}
67
+ <span className="text-muted"> h</span>
68
+ </dd>
69
+ </div>
70
+ ))}
71
+ </dl>
72
+ </div>
73
+ );
74
+ }
src/components/docs/SplitsTable.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Badge from "@/components/ui/Badge";
2
+ import { fmt } from "@/lib/data";
3
+ import type { DatasetInfo } from "@/lib/types";
4
+ import HourBar from "./HourBar";
5
+
6
+ export interface SplitsTableProps {
7
+ /** Frozen split inventory of a live dataset. */
8
+ splits: NonNullable<DatasetInfo["splits"]>;
9
+ /** Dataset name for the accessible caption, e.g. "ViMedCSS". */
10
+ datasetName?: string;
11
+ }
12
+
13
+ const ROWS: ReadonlyArray<{
14
+ key: keyof NonNullable<DatasetInfo["splits"]>;
15
+ name: string;
16
+ tone: "muted" | "accent" | "accent-soft";
17
+ badge?: { label: string; variant: "accent" | "outline" };
18
+ role: string;
19
+ }> = [
20
+ {
21
+ key: "train",
22
+ name: "Train",
23
+ tone: "muted",
24
+ role: "Adaptation corpus — Track C systems may train on this split and nothing else.",
25
+ },
26
+ {
27
+ key: "valid",
28
+ name: "Valid",
29
+ tone: "muted",
30
+ role: "Hyperparameter tuning and checkpoint selection. Never reported as a result.",
31
+ },
32
+ {
33
+ key: "test",
34
+ name: "Test",
35
+ tone: "accent",
36
+ badge: { label: "Primary", variant: "accent" },
37
+ role: "The primary leaderboard — every headline number on the home page is scored here.",
38
+ },
39
+ {
40
+ key: "hard",
41
+ name: "Hard",
42
+ tone: "accent-soft",
43
+ badge: { label: "Generalization", variant: "outline" },
44
+ role: "Rare and unseen terms, disjoint from Train — the generalization leaderboard.",
45
+ },
46
+ ];
47
+
48
+ /** Dataset split table with proportional hour-bars and role captions. */
49
+ export default function SplitsTable({ splits, datasetName }: SplitsTableProps) {
50
+ const totalHours = ROWS.reduce((sum, r) => sum + splits[r.key].hours, 0);
51
+
52
+ return (
53
+ <div className="hairline spotlight-row depth-1 overflow-x-auto rounded-sm bg-surface">
54
+ <table className="w-full min-w-[680px] border-collapse text-sm">
55
+ <caption className="sr-only">
56
+ {datasetName ?? "Dataset"} splits: hours, utterances, share of total
57
+ audio, and the role of each split
58
+ </caption>
59
+ <thead>
60
+ <tr className="hairline-b text-left">
61
+ <th
62
+ scope="col"
63
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
64
+ >
65
+ Split
66
+ </th>
67
+ <th
68
+ scope="col"
69
+ className="num px-4 py-3 text-right text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
70
+ >
71
+ Hours
72
+ </th>
73
+ <th
74
+ scope="col"
75
+ className="num px-4 py-3 text-right text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
76
+ >
77
+ Utterances
78
+ </th>
79
+ <th
80
+ scope="col"
81
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
82
+ >
83
+ Share of audio
84
+ </th>
85
+ <th
86
+ scope="col"
87
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
88
+ >
89
+ Role
90
+ </th>
91
+ </tr>
92
+ </thead>
93
+ <tbody>
94
+ {ROWS.map((row, i) => {
95
+ const s = splits[row.key];
96
+ const share = s.hours / totalHours;
97
+ return (
98
+ <tr
99
+ key={row.key}
100
+ className={i < ROWS.length - 1 ? "hairline-b align-top" : "align-top"}
101
+ >
102
+ <th
103
+ scope="row"
104
+ className="whitespace-nowrap px-4 py-3.5 text-left font-medium text-text"
105
+ >
106
+ <span className="inline-flex items-center gap-2">
107
+ {row.name}
108
+ {row.badge && (
109
+ <Badge variant={row.badge.variant}>{row.badge.label}</Badge>
110
+ )}
111
+ </span>
112
+ </th>
113
+ <td className="num whitespace-nowrap px-4 py-3.5 text-right text-text">
114
+ {fmt(s.hours)}
115
+ <span className="text-muted"> h</span>
116
+ </td>
117
+ <td className="num whitespace-nowrap px-4 py-3.5 text-right text-text">
118
+ {s.utts.toLocaleString("en-US")}
119
+ </td>
120
+ <td className="px-4 py-3.5">
121
+ <div className="flex items-center gap-2.5">
122
+ <HourBar share={share} tone={row.tone} className="w-24 md:w-32" />
123
+ <span className="num text-[11px] text-muted">
124
+ {(share * 100).toFixed(1)}%
125
+ </span>
126
+ </div>
127
+ </td>
128
+ <td className="min-w-[16rem] px-4 py-3.5 text-[13px] leading-relaxed text-muted">
129
+ {row.role}
130
+ </td>
131
+ </tr>
132
+ );
133
+ })}
134
+ </tbody>
135
+ </table>
136
+ </div>
137
+ );
138
+ }
src/components/docs/StatTile.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+
3
+ export interface StatTileProps {
4
+ label: string;
5
+ /** pre-formatted value, e.g. "34.57" or "2–21" */
6
+ value: string;
7
+ /** rendered after the value, muted, e.g. "h" or "s" */
8
+ suffix?: string;
9
+ /** small mono sub-line */
10
+ sub?: string;
11
+ className?: string;
12
+ }
13
+
14
+ /**
15
+ * Static stat tile for docs pages — visually identical to MetricCard but
16
+ * server-rendered and string-valued (handles ranges like "2–21 s").
17
+ */
18
+ export default function StatTile({
19
+ label,
20
+ value,
21
+ suffix,
22
+ sub,
23
+ className,
24
+ }: StatTileProps) {
25
+ return (
26
+ <div
27
+ className={clsx(
28
+ "hairline spotlight-card depth-1 flex flex-col gap-1 rounded-sm bg-surface px-4 py-3",
29
+ className,
30
+ )}
31
+ >
32
+ <span className="text-[11px] uppercase tracking-[0.14em] text-muted">
33
+ {label}
34
+ </span>
35
+ <span className="num text-2xl leading-none text-text">
36
+ {value}
37
+ {suffix && <span className="ml-0.5 text-base text-muted">{suffix}</span>}
38
+ </span>
39
+ {sub && <span className="num text-[11px] text-muted">{sub}</span>}
40
+ </div>
41
+ );
42
+ }
src/components/docs/TopicBars.tsx ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { fmt } from "@/lib/data";
2
+ import HourBar from "./HourBar";
3
+
4
+ export interface TopicBarsProps {
5
+ items: ReadonlyArray<{ label: string; vi?: string; hours: number }>;
6
+ }
7
+
8
+ /** Horizontal topic-distribution bars (widths relative to the largest topic). */
9
+ export default function TopicBars({ items }: TopicBarsProps) {
10
+ const max = Math.max(...items.map((t) => t.hours));
11
+ const total = items.reduce((sum, t) => sum + t.hours, 0);
12
+
13
+ return (
14
+ <ul className="hairline depth-1 flex flex-col rounded-sm bg-surface px-4 py-1">
15
+ {items.map((t, i) => (
16
+ <li
17
+ key={t.label}
18
+ className={
19
+ "grid grid-cols-[minmax(9rem,12rem)_1fr_auto] items-center gap-x-4 py-3 " +
20
+ (i < items.length - 1 ? "hairline-b" : "")
21
+ }
22
+ >
23
+ <span className="flex flex-col">
24
+ <span className="text-[13px] text-text">{t.label}</span>
25
+ {t.vi && (
26
+ <span lang="vi" className="text-[11px] leading-snug text-muted">
27
+ {t.vi}
28
+ </span>
29
+ )}
30
+ </span>
31
+ {/* the dominant domain carries the one quiet phosphor note */}
32
+ <HourBar
33
+ share={t.hours / max}
34
+ tone={t.hours === max ? "accent-soft" : "muted"}
35
+ />
36
+ <span className="num whitespace-nowrap text-right text-[12px] text-text">
37
+ {fmt(t.hours, 1)}
38
+ <span className="text-muted"> h</span>
39
+ <span className="ml-2 hidden text-muted sm:inline">
40
+ {((t.hours / total) * 100).toFixed(0)}%
41
+ </span>
42
+ </span>
43
+ </li>
44
+ ))}
45
+ </ul>
46
+ );
47
+ }
src/components/docs/TracksTable.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import Badge from "@/components/ui/Badge";
3
+
4
+ type TrackStatus = "live" | "config-ready" | "roadmap";
5
+
6
+ const STATUS: Record<
7
+ TrackStatus,
8
+ { label: string; variant: "accent" | "outline" | "ghost" }
9
+ > = {
10
+ live: { label: "Live", variant: "accent" },
11
+ "config-ready": { label: "Config-ready", variant: "outline" },
12
+ roadmap: { label: "Roadmap", variant: "ghost" },
13
+ };
14
+
15
+ const TRACKS: ReadonlyArray<{
16
+ id: string;
17
+ name: string;
18
+ rule: string;
19
+ status: TrackStatus;
20
+ }> = [
21
+ {
22
+ id: "A",
23
+ name: "Zero-shot",
24
+ rule: "The model exactly as released. No dataset-specific signal of any kind — no prompts, no bias lists, no weight updates.",
25
+ status: "live",
26
+ },
27
+ {
28
+ id: "B",
29
+ name: "Contextual biasing",
30
+ rule: "Bias lists (medical-term hotwords or prompts) allowed at decode time. No weight updates; the bias list is recorded in the run manifest.",
31
+ status: "live",
32
+ },
33
+ {
34
+ id: "C",
35
+ name: "Adapted",
36
+ rule: "Weights updated using the dataset's frozen Train split only; Valid for tuning. No other in-domain data of any kind.",
37
+ status: "live",
38
+ },
39
+ {
40
+ id: "D",
41
+ name: "Open",
42
+ rule: "Any external data or method, fully disclosed. The anything-goes track.",
43
+ status: "roadmap",
44
+ },
45
+ {
46
+ id: "E",
47
+ name: "Efficiency",
48
+ rule: "Deployment-realistic constraints: under 1B parameters, CPU decode, under 8GB VRAM.",
49
+ status: "config-ready",
50
+ },
51
+ {
52
+ id: "F",
53
+ name: "Conversational",
54
+ rule: "WER + DER on multi-speaker dialogue. Requires conversational corpora (VietMed, clinic data) — the track closest to production.",
55
+ status: "roadmap",
56
+ },
57
+ ];
58
+
59
+ /** Tracks A–F: what each track allows, with launch-status chips. */
60
+ export default function TracksTable() {
61
+ return (
62
+ <div className="hairline spotlight-row depth-1 overflow-x-auto rounded-sm bg-surface">
63
+ <table className="w-full min-w-[640px] border-collapse text-sm">
64
+ <caption className="sr-only">
65
+ Evaluation tracks A through F with their rules and launch status
66
+ </caption>
67
+ <thead>
68
+ <tr className="hairline-b text-left">
69
+ <th
70
+ scope="col"
71
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
72
+ >
73
+ Track
74
+ </th>
75
+ <th
76
+ scope="col"
77
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
78
+ >
79
+ Allowed signal
80
+ </th>
81
+ <th
82
+ scope="col"
83
+ className="num px-4 py-3 text-[11px] font-normal uppercase tracking-[0.14em] text-muted"
84
+ >
85
+ Status
86
+ </th>
87
+ </tr>
88
+ </thead>
89
+ <tbody>
90
+ {TRACKS.map((t, i) => {
91
+ const dim = t.status === "roadmap";
92
+ return (
93
+ <tr
94
+ key={t.id}
95
+ className={i < TRACKS.length - 1 ? "hairline-b align-top" : "align-top"}
96
+ >
97
+ <th
98
+ scope="row"
99
+ className="whitespace-nowrap px-4 py-3.5 text-left"
100
+ >
101
+ <span className="inline-flex items-baseline gap-2.5">
102
+ <span
103
+ className={clsx(
104
+ "num text-base",
105
+ dim ? "text-muted/70" : "text-text",
106
+ )}
107
+ >
108
+ {t.id}
109
+ </span>
110
+ <span
111
+ className={clsx(
112
+ "text-sm font-medium",
113
+ dim ? "text-muted/70" : "text-text",
114
+ )}
115
+ >
116
+ {t.name}
117
+ </span>
118
+ </span>
119
+ </th>
120
+ <td
121
+ className={clsx(
122
+ "min-w-[20rem] px-4 py-3.5 text-[13px] leading-relaxed",
123
+ dim ? "text-muted/70" : "text-muted",
124
+ )}
125
+ >
126
+ {t.rule}
127
+ </td>
128
+ <td className="whitespace-nowrap px-4 py-3.5">
129
+ <Badge variant={STATUS[t.status].variant}>
130
+ {STATUS[t.status].label}
131
+ </Badge>
132
+ </td>
133
+ </tr>
134
+ );
135
+ })}
136
+ </tbody>
137
+ </table>
138
+ </div>
139
+ );
140
+ }
src/components/leaderboard/HeroStats.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
5
+ import { ArrowUpRight } from "lucide-react";
6
+
7
+ /* layout effect on the client, plain effect during SSR (where it never
8
+ runs) — silences React's useLayoutEffect-on-server warning */
9
+ const useIsoLayoutEffect =
10
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
11
+
12
+ export interface HeroStatsProps {
13
+ /** total audio hours, e.g. 34.57 */
14
+ hours: number;
15
+ /** utterance count, e.g. 16576 */
16
+ utterances: number;
17
+ /** distinct code-switched terms, e.g. 889 */
18
+ csTerms: number;
19
+ /** dataset the snapshot belongs to, e.g. "ViMedCSS" */
20
+ datasetName: string;
21
+ /** dataset card route, e.g. "/datasets/vimedcss" */
22
+ datasetHref: string;
23
+ }
24
+
25
+ /** Spring-based easing for smooth, physical feel */
26
+ function springEase(t: number): number {
27
+ return 1 - Math.pow(1 - t, 3);
28
+ }
29
+
30
+ /** Shared 0→1 spring ease progress over ~700ms (instant under reduced motion).
31
+ Starts at 1 so server markup (and no-JS readers) carry the real values —
32
+ the pre-paint layout effect rewinds to 0 before the count-up plays. */
33
+ function useSpringCount(duration = 700): number {
34
+ const [progress, setProgress] = useState(1);
35
+ const raf = useRef<number>(0);
36
+
37
+ useIsoLayoutEffect(() => {
38
+ const reduced = window.matchMedia(
39
+ "(prefers-reduced-motion: reduce)",
40
+ ).matches;
41
+ if (reduced) return;
42
+ /* rewind before first paint, then spring up to the real values */
43
+ setProgress(0);
44
+ let t0: number | undefined;
45
+ const tick = (t: number) => {
46
+ t0 ??= t;
47
+ const p = Math.min((t - t0) / duration, 1);
48
+ setProgress(springEase(p));
49
+ if (p < 1) raf.current = requestAnimationFrame(tick);
50
+ };
51
+ raf.current = requestAnimationFrame(tick);
52
+ return () => cancelAnimationFrame(raf.current);
53
+ }, [duration]);
54
+
55
+ return progress;
56
+ }
57
+
58
+ /**
59
+ * Anchor-dataset snapshot band: a bento panel with a scope strip naming the
60
+ * dataset the numbers belong to (the hub hosts several datasets, so these
61
+ * stats must never read as platform totals), then four stat tiles whose
62
+ * numerals spring-count in once on mount.
63
+ */
64
+ export default function HeroStats({
65
+ hours,
66
+ utterances,
67
+ csTerms,
68
+ datasetName,
69
+ datasetHref,
70
+ }: HeroStatsProps) {
71
+ const p = useSpringCount();
72
+
73
+ const cells: { value: string; unit?: string; label: string }[] = [
74
+ { value: (hours * p).toFixed(2), unit: "h", label: "audio" },
75
+ {
76
+ value: Math.round(utterances * p).toLocaleString("en-US"),
77
+ label: "utterances",
78
+ },
79
+ {
80
+ value: Math.round(csTerms * p).toLocaleString("en-US"),
81
+ label: "CS medical terms",
82
+ },
83
+ { value: "2–21", unit: "s", label: "clip length" },
84
+ ];
85
+
86
+ return (
87
+ <section
88
+ aria-label={`${datasetName} anchor-dataset snapshot`}
89
+ className="hairline depth-2 overflow-hidden rounded-sm"
90
+ >
91
+ {/* scope strip — these numbers describe the anchor dataset, not the hub */}
92
+ <header className="hairline-b flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 bg-surface-2/60 px-4 py-2.5 backdrop-blur-sm">
93
+ <p className="num text-[11px] tracking-[0.2em] text-muted uppercase">
94
+ Anchor dataset snapshot —{" "}
95
+ <span className="text-text">{datasetName} v1</span>
96
+ </p>
97
+ <Link
98
+ href={datasetHref}
99
+ className="num inline-flex items-center gap-1 rounded-sm text-[11px] tracking-[0.14em] text-muted uppercase transition-colors duration-150 ease-out hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70"
100
+ >
101
+ Dataset card
102
+ <ArrowUpRight size={12} aria-hidden />
103
+ </Link>
104
+ </header>
105
+
106
+ <dl className="grid grid-cols-2 gap-px bg-line md:grid-cols-4">
107
+ {cells.map((c, i) => (
108
+ <div
109
+ key={c.label}
110
+ className="flex flex-col-reverse gap-1.5 bg-surface px-4 py-4 spring-in"
111
+ style={{ animationDelay: `${120 + i * 60}ms` }}
112
+ >
113
+ <dt className="text-[11px] uppercase tracking-[0.16em] text-muted">
114
+ {c.label}
115
+ </dt>
116
+ <dd className="num text-3xl leading-none text-text">
117
+ {c.value}
118
+ {c.unit && (
119
+ <span className="ml-1 text-lg text-muted">{c.unit}</span>
120
+ )}
121
+ </dd>
122
+ </div>
123
+ ))}
124
+ </dl>
125
+ </section>
126
+ );
127
+ }
src/components/leaderboard/LeaderboardTable.tsx ADDED
@@ -0,0 +1,1013 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import clsx from "clsx";
4
+ import Link from "next/link";
5
+ import { useEffect, useMemo, useRef, useState } from "react";
6
+ import { ArrowUp, ChevronsUpDown, Search, ShieldCheck } from "lucide-react";
7
+ import { MotionConfig, motion } from "framer-motion";
8
+ import Badge from "@/components/ui/Badge";
9
+ import CIBar from "@/components/ui/CIBar";
10
+ import FlagBadges from "@/components/ui/FlagBadges";
11
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
12
+ import SplitToggle from "@/components/ui/SplitToggle";
13
+ import {
14
+ HIGHER_IS_BETTER,
15
+ categoryLabels,
16
+ fmt,
17
+ metricLabels,
18
+ splitLabels,
19
+ trackLabels,
20
+ } from "@/lib/data";
21
+ import type {
22
+ Category,
23
+ MetricKey,
24
+ ModelRow,
25
+ Split,
26
+ SplitInfo,
27
+ Track,
28
+ } from "@/lib/types";
29
+
30
+ export interface LeaderboardTableProps {
31
+ models: ModelRow[];
32
+ /** Dataset the board is scoped to, e.g. "ViMedCSS" — shown in the title. */
33
+ datasetName: string;
34
+ /** Hard-split profile, for the explanatory note when Hard is selected. */
35
+ hardSplit: SplitInfo;
36
+ }
37
+
38
+ /* ------------------------------------------------------------------ */
39
+ /* Column + filter configuration */
40
+ /* ------------------------------------------------------------------ */
41
+
42
+ interface MetricCol {
43
+ key: MetricKey;
44
+ label: string;
45
+ title: string;
46
+ }
47
+
48
+ const METRIC_COLS: MetricCol[] = [
49
+ { key: "wer", label: "WER", title: "Word error rate (%) — lower is better" },
50
+ {
51
+ key: "csWer",
52
+ label: "CS-WER",
53
+ title: "Errors inside code-switched English spans (%) — lower is better",
54
+ },
55
+ {
56
+ key: "nWer",
57
+ label: "N-WER",
58
+ title: "WER on Vietnamese-only tokens (%) — lower is better",
59
+ },
60
+ {
61
+ key: "cer",
62
+ label: "CER",
63
+ title: "Character error rate (%) — lower is better",
64
+ },
65
+ {
66
+ key: "mtrExact",
67
+ label: "MTR",
68
+ title:
69
+ "Medical term recall, exact over fuzzy tier (%) — higher is better; sorts by exact",
70
+ },
71
+ {
72
+ key: "rtfx",
73
+ label: "RTFx",
74
+ title: "Audio seconds ÷ wall seconds — higher is faster",
75
+ },
76
+ ];
77
+
78
+ const TRACKS: ("all" | Track)[] = ["all", "A", "B", "C"];
79
+
80
+ const CATEGORY_SHORT: Record<Category, string> = {
81
+ "vn-open": "VN open",
82
+ "multilingual-open": "Multilingual",
83
+ medical: "Medical",
84
+ "api-intl": "API · intl",
85
+ "api-vn": "API · VN",
86
+ };
87
+
88
+ const CATEGORIES: ("all" | Category)[] = [
89
+ "all",
90
+ "vn-open",
91
+ "multilingual-open",
92
+ "medical",
93
+ "api-intl",
94
+ "api-vn",
95
+ ];
96
+
97
+ /* ------------------------------------------------------------------ */
98
+ /* Small local pieces */
99
+ /* ------------------------------------------------------------------ */
100
+
101
+ function FilterChip({
102
+ active,
103
+ onClick,
104
+ title,
105
+ children,
106
+ }: {
107
+ active: boolean;
108
+ onClick: () => void;
109
+ title?: string;
110
+ children: React.ReactNode;
111
+ }) {
112
+ return (
113
+ <button
114
+ type="button"
115
+ aria-pressed={active}
116
+ onClick={onClick}
117
+ title={title}
118
+ className={clsx(
119
+ "num rounded-sm border px-2 py-1 text-[11px] leading-4 tracking-wide whitespace-nowrap",
120
+ "transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.96]",
121
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
122
+ active
123
+ ? "border-muted/40 bg-surface-2 text-text"
124
+ : "border-line bg-transparent text-muted hover:bg-surface-2/60 hover:text-text",
125
+ )}
126
+ >
127
+ {children}
128
+ </button>
129
+ );
130
+ }
131
+
132
+ /** 244M / 1.55B from the paramsB field. */
133
+ function fmtParams(b?: number): string {
134
+ if (b === undefined) return "";
135
+ return b < 1 ? `${Math.round(b * 1000)}M` : `${b}B`;
136
+ }
137
+
138
+ /* ------------------------------------------------------------------ */
139
+ /* Table */
140
+ /* ------------------------------------------------------------------ */
141
+
142
+ export default function LeaderboardTable({
143
+ models,
144
+ datasetName,
145
+ hardSplit,
146
+ }: LeaderboardTableProps) {
147
+ const [split, setSplit] = useState<Split>("test");
148
+ const [track, setTrack] = useState<"all" | Track>("all");
149
+ const [category, setCategory] = useState<"all" | Category>("all");
150
+ const [zone1Only, setZone1Only] = useState(false);
151
+ const [query, setQuery] = useState("");
152
+ const [sortKey, setSortKey] = useState<MetricKey>("wer");
153
+ const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
154
+ const [showEmptyCols, setShowEmptyCols] = useState(false);
155
+
156
+ /* horizontal-scroll affordance: a right-edge fade that lifts once the
157
+ table is scrolled to its end (or fits without scrolling) */
158
+ const scrollRef = useRef<HTMLDivElement>(null);
159
+ const [moreRight, setMoreRight] = useState(false);
160
+
161
+ useEffect(() => {
162
+ const el = scrollRef.current;
163
+ if (!el) return;
164
+ const update = () =>
165
+ setMoreRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 8);
166
+ update();
167
+ el.addEventListener("scroll", update, { passive: true });
168
+ const ro = new ResizeObserver(update);
169
+ ro.observe(el);
170
+ return () => {
171
+ el.removeEventListener("scroll", update);
172
+ ro.disconnect();
173
+ };
174
+ }, []);
175
+
176
+ const bestFirstDir = HIGHER_IS_BETTER.has(sortKey) ? "desc" : "asc";
177
+
178
+ function onSort(key: MetricKey) {
179
+ if (key === sortKey) {
180
+ setSortDir((d) => (d === "asc" ? "desc" : "asc"));
181
+ } else {
182
+ setSortKey(key);
183
+ setSortDir(HIGHER_IS_BETTER.has(key) ? "desc" : "asc");
184
+ }
185
+ }
186
+
187
+ const { scored, unscored } = useMemo(() => {
188
+ const q = query.trim().toLowerCase();
189
+ const filtered = models.filter(
190
+ (m) =>
191
+ (track === "all" || m.track === track) &&
192
+ (category === "all" || m.category === category) &&
193
+ (!zone1Only || m.flags?.zone1 === true) &&
194
+ (!q || `${m.name} ${m.org} ${m.slug}`.toLowerCase().includes(q)),
195
+ );
196
+ const hasMetric = (m: ModelRow) =>
197
+ typeof m.metrics?.[split]?.[sortKey] === "number";
198
+ const dir = sortDir === "asc" ? 1 : -1;
199
+ const scored = filtered
200
+ .filter(hasMetric)
201
+ .sort(
202
+ (a, b) =>
203
+ dir *
204
+ ((a.metrics![split]![sortKey] as number) -
205
+ (b.metrics![split]![sortKey] as number)),
206
+ );
207
+ /* unscored order: measured-elsewhere rows, then running, then planned */
208
+ const statusRank = (m: ModelRow) =>
209
+ m.provenance === "planned" ? 2 : m.provenance === "running" ? 1 : 0;
210
+ const unscored = filtered
211
+ .filter((m) => !hasMetric(m))
212
+ .sort((a, b) => statusRank(a) - statusRank(b));
213
+ return { scored, unscored };
214
+ }, [models, split, track, category, zone1Only, query, sortKey, sortDir]);
215
+
216
+ /** Best-first rank regardless of the current sort direction. */
217
+ const rankOf = (i: number) =>
218
+ sortDir === bestFirstDir ? i + 1 : scored.length - i;
219
+
220
+ /* metric columns with no measured value in the current view: header and
221
+ dashes drop to whisper weight so absence never outshouts presence */
222
+ const emptyCols = useMemo(() => {
223
+ const rows = [...scored, ...unscored];
224
+ return new Set(
225
+ METRIC_COLS.filter(
226
+ (c) =>
227
+ !rows.some((m) => typeof m.metrics?.[split]?.[c.key] === "number"),
228
+ ).map((c) => c.key),
229
+ );
230
+ }, [scored, unscored, split]);
231
+
232
+ /* fully-empty secondary columns collapse into one ghost column by
233
+ default (WER / CS-WER, the headline pair, never collapse); the moment
234
+ any row carries a value, the column restores automatically */
235
+ const collapsible = METRIC_COLS.filter(
236
+ (c) => c.key !== "wer" && c.key !== "csWer" && emptyCols.has(c.key),
237
+ );
238
+ const collapsed = !showEmptyCols && collapsible.length > 0;
239
+ const visibleCols = collapsed
240
+ ? METRIC_COLS.filter((c) => !collapsible.some((e) => e.key === c.key))
241
+ : METRIC_COLS;
242
+ const visibleKeys = new Set(visibleCols.map((c) => c.key));
243
+ const collapsedLabels = collapsible.map((c) => c.label).join(", ");
244
+ /* 6 fixed columns (#, model, params, track, license, provenance) +
245
+ metric columns + the ghost/toggle column when anything is collapsible */
246
+ const colCount = 6 + visibleCols.length + (collapsible.length > 0 ? 1 : 0);
247
+ /* undefined = no ghost column · "" = column present but expanded */
248
+ const ghostCell =
249
+ collapsible.length > 0 ? (collapsed ? collapsedLabels : "") : undefined;
250
+
251
+ return (
252
+ /* reducedMotion="user" makes framer-motion honor prefers-reduced-motion
253
+ (transforms freeze, opacity still resolves) — the CSS-side animations
254
+ are already covered by the global override in globals.css */
255
+ <MotionConfig reducedMotion="user">
256
+ <section
257
+ aria-labelledby="leaderboard-heading"
258
+ className="flex flex-col gap-3"
259
+ >
260
+ {/* ----- title — the board is scoped to one dataset + split ----- */}
261
+ <h2
262
+ id="leaderboard-heading"
263
+ className="font-display mb-1 text-[1.7rem] leading-tight tracking-tight text-text md:text-3xl"
264
+ >
265
+ Leaderboard — {datasetName}{" "}
266
+ <span className="text-muted italic">
267
+ {splitLabels[split].toLowerCase()} split
268
+ </span>
269
+ </h2>
270
+
271
+ {/* ----- controls ----- */}
272
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-3">
273
+ <SplitToggle value={split} onChange={setSplit} />
274
+
275
+ <div
276
+ role="group"
277
+ aria-label="Track filter"
278
+ className="flex items-center gap-1.5"
279
+ >
280
+ <span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
281
+ Track
282
+ </span>
283
+ {TRACKS.map((t) => (
284
+ <FilterChip
285
+ key={t}
286
+ active={track === t}
287
+ onClick={() => setTrack(t)}
288
+ title={t === "all" ? "All tracks" : trackLabels[t]}
289
+ >
290
+ {t === "all" ? "All" : t}
291
+ </FilterChip>
292
+ ))}
293
+ </div>
294
+
295
+ <div
296
+ role="group"
297
+ aria-label="Category filter"
298
+ className="flex flex-wrap items-center gap-1.5"
299
+ >
300
+ <span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
301
+ Class
302
+ </span>
303
+ {CATEGORIES.map((c) => (
304
+ <FilterChip
305
+ key={c}
306
+ active={category === c}
307
+ onClick={() => setCategory(c)}
308
+ title={c === "all" ? "All model classes" : categoryLabels[c]}
309
+ >
310
+ {c === "all" ? "All" : CATEGORY_SHORT[c]}
311
+ </FilterChip>
312
+ ))}
313
+ </div>
314
+
315
+ <button
316
+ type="button"
317
+ aria-pressed={zone1Only}
318
+ onClick={() => setZone1Only((v) => !v)}
319
+ title="Zone 1: self-hostable on one 24GB GPU, license-clean"
320
+ className={clsx(
321
+ "num inline-flex items-center gap-1.5 rounded-sm border px-2 py-1 text-[11px] leading-4 tracking-wide",
322
+ "transition-[color,background-color,border-color,transform] duration-150 ease-out active:scale-[0.96]",
323
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
324
+ zone1Only
325
+ ? "border-accent/30 bg-accent-dim text-accent"
326
+ : "border-line text-muted hover:bg-surface-2/60 hover:text-text",
327
+ )}
328
+ >
329
+ <ShieldCheck size={13} aria-hidden />
330
+ Zone-1 deployable
331
+ </button>
332
+
333
+ <label className="relative ml-auto">
334
+ <span className="sr-only">Search models</span>
335
+ <Search
336
+ size={14}
337
+ aria-hidden
338
+ className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted"
339
+ />
340
+ <input
341
+ type="search"
342
+ value={query}
343
+ onChange={(e) => setQuery(e.target.value)}
344
+ placeholder="Search model or org…"
345
+ className={clsx(
346
+ "hairline num w-52 rounded-sm bg-surface py-1.5 pr-2 pl-8 text-[12px] text-text",
347
+ "placeholder:text-muted/70",
348
+ "focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-accent/60",
349
+ )}
350
+ />
351
+ </label>
352
+ </div>
353
+
354
+ {/* ----- hard-split note ----- */}
355
+ {split === "hard" && (
356
+ <p className="hairline rounded-sm bg-surface-2/60 px-3 py-2 text-[12px] leading-relaxed text-muted">
357
+ <span className="num text-text">Hard split</span> —{" "}
358
+ <span className="num">{fmt(hardSplit.hours)} h</span> /{" "}
359
+ <span className="num">
360
+ {hardSplit.utts.toLocaleString("en-US")}
361
+ </span>{" "}
362
+ utterances of rare or unseen code-switched terms: the generalization
363
+ leaderboard. Paper-imported rows have not published Hard-split
364
+ numbers, so fewer rows are scored here.
365
+ </p>
366
+ )}
367
+
368
+ {/* ----- mobile stacked rows (below md): rank + name, then WER ----- */}
369
+ <div className="hairline overflow-hidden rounded-sm bg-surface md:hidden">
370
+ <ol className="divide-y divide-line">
371
+ {scored.map((m, i) => (
372
+ <MobileRow
373
+ key={m.slug}
374
+ model={m}
375
+ split={split}
376
+ rank={rankOf(i)}
377
+ />
378
+ ))}
379
+ </ol>
380
+ {unscored.length > 0 && (
381
+ <>
382
+ <p className="num hairline-t bg-surface-2/40 px-3 py-2 text-[10.5px] tracking-[0.2em] text-muted uppercase">
383
+ Unscored on {splitLabels[split]} — awaiting runs or imports
384
+ </p>
385
+ <ul className="hairline-t divide-y divide-line">
386
+ {unscored.map((m) => (
387
+ <MobileRow key={m.slug} model={m} split={split} />
388
+ ))}
389
+ </ul>
390
+ </>
391
+ )}
392
+ {scored.length === 0 && unscored.length === 0 && (
393
+ <p className="px-3 py-10 text-center text-sm text-muted">
394
+ No models match the current filters.
395
+ </p>
396
+ )}
397
+ </div>
398
+
399
+ {/* ----- full table (md and up) -----
400
+ breakout-wide lets the board outgrow the editorial column on xl+
401
+ screens — every column fits without horizontal scroll */}
402
+ <div className="breakout-wide">
403
+ <div className="relative">
404
+ <motion.div
405
+ ref={scrollRef}
406
+ className="hairline hidden overflow-x-auto rounded-sm bg-surface md:block"
407
+ initial={{ opacity: 0, y: 16 }}
408
+ animate={{ opacity: 1, y: 0 }}
409
+ transition={{
410
+ duration: 0.5,
411
+ delay: 0.2,
412
+ ease: [0.22, 1, 0.36, 1],
413
+ }}
414
+ >
415
+ <table className="w-full min-w-[1024px] border-collapse text-left">
416
+ <caption className="sr-only">
417
+ Model leaderboard on the {datasetName} {splitLabels[split]}{" "}
418
+ split, sorted by {metricLabels[sortKey]}
419
+ </caption>
420
+ <thead>
421
+ <tr className="hairline-b bg-surface-2/50">
422
+ <th
423
+ scope="col"
424
+ className="num py-2.5 pr-1 pl-3 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
425
+ >
426
+ #
427
+ </th>
428
+ <th
429
+ scope="col"
430
+ className="num px-3 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
431
+ >
432
+ Model
433
+ </th>
434
+ <th
435
+ scope="col"
436
+ title="Parameter count"
437
+ className="num px-2 py-2.5 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
438
+ >
439
+ Params
440
+ </th>
441
+ <th
442
+ scope="col"
443
+ className="num px-2 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
444
+ >
445
+ Track
446
+ </th>
447
+ <th
448
+ scope="col"
449
+ className="num px-2 py-2.5 text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
450
+ >
451
+ License
452
+ </th>
453
+ {visibleCols.map((col) => {
454
+ const active = sortKey === col.key;
455
+ const empty = emptyCols.has(col.key);
456
+ return (
457
+ <th
458
+ key={col.key}
459
+ scope="col"
460
+ aria-sort={
461
+ active
462
+ ? sortDir === "asc"
463
+ ? "ascending"
464
+ : "descending"
465
+ : "none"
466
+ }
467
+ className="px-2 py-1.5 text-right"
468
+ >
469
+ <button
470
+ type="button"
471
+ onClick={() => onSort(col.key)}
472
+ title={col.title}
473
+ className={clsx(
474
+ "group num inline-flex items-center gap-1 rounded-sm px-1.5 py-1 text-[11px] font-normal tracking-[0.14em] uppercase",
475
+ "transition-[color,background-color,transform] duration-150 ease-out",
476
+ "hover:bg-surface-2/70 active:scale-[0.95]",
477
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70",
478
+ active
479
+ ? "text-text"
480
+ : empty
481
+ ? "text-muted/50 hover:text-muted"
482
+ : "text-muted hover:text-text",
483
+ )}
484
+ >
485
+ {col.label}
486
+ {active ? (
487
+ /* one arrow that flips — direction toggles read as a
488
+ rotation instead of an icon swap */
489
+ <ArrowUp
490
+ size={11}
491
+ aria-hidden
492
+ className={clsx(
493
+ "text-accent transition-transform duration-200 ease-out",
494
+ sortDir === "desc" && "rotate-180",
495
+ )}
496
+ />
497
+ ) : (
498
+ <ChevronsUpDown
499
+ size={11}
500
+ aria-hidden
501
+ className="opacity-50 transition-opacity duration-150 ease-out group-hover:opacity-90"
502
+ />
503
+ )}
504
+ </button>
505
+ </th>
506
+ );
507
+ })}
508
+ {collapsible.length > 0 && (
509
+ <th scope="col" className="px-2 py-1.5 text-right">
510
+ <button
511
+ type="button"
512
+ aria-pressed={showEmptyCols}
513
+ onClick={() => setShowEmptyCols((v) => !v)}
514
+ title={
515
+ showEmptyCols
516
+ ? "Collapse the unmeasured columns"
517
+ : `Awaiting platform runs: ${collapsedLabels}`
518
+ }
519
+ className={clsx(
520
+ "num inline-flex items-center gap-1 rounded-sm border border-line/60 px-1.5 py-1",
521
+ "text-[10.5px] font-normal tracking-[0.14em] whitespace-nowrap uppercase",
522
+ "text-muted/60 transition-[color,background-color,transform] duration-150 ease-out",
523
+ "hover:bg-surface-2/70 hover:text-text active:scale-[0.96]",
524
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent/70",
525
+ )}
526
+ >
527
+ {showEmptyCols
528
+ ? "− collapse"
529
+ : `+${collapsible.length} awaiting runs`}
530
+ </button>
531
+ </th>
532
+ )}
533
+ <th
534
+ scope="col"
535
+ className="num py-2.5 pr-3 pl-2 text-right text-[11px] font-normal tracking-[0.14em] text-muted uppercase"
536
+ >
537
+ Provenance
538
+ </th>
539
+ </tr>
540
+ </thead>
541
+ <tbody>
542
+ {scored.map((m, i) => (
543
+ <Row
544
+ key={m.slug}
545
+ model={m}
546
+ split={split}
547
+ rank={rankOf(i)}
548
+ emptyCols={emptyCols}
549
+ visibleKeys={visibleKeys}
550
+ ghostCell={ghostCell}
551
+ index={i}
552
+ />
553
+ ))}
554
+
555
+ {unscored.length > 0 && (
556
+ <tr className="hairline-t">
557
+ <td
558
+ colSpan={colCount}
559
+ className="num bg-surface-2/40 px-3 pt-2.5 pb-2 text-[10.5px] tracking-[0.2em] text-muted uppercase"
560
+ >
561
+ Unscored on {splitLabels[split]} ·{" "}
562
+ {metricLabels[sortKey]} — awaiting runs or imports
563
+ </td>
564
+ </tr>
565
+ )}
566
+ {unscored.map((m, i) => (
567
+ <Row
568
+ key={m.slug}
569
+ model={m}
570
+ split={split}
571
+ rank={undefined}
572
+ emptyCols={emptyCols}
573
+ visibleKeys={visibleKeys}
574
+ ghostCell={ghostCell}
575
+ index={scored.length + i}
576
+ />
577
+ ))}
578
+
579
+ {scored.length === 0 && unscored.length === 0 && (
580
+ <tr>
581
+ <td
582
+ colSpan={colCount}
583
+ className="px-3 py-10 text-center text-sm text-muted"
584
+ >
585
+ No models match the current filters.
586
+ </td>
587
+ </tr>
588
+ )}
589
+ </tbody>
590
+ </table>
591
+ </motion.div>
592
+
593
+ {/* right-edge fade: columns continue past the fold — lifts at scroll end */}
594
+ <div
595
+ aria-hidden
596
+ className={clsx(
597
+ "pointer-events-none absolute inset-y-0 right-0 hidden w-14 rounded-r-sm",
598
+ "bg-gradient-to-l from-surface to-transparent",
599
+ "transition-opacity duration-300 ease-out md:block",
600
+ moreRight ? "opacity-100" : "opacity-0",
601
+ )}
602
+ />
603
+ </div>
604
+
605
+ <p className="num mt-3 text-right text-[11px] text-muted">
606
+ {scored.length + unscored.length} / {models.length} models ·{" "}
607
+ {splitLabels[split]} split · sorted by {metricLabels[sortKey]}
608
+ </p>
609
+ </div>
610
+ </section>
611
+ </MotionConfig>
612
+ );
613
+ }
614
+
615
+ /* ------------------------------------------------------------------ */
616
+ /* Row */
617
+ /* ------------------------------------------------------------------ */
618
+
619
+ function Row({
620
+ model: m,
621
+ split,
622
+ rank,
623
+ emptyCols,
624
+ visibleKeys,
625
+ ghostCell,
626
+ index = 0,
627
+ }: {
628
+ model: ModelRow;
629
+ split: Split;
630
+ rank?: number;
631
+ emptyCols: ReadonlySet<MetricKey>;
632
+ /** metric columns currently rendered (empty ones collapse away) */
633
+ visibleKeys: ReadonlySet<MetricKey>;
634
+ /** undefined = no ghost column · "" = present but expanded ·
635
+ non-empty = collapsed, labels for the tooltip */
636
+ ghostCell?: string;
637
+ index?: number;
638
+ }) {
639
+ const s = m.metrics?.[split];
640
+ const isTop = rank === 1;
641
+ const running = m.provenance === "running";
642
+ const dimmed = rank === undefined;
643
+ const show = (key: MetricKey) => visibleKeys.has(key);
644
+ const missing = (key: MetricKey) => (
645
+ <MissingCell
646
+ running={running}
647
+ provenance={m.provenance}
648
+ dim={emptyCols.has(key)}
649
+ />
650
+ );
651
+
652
+ return (
653
+ <motion.tr
654
+ /* hover surface + phosphor sweep come from .spotlight-row */
655
+ className="spotlight-row hairline-t"
656
+ initial={{ opacity: 0, y: 8 }}
657
+ animate={{ opacity: 1, y: 0 }}
658
+ transition={{
659
+ duration: 0.3,
660
+ /* cap the stagger so deep rows don't appear seconds late */
661
+ delay: Math.min(index, 12) * 0.04,
662
+ ease: [0.22, 1, 0.36, 1],
663
+ }}
664
+ >
665
+ {/* rank + accent rail for #1 · soft accent rail for running rows */}
666
+ <td
667
+ className={clsx(
668
+ "num border-l-2 py-3 pr-1 pl-3 text-right align-top text-[13px]",
669
+ isTop
670
+ ? "border-l-accent text-accent"
671
+ : running
672
+ ? "border-l-accent/40 text-muted"
673
+ : "border-l-transparent text-muted",
674
+ )}
675
+ >
676
+ {rank ?? "—"}
677
+ </td>
678
+
679
+ {/* model */}
680
+ <td className="px-3 py-3 align-top">
681
+ <div className="flex items-center gap-2">
682
+ <Link
683
+ href={`/models/${m.slug}`}
684
+ title={m.note}
685
+ className={clsx(
686
+ "max-w-[26ch] truncate text-[13px] leading-snug lg:max-w-[36ch]",
687
+ dimmed ? "text-muted" : "text-text",
688
+ "transition-colors duration-150 ease-out hover:text-accent",
689
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
690
+ m.provenance === "paper-imported" &&
691
+ "underline decoration-line decoration-dashed underline-offset-4",
692
+ )}
693
+ >
694
+ {m.name}
695
+ </Link>
696
+ <FlagBadges flags={m.flags} className="shrink-0" />
697
+ </div>
698
+ <div className="mt-0.5 text-[11px] text-muted">{m.org}</div>
699
+ </td>
700
+
701
+ {/* params — split out of the org sub-line into its own column */}
702
+ <td className="num px-2 py-3 text-right align-top text-[12px]">
703
+ {m.paramsB !== undefined ? (
704
+ <span className={dimmed ? "text-muted" : "text-text"}>
705
+ {fmtParams(m.paramsB)}
706
+ </span>
707
+ ) : (
708
+ <span className="text-muted/70" aria-label="parameter count unknown">
709
+
710
+ </span>
711
+ )}
712
+ </td>
713
+
714
+ {/* track */}
715
+ <td className="px-2 py-3 align-top">
716
+ <Badge variant="outline" title={trackLabels[m.track]}>
717
+ {m.track}
718
+ </Badge>
719
+ </td>
720
+
721
+ {/* license */}
722
+ <td className="num max-w-[14ch] px-2 py-3 align-top text-[11px] leading-[1.45] break-words text-muted">
723
+ {m.license}
724
+ </td>
725
+
726
+ {/* WER — the headline number, with fixed-scale range bar */}
727
+ <td className="px-2 py-3 text-right align-top">
728
+ {typeof s?.wer === "number" ? (
729
+ <div className="flex flex-col items-end gap-1">
730
+ <span
731
+ className={clsx(
732
+ "num text-[15px] leading-none",
733
+ isTop ? "text-accent" : "text-text",
734
+ )}
735
+ title={
736
+ s.werCi
737
+ ? `95% CI ${fmt(s.werCi[0])}–${fmt(s.werCi[1])}`
738
+ : undefined
739
+ }
740
+ >
741
+ {fmt(s.wer)}
742
+ </span>
743
+ <CIBar value={s.wer} ci={s.werCi} />
744
+ </div>
745
+ ) : (
746
+ missing("wer")
747
+ )}
748
+ </td>
749
+
750
+ {/* CS-WER — the code-switching metric gets the same micro-bar as WER */}
751
+ <td className="px-2 py-3 text-right align-top">
752
+ {typeof s?.csWer === "number" ? (
753
+ <div className="flex flex-col items-end gap-1">
754
+ <span
755
+ className="num text-[13px] leading-none text-text"
756
+ title={
757
+ s.csWerCi
758
+ ? `95% CI ${fmt(s.csWerCi[0])}–${fmt(s.csWerCi[1])}`
759
+ : undefined
760
+ }
761
+ >
762
+ {fmt(s.csWer)}
763
+ </span>
764
+ <CIBar value={s.csWer} ci={s.csWerCi} />
765
+ </div>
766
+ ) : (
767
+ missing("csWer")
768
+ )}
769
+ </td>
770
+
771
+ {/* N-WER */}
772
+ {show("nWer") && (
773
+ <td className="num px-2 py-3 text-right align-top text-[12px] text-text">
774
+ {typeof s?.nWer === "number" ? fmt(s.nWer) : missing("nWer")}
775
+ </td>
776
+ )}
777
+
778
+ {/* CER */}
779
+ {show("cer") && (
780
+ <td className="num px-2 py-3 text-right align-top text-[12px] text-text">
781
+ {typeof s?.cer === "number" ? fmt(s.cer) : missing("cer")}
782
+ </td>
783
+ )}
784
+
785
+ {/* MTR exact / fuzzy, stacked */}
786
+ {show("mtrExact") && (
787
+ <td className="px-2 py-3 text-right align-top">
788
+ {typeof s?.mtrExact === "number" ||
789
+ typeof s?.mtrFuzzy === "number" ? (
790
+ <div className="flex flex-col items-end gap-0.5">
791
+ <span className="num text-[12px] leading-none text-text">
792
+ {fmt(s?.mtrExact, 1)}
793
+ </span>
794
+ <span
795
+ className="num text-[11px] leading-none text-muted"
796
+ title="Fuzzy tier"
797
+ >
798
+ {fmt(s?.mtrFuzzy, 1)} fz
799
+ </span>
800
+ </div>
801
+ ) : (
802
+ missing("mtrExact")
803
+ )}
804
+ </td>
805
+ )}
806
+
807
+ {/* RTFx */}
808
+ {show("rtfx") && (
809
+ <td className="num px-2 py-3 text-right align-top text-[12px] text-text">
810
+ {typeof s?.rtfx === "number" ? (
811
+ <>
812
+ {fmt(s.rtfx, 1)}
813
+ <span className="text-muted">×</span>
814
+ </>
815
+ ) : (
816
+ missing("rtfx")
817
+ )}
818
+ </td>
819
+ )}
820
+
821
+ {/* collapsed empty columns: one narrow ghost cell */}
822
+ {ghostCell !== undefined && (
823
+ <td className="px-2 py-3 text-right align-top">
824
+ {ghostCell !== "" && (
825
+ <span
826
+ className="num text-[11px] tracking-[0.2em] text-muted/40"
827
+ title={`${ghostCell} — awaiting platform runs`}
828
+ aria-label={`${ghostCell}: awaiting platform runs`}
829
+ >
830
+ · · ·
831
+ </span>
832
+ )}
833
+ </td>
834
+ )}
835
+
836
+ {/* provenance */}
837
+ <td className="py-3 pr-3 pl-2 text-right align-top">
838
+ <ProvenanceChip provenance={m.provenance} />
839
+ </td>
840
+ </motion.tr>
841
+ );
842
+ }
843
+
844
+ /**
845
+ * Evidence-gap placeholder: "…" for in-flight runs, a dotted ghost dash for
846
+ * paper-imported gaps (footnote [2] — the paper never published the value),
847
+ * an em-dash otherwise. `dim` drops fully-empty columns to whisper weight.
848
+ */
849
+ function MissingCell({
850
+ running,
851
+ provenance,
852
+ dim,
853
+ }: {
854
+ running: boolean;
855
+ provenance: ModelRow["provenance"];
856
+ dim?: boolean;
857
+ }) {
858
+ if (running) {
859
+ return (
860
+ <span
861
+ className="num text-[12px] text-muted/80"
862
+ aria-label="run in progress"
863
+ >
864
+
865
+ </span>
866
+ );
867
+ }
868
+ if (provenance === "paper-imported") {
869
+ return (
870
+ <span
871
+ role="img"
872
+ title="not published in source paper"
873
+ aria-label="not published in source paper"
874
+ className={clsx(
875
+ "inline-block w-4 border-b border-dotted align-middle",
876
+ dim ? "border-muted/40" : "border-muted/70",
877
+ )}
878
+ />
879
+ );
880
+ }
881
+ return (
882
+ <span
883
+ className={clsx(
884
+ "num text-[12px]",
885
+ dim ? "text-muted/40" : "text-muted/70",
886
+ )}
887
+ aria-label="not measured"
888
+ >
889
+
890
+ </span>
891
+ );
892
+ }
893
+
894
+ /* ------------------------------------------------------------------ */
895
+ /* Mobile row (below md): rank + name, then WER — the headline metric */
896
+ /* stays on screen without horizontal scroll */
897
+ /* ------------------------------------------------------------------ */
898
+
899
+ function MobileRow({
900
+ model: m,
901
+ split,
902
+ rank,
903
+ }: {
904
+ model: ModelRow;
905
+ split: Split;
906
+ rank?: number;
907
+ }) {
908
+ const s = m.metrics?.[split];
909
+ const isTop = rank === 1;
910
+ const running = m.provenance === "running";
911
+ const dimmed = rank === undefined;
912
+
913
+ return (
914
+ <li
915
+ className={clsx(
916
+ "spotlight-row border-l-2 px-3 py-3",
917
+ isTop
918
+ ? "border-l-accent"
919
+ : running
920
+ ? "border-l-accent/40"
921
+ : "border-l-transparent",
922
+ )}
923
+ >
924
+ <div className="flex items-center gap-2">
925
+ <span
926
+ className={clsx(
927
+ "num w-5 shrink-0 text-right text-[13px]",
928
+ isTop ? "text-accent" : "text-muted",
929
+ )}
930
+ >
931
+ {rank ?? "—"}
932
+ </span>
933
+ <Link
934
+ href={`/models/${m.slug}`}
935
+ title={m.note}
936
+ className={clsx(
937
+ "min-w-0 line-clamp-2 text-[13px] leading-snug",
938
+ dimmed ? "text-muted" : "text-text",
939
+ "transition-colors duration-150 ease-out hover:text-accent",
940
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent/70",
941
+ m.provenance === "paper-imported" &&
942
+ "underline decoration-line decoration-dashed underline-offset-4",
943
+ )}
944
+ >
945
+ {m.name}
946
+ </Link>
947
+ <FlagBadges flags={m.flags} className="shrink-0" />
948
+ </div>
949
+ <div className="mt-1.5 flex items-end justify-between gap-3 pl-7">
950
+ <div className="min-w-0 text-[11px] text-muted">
951
+ <p className="truncate">
952
+ {m.org}
953
+ {m.paramsB !== undefined && (
954
+ <span className="num"> · {fmtParams(m.paramsB)}</span>
955
+ )}
956
+ </p>
957
+ <div className="mt-1.5">
958
+ <ProvenanceChip provenance={m.provenance} />
959
+ </div>
960
+ </div>
961
+ <div className="flex shrink-0 flex-col items-end gap-1">
962
+ {typeof s?.wer === "number" ? (
963
+ <>
964
+ <span
965
+ className={clsx(
966
+ "num text-[15px] leading-none",
967
+ isTop ? "text-accent" : "text-text",
968
+ )}
969
+ title={
970
+ s.werCi
971
+ ? `95% CI ${fmt(s.werCi[0])}–${fmt(s.werCi[1])}`
972
+ : undefined
973
+ }
974
+ >
975
+ {fmt(s.wer)}
976
+ <span className="ml-1 text-[10.5px] tracking-wide text-muted">
977
+ WER
978
+ </span>
979
+ </span>
980
+ <CIBar value={s.wer} ci={s.werCi} />
981
+ {/* CS-WER — the benchmark's defining metric stays on mobile */}
982
+ {typeof s.csWer === "number" && (
983
+ <>
984
+ <span
985
+ className="num mt-1 text-[12px] leading-none text-text"
986
+ title={
987
+ s.csWerCi
988
+ ? `95% CI ${fmt(s.csWerCi[0])}–${fmt(s.csWerCi[1])}`
989
+ : undefined
990
+ }
991
+ >
992
+ {fmt(s.csWer)}
993
+ <span className="ml-1 text-[10.5px] tracking-wide text-muted">
994
+ CS-WER
995
+ </span>
996
+ </span>
997
+ <CIBar value={s.csWer} ci={s.csWerCi} />
998
+ </>
999
+ )}
1000
+ </>
1001
+ ) : (
1002
+ <span
1003
+ className="num text-[12px] text-muted/60"
1004
+ aria-label={running ? "run in progress" : "not measured"}
1005
+ >
1006
+ {running ? "…" : "—"}
1007
+ </span>
1008
+ )}
1009
+ </div>
1010
+ </div>
1011
+ </li>
1012
+ );
1013
+ }
src/components/model/DatasetScope.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import { ArrowUpRight } from "lucide-react";
3
+ import type { DatasetInfo } from "@/lib/types";
4
+
5
+ export interface DatasetScopeProps {
6
+ dataset: DatasetInfo;
7
+ }
8
+
9
+ /**
10
+ * Scope bar pinned under the model header: MedASR Bench is a multi-dataset
11
+ * hub, so every number on a model page is measured on exactly ONE dataset.
12
+ * Today that is the anchor (ViMedCSS); when a second dataset goes live this
13
+ * bar is the natural home for a dataset switcher.
14
+ */
15
+ export default function DatasetScope({ dataset }: DatasetScopeProps) {
16
+ const stats = [
17
+ typeof dataset.hours === "number" ? `${dataset.hours} h` : null,
18
+ typeof dataset.utterances === "number"
19
+ ? `${dataset.utterances.toLocaleString("en-US")} utts`
20
+ : null,
21
+ typeof dataset.csTerms === "number"
22
+ ? `${dataset.csTerms} CS terms`
23
+ : null,
24
+ ].filter((s): s is string => s !== null);
25
+
26
+ return (
27
+ <div className="glass depth-1 flex flex-wrap items-center justify-between gap-x-6 gap-y-2 rounded-sm px-4 py-3">
28
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
29
+ <span className="num text-[11px] uppercase tracking-[0.18em] text-muted">
30
+ Results on
31
+ </span>
32
+ <span className="font-display text-lg leading-none text-text">
33
+ {dataset.name}
34
+ </span>
35
+ {dataset.fullName && (
36
+ <span className="text-[13px] text-muted">{dataset.fullName}</span>
37
+ )}
38
+ {stats.length > 0 && (
39
+ <span className="num text-[11px] text-muted">
40
+ {stats.join(" · ")}
41
+ </span>
42
+ )}
43
+ </div>
44
+ <Link
45
+ href={`/datasets/${dataset.id}`}
46
+ className="num inline-flex items-center gap-1 text-[12px] tracking-wide text-text transition-colors duration-200 ease-out hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
47
+ >
48
+ Dataset profile
49
+ <ArrowUpRight size={13} aria-hidden />
50
+ </Link>
51
+ </div>
52
+ );
53
+ }
src/components/model/FlagNotes.tsx ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import type { ComponentType } from "react";
3
+ import { AlertTriangle, Lock, ShieldCheck, Snowflake } from "lucide-react";
4
+ import type { ModelFlags } from "@/lib/types";
5
+
6
+ export interface FlagNotesProps {
7
+ flags?: ModelFlags;
8
+ className?: string;
9
+ }
10
+
11
+ interface Note {
12
+ key: keyof ModelFlags;
13
+ Icon: ComponentType<{ size?: number | string; className?: string }>;
14
+ iconClass: string;
15
+ labelClass: string;
16
+ label: string;
17
+ text: string;
18
+ }
19
+
20
+ const NOTES: Note[] = [
21
+ {
22
+ key: "contamination",
23
+ Icon: AlertTriangle,
24
+ iconClass: "text-warn",
25
+ labelClass: "text-warn",
26
+ label: "Contamination",
27
+ text: "this model’s family seeded the ViMedCSS labels (Gemini 2.5 Pro, then human-verified at κ 0.65), so its scores carry a permanent contamination flag.",
28
+ },
29
+ {
30
+ key: "referenceOnly",
31
+ Icon: Lock,
32
+ iconClass: "text-muted",
33
+ labelClass: "text-text",
34
+ label: "Reference-only",
35
+ text: "CC-BY-NC-ND — cannot be fine-tuned or deployed commercially; shown as a yardstick.",
36
+ },
37
+ {
38
+ key: "frozen",
39
+ Icon: Snowflake,
40
+ iconClass: "text-muted",
41
+ labelClass: "text-text",
42
+ label: "Frozen",
43
+ text: "the maintaining organization has stopped updates; results are pinned to the last public checkpoint.",
44
+ },
45
+ {
46
+ key: "zone1",
47
+ Icon: ShieldCheck,
48
+ iconClass: "text-accent",
49
+ labelClass: "text-text",
50
+ label: "Zone 1",
51
+ text: "self-hostable on a single 24 GB GPU with a license clean for deployment.",
52
+ },
53
+ ];
54
+
55
+ /** Plain-language explanations for each active flag (pairs with FlagBadges icons). */
56
+ export default function FlagNotes({ flags, className }: FlagNotesProps) {
57
+ if (!flags) return null;
58
+ const active = NOTES.filter((n) => flags[n.key]);
59
+ if (active.length === 0) return null;
60
+
61
+ return (
62
+ <ul className={clsx("flex flex-col gap-2", className)}>
63
+ {active.map(({ key, Icon, iconClass, labelClass, label, text }) => (
64
+ <li
65
+ key={key}
66
+ className="flex items-start gap-2.5 text-[13px] leading-relaxed text-muted"
67
+ >
68
+ <span aria-hidden className={clsx("mt-[3px] shrink-0", iconClass)}>
69
+ <Icon size={14} />
70
+ </span>
71
+ <span>
72
+ <span className={clsx("font-medium", labelClass)}>{label}</span>{" "}
73
+ — {text}
74
+ </span>
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ );
79
+ }
src/components/model/FrontierThumb.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ frontierSegments,
3
+ niceDomain,
4
+ paretoData,
5
+ paretoFrontier,
6
+ } from "@/components/compare/compare-utils";
7
+ import { fmt } from "@/lib/data";
8
+ import type { ModelRow } from "@/lib/types";
9
+
10
+ export interface FrontierThumbProps {
11
+ models: ModelRow[];
12
+ /** slug of the model this page profiles — drawn in accent */
13
+ slug: string;
14
+ /** dataset the frontier is computed on, e.g. "ViMedCSS" */
15
+ datasetName?: string;
16
+ }
17
+
18
+ const W = 232;
19
+ const H = 192;
20
+ const PAD = 12;
21
+
22
+ /**
23
+ * "Position on the frontier" thumbnail: the Pareto domain (Test split,
24
+ * WER × CS-WER) with this model's dot in accent and every other system
25
+ * ghosted. Pure inline SVG — no chart runtime.
26
+ */
27
+ export default function FrontierThumb({
28
+ models,
29
+ slug,
30
+ datasetName,
31
+ }: FrontierThumbProps) {
32
+ const data = paretoData(models, "test");
33
+ const self = data.find((d) => d.model.slug === slug);
34
+ if (!self || data.length < 2) return null;
35
+
36
+ const [xLo, xHi] = niceDomain(data.map((d) => d.wer));
37
+ const [yLo, yHi] = niceDomain(data.map((d) => d.csWer));
38
+ const sx = (v: number) => PAD + ((v - xLo) / (xHi - xLo)) * (W - 2 * PAD);
39
+ const sy = (v: number) =>
40
+ PAD + (1 - (v - yLo) / (yHi - yLo)) * (H - 2 * PAD);
41
+
42
+ const frontier = paretoFrontier(data);
43
+ const segments = frontierSegments(frontier, xHi, yHi);
44
+ const onFrontier = frontier.some((d) => d.model.slug === slug);
45
+
46
+ return (
47
+ <aside
48
+ aria-label="Position on the Pareto frontier"
49
+ className="flex flex-col gap-4"
50
+ >
51
+ <p className="num text-[11px] uppercase tracking-[0.18em] text-muted">
52
+ Position on the frontier
53
+ </p>
54
+ <div className="hairline depth-1 rounded-sm bg-surface p-4">
55
+ <svg
56
+ viewBox={`0 0 ${W} ${H}`}
57
+ className="block w-full"
58
+ role="img"
59
+ aria-label={`Thumbnail scatter of the WER versus CS-WER trade-off on the ${datasetName ? `${datasetName} ` : ""}Test split. This model sits at ${fmt(self.wer)} WER and ${fmt(self.csWer)} CS-WER${onFrontier ? " and defines the Pareto frontier" : ""}.`}
60
+ >
61
+ {/* plot frame */}
62
+ <rect
63
+ x={PAD}
64
+ y={PAD}
65
+ width={W - 2 * PAD}
66
+ height={H - 2 * PAD}
67
+ fill="none"
68
+ stroke="var(--line)"
69
+ strokeWidth={1}
70
+ />
71
+ {/* frontier staircase */}
72
+ {segments.map(([a, b], i) => (
73
+ <line
74
+ key={i}
75
+ x1={sx(a.x)}
76
+ y1={sy(a.y)}
77
+ x2={sx(b.x)}
78
+ y2={sy(b.y)}
79
+ stroke="var(--accent)"
80
+ strokeWidth={1}
81
+ strokeDasharray="3 2.5"
82
+ strokeOpacity={0.55}
83
+ />
84
+ ))}
85
+ {/* ghosted peers */}
86
+ {data
87
+ .filter((d) => d.model.slug !== slug)
88
+ .map((d) => (
89
+ <circle
90
+ key={d.model.slug}
91
+ cx={sx(d.wer)}
92
+ cy={sy(d.csWer)}
93
+ r={3}
94
+ style={{ fill: "var(--muted)" }}
95
+ fillOpacity={0.45}
96
+ />
97
+ ))}
98
+ {/* this model */}
99
+ <circle
100
+ cx={sx(self.wer)}
101
+ cy={sy(self.csWer)}
102
+ r={8}
103
+ fill="none"
104
+ stroke="var(--accent)"
105
+ strokeOpacity={0.5}
106
+ strokeWidth={1}
107
+ />
108
+ <circle
109
+ cx={sx(self.wer)}
110
+ cy={sy(self.csWer)}
111
+ r={4.5}
112
+ style={{ fill: "var(--accent)" }}
113
+ fillOpacity={0.3}
114
+ stroke="var(--accent)"
115
+ strokeWidth={1.25}
116
+ />
117
+ <circle
118
+ cx={sx(self.wer)}
119
+ cy={sy(self.csWer)}
120
+ r={1.6}
121
+ style={{ fill: "var(--accent)" }}
122
+ />
123
+ {/* axis cues */}
124
+ <text
125
+ x={W - PAD}
126
+ y={H - 2}
127
+ textAnchor="end"
128
+ style={{
129
+ fill: "var(--muted)",
130
+ fontSize: 9,
131
+ letterSpacing: "0.14em",
132
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
133
+ }}
134
+ >
135
+ WER % · ← BETTER
136
+ </text>
137
+ <text
138
+ x={8}
139
+ y={PAD + 4}
140
+ textAnchor="end"
141
+ transform={`rotate(-90 8 ${PAD + 4})`}
142
+ style={{
143
+ fill: "var(--muted)",
144
+ fontSize: 9,
145
+ letterSpacing: "0.14em",
146
+ fontFamily: "var(--font-mono), 'IBM Plex Mono', monospace",
147
+ }}
148
+ >
149
+ CS-WER % · ↓ BETTER
150
+ </text>
151
+ </svg>
152
+ <p className="num mt-3 text-[11px] tracking-wide text-muted">
153
+ <span className="text-accent">●</span> this model ·{" "}
154
+ {fmt(self.wer)} WER / {fmt(self.csWer)} CS-WER ·{" "}
155
+ {datasetName ? `${datasetName} · ` : ""}Test split
156
+ {onFrontier && (
157
+ <span className="text-accent"> · on the frontier</span>
158
+ )}
159
+ </p>
160
+ </div>
161
+ </aside>
162
+ );
163
+ }
src/components/model/GeneralizationStrip.tsx ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import clsx from "clsx";
2
+ import { FlaskConical } from "lucide-react";
3
+ import { fmt } from "@/lib/data";
4
+ import type { ModelRow, Provenance, SplitInfo, SplitMetrics } from "@/lib/types";
5
+ import SectionLabel from "./SectionLabel";
6
+
7
+ export interface GeneralizationStripProps {
8
+ model: ModelRow;
9
+ hardInfo: SplitInfo;
10
+ }
11
+
12
+ function hasNumbers(m?: SplitMetrics): boolean {
13
+ return !!m && Object.values(m).some((v) => typeof v === "number");
14
+ }
15
+
16
+ function DeltaTile({
17
+ label,
18
+ test,
19
+ hard,
20
+ }: {
21
+ label: string;
22
+ test?: number;
23
+ hard?: number;
24
+ }) {
25
+ const d =
26
+ typeof test === "number" && typeof hard === "number" ? hard - test : null;
27
+ const degrading = d !== null && d > 0;
28
+ return (
29
+ <div className="hairline depth-1 hover-lift flex min-w-[160px] flex-col gap-1 rounded-sm bg-surface px-4 py-3">
30
+ <span className="text-[11px] uppercase tracking-[0.14em] text-muted">
31
+ Δ {label}
32
+ </span>
33
+ <span
34
+ className={clsx(
35
+ "num text-2xl leading-none",
36
+ d === null ? "text-muted/50" : degrading ? "text-warn" : "text-text",
37
+ )}
38
+ >
39
+ {d === null ? "—" : `${d >= 0 ? "+" : "−"}${Math.abs(d).toFixed(2)}`}
40
+ </span>
41
+ <span className="num text-[11px] text-muted">
42
+ {fmt(test)} → {fmt(hard)}
43
+ </span>
44
+ </div>
45
+ );
46
+ }
47
+
48
+ function GhostTile({ label }: { label: string }) {
49
+ return (
50
+ <div
51
+ aria-hidden
52
+ className="flex min-w-[160px] flex-col gap-1 rounded-sm border border-dashed border-line px-4 py-3"
53
+ >
54
+ <span className="text-[11px] uppercase tracking-[0.14em] text-muted/70">
55
+ Δ {label}
56
+ </span>
57
+ <span className="num text-2xl leading-none text-muted/50">—</span>
58
+ <span className="num text-[11px] text-muted/50">test → hard</span>
59
+ </div>
60
+ );
61
+ }
62
+
63
+ function gapCopy(provenance: Provenance): string {
64
+ if (provenance === "running")
65
+ return "This model’s pilot run is executing on Test + Hard right now — the delta lands here the moment it commits.";
66
+ if (provenance === "planned")
67
+ return "A platform run covering Test + Hard is on the roadmap for this model.";
68
+ return "Published results cover Test only; the platform pilot re-runs every model on Test + Hard, and this gap closes when its run lands.";
69
+ }
70
+
71
+ /**
72
+ * Test → Hard generalization: signed delta tiles when both splits are
73
+ * measured (amber when degrading), otherwise a dashed evidence-gap callout
74
+ * with ghost tiles showing the shape of what will appear.
75
+ */
76
+ export default function GeneralizationStrip({
77
+ model,
78
+ hardInfo,
79
+ }: GeneralizationStripProps) {
80
+ const test = model.metrics?.test;
81
+ const hard = model.metrics?.hard;
82
+ const measured = hasNumbers(test) && hasNumbers(hard);
83
+
84
+ return (
85
+ <section className="flex flex-col gap-4">
86
+ <SectionLabel
87
+ index="03"
88
+ title="Generalization"
89
+ hint="Test → Hard delta"
90
+ />
91
+
92
+ {measured ? (
93
+ <div className="flex flex-col gap-5 md:flex-row md:items-center md:justify-between">
94
+ <div className="flex flex-wrap gap-3">
95
+ <DeltaTile label="WER" test={test?.wer} hard={hard?.wer} />
96
+ <DeltaTile label="CS-WER" test={test?.csWer} hard={hard?.csWer} />
97
+ </div>
98
+ <p className="max-w-sm text-[13px] leading-relaxed text-muted">
99
+ The Hard split holds{" "}
100
+ <span className="num">{fmt(hardInfo.hours)}</span>
101
+ <span className="num text-[11px]">{" "}h</span> of rare and
102
+ unseen code-switched terms. A positive Δ (amber) means the model
103
+ degrades off the common-term distribution.
104
+ </p>
105
+ </div>
106
+ ) : (
107
+ <div className="flex flex-col gap-5 rounded-sm border border-dashed border-line p-5 md:flex-row md:items-center md:justify-between md:p-6">
108
+ <div className="flex max-w-xl items-start gap-4">
109
+ <span aria-hidden className="mt-1 shrink-0 text-muted">
110
+ <FlaskConical size={18} />
111
+ </span>
112
+ <div>
113
+ <h3 className="font-display text-lg italic text-text">
114
+ Evidence gap — no Hard-split measurement yet
115
+ </h3>
116
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted">
117
+ The Hard split holds{" "}
118
+ <span className="num">{fmt(hardInfo.hours)}</span>
119
+ <span className="num text-[11px]">{" "}h</span> /{" "}
120
+ <span className="num">
121
+ {hardInfo.utts.toLocaleString("en-US")}
122
+ </span>{" "}
123
+ utterances of rare and unseen code-switched terms — the
124
+ generalization leaderboard. {gapCopy(model.provenance)}
125
+ </p>
126
+ </div>
127
+ </div>
128
+ <div className="flex shrink-0 flex-wrap gap-3">
129
+ <GhostTile label="WER" />
130
+ <GhostTile label="CS-WER" />
131
+ </div>
132
+ </div>
133
+ )}
134
+ </section>
135
+ );
136
+ }
src/components/model/MetricPanel.tsx ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import MetricCard from "@/components/ui/MetricCard";
5
+ import SplitToggle from "@/components/ui/SplitToggle";
6
+ import { NORMALIZER_VERSION, splitLabels } from "@/lib/data";
7
+ import type {
8
+ ModelRow,
9
+ Provenance,
10
+ Split,
11
+ SplitInfo,
12
+ SplitMetrics,
13
+ } from "@/lib/types";
14
+ import SectionLabel from "./SectionLabel";
15
+
16
+ export interface MetricPanelProps {
17
+ /** dataset these results are scoped to, e.g. "ViMedCSS" — the hub hosts
18
+ multiple datasets, so the section header names the one being shown */
19
+ datasetName: string;
20
+ metrics?: ModelRow["metrics"];
21
+ splits: { test: SplitInfo; hard: SplitInfo };
22
+ provenance: Provenance;
23
+ }
24
+
25
+ const VALUE_KEYS = [
26
+ "wer",
27
+ "csWer",
28
+ "nWer",
29
+ "cer",
30
+ "mtrExact",
31
+ "rtfx",
32
+ ] as const;
33
+
34
+ const METRIC_CHIPS = ["WER", "CS-WER", "N-WER", "CER", "MTR", "RTFx"] as const;
35
+
36
+ function emptyNote(provenance: Provenance, split: Split): string {
37
+ if (provenance === "running")
38
+ return "No committed measurements yet — the pilot run executing now will populate this panel.";
39
+ if (provenance === "planned")
40
+ return "No measurements yet — this model is on the evaluation roadmap.";
41
+ return `No measurements recorded on the ${splitLabels[split]} split yet.`;
42
+ }
43
+
44
+ /** Per-card microcopy for a missing value, by row provenance. */
45
+ function gapReason(provenance: Provenance): string {
46
+ if (provenance === "paper-imported") return "not published";
47
+ if (provenance === "running" || provenance === "planned")
48
+ return "awaiting pilot run";
49
+ return "not recorded";
50
+ }
51
+
52
+ /**
53
+ * Split-toggleable metric grid: WER / CS-WER / N-WER / CER / MTR (exact) /
54
+ * RTFx as count-up MetricCards, with 95% CI sub-lines where measured.
55
+ */
56
+ export default function MetricPanel({
57
+ datasetName,
58
+ metrics,
59
+ splits,
60
+ provenance,
61
+ }: MetricPanelProps) {
62
+ const [split, setSplit] = useState<Split>("test");
63
+ const m: SplitMetrics | undefined = metrics?.[split];
64
+ const info = splits[split];
65
+ const hasAny = VALUE_KEYS.some((k) => typeof m?.[k] === "number");
66
+
67
+ return (
68
+ <section className="flex flex-col gap-4">
69
+ <div className="flex flex-wrap items-end justify-between gap-3">
70
+ <SectionLabel
71
+ index="02"
72
+ title={`Results — ${datasetName}`}
73
+ hint={`${splitLabels[split]} — ${info.hours} h · ${info.utts.toLocaleString("en-US")} utterances`}
74
+ />
75
+ <SplitToggle value={split} onChange={setSplit} />
76
+ </div>
77
+
78
+ {hasAny ? (
79
+ /* Stable keys: toggling splits updates values in place instead of
80
+ remounting cards and replaying the one-shot count-up. */
81
+ <div className="glass depth-2 grid grid-cols-2 gap-3 rounded-sm p-3 md:grid-cols-3">
82
+ <MetricCard
83
+ key="wer"
84
+ label="WER"
85
+ value={m?.wer}
86
+ suffix="%"
87
+ ci={m?.werCi}
88
+ gapReason={gapReason(provenance)}
89
+ />
90
+ <MetricCard
91
+ key="cswer"
92
+ label="CS-WER"
93
+ value={m?.csWer}
94
+ suffix="%"
95
+ ci={m?.csWerCi}
96
+ gapReason={gapReason(provenance)}
97
+ />
98
+ <MetricCard
99
+ key="nwer"
100
+ label="N-WER"
101
+ value={m?.nWer}
102
+ suffix="%"
103
+ gapReason={gapReason(provenance)}
104
+ />
105
+ <MetricCard
106
+ key="cer"
107
+ label="CER"
108
+ value={m?.cer}
109
+ suffix="%"
110
+ gapReason={gapReason(provenance)}
111
+ />
112
+ <MetricCard
113
+ key="mtr"
114
+ label="MTR (exact)"
115
+ value={m?.mtrExact}
116
+ suffix="%"
117
+ gapReason={gapReason(provenance)}
118
+ />
119
+ <MetricCard
120
+ key="rtfx"
121
+ label="RTFx"
122
+ value={m?.rtfx}
123
+ suffix="×"
124
+ gapReason={gapReason(provenance)}
125
+ />
126
+ </div>
127
+ ) : (
128
+ /* flatlined-ECG empty state: one composed panel instead of a
129
+ checkerboard of em-dash cards */
130
+ <div className="hairline depth-1 rounded-sm bg-surface px-5 py-6">
131
+ <div aria-hidden className="flex items-center">
132
+ <span className="h-px flex-1 bg-accent/20" />
133
+ <span className="pulse-dot size-2 shrink-0 rounded-full bg-accent" />
134
+ </div>
135
+ <p className="mt-4 font-display text-lg italic text-text">
136
+ no trace yet — awaiting platform run
137
+ </p>
138
+ <p className="mt-1.5 max-w-xl text-[13px] leading-relaxed text-muted">
139
+ {emptyNote(provenance, split)}
140
+ </p>
141
+ <ul className="mt-4 flex flex-wrap gap-1.5" aria-label="Metrics this run will record">
142
+ {METRIC_CHIPS.map((c) => (
143
+ <li
144
+ key={c}
145
+ className="num rounded-sm border border-line/70 px-1.5 py-0.5 text-[11px] tracking-wide text-muted"
146
+ >
147
+ {c}
148
+ </li>
149
+ ))}
150
+ </ul>
151
+ </div>
152
+ )}
153
+
154
+ <p className="num text-[11px] text-muted">
155
+ WER family in %, lower is better · MTR higher is better · RTFx = audio
156
+ s ÷ wall s, higher is faster · normalizer {NORMALIZER_VERSION}
157
+ </p>
158
+ </section>
159
+ );
160
+ }
src/components/model/ModelHeader.tsx ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Badge from "@/components/ui/Badge";
2
+ import FlagBadges from "@/components/ui/FlagBadges";
3
+ import ProvenanceChip from "@/components/ui/ProvenanceChip";
4
+ import { categoryLabels, trackLabels } from "@/lib/data";
5
+ import type { ModelRow } from "@/lib/types";
6
+ import FlagNotes from "./FlagNotes";
7
+
8
+ export interface ModelHeaderProps {
9
+ model: ModelRow;
10
+ }
11
+
12
+ /** "0.6" → "0.6B"; undefined → "—". */
13
+ function fmtParams(paramsB?: number): string {
14
+ return paramsB === undefined ? "—" : `${paramsB}B`;
15
+ }
16
+
17
+ /**
18
+ * Model identity block: serif name, org, mono param count, license /
19
+ * track chips, provenance chip, flag icons + plain-language flag notes,
20
+ * and the editorial note line.
21
+ */
22
+ export default function ModelHeader({ model }: ModelHeaderProps) {
23
+ return (
24
+ <header className="hairline-b pb-6 spring-up">
25
+ <p className="num mb-3 text-[11px] uppercase tracking-[0.22em] text-muted spring-in">
26
+ Model profile · {categoryLabels[model.category]}
27
+ </p>
28
+
29
+ <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-3">
30
+ <h1 className="font-display text-4xl leading-[1.05] text-text md:text-5xl spring-in" style={{ animationDelay: "60ms" }}>
31
+ {model.name}
32
+ </h1>
33
+ <div className="mb-1.5 spring-in" style={{ animationDelay: "90ms" }}>
34
+ <ProvenanceChip provenance={model.provenance} />
35
+ </div>
36
+ </div>
37
+
38
+ <div className="mt-4 flex flex-wrap items-center gap-x-3 gap-y-2 spring-in" style={{ animationDelay: "120ms" }}>
39
+ <span className="text-sm text-muted">{model.org}</span>
40
+ <span aria-hidden className="text-muted/40">
41
+ ·
42
+ </span>
43
+ <span className="num text-sm text-text" title="Parameter count">
44
+ {fmtParams(model.paramsB)}
45
+ </span>
46
+ <Badge variant="outline" title={`License: ${model.license}`}>
47
+ {model.license}
48
+ </Badge>
49
+ <Badge variant="neutral" title={trackLabels[model.track]}>
50
+ {trackLabels[model.track]}
51
+ </Badge>
52
+ <FlagBadges flags={model.flags} />
53
+ </div>
54
+
55
+ {model.note && (
56
+ <p className="mt-4 font-display text-lg italic text-muted spring-in" style={{ animationDelay: "180ms" }}>
57
+ {model.note}
58
+ </p>
59
+ )}
60
+
61
+ <div className="mt-4 max-w-2xl spring-in" style={{ animationDelay: "240ms" }}>
62
+ <FlagNotes flags={model.flags} />
63
+ </div>
64
+ </header>
65
+ );
66
+ }