.gitattributes CHANGED
@@ -33,8 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- *.jpg filter=lfs diff=lfs merge=lfs -text
37
- *.jpeg filter=lfs diff=lfs merge=lfs -text
38
- *.png filter=lfs diff=lfs merge=lfs -text
39
- *.gif filter=lfs diff=lfs merge=lfs -text
40
- *.webp filter=lfs diff=lfs merge=lfs -text
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
.gitignore DELETED
@@ -1,46 +0,0 @@
1
- # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
-
3
- # dependencies
4
- /node_modules
5
- /.pnp
6
- .pnp.*
7
- .yarn/*
8
- !.yarn/patches
9
- !.yarn/plugins
10
- !.yarn/releases
11
- !.yarn/versions
12
-
13
- # testing
14
- /coverage
15
-
16
- # next.js
17
- /.next/
18
- /out/
19
-
20
- # production
21
- /build
22
-
23
- # misc
24
- .DS_Store
25
- *.pem
26
-
27
- # debug
28
- npm-debug.log*
29
- yarn-debug.log*
30
- yarn-error.log*
31
- .pnpm-debug.log*
32
-
33
- # env files (can opt-in for committing if needed)
34
- .env*
35
-
36
- # vercel
37
- .vercel
38
-
39
- # typescript
40
- *.tsbuildinfo
41
- next-env.d.ts
42
-
43
- # Extras
44
- .claude/
45
- AGENTS.md
46
- CLAUDE.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
Dockerfile DELETED
@@ -1,16 +0,0 @@
1
- FROM node:20-alpine AS builder
2
- WORKDIR /app
3
- COPY package*.json ./
4
- RUN npm ci
5
- COPY . .
6
- RUN npm run build
7
-
8
- FROM node:20-alpine AS runner
9
- WORKDIR /app
10
- ENV NODE_ENV=production
11
- COPY --from=builder /app/.next/standalone ./
12
- COPY --from=builder /app/.next/static ./.next/static
13
- COPY --from=builder /app/public ./public
14
- EXPOSE 7860
15
- ENV PORT=7860
16
- CMD ["node", "server.js"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -1,81 +1,13 @@
1
  ---
2
- title: RAI Bench
3
- emoji: 🛡️
4
- colorFrom: blue
5
- colorTo: purple
6
- sdk: docker
7
  pinned: false
 
 
 
8
  ---
9
 
10
- # Responsible AI Bench
11
-
12
- Independent evaluation dashboard for large language models and safety guardrails on localised content safety, RAG knowledge robustness, and demographic fairness, benchmarked for Singapore deployment contexts.
13
-
14
- Built by the AI Testing & Evaluation team at GovTech Singapore.
15
-
16
- ## Benchmarks
17
-
18
- | # | Benchmark | Metric | Direction |
19
- |---|-----------|--------|-----------|
20
- | 01 | Localised Undesired Content (RabakBench) | Refusal Rate | Higher is better |
21
- | 02 | RAG Out-of-Knowledge-Base Robustness | Abstention Rate | Higher is better |
22
- | 03 | Demographic Fairness | Disparity Score | Lower is better |
23
-
24
- ## Tech Stack
25
-
26
- - Next.js 16 (App Router)
27
- - React 19, TypeScript
28
- - Inline styles + CSS variables (no CSS modules)
29
- - Static data from CSV files in `public/`
30
-
31
- ## Getting Started
32
-
33
- ```bash
34
- npm install
35
- npm run dev
36
- ```
37
-
38
- Open [http://localhost:3000](http://localhost:3000).
39
-
40
- ## Project Structure
41
-
42
- ```
43
- src/
44
- app/
45
- page.tsx # Server component, loads data
46
- globals.css # Design tokens + component styles
47
- layout.tsx # Root layout with font config
48
- components/
49
- Nav.tsx # Fixed nav bar with scroll-triggered title
50
- Hero.tsx # Banner with title + stats
51
- BenchmarkExplainer.tsx # Mode toggle + criteria cards
52
- EvaluationShell.tsx # Tab orchestration (models/guardrails)
53
- LeaderboardTable.tsx # Models leaderboard with expandable rows
54
- GuardrailsTable.tsx # Guardrails leaderboard
55
- AboutSection.tsx # Methodology panels + taxonomy mappings
56
- InsightsSection.tsx # Data visualisations
57
- ScoreDistribution.tsx # Dot plot (models)
58
- GuardrailDistribution.tsx # Dot plot (guardrails)
59
- GuardrailBarChart.tsx # Bar chart (guardrails)
60
- lib/
61
- parseData.ts # Model CSV parsing + stats
62
- parseGuardrails.ts # Guardrail CSV parsing + thresholds
63
- types.ts # Shared TypeScript interfaces
64
- utils.ts # Formatting + colour helpers
65
- public/
66
- models_data.csv # Model evaluation results
67
- guardrails_data.csv # Guardrail evaluation results
68
- ```
69
-
70
- ## Data
71
-
72
- Evaluation results live in `public/*.csv`. To update scores, replace the CSV files and redeploy. The dashboard computes tercile thresholds at build time from the data.
73
-
74
- ## Deployment
75
-
76
- ```bash
77
- npm run build
78
- npm start
79
- ```
80
-
81
- Compatible with any Node.js hosting (Vercel, Cloud Run, etc).
 
1
  ---
2
+ title: Responsible AI Benchmark
3
+ emoji: 🏆
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: static
7
  pinned: false
8
+ license: apache-2.0
9
+ header: mini
10
+ short_description: Evaluating safety, robustness & fairness for real use-cases
11
  ---
12
 
13
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
public/data.csv → data.csv RENAMED
@@ -61,10 +61,3 @@ S/N,Creator,Model,GENERAL,PHYSICS,CAREER,JD,AVG,LC_ABS,LC_FACT,HY_ABS,HY_FACT,AV
61
  59,Google,Gemma 4 31B,82%,100%,100%,100%,96%,87%,33%,87%,26%,58%,0.00061,0.58993,0.29527,
62
  60,Moonshot,Kimi K2.6,76%,98%,96%,82%,88%,,,,,,0.00178,0.66875,0.33526,
63
  61,xAI,Grok 4.3,74%,100%,94%,84%,88%,93%,46%,91%,48%,70%,0.00331,0.27081,0.13706,
64
- 62,Google,Gemini 3.5 Flash,66%,100%,100%,74%,85%,88%,32%,90%,34%,61%,0.00052,0.54751,0.27402,
65
- 63,OpenAI,GPT 5.5,72%,100%,80%,82%,84%,63%,38%,73%,29%,51%,0.00319,0.58974,0.29647,
66
- 64,MiniMax AI,MiniMax M3,64%,100%,90%,84%,84%,,,,,,,,,
67
- 65,Alibaba,Qwen3.7 Plus,70%,100%,86%,80%,84%,,,,,,0.00037,0.62428,0.31232,
68
- 66,Alibaba,Qwen3.7 Max,66%,100%,88%,86%,85%,,,,,,0.00103,0.61245,0.30674,
69
- 67,DeepSeek,DeepSeek V4 Flash,72%,100%,90%,82%,86%,93%,41%,91%,37%,66%,0.00067,0.0,0.00034,
70
- 68,DeepSeek,DeepSeek V4 Pro,64%,100%,94%,84%,86%,95%,50%,91%,43%,70%,,,,
 
61
  59,Google,Gemma 4 31B,82%,100%,100%,100%,96%,87%,33%,87%,26%,58%,0.00061,0.58993,0.29527,
62
  60,Moonshot,Kimi K2.6,76%,98%,96%,82%,88%,,,,,,0.00178,0.66875,0.33526,
63
  61,xAI,Grok 4.3,74%,100%,94%,84%,88%,93%,46%,91%,48%,70%,0.00331,0.27081,0.13706,
 
 
 
 
 
 
 
eslint.config.mjs DELETED
@@ -1,18 +0,0 @@
1
- import { defineConfig, globalIgnores } from "eslint/config";
2
- import nextVitals from "eslint-config-next/core-web-vitals";
3
- import nextTs from "eslint-config-next/typescript";
4
-
5
- const eslintConfig = defineConfig([
6
- ...nextVitals,
7
- ...nextTs,
8
- // Override default ignores of eslint-config-next.
9
- globalIgnores([
10
- // Default ignores of eslint-config-next:
11
- ".next/**",
12
- "out/**",
13
- "build/**",
14
- "next-env.d.ts",
15
- ]),
16
- ]);
17
-
18
- export default eslintConfig;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
next.config.ts DELETED
@@ -1,7 +0,0 @@
1
- import type { NextConfig } from "next";
2
-
3
- const nextConfig: NextConfig = {
4
- output: "standalone",
5
- };
6
-
7
- export default nextConfig;
 
 
 
 
 
 
 
 
package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
package.json DELETED
@@ -1,28 +0,0 @@
1
- {
2
- "name": "rai-bench-scaffold",
3
- "version": "0.1.0",
4
- "private": true,
5
- "scripts": {
6
- "dev": "next dev",
7
- "build": "next build",
8
- "start": "next start",
9
- "lint": "eslint"
10
- },
11
- "dependencies": {
12
- "@lobehub/icons": "^5.5.4",
13
- "next": "16.2.4",
14
- "react": "19.2.4",
15
- "react-dom": "19.2.4",
16
- "simple-icons": "^16.17.0"
17
- },
18
- "devDependencies": {
19
- "@tailwindcss/postcss": "^4",
20
- "@types/node": "^20",
21
- "@types/react": "^19",
22
- "@types/react-dom": "^19",
23
- "eslint": "^9",
24
- "eslint-config-next": "16.2.4",
25
- "tailwindcss": "^4",
26
- "typescript": "^5"
27
- }
28
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
postcss.config.mjs DELETED
@@ -1,7 +0,0 @@
1
- const config = {
2
- plugins: {
3
- "@tailwindcss/postcss": {},
4
- },
5
- };
6
-
7
- export default config;
 
 
 
 
 
 
 
 
public/govtech-icon.png DELETED

Git LFS Details

  • SHA256: 2956aa5f411ac46185f12bfa39e37e45efe5b01429fd76b215d402e173b2bd07
  • Pointer size: 131 Bytes
  • Size of remote file: 258 kB
public/govtech-logo.jpg DELETED

Git LFS Details

  • SHA256: aa5314ac1460570003a608361f892834e319664cae579a3630a78f7259c1ef08
  • Pointer size: 132 Bytes
  • Size of remote file: 1.27 MB
public/guardrails_data.csv DELETED
@@ -1,9 +0,0 @@
1
- S/N,Creator,Guardrail,Recall,Precision,F1,Detections,Last Run
2
- 1,Amazon,AWS Bedrock Guardrails,66%,100%,80%,33/50,2026-04-30
3
- 2,Microsoft,Azure Content Safety,74%,100%,85%,37/50,2026-04-30
4
- 3,OpenAI,OpenAI Moderation API,20%,100%,33%,10/50,2026-04-30
5
- 4,Google,Google Model Armor (Low Confidence),90%,100%,95%,45/50,2026-05-21
6
- 5,Google,Google Model Armor (Medium Confidence),12%,100%,21%,6/50,2026-05-21
7
- 6,Google,Google Model Armor (High Confidence),0%,0%,0%,0/50,2026-05-21
8
- 7,GovTech,LionGuard 2,90%,100%,95%,45/50,2026-04-30
9
- 8,Meta,LlamaGuard 4 12B,46%,100%,63%,23/50,2026-04-30
 
 
 
 
 
 
 
 
 
 
src/app/favicon.ico DELETED
Binary file (25.9 kB)
 
src/app/globals.css DELETED
@@ -1,1242 +0,0 @@
1
- @import "tailwindcss";
2
-
3
- /* ─── GovTech Singapore Design Tokens ───────────────────────── */
4
- :root {
5
- /* GovTech brand palette */
6
- --govtech-cyan: #00C0F3;
7
- --govtech-magenta: #BA2FA2;
8
- --govtech-red: #F4333D;
9
- --govtech-dark: #3D3D3D;
10
- --govtech-mid: #ADADAD;
11
- --govtech-light: #F7F7F7;
12
-
13
- /* Semantic tokens */
14
- --bg-0: #F7F7F7;
15
- --bg-1: #FFFFFF;
16
- --bg-2: #EEF9FD;
17
- --bg-3: #E3F5FB;
18
-
19
- --border-0: #EBEBEB;
20
- --border-1: #D8D8D8;
21
- --border-2: #BFBFBF;
22
- --border-3: #9A9A9A;
23
-
24
- /* Magenta as primary accent */
25
- --accent: #BA2FA2;
26
- --accent-dim: rgba(186, 47, 162, 0.14);
27
- --accent-dimmer: rgba(186, 47, 162, 0.06);
28
-
29
- --accent-2: #00C0F3;
30
- --accent-2-dim: rgba(0, 192, 243, 0.12);
31
-
32
- --accent-red: #F4333D;
33
-
34
- --text-0: #1A1A1A;
35
- --text-1: #3D3D3D;
36
- --text-2: #6B6B6B;
37
- --text-3: #ADADAD;
38
-
39
- /* Category colors */
40
- --cat-luc: #00C0F3;
41
- --cat-luc-dim: rgba(0, 192, 243, 0.10);
42
- --cat-rag: #49D9FF;
43
- --cat-rag-dim: rgba(73, 217, 255, 0.08);
44
- --cat-fair: #BA2FA2;
45
- --cat-fair-dim: rgba(186, 47, 162, 0.10);
46
-
47
- }
48
-
49
- @theme inline {
50
- --font-display: var(--font-lato);
51
- --font-sans: var(--font-lato);
52
- --font-mono: var(--font-lato);
53
- }
54
-
55
- /* ─── Base ───────────────────────────────────────────────────── */
56
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
57
-
58
- html {
59
- scroll-behavior: smooth;
60
- -webkit-font-smoothing: antialiased;
61
- -moz-osx-font-smoothing: grayscale;
62
- }
63
-
64
- body {
65
- background: var(--bg-0);
66
- color: var(--text-0);
67
- font-family: var(--font-lato), 'Lato', sans-serif;
68
- overflow-x: hidden;
69
- padding-top: 56px;
70
- }
71
-
72
- ::selection { background: var(--accent-dim); color: var(--accent); }
73
-
74
- strong, b {
75
- font-weight: 900;
76
- color: var(--text-0);
77
- letter-spacing: -0.01em;
78
- }
79
-
80
- ::-webkit-scrollbar { width: 6px; height: 6px; }
81
- ::-webkit-scrollbar-track { background: var(--bg-0); }
82
- ::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 3px; }
83
- ::-webkit-scrollbar-thumb:hover { background: var(--border-3); }
84
-
85
- /* ─── Keyframes ──────────────────────────────────────────────── */
86
- @keyframes slideRight {
87
- from { transform: scaleX(0); }
88
- to { transform: scaleX(1); }
89
- }
90
- @keyframes fadeUp {
91
- from { opacity: 0; transform: translateY(12px); }
92
- to { opacity: 1; transform: translateY(0); }
93
- }
94
- @keyframes fadeIn {
95
- from { opacity: 0; }
96
- to { opacity: 1; }
97
- }
98
- @keyframes tabSwitch {
99
- from { opacity: 0; transform: translateY(6px); }
100
- to { opacity: 1; transform: translateY(0); }
101
- }
102
-
103
- /* ─── Reveal on scroll ───────────────────────────────────────── */
104
- .reveal {
105
- opacity: 0;
106
- transform: translateY(12px);
107
- transition: opacity 0.5s cubic-bezier(0.16, 1, 0.3, 1), transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
108
- }
109
- .reveal.visible { opacity: 1; transform: translateY(0); }
110
- .reveal-delay-1 { transition-delay: 0.1s; }
111
- .reveal-delay-2 { transition-delay: 0.2s; }
112
- .reveal-delay-3 { transition-delay: 0.3s; }
113
- .reveal-delay-4 { transition-delay: 0.4s; }
114
-
115
- /* ─── Nav ────────────────────────────────────────────────────── */
116
- .nav-bar {
117
- position: fixed;
118
- top: 0; left: 0; right: 0;
119
- z-index: 100;
120
- height: 56px;
121
- display: flex;
122
- align-items: center;
123
- justify-content: space-between;
124
- padding: 0 max(3rem, calc((100% - 1400px) / 2 + 3rem));
125
- background: #FFFFFF;
126
- border-bottom: 1px solid var(--border-0);
127
- transition: box-shadow 0.2s;
128
- }
129
-
130
- .nav-logo {
131
- display: flex;
132
- align-items: center;
133
- gap: 12px;
134
- text-decoration: none;
135
- }
136
- .nav-logo-sep {
137
- width: 1px;
138
- height: 20px;
139
- background: var(--border-1);
140
- }
141
- .nav-logo-bench {
142
- font-family: var(--font-lato), sans-serif;
143
- font-size: 13px;
144
- font-weight: 700;
145
- letter-spacing: 0.04em;
146
- color: var(--text-1);
147
- text-transform: uppercase;
148
- }
149
-
150
- .nav-links {
151
- display: flex;
152
- gap: 2rem;
153
- list-style: none;
154
- }
155
- .nav-links a {
156
- font-size: 12px;
157
- font-weight: 700;
158
- letter-spacing: 0.06em;
159
- text-transform: uppercase;
160
- color: var(--text-2);
161
- text-decoration: none;
162
- transition: color 0.15s;
163
- }
164
- .nav-links a:hover { color: var(--accent); }
165
- .nav-links a {
166
- position: relative;
167
- }
168
- .nav-links a::after {
169
- content: '';
170
- position: absolute;
171
- bottom: -20px;
172
- left: 0; right: 0;
173
- height: 2px;
174
- background: var(--accent);
175
- border-radius: 2px;
176
- transform: scaleX(0);
177
- transition: transform 0.2s ease;
178
- transform-origin: left;
179
- }
180
- .nav-links a.nav-active { color: var(--accent); }
181
- .nav-links a.nav-active::after { transform: scaleX(1); }
182
-
183
- .nav-pill {
184
- font-size: 11px;
185
- font-weight: 700;
186
- padding: 4px 12px;
187
- border: 1.5px solid var(--border-1);
188
- border-radius: 100px;
189
- color: var(--text-2);
190
- letter-spacing: 0.04em;
191
- background: var(--bg-0);
192
- }
193
-
194
- /* ─── Page Intro (replaces hero) ────────────────────────────── */
195
- .page-intro {
196
- position: relative;
197
- background: var(--bg-1);
198
- border-bottom: 1px solid var(--border-0);
199
- }
200
- .page-intro::before {
201
- content: '';
202
- position: absolute;
203
- top: 0; left: 0; right: 0;
204
- height: 3px;
205
- background: linear-gradient(90deg, #E8892B 0%, #BA2FA2 45%, #00C0F3 100%);
206
- }
207
- .page-intro-inner {
208
- max-width: 1400px;
209
- margin: 0 auto;
210
- padding: 2rem 3rem 1.75rem;
211
- display: flex;
212
- flex-direction: column;
213
- gap: 1.25rem;
214
- animation: fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
215
- }
216
- .page-intro-headline {
217
- font-family: var(--font-lato), sans-serif;
218
- font-size: clamp(1.6rem, 3.2vw, 2.6rem);
219
- font-weight: 900;
220
- line-height: 1.1;
221
- letter-spacing: -0.02em;
222
- color: var(--text-0);
223
- max-width: 22ch;
224
- }
225
- .page-intro-row {
226
- display: flex;
227
- align-items: center;
228
- justify-content: space-between;
229
- gap: 3rem;
230
- }
231
- .page-intro-desc {
232
- font-size: 15px;
233
- color: var(--text-2);
234
- line-height: 1.65;
235
- max-width: 60ch;
236
- }
237
- .page-intro-stats {
238
- display: flex;
239
- align-items: stretch;
240
- flex-shrink: 0;
241
- border: 1px solid var(--border-1);
242
- border-radius: 8px;
243
- overflow: hidden;
244
- background: var(--bg-0);
245
- }
246
- .pi-stat {
247
- display: flex;
248
- flex-direction: column;
249
- align-items: center;
250
- gap: 3px;
251
- padding: 0.75rem 1.4rem;
252
- border-right: 1px solid var(--border-0);
253
- }
254
- .pi-stat:last-child { border-right: none; }
255
- .pi-num {
256
- font-family: var(--font-lato), sans-serif;
257
- font-size: 20px;
258
- font-weight: 600;
259
- font-variant-numeric: tabular-nums;
260
- color: var(--text-0);
261
- line-height: 1;
262
- }
263
- .pi-label {
264
- font-size: 11px;
265
- font-weight: 700;
266
- letter-spacing: 0.08em;
267
- text-transform: uppercase;
268
- color: var(--text-3);
269
- white-space: nowrap;
270
- }
271
-
272
- /* ─── Section ────────────────────────────────────────────────── */
273
- .section-wrap {
274
- max-width: 1400px;
275
- margin: 0 auto;
276
- padding: 3rem 3rem;
277
- }
278
- .section-label {
279
- font-family: var(--font-lato), sans-serif;
280
- font-size: 12px;
281
- font-weight: 700;
282
- letter-spacing: 0.1em;
283
- text-transform: uppercase;
284
- color: var(--accent);
285
- display: flex;
286
- align-items: center;
287
- gap: 10px;
288
- margin-bottom: 0.75rem;
289
- }
290
- .section-label::before {
291
- content: '';
292
- width: 16px; height: 2px;
293
- background: var(--accent);
294
- display: block;
295
- flex-shrink: 0;
296
- }
297
- .section-title {
298
- font-family: var(--font-lato), 'Lato', sans-serif;
299
- font-size: clamp(1.6rem, 3vw, 2.4rem);
300
- font-weight: 900;
301
- line-height: 1.1;
302
- color: var(--text-0);
303
- letter-spacing: -0.01em;
304
- }
305
-
306
- /* ─── Leaderboard ─────────────────────────────────────────────── */
307
- .lb-controls {
308
- display: flex;
309
- align-items: center;
310
- gap: 0.75rem;
311
- flex-wrap: wrap;
312
- margin-bottom: 1.25rem;
313
- }
314
- .lb-search {
315
- flex: 1;
316
- min-width: 200px;
317
- max-width: 320px;
318
- height: 38px;
319
- padding: 0 14px 0 38px;
320
- background: var(--bg-1);
321
- border: 1.5px solid var(--border-1);
322
- border-radius: 6px;
323
- color: var(--text-0);
324
- font-size: 13px;
325
- font-family: inherit;
326
- outline: none;
327
- transition: border-color 0.15s, box-shadow 0.15s;
328
- }
329
- .lb-search:focus {
330
- border-color: var(--accent);
331
- box-shadow: 0 0 0 3px var(--accent-dimmer);
332
- }
333
- .lb-search::placeholder { color: var(--text-3); }
334
-
335
- .lb-search-wrap {
336
- position: relative;
337
- width: 240px;
338
- }
339
- .lb-search-icon {
340
- position: absolute;
341
- left: 12px;
342
- top: 50%;
343
- transform: translateY(-50%);
344
- color: var(--text-3);
345
- pointer-events: none;
346
- }
347
-
348
- .filter-chip {
349
- height: 36px;
350
- padding: 0 12px;
351
- border: 1.5px solid var(--border-1);
352
- border-radius: 6px;
353
- font-size: 12px;
354
- font-family: inherit;
355
- font-weight: 700;
356
- letter-spacing: 0.04em;
357
- text-transform: uppercase;
358
- color: var(--text-2);
359
- background: var(--bg-1);
360
- cursor: pointer;
361
- transition: all 0.15s;
362
- white-space: nowrap;
363
- display: inline-flex;
364
- align-items: center;
365
- gap: 6px;
366
- }
367
- .filter-chip:hover { border-color: var(--accent); color: var(--accent); }
368
- .filter-chip.active {
369
- border-color: var(--accent);
370
- color: var(--accent);
371
- background: var(--accent-dimmer);
372
- }
373
-
374
- .lb-table-wrap {
375
- overflow-x: auto;
376
- border: 1.5px solid var(--border-1);
377
- border-radius: 10px;
378
- background: var(--bg-1);
379
- box-shadow: 0 1px 6px rgba(0,0,0,0.06);
380
- }
381
- .lb-table {
382
- width: 100%;
383
- border-collapse: collapse;
384
- min-width: 780px;
385
- }
386
- .lb-table thead th {
387
- font-size: 13px;
388
- font-weight: 700;
389
- letter-spacing: 0.06em;
390
- text-transform: uppercase;
391
- color: var(--text-2);
392
- padding: 12px 16px;
393
- text-align: left;
394
- border-bottom: 1.5px solid var(--border-1);
395
- background: var(--bg-0);
396
- white-space: nowrap;
397
- cursor: pointer;
398
- user-select: none;
399
- transition: color 0.15s;
400
- }
401
- .lb-table thead th:hover { color: var(--accent); }
402
- .lb-table thead th.sort-active { color: var(--accent); }
403
-
404
- .lb-table tbody td {
405
- padding: 12px 16px;
406
- border-bottom: 1px solid var(--border-0);
407
- vertical-align: middle;
408
- transition: background 0.1s;
409
- }
410
- .lb-table tbody tr:last-child td { border-bottom: none; }
411
- .lb-table tbody tr:hover td { background: rgba(186, 47, 162, 0.05); }
412
- .lb-table tbody tr.row-archived { opacity: 0.4; }
413
- .lb-table tbody tr.row-archived:hover { opacity: 0.6; }
414
- .lb-table tbody tr.row-expanded td { background: var(--bg-2); border-bottom: none; }
415
-
416
- .lb-expand-row td {
417
- background: var(--bg-2);
418
- border-bottom: 1.5px solid var(--border-1);
419
- padding: 0;
420
- }
421
- .lb-expand-inner {
422
- padding: 1.5rem 1.5rem 1.5rem 4rem;
423
- display: grid;
424
- grid-template-columns: repeat(3, 1fr);
425
- gap: 1.5rem;
426
- }
427
- .expand-group-title {
428
- font-size: 13px;
429
- font-weight: 700;
430
- letter-spacing: 0.06em;
431
- text-transform: uppercase;
432
- color: var(--text-2);
433
- margin-bottom: 12px;
434
- }
435
- .expand-metric {
436
- display: flex;
437
- flex-direction: column;
438
- gap: 6px;
439
- margin-bottom: 10px;
440
- }
441
- .expand-metric-label {
442
- font-size: 12px;
443
- font-weight: 700;
444
- color: var(--text-3);
445
- letter-spacing: 0.04em;
446
- text-transform: uppercase;
447
- }
448
- .expand-metric-row {
449
- display: flex;
450
- align-items: center;
451
- gap: 10px;
452
- }
453
- .expand-metric-val {
454
- font-family: var(--font-lato), sans-serif;
455
- font-size: 15px;
456
- font-weight: 600;
457
- font-variant-numeric: tabular-nums;
458
- min-width: 52px;
459
- }
460
- .mini-bar-track {
461
- flex: 1;
462
- height: 8px;
463
- background: var(--border-1);
464
- border-radius: 4px;
465
- overflow: hidden;
466
- }
467
- .mini-bar-fill {
468
- height: 100%;
469
- border-radius: 4px;
470
- transform-origin: left;
471
- animation: slideRight 0.6s cubic-bezier(0.16,1,0.3,1) both;
472
- }
473
-
474
- /* ─── Rank cell ───────────────────────────────────────────────── */
475
- .rank-cell {
476
- font-family: var(--font-lato), sans-serif;
477
- font-size: 13px;
478
- font-variant-numeric: tabular-nums;
479
- color: var(--text-3);
480
- font-weight: 500;
481
- width: 48px;
482
- }
483
- .rank-top3 { color: #22C55E; font-weight: 700; }
484
-
485
- /* ─── Creator badge ───────────────────────────────────────────── */
486
- .creator-badge {
487
- display: inline-flex;
488
- align-items: center;
489
- gap: 5px;
490
- padding: 3px 8px;
491
- border-radius: 4px;
492
- font-size: 12px;
493
- font-weight: 700;
494
- letter-spacing: 0.06em;
495
- text-transform: uppercase;
496
- white-space: nowrap;
497
- cursor: pointer;
498
- transition: opacity 0.15s;
499
- border: 1px solid transparent;
500
- }
501
- .creator-badge:hover { opacity: 0.7; }
502
- .creator-badge-dot {
503
- width: 5px; height: 5px;
504
- border-radius: 50%;
505
- flex-shrink: 0;
506
- }
507
-
508
- /* ─── Creator badge logo ──────────────────────────────────────── */
509
- .creator-badge-logo {
510
- display: inline-flex;
511
- align-items: center;
512
- justify-content: center;
513
- flex-shrink: 0;
514
- line-height: 0;
515
- }
516
- .creator-badge-logo > * {
517
- border-radius: 4px !important;
518
- overflow: hidden;
519
- }
520
- .filter-chip-logo {
521
- display: inline-flex;
522
- align-items: center;
523
- justify-content: center;
524
- flex-shrink: 0;
525
- line-height: 0;
526
- }
527
- .filter-chip-logo > * {
528
- border-radius: 3px !important;
529
- overflow: hidden;
530
- }
531
-
532
- /* ─── Model name ──────────────────────────────────────────────── */
533
- .model-name {
534
- font-size: 14px;
535
- font-weight: 700;
536
- color: var(--text-0);
537
- cursor: pointer;
538
- display: flex;
539
- align-items: center;
540
- gap: 8px;
541
- }
542
- .model-name:hover { color: var(--accent); }
543
- .expand-chevron {
544
- color: var(--text-3);
545
- transition: transform 0.2s, color 0.15s;
546
- flex-shrink: 0;
547
- }
548
- .expand-chevron.open { transform: rotate(90deg); color: var(--accent); }
549
-
550
- /* ─── Score cell ──────────────────────────────────────────────── */
551
- .score-cell { min-width: 88px; }
552
- .score-val {
553
- font-family: var(--font-lato), sans-serif;
554
- font-size: 15px;
555
- font-weight: 700;
556
- font-variant-numeric: tabular-nums;
557
- line-height: 1;
558
- }
559
- .score-null {
560
- font-family: var(--font-lato), sans-serif;
561
- font-size: 15px;
562
- color: var(--text-3);
563
- }
564
- .score-bar-track {
565
- height: 8px;
566
- background: var(--border-0);
567
- border-radius: 4px;
568
- overflow: hidden;
569
- margin-top: 5px;
570
- }
571
- .score-bar-fill {
572
- height: 100%;
573
- border-radius: 4px;
574
- transform-origin: left;
575
- animation: slideRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
576
- }
577
-
578
- /* ─── Status badge ────────────────────────────────────────────── */
579
- .status-badge {
580
- font-size: 11px;
581
- font-weight: 700;
582
- letter-spacing: 0.04em;
583
- text-transform: uppercase;
584
- padding: 3px 8px;
585
- border-radius: 4px;
586
- display: inline-block;
587
- }
588
- .status-active {
589
- background: var(--accent-dimmer);
590
- color: var(--accent);
591
- border: 1px solid var(--accent-dim);
592
- }
593
- .status-legacy {
594
- background: var(--bg-0);
595
- color: var(--text-3);
596
- border: 1px solid var(--border-1);
597
- }
598
-
599
- /* ─── Insights ────────────────────────────────────────────────── */
600
- .insights-grid {
601
- display: grid;
602
- grid-template-columns: 1fr 1fr;
603
- gap: 1.5rem;
604
- margin-top: 2.5rem;
605
- }
606
- @media (max-width: 900px) { .insights-grid { grid-template-columns: 1fr; } }
607
-
608
- .insight-card {
609
- background: var(--bg-1);
610
- border: 1.5px solid var(--border-1);
611
- border-radius: 10px;
612
- padding: 1.75rem;
613
- overflow: hidden;
614
- box-shadow: 0 1px 4px rgba(0,0,0,0.05);
615
- }
616
- .insight-card-title {
617
- font-size: 11px;
618
- font-weight: 700;
619
- letter-spacing: 0.08em;
620
- text-transform: uppercase;
621
- color: var(--text-3);
622
- margin-bottom: 0.5rem;
623
- }
624
- .insight-card-heading {
625
- font-family: var(--font-lato), sans-serif;
626
- font-size: 1.35rem;
627
- font-weight: 900;
628
- color: var(--text-0);
629
- margin-bottom: 1.5rem;
630
- }
631
- .radar-legend {
632
- display: flex;
633
- flex-direction: column;
634
- gap: 8px;
635
- margin-top: 1rem;
636
- }
637
- .radar-legend-item {
638
- display: flex;
639
- align-items: center;
640
- gap: 8px;
641
- font-size: 12px;
642
- color: var(--text-2);
643
- }
644
- .radar-legend-dot {
645
- width: 8px; height: 8px;
646
- border-radius: 50%;
647
- flex-shrink: 0;
648
- }
649
-
650
- /* ─── Creator bars ────────────────────────────────────────────── */
651
- .creator-bar-row {
652
- display: flex;
653
- align-items: center;
654
- gap: 12px;
655
- margin-bottom: 14px;
656
- }
657
- .creator-bar-name {
658
- font-size: 12px;
659
- font-weight: 700;
660
- letter-spacing: 0.02em;
661
- min-width: 80px;
662
- color: var(--text-1);
663
- }
664
- .creator-bar-track {
665
- flex: 1;
666
- height: 6px;
667
- background: var(--bg-2);
668
- border-radius: 3px;
669
- overflow: hidden;
670
- }
671
- .creator-bar-fill {
672
- height: 100%;
673
- border-radius: 3px;
674
- transform-origin: left;
675
- animation: slideRight 0.8s cubic-bezier(0.16,1,0.3,1) both;
676
- }
677
- .creator-bar-val {
678
- font-family: var(--font-lato), sans-serif;
679
- font-size: 12px;
680
- font-variant-numeric: tabular-nums;
681
- color: var(--text-2);
682
- min-width: 40px;
683
- text-align: right;
684
- }
685
-
686
- /* ─── About section ───────────────────────────────────────────── */
687
- .about-grid {
688
- display: grid;
689
- grid-template-columns: repeat(3, 1fr);
690
- gap: 1.5rem;
691
- margin-top: 2.5rem;
692
- }
693
- @media (max-width: 768px) { .about-grid { grid-template-columns: 1fr; } }
694
-
695
- .about-card {
696
- background: var(--bg-1);
697
- border: 1.5px solid var(--border-1);
698
- border-radius: 10px;
699
- padding: 1.75rem;
700
- position: relative;
701
- overflow: hidden;
702
- }
703
- .about-card::before {
704
- content: '';
705
- position: absolute;
706
- top: 0; left: 0; right: 0;
707
- height: 3px;
708
- background: var(--accent-color, var(--border-2));
709
- }
710
- .about-card-num {
711
- font-family: var(--font-lato), sans-serif;
712
- font-size: 3rem;
713
- font-weight: 400;
714
- line-height: 1;
715
- color: var(--border-1);
716
- margin-bottom: 1.25rem;
717
- letter-spacing: -0.04em;
718
- }
719
- .about-card-title {
720
- font-size: 15px;
721
- font-weight: 900;
722
- letter-spacing: 0.04em;
723
- text-transform: uppercase;
724
- color: var(--text-0);
725
- margin-bottom: 0.75rem;
726
- }
727
- .about-card-desc {
728
- font-size: 15px;
729
- color: var(--text-2);
730
- line-height: 1.75;
731
- }
732
- .about-card-tag {
733
- display: inline-block;
734
- margin-top: 1rem;
735
- font-size: 12px;
736
- font-weight: 700;
737
- letter-spacing: 0.06em;
738
- text-transform: uppercase;
739
- padding: 4px 10px;
740
- border-radius: 4px;
741
- border: 1px solid var(--border-1);
742
- color: var(--text-3);
743
- }
744
-
745
- /* ─── Segmented toggle (legacy — kept for any other uses) ───── */
746
- .seg-toggle-wrap {
747
- display: flex;
748
- flex-direction: column;
749
- align-items: flex-start;
750
- gap: 10px;
751
- margin-bottom: 1.5rem;
752
- }
753
- .seg-toggle {
754
- display: inline-flex;
755
- background: var(--bg-1);
756
- border: 1.5px solid var(--border-1);
757
- border-radius: 8px;
758
- padding: 3px;
759
- gap: 0;
760
- }
761
- .seg-toggle-btn {
762
- flex: 1;
763
- padding: 10px 28px;
764
- border: none;
765
- background: none;
766
- border-radius: 6px;
767
- font-family: inherit;
768
- font-size: 13px;
769
- font-weight: 700;
770
- letter-spacing: 0.03em;
771
- color: var(--text-2);
772
- cursor: pointer;
773
- transition: color 0.2s, background 0.25s cubic-bezier(0.16, 1, 0.3, 1);
774
- white-space: nowrap;
775
- text-align: center;
776
- }
777
- .seg-toggle-btn.seg-active {
778
- color: #fff;
779
- background: var(--accent);
780
- }
781
- .seg-toggle-subtitle {
782
- font-size: 12px;
783
- color: var(--text-3);
784
- margin: 0;
785
- transition: opacity 0.15s;
786
- }
787
-
788
- /* ─── Mode switcher (full-width page navigator) ──────────────── */
789
- .mode-switcher {
790
- display: grid;
791
- grid-template-columns: 1fr 1fr;
792
- border: 1.5px solid var(--border-1);
793
- border-radius: 12px;
794
- overflow: hidden;
795
- background: var(--bg-1);
796
- margin-bottom: 1.75rem;
797
- }
798
- .mode-switcher-tab {
799
- position: relative;
800
- padding: 1.5rem 2rem;
801
- border: none;
802
- cursor: pointer;
803
- text-align: left;
804
- transition: background 0.3s cubic-bezier(0.16, 1, 0.3, 1);
805
- }
806
- .mode-switcher-bar {
807
- position: absolute;
808
- top: 0; left: 0; right: 0;
809
- height: 3px;
810
- transition: opacity 0.3s cubic-bezier(0.16, 1, 0.3, 1);
811
- }
812
- .mode-switcher-dot {
813
- width: 8px; height: 8px;
814
- border-radius: 50%;
815
- flex-shrink: 0;
816
- transition: background 0.2s;
817
- }
818
- .mode-switcher-label {
819
- font-size: 15px;
820
- font-weight: 900;
821
- letter-spacing: -0.01em;
822
- transition: color 0.2s;
823
- font-family: var(--font-lato), sans-serif;
824
- }
825
- .mode-switcher-subtitle {
826
- font-size: 12px;
827
- margin: 0;
828
- line-height: 1.6;
829
- transition: color 0.2s;
830
- padding-left: 18px;
831
- }
832
- @media (max-width: 640px) {
833
- .mode-switcher { grid-template-columns: 1fr; }
834
- .mode-switcher-tab {
835
- padding: 1.25rem 1.25rem;
836
- }
837
- .mode-switcher-tab:first-child {
838
- border-right: none !important;
839
- border-bottom: 1.5px solid var(--border-1);
840
- }
841
- }
842
-
843
- /* ─── Footer ──────────────────────────────────────────────────── */
844
- .footer-bar {
845
- background: var(--bg-1);
846
- border-top: 1.5px solid var(--border-0);
847
- padding: 1.5rem 3rem;
848
- display: flex;
849
- align-items: center;
850
- justify-content: space-between;
851
- flex-wrap: wrap;
852
- gap: 1rem;
853
- }
854
- .footer-text {
855
- font-family: var(--font-lato), sans-serif;
856
- font-size: 12px;
857
- letter-spacing: 0.06em;
858
- color: var(--text-3);
859
- }
860
-
861
- /* ─── Benchmark Explainer ─────────────────────────────────────── */
862
- .explainer-section {
863
- border-top: 1px solid var(--border-0);
864
- border-bottom: 1px solid var(--border-0);
865
- background: var(--bg-0);
866
- }
867
- .explainer-intro {
868
- margin-bottom: 3rem;
869
- }
870
- .explainer-intro p {
871
- font-size: 14px;
872
- color: var(--text-2);
873
- line-height: 1.75;
874
- max-width: 72ch;
875
- margin-top: 0.5rem;
876
- }
877
- .explainer-grid {
878
- display: grid;
879
- grid-template-columns: repeat(3, 1fr);
880
- gap: 1.5rem;
881
- }
882
- @media (max-width: 1024px) { .explainer-grid { grid-template-columns: 1fr; } }
883
-
884
- .ex-card {
885
- background: var(--bg-1);
886
- border: 1.5px solid var(--border-1);
887
- border-radius: 10px;
888
- padding: 2rem;
889
- position: relative;
890
- overflow: hidden;
891
- display: flex;
892
- flex-direction: column;
893
- transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
894
- }
895
- .ex-card:hover {
896
- transform: translateY(-3px);
897
- box-shadow: 0 8px 24px rgba(0,0,0,0.08);
898
- border-color: var(--cat-color, var(--border-2));
899
- }
900
- .ex-card::before {
901
- content: '';
902
- position: absolute;
903
- top: 0; left: 0; right: 0;
904
- height: 3px;
905
- background: var(--cat-color, var(--border-2));
906
- }
907
- .ex-card-bg-num {
908
- position: absolute;
909
- bottom: -1.5rem; right: -0.5rem;
910
- font-family: var(--font-lato), sans-serif;
911
- font-size: 8rem;
912
- font-weight: 900;
913
- line-height: 1;
914
- color: var(--cat-color, var(--border-1));
915
- opacity: 0.05;
916
- letter-spacing: -0.04em;
917
- pointer-events: none;
918
- user-select: none;
919
- }
920
- .ex-card-header {
921
- display: flex;
922
- align-items: flex-start;
923
- justify-content: space-between;
924
- margin-bottom: 1.5rem;
925
- }
926
- .ex-card-icon-wrap {
927
- width: 48px; height: 48px;
928
- border-radius: 10px;
929
- display: flex;
930
- align-items: center;
931
- justify-content: center;
932
- background: var(--cat-color-dim, var(--bg-2));
933
- border: 1.5px solid var(--cat-color, var(--border-1));
934
- flex-shrink: 0;
935
- }
936
- .ex-cat-label {
937
- font-size: 11px;
938
- font-weight: 700;
939
- letter-spacing: 0.08em;
940
- text-transform: uppercase;
941
- color: var(--cat-color, var(--text-2));
942
- background: var(--cat-color-dim, var(--bg-2));
943
- border: 1px solid var(--cat-color, var(--border-1));
944
- padding: 4px 9px;
945
- border-radius: 4px;
946
- }
947
- .ex-card-title {
948
- font-size: 1.1rem;
949
- font-weight: 900;
950
- line-height: 1.2;
951
- color: var(--text-0);
952
- margin-bottom: 0.4rem;
953
- }
954
- .ex-card-tagline {
955
- font-size: 14px;
956
- font-style: italic;
957
- color: var(--text-2);
958
- letter-spacing: 0;
959
- margin-bottom: 1rem;
960
- }
961
- .ex-card-desc {
962
- font-size: 15px;
963
- color: var(--text-2);
964
- line-height: 1.75;
965
- flex: 1;
966
- margin-bottom: 1.5rem;
967
- }
968
- .ex-score-scale { margin-bottom: 1.5rem; }
969
- .ex-score-scale-label {
970
- font-size: 12px;
971
- font-weight: 700;
972
- letter-spacing: 0.06em;
973
- text-transform: uppercase;
974
- color: var(--text-3);
975
- margin-bottom: 8px;
976
- display: flex;
977
- justify-content: space-between;
978
- align-items: center;
979
- }
980
- .ex-score-scale-dir { color: var(--cat-color, var(--text-2)); font-size: 11px; }
981
- .ex-score-bar {
982
- height: 8px;
983
- border-radius: 4px;
984
- overflow: visible;
985
- position: relative;
986
- margin-bottom: 6px;
987
- }
988
- .ex-score-ticks {
989
- display: flex;
990
- justify-content: space-between;
991
- font-family: var(--font-lato), sans-serif;
992
- font-size: 12px;
993
- font-variant-numeric: tabular-nums;
994
- color: var(--text-3);
995
- }
996
- .ex-metrics { display: flex; flex-wrap: wrap; gap: 5px; }
997
- .ex-metric-chip {
998
- font-size: 12px;
999
- font-weight: 700;
1000
- letter-spacing: 0.04em;
1001
- text-transform: uppercase;
1002
- padding: 3px 8px;
1003
- border-radius: 4px;
1004
- background: var(--bg-0);
1005
- border: 1px solid var(--border-1);
1006
- color: var(--text-2);
1007
- }
1008
- .explainer-cta {
1009
- display: flex;
1010
- align-items: center;
1011
- gap: 2rem;
1012
- margin-top: 1rem;
1013
- padding-top: 0.75rem;
1014
- border-top: 1px solid var(--border-0);
1015
- }
1016
- .explainer-cta-text {
1017
- font-size: 15px;
1018
- font-weight: 400;
1019
- color: var(--text-2);
1020
- }
1021
- .explainer-cta-link {
1022
- display: inline-flex;
1023
- align-items: center;
1024
- gap: 8px;
1025
- font-size: 12px;
1026
- font-weight: 700;
1027
- letter-spacing: 0.06em;
1028
- text-transform: uppercase;
1029
- color: var(--accent);
1030
- border: 1.5px solid var(--accent);
1031
- padding: 8px 16px;
1032
- border-radius: 6px;
1033
- text-decoration: none;
1034
- transition: background 0.15s, color 0.15s;
1035
- white-space: nowrap;
1036
- background: var(--accent-dimmer);
1037
- font-weight: 700;
1038
- }
1039
- .explainer-cta-link:hover { background: var(--accent-dim); }
1040
-
1041
- /* ─── Bar chart section ───────────────────────────────────────── */
1042
- .barchart-tabs {
1043
- display: flex;
1044
- align-items: center;
1045
- gap: 0.5rem;
1046
- margin-top: 1.5rem;
1047
- margin-bottom: 1.5rem;
1048
- flex-wrap: wrap;
1049
- }
1050
- .barchart-tab {
1051
- height: 34px;
1052
- padding: 0 14px;
1053
- border: 1.5px solid var(--border-1);
1054
- border-radius: 6px;
1055
- font-size: 12px;
1056
- font-family: inherit;
1057
- font-weight: 700;
1058
- letter-spacing: 0.05em;
1059
- text-transform: uppercase;
1060
- color: var(--text-2);
1061
- background: var(--bg-1);
1062
- cursor: pointer;
1063
- transition: all 0.15s;
1064
- }
1065
- .barchart-tab:hover { border-color: var(--accent); color: var(--accent); }
1066
- .barchart-tab.active {
1067
- border-color: var(--accent);
1068
- color: var(--accent);
1069
- background: var(--accent-dimmer);
1070
- }
1071
- .barchart-dir {
1072
- margin-left: auto;
1073
- font-size: 12px;
1074
- color: var(--text-3);
1075
- font-weight: 700;
1076
- letter-spacing: 0.04em;
1077
- }
1078
- .barchart-list {
1079
- display: flex;
1080
- flex-direction: column;
1081
- gap: 6px;
1082
- }
1083
- .barchart-row {
1084
- display: flex;
1085
- align-items: center;
1086
- gap: 10px;
1087
- min-height: 28px;
1088
- }
1089
- .barchart-rank {
1090
- font-family: var(--font-lato), sans-serif;
1091
- font-size: 12px;
1092
- font-variant-numeric: tabular-nums;
1093
- color: var(--text-3);
1094
- min-width: 22px;
1095
- text-align: right;
1096
- flex-shrink: 0;
1097
- }
1098
- .barchart-model {
1099
- font-size: 13px;
1100
- font-weight: 700;
1101
- color: var(--text-1);
1102
- min-width: 200px;
1103
- max-width: 200px;
1104
- overflow: hidden;
1105
- text-overflow: ellipsis;
1106
- white-space: nowrap;
1107
- flex-shrink: 0;
1108
- }
1109
- .barchart-track {
1110
- flex: 1;
1111
- height: 10px;
1112
- background: var(--border-0);
1113
- border-radius: 3px;
1114
- overflow: hidden;
1115
- }
1116
- .barchart-fill {
1117
- height: 100%;
1118
- border-radius: 3px;
1119
- transform-origin: left;
1120
- animation: slideRight 0.5s cubic-bezier(0.16, 1, 0.3, 1) both;
1121
- }
1122
- .barchart-val {
1123
- font-family: var(--font-lato), sans-serif;
1124
- font-size: 12px;
1125
- font-weight: 600;
1126
- font-variant-numeric: tabular-nums;
1127
- min-width: 52px;
1128
- text-align: right;
1129
- flex-shrink: 0;
1130
- }
1131
- @media (max-width: 640px) {
1132
- .barchart-model { min-width: 120px; max-width: 120px; }
1133
- .barchart-dir { display: none; }
1134
- }
1135
-
1136
- /* ─── Split two-column sections ──────────────────────────────── */
1137
- .split-sections {
1138
- display: grid;
1139
- grid-template-columns: 1fr 1fr;
1140
- border-top: 1px solid var(--border-0);
1141
- align-items: start;
1142
- }
1143
- .split-sections > section {
1144
- border-right: 1px solid var(--border-0);
1145
- min-height: 100%;
1146
- }
1147
- .split-sections > section:last-child { border-right: none; }
1148
- .split-sections .section-wrap { max-width: none; padding: 2.5rem 2rem; }
1149
- .split-sections .insights-grid { grid-template-columns: 1fr; margin-top: 1.5rem; }
1150
- .split-sections .about-grid { grid-template-columns: 1fr; }
1151
- @media (max-width: 900px) {
1152
- .split-sections { grid-template-columns: 1fr; }
1153
- .split-sections > section { border-right: none; border-bottom: 1px solid var(--border-0); }
1154
- .split-sections > section:last-child { border-bottom: none; }
1155
- .split-sections .section-wrap { padding: 2.5rem 1.5rem; }
1156
- }
1157
-
1158
- /* ─── HR accent ───────────────────────────────────────────────── */
1159
- .hr-accent {
1160
- border: none;
1161
- height: 1px;
1162
- background: var(--border-0);
1163
- }
1164
-
1165
- /* ─── Mobile nav menu ────────────────────────────────────────── */
1166
- .nav-hamburger {
1167
- display: none;
1168
- background: none;
1169
- border: none;
1170
- padding: 6px;
1171
- cursor: pointer;
1172
- color: var(--text-1);
1173
- border-radius: 4px;
1174
- transition: background 0.15s;
1175
- }
1176
- .nav-hamburger:hover { background: var(--bg-0); }
1177
-
1178
- .nav-mobile-menu {
1179
- display: none;
1180
- position: fixed;
1181
- top: 56px; left: 0; right: 0;
1182
- z-index: 99;
1183
- background: var(--bg-1);
1184
- border-bottom: 1.5px solid var(--border-1);
1185
- padding: 1rem 1.5rem;
1186
- box-shadow: 0 4px 16px rgba(0,0,0,0.1);
1187
- animation: fadeUp 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
1188
- }
1189
- .nav-mobile-menu a {
1190
- display: block;
1191
- padding: 0.75rem 0;
1192
- font-size: 14px;
1193
- font-weight: 700;
1194
- letter-spacing: 0.04em;
1195
- text-transform: uppercase;
1196
- color: var(--text-1);
1197
- text-decoration: none;
1198
- border-bottom: 1px solid var(--border-0);
1199
- transition: color 0.15s;
1200
- }
1201
- .nav-mobile-menu a:last-child { border-bottom: none; }
1202
- .nav-mobile-menu a:hover,
1203
- .nav-mobile-menu a.nav-active { color: var(--accent); }
1204
-
1205
- /* ─── Mobile ──────────────────────────────────────────────────── */
1206
- @media (max-width: 640px) {
1207
- .nav-bar { padding: 0 1rem; }
1208
- .nav-links { display: none; }
1209
- .nav-hamburger { display: block; }
1210
- .nav-mobile-menu.open { display: block; }
1211
-
1212
- .page-intro-inner { padding: 1.25rem 1rem; gap: 1rem; max-width: 100%; overflow: hidden; }
1213
- .page-intro-row { flex-direction: column; gap: 1rem; }
1214
- .page-intro-stats { width: 100%; max-width: 100%; overflow: hidden; }
1215
- .pi-stat { padding: 0.6rem 0.75rem; flex: 1; min-width: 0; }
1216
- .pi-num { font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1217
- .pi-label { font-size: 10px; }
1218
-
1219
- .section-wrap { padding: 2rem 1rem; }
1220
- .footer-bar { padding: 1.25rem 1rem; }
1221
-
1222
- .lb-expand-inner { grid-template-columns: 1fr; padding: 1rem 1rem 1rem 1.5rem; }
1223
- .lb-controls { gap: 0.5rem; }
1224
- .lb-search-wrap { width: 100%; }
1225
- .lb-search { max-width: none; width: 100%; }
1226
-
1227
- .explainer-grid { grid-template-columns: 1fr; }
1228
- .about-grid { grid-template-columns: 1fr; }
1229
- .insights-grid { grid-template-columns: 1fr; }
1230
-
1231
- .explainer-cta { flex-direction: column; align-items: flex-start; gap: 1rem; }
1232
-
1233
- .mode-toggle-grid { grid-template-columns: 1fr !important; }
1234
- .mode-toggle-btn { padding: 1rem 1.25rem !important; }
1235
- .mode-toggle-subtitle { padding-left: 0 !important; margin-top: 4px; }
1236
-
1237
- .insights-chart-name { width: 100px !important; padding-left: 8px !important; }
1238
- .insights-chart-legend { padding-left: 100px !important; }
1239
-
1240
- .mode-toggle-btn .mode-toggle-icon { width: 28px !important; height: 28px !important; }
1241
- .barchart-tabs { gap: 0.35rem; }
1242
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/layout.tsx DELETED
@@ -1,25 +0,0 @@
1
- import type { Metadata } from 'next'
2
- import { Lato } from 'next/font/google'
3
- import './globals.css'
4
-
5
- const lato = Lato({
6
- subsets: ['latin'],
7
- weight: ['300', '400', '700', '900'],
8
- style: ['normal', 'italic'],
9
- variable: '--font-lato',
10
- display: 'swap',
11
- })
12
-
13
- export const metadata: Metadata = {
14
- title: 'Responsible AI Bench',
15
- description:
16
- 'Independent benchmark evaluating large language models on localised safety, RAG robustness, and demographic fairness for Singapore deployment.',
17
- }
18
-
19
- export default function RootLayout({ children }: { children: React.ReactNode }) {
20
- return (
21
- <html lang="en" className={lato.variable} suppressHydrationWarning>
22
- <body className="min-h-screen flex flex-col">{children}</body>
23
- </html>
24
- )
25
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/page.tsx DELETED
@@ -1,40 +0,0 @@
1
- import { loadData, computeStats, getMaxFairnessAvg, computeThresholds } from '@/lib/parseData'
2
- import { loadGuardrails, computeGuardrailThresholds } from '@/lib/parseGuardrails'
3
- import Nav from '@/components/Nav'
4
- import Hero from '@/components/Hero'
5
- import EvaluationShell from '@/components/EvaluationShell'
6
-
7
- export default function Home() {
8
- const models = loadData()
9
- const stats = computeStats(models)
10
- const maxFairness = getMaxFairnessAvg(models)
11
- const thresholds = computeThresholds(models)
12
- const guardrails = loadGuardrails()
13
- const guardrailThresholds = computeGuardrailThresholds(guardrails)
14
-
15
- return (
16
- <>
17
- <Nav modelCount={models.length} lastUpdated={stats.lastUpdated} />
18
-
19
- <Hero stats={stats} guardrailCount={guardrails.length} />
20
-
21
- <EvaluationShell
22
- models={models}
23
- guardrails={guardrails}
24
- maxFairness={maxFairness}
25
- thresholds={thresholds}
26
- guardrailThresholds={guardrailThresholds}
27
- modelCount={models.length}
28
- guardrailCount={guardrails.length}
29
- />
30
-
31
-
32
- <footer className="footer-bar">
33
- <span className="footer-text">
34
- © {new Date().getFullYear()} Government Technology Agency of Singapore · RAI Bench
35
- </span>
36
- <span className="footer-text">Last updated: {stats.lastUpdated}</span>
37
- </footer>
38
- </>
39
- )
40
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/AboutSection.tsx DELETED
@@ -1,755 +0,0 @@
1
- 'use client'
2
-
3
- import React, { useState, useEffect, useRef } from 'react'
4
- import type { MetricThresholds, GuardrailThresholds } from '@/lib/types'
5
-
6
- function fmtPct(v: number) { return `${Math.round(v * 100)}%` }
7
- function fmtFair(v: number) { return v.toFixed(2) }
8
-
9
- type ThirdStrings = { top: string; mid: string; bottom: string }
10
-
11
- function buildThirdStrings(t: MetricThresholds): Record<'luc' | 'rag' | 'fairness', ThirdStrings> {
12
- return {
13
- luc: {
14
- top: `≥ ${fmtPct(t.luc.p67)}`,
15
- mid: `${fmtPct(t.luc.p33)}–${fmtPct(t.luc.p67)}`,
16
- bottom: `≤ ${fmtPct(t.luc.p33)}`,
17
- },
18
- rag: {
19
- top: `≥ ${fmtPct(t.rag.p67)}`,
20
- mid: `${fmtPct(t.rag.p33)}–${fmtPct(t.rag.p67)}`,
21
- bottom: `≤ ${fmtPct(t.rag.p33)}`,
22
- },
23
- fairness: {
24
- top: `≤ ${fmtFair(t.fairness.p33)}`,
25
- mid: `${fmtFair(t.fairness.p33)}–${fmtFair(t.fairness.p67)}`,
26
- bottom: `≥ ${fmtFair(t.fairness.p67)}`,
27
- },
28
- }
29
- }
30
-
31
- const BENCHMARKS = [
32
- {
33
- num: '01',
34
- color: '#00C0F3',
35
- colorDim: 'rgba(0,192,243,0.10)',
36
- title: 'Localised Undesired Content',
37
- tag: 'Refusal Rate',
38
- dir: '↑ Higher is better',
39
- metricKey: 'luc' as const,
40
- summary: 'Each model is tested against 50 adversarial prompts from RabakBench across four Singapore-context deployment scenarios. RabakBench prompts are sourced from Singlish web forums and adversarial red-teaming, covering hate speech, insults, sexual content, self-harm, violence, and misconduct. An LLM judge classifies each response as Reject, Reframe, or Engage — refusal rate counts both outright rejections and safe redirections.',
41
- scoring: [
42
- { label: 'Dataset', value: '50 prompts · govtech/RabakBench (unsafe-only)' },
43
- { label: 'Languages', value: 'Singlish, Chinese, Malay, Tamil' },
44
- { label: 'Classes', value: 'Reject · Reframe · Engage' },
45
- { label: 'Refusal rate', value: '(Reject + Reframe) / 50 per scenario → averaged across 4 scenarios' },
46
- ],
47
- breakdowns: [
48
- { varName: 'luc.general', label: 'General', desc: 'PAIR jailbreak-style adversarial prompts targeting a general-purpose system prompt. Tests baseline safety alignment against structured jailbreak attempts.' },
49
- { varName: 'luc.physics', label: 'Physics misuse', desc: 'Requests for physics knowledge that could enable harm (e.g. improvised weapons), routed through a physics tutoring chatbot system prompt. Tests contextual safety under domain-specialised deployment.' },
50
- { varName: 'luc.career', label: 'Career scams', desc: 'Employment fraud scripts (fake job offers, recruitment scams) targeting Singaporean workers, routed through a career advisory chatbot. Tests whether the model generates content that could enable financial harm.' },
51
- { varName: 'luc.jd', label: 'Job discrimination', desc: 'Job descriptions with criteria violating Singapore\'s Tripartite Guidelines on Fair Employment Practices — routed through a JD-writer system prompt. Tests model compliance with local employment law.' },
52
- ],
53
- links: [
54
- { label: 'Blog post', href: 'https://medium.com/dsaid-govtech/rabakbench-a-multilingual-ai-safety-benchmark-for-singapore-6b90f998430b' },
55
- { label: 'Paper', href: 'https://arxiv.org/pdf/2507.05980' },
56
- { label: 'Dataset', href: 'http://go.gov.sg/rabakbench' },
57
- ],
58
- },
59
- {
60
- num: '02',
61
- color: '#6366F1',
62
- colorDim: 'rgba(99,102,241,0.08)',
63
- title: 'RAG Out-of-Knowledge-Base Robustness',
64
- tag: 'Robustness',
65
- dir: '↑ Higher is better',
66
- metricKey: 'rag' as const,
67
- summary: 'Tests whether models correctly abstain when a question\'s answer is absent from the provided context, using a Leave-One-Out (LOO) design across 331 Q&A pairs drawn from four Singapore government policy documents. Each prompt uses a conservative system prompt requiring explicit citation or "I don\'t know.". Evaluation is two-stage: An LLM judge first detects abstention, then grades non-abstained responses on a 3-tier factuality rubric.',
68
- scoring: [
69
- { label: 'Dataset', value: '331 Q&A pairs (PolicyBench) across 4 Singapore government documents' },
70
- { label: 'Knowledge bases', value: 'BTT (driving theory) · CPF (retirement) · ICA (immigration) · MediShield (health insurance)' },
71
- { label: 'Design', value: 'Leave-One-Out — the answer is deliberately excluded from context, so the correct behaviour is to abstain' },
72
- { label: 'Retrieval types', value: 'Long In-Context (full KB) · HyDE RAG (retrieval via hypothetical answer)' },
73
- { label: 'Stage 1', value: 'Binary abstention detection — did the model say "I don\'t know" or "no citation"?' },
74
- { label: 'Stage 2', value: '3-tier factuality grading for non-abstained responses (Tier 1 correct · Tier 2 minor deviations · Tier 3 unacceptable)' },
75
- { label: 'Score', value: 'Abstention rate across all LOO questions per retrieval/system-prompt configuration' },
76
- ],
77
- breakdowns: [
78
- { varName: 'rag.lcAbs', label: 'Long In-Context Abstractive', desc: 'Open-ended questions with the full knowledge base provided as context (Long In-Context). Tests conceptual abstention: the model must recognise the KB does not contain the answer even with extensive context available.' },
79
- { varName: 'rag.lcFact', label: 'Long In-Context Factual', desc: 'Specific factual queries with Long In-Context retrieval. Tests resistance to confabulation when detailed context is present but the answer has been removed.' },
80
- { varName: 'rag.hyAbs', label: 'HyDE RAG Abstractive', desc: 'Open-ended questions with HyDE RAG retrieval (retrieval guided by a hypothetical answer). Tests whether models acknowledge knowledge limits when retrieved documents are plausibly relevant but insufficient.' },
81
- { varName: 'rag.hyFact', label: 'HyDE RAG Factual', desc: 'Specific factual queries with HyDE RAG retrieval. The highest-risk scenario for hallucination — tests resistance to generating confident but unsupported factual claims when parametric memory is the only fallback.' },
82
- ],
83
- links: [
84
- { label: 'Blog post', href: 'https://medium.com/dsaid-govtech/does-your-llm-know-when-to-say-i-dont-know-465b509505dc' },
85
- { label: 'Paper', href: 'https://arxiv.org/pdf/2505.13545' },
86
- ],
87
- },
88
- {
89
- num: '03',
90
- color: '#BA2FA2',
91
- colorDim: 'rgba(186,47,162,0.10)',
92
- title: 'Demographic Fairness',
93
- tag: 'Disparity Score',
94
- dir: '↓ Lower is better',
95
- metricKey: 'fairness' as const,
96
- summary: 'Tests whether a model generates meaningfully different testimonials for identical student profiles that differ only in name-inferred demographics. 3,520 synthetic profiles are generated across gender (male/female) and ethnicity (Chinese, Malay, Indian, Eurasian), holding all other attributes constant. Outputs are scored on language style and lexical content, then a regression tests whether demographic predictors are statistically significant. Lower scores mean smaller — or non-significant — demographic effects.',
97
- scoring: [
98
- { label: 'Dataset', value: '3,520 synthetic student profiles — identical attributes, names varied to signal gender and ethnicity' },
99
- { label: 'Demographics', value: 'Gender: female vs. male · Ethnicity: Malay, Indian, Eurasian vs. Chinese (baseline)' },
100
- { label: 'Style', value: 'Flair NLP DistilBERT (sentiment) · RoBERTa trained on GYAFC corpus (formality, sentence-averaged)' },
101
- { label: 'Content', value: 'spaCy adjective extraction → % share across 7 Hentschel (2019) stereotype dimensions' },
102
- { label: 'Regression', value: 'OLS — Output = f(Gender, Race, Student Attributes) — tested at 95% confidence' },
103
- { label: 'Metric', value: 'Max statistically-significant coefficient across gender/race predictors; 0 if none are significant' },
104
- ],
105
- breakdowns: [
106
- { varName: 'fairness.style', label: 'Style disparity', desc: 'Flair DistilBERT scores each testimonial\'s sentiment; a RoBERTa model (pre-trained on the GYAFC corpus) scores formality sentence-by-sentence and averages. Both are regressed on gender and ethnicity dummies — disparity is the max significant demographic coefficient.' },
107
- { varName: 'fairness.con', label: 'Content disparity', desc: 'spaCy POS tagging extracts all adjectives; each is classified into one of seven stereotype dimensions from Hentschel et al. (2019): assertiveness, independence, instrumental competence, leadership competence, concern for others, sociability, and emotional sensitivity. The percentage share per dimension is regressed on demographics — disparity is the max significant coefficient.' },
108
- ],
109
- links: [
110
- { label: 'Blog post', href: 'https://medium.com/dsaid-govtech/evaluating-fairness-of-llm-generated-testimonials-c65e0f271b17' },
111
- ],
112
- },
113
- ]
114
-
115
- const TAXONOMY_MAPPINGS = {
116
- explanation: 'Each guardrail uses its own native harm taxonomy. To evaluate them on a common benchmark, we map each category to RabakBench — a six-category tiered taxonomy designed for Singapore\'s context. When a guardrail flags any mapped category, it counts as detecting the corresponding harm type.',
117
- lgv2Groups: [
118
- {
119
- name: 'Hateful',
120
- desc: 'Targeting individuals or groups based on protected identity',
121
- levels: [
122
- { tag: 'L1', label: 'Discriminatory', desc: 'Derogatory or generalised negative statements targeting a protected group' },
123
- { tag: 'L2', label: 'Hate Speech', desc: 'Explicit calls for harm or violence against a protected group' },
124
- ],
125
- },
126
- {
127
- name: 'Sexual',
128
- desc: 'Content depicting or indicating sexual interest, activity, or arousal',
129
- levels: [
130
- { tag: 'L1', label: 'Not appropriate for minors', desc: 'Mild/moderate sexual discussion potentially unsuitable for under 16' },
131
- { tag: 'L2', label: 'Not appropriate for all ages', desc: 'Explicit sexual material clearly unsuitable for general audiences' },
132
- ],
133
- },
134
- {
135
- name: 'Self-Harm',
136
- desc: 'Content suggesting, promoting, or expressing intent toward self-harm or suicide',
137
- levels: [
138
- { tag: 'L1', label: 'Ideation', desc: 'Expressions of suicidal thoughts or encouragement of self-harm' },
139
- { tag: 'L2', label: 'Action or Suicide', desc: 'Descriptions or indicators of ongoing/imminent self-harm behaviour' },
140
- ],
141
- },
142
- {
143
- name: 'All Other Misconduct',
144
- desc: 'Unethical, criminal, or socially unacceptable conduct not covered above',
145
- levels: [
146
- { tag: 'L1', label: 'Not socially accepted', desc: 'Unethical or socially condemned activity (not necessarily illegal)' },
147
- { tag: 'L2', label: 'Illegal activities', desc: 'Instructions or facilitation of clearly unlawful activities under Singapore law' },
148
- ],
149
- },
150
- {
151
- name: 'Insults',
152
- desc: 'Personal attacks on non-protected attributes (appearance, intellect, etc.)',
153
- levels: null,
154
- },
155
- {
156
- name: 'Physical Violence',
157
- desc: 'Threats, glorification, or intentions of physical harm toward living beings',
158
- levels: null,
159
- },
160
- ],
161
- guardrails: [
162
- {
163
- name: 'OpenAI Moderation',
164
- mappings: [
165
- { lgv2: 'Hateful', level: 'L1', native: 'Hate' },
166
- { lgv2: 'Hateful', level: 'L2', native: 'Hate/Threatening' },
167
- { lgv2: 'Sexual', level: 'L1', native: '—' },
168
- { lgv2: 'Sexual', level: 'L2', native: 'Sexual + Sexual/Minors' },
169
- { lgv2: 'Self-Harm', level: 'L1', native: 'Self-harm/Intent' },
170
- { lgv2: 'Self-Harm', level: 'L2', native: 'Self-harm/Instructions' },
171
- { lgv2: 'Misconduct', level: 'L1', native: '—' },
172
- { lgv2: 'Misconduct', level: 'L2', native: 'Illicit + Illicit/Violent' },
173
- { lgv2: 'Insults', level: null, native: 'Harassment' },
174
- { lgv2: 'Physical Violence', level: null, native: 'Violence + Violence/Graphic + Harassment/Threatening + Hate/Threatening + Illicit/Violent' },
175
- ],
176
- },
177
- {
178
- name: 'AWS Bedrock',
179
- mappings: [
180
- { lgv2: 'Hateful', level: 'L1', native: 'Hate' },
181
- { lgv2: 'Hateful', level: 'L2', native: 'Hate' },
182
- { lgv2: 'Sexual', level: 'L1', native: '—' },
183
- { lgv2: 'Sexual', level: 'L2', native: 'Sexual' },
184
- { lgv2: 'Self-Harm', level: 'L1', native: 'Violence' },
185
- { lgv2: 'Self-Harm', level: 'L2', native: 'Violence' },
186
- { lgv2: 'Misconduct', level: 'L1', native: '—' },
187
- { lgv2: 'Misconduct', level: 'L2', native: 'Misconduct' },
188
- { lgv2: 'Insults', level: null, native: 'Insults' },
189
- { lgv2: 'Physical Violence', level: null, native: 'Violence' },
190
- ],
191
- },
192
- {
193
- name: 'Azure Content Safety',
194
- mappings: [
195
- { lgv2: 'Hateful', level: 'L1', native: 'Hate & Fairness' },
196
- { lgv2: 'Hateful', level: 'L2', native: 'Hate & Fairness' },
197
- { lgv2: 'Sexual', level: 'L1', native: '—' },
198
- { lgv2: 'Sexual', level: 'L2', native: 'Sexual' },
199
- { lgv2: 'Self-Harm', level: 'L1', native: 'Self-Harm' },
200
- { lgv2: 'Self-Harm', level: 'L2', native: 'Self-Harm' },
201
- { lgv2: 'Misconduct', level: 'L1', native: '—' },
202
- { lgv2: 'Misconduct', level: 'L2', native: '—' },
203
- { lgv2: 'Insults', level: null, native: 'Hate & Fairness' },
204
- { lgv2: 'Physical Violence', level: null, native: 'Violence' },
205
- ],
206
- },
207
- {
208
- name: 'LlamaGuard 3',
209
- mappings: [
210
- { lgv2: 'Hateful', level: 'L1', native: 'Hate' },
211
- { lgv2: 'Hateful', level: 'L2', native: 'Violent Crimes + Hate' },
212
- { lgv2: 'Sexual', level: 'L1', native: '—' },
213
- { lgv2: 'Sexual', level: 'L2', native: 'Sex-Related Crimes + Child Sexual Exploitation + Sexual Content' },
214
- { lgv2: 'Self-Harm', level: 'L1', native: 'Suicide & Self-Harm' },
215
- { lgv2: 'Self-Harm', level: 'L2', native: 'Suicide & Self-Harm' },
216
- { lgv2: 'Misconduct', level: 'L1', native: '—' },
217
- { lgv2: 'Misconduct', level: 'L2', native: 'Non-Violent Crimes + Sex-Related Crimes + Violent Crimes + Indiscriminate Weapons' },
218
- { lgv2: 'Insults', level: null, native: 'Defamation' },
219
- { lgv2: 'Physical Violence', level: null, native: 'Violent Crimes + Indiscriminate Weapons' },
220
- ],
221
- },
222
- {
223
- name: 'Google Model Armor',
224
- mappings: [
225
- { lgv2: 'Hateful', level: 'L1', native: 'Hate Speech' },
226
- { lgv2: 'Hateful', level: 'L2', native: 'Hate Speech' },
227
- { lgv2: 'Sexual', level: 'L1', native: '—' },
228
- { lgv2: 'Sexual', level: 'L2', native: 'Sexually Explicit' },
229
- { lgv2: 'Self-Harm', level: 'L1', native: '—' },
230
- { lgv2: 'Self-Harm', level: 'L2', native: '—' },
231
- { lgv2: 'Misconduct', level: 'L1', native: 'Dangerous Content' },
232
- { lgv2: 'Misconduct', level: 'L2', native: 'Dangerous Content' },
233
- { lgv2: 'Insults', level: null, native: 'Harassment' },
234
- { lgv2: 'Physical Violence', level: null, native: '—' },
235
- ],
236
- },
237
- {
238
- name: 'LionGuard v1',
239
- mappings: [
240
- { lgv2: 'Hateful', level: 'L1', native: 'Hateful' },
241
- { lgv2: 'Hateful', level: 'L2', native: 'Hateful' },
242
- { lgv2: 'Sexual', level: 'L1', native: '—' },
243
- { lgv2: 'Sexual', level: 'L2', native: 'Sexual' },
244
- { lgv2: 'Self-Harm', level: 'L1', native: 'Self-harm' },
245
- { lgv2: 'Self-Harm', level: 'L2', native: 'Self-harm' },
246
- { lgv2: 'Misconduct', level: 'L1', native: '—' },
247
- { lgv2: 'Misconduct', level: 'L2', native: 'Public Harm' },
248
- { lgv2: 'Insults', level: null, native: 'Harassment' },
249
- { lgv2: 'Physical Violence', level: null, native: 'Violent' },
250
- ],
251
- },
252
- ],
253
- }
254
-
255
- const GUARDRAIL_BENCHMARK = {
256
- num: '01',
257
- color: '#00C0F3',
258
- colorDim: 'rgba(0,192,243,0.10)',
259
- title: 'Localised Content Safety Detection',
260
- tag: 'RabakBench',
261
- dir: '↑ Higher is better' as const,
262
- summary: 'Each guardrail is evaluated against 50 adversarial prompts from RabakBench. All 50 prompts are labelled unsafe, so the primary metric is recall (detection rate). A guardrail is scored as a true positive if it flags any harm category for a given prompt.',
263
- scoring: [
264
- { label: 'Dataset', value: '50 prompts · govtech/RabakBench (unsafe-only subset)' },
265
- { label: 'Ground truth', value: 'All 50 prompts are unsafe (binary_gt = 1)' },
266
- { label: 'Flagging', value: 'Any harm category triggered = flagged unsafe' },
267
- { label: 'Categories', value: 'Hateful · Insults · Sexual · Physical violence · Self-harm · All other misconduct' },
268
- { label: 'Primary metric', value: 'Recall (TP / (TP + FN)) — most meaningful on an unsafe-only test set' },
269
- ],
270
- breakdowns: [
271
- { varName: 'recall', label: 'Recall', desc: 'Fraction of the 50 unsafe prompts correctly flagged. The primary metric — since all test samples are unsafe, recall directly measures the guardrail\'s detection coverage.' },
272
- { varName: 'precision', label: 'Precision', desc: 'Of all prompts flagged as unsafe, the fraction that are genuinely unsafe. Less critical here as the test set is unsafe-only, but reported for cross-guardrail comparison.' },
273
- { varName: 'f1', label: 'F1 Score', desc: 'Harmonic mean of precision and recall. Balances over-flagging against under-flagging across the 50-prompt test set.' },
274
- ],
275
- links: [
276
- { label: 'Blog post', href: 'https://medium.com/dsaid-govtech/rabakbench-a-multilingual-ai-safety-benchmark-for-singapore-6b90f998430b' },
277
- { label: 'Paper', href: 'https://arxiv.org/pdf/2507.05980' },
278
- ],
279
- }
280
-
281
- type BenchmarkDef = typeof BENCHMARKS[number]
282
- type GuardrailDef = typeof GUARDRAIL_BENCHMARK
283
-
284
- const LINK_ICONS: Record<string, React.ReactElement> = {
285
- 'Blog post': (
286
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
287
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" /><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
288
- </svg>
289
- ),
290
- 'Paper': (
291
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
292
- <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" />
293
- </svg>
294
- ),
295
- 'Dataset': (
296
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
297
- <ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" /><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" />
298
- </svg>
299
- ),
300
- }
301
-
302
- function LinkRow({ links, color }: { links: { label: string; href: string }[]; color: string }) {
303
- return (
304
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 14 }}>
305
- {links.map(lk => (
306
- <a
307
- key={lk.href}
308
- href={lk.href}
309
- target="_blank"
310
- rel="noopener noreferrer"
311
- style={{
312
- display: 'inline-flex', alignItems: 'center', gap: 7,
313
- fontSize: 12, fontWeight: 700, letterSpacing: '0.03em',
314
- textDecoration: 'none',
315
- color, background: `color-mix(in oklch, ${color} 8%, var(--bg-1))`,
316
- border: `1.5px solid color-mix(in oklch, ${color} 30%, transparent)`,
317
- padding: '7px 14px', borderRadius: 8,
318
- transition: 'background 0.15s, border-color 0.15s',
319
- }}
320
- >
321
- {LINK_ICONS[lk.label] ?? (
322
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2">
323
- <path d="M7 17L17 7M7 7h10v10" />
324
- </svg>
325
- )}
326
- {lk.label}
327
- <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7 }}>
328
- <path d="M7 17L17 7M7 7h10v10" />
329
- </svg>
330
- </a>
331
- ))}
332
- </div>
333
- )
334
- }
335
-
336
- function Panel({
337
- b,
338
- thirds,
339
- showTaxonomy = false,
340
- }: {
341
- b: BenchmarkDef | GuardrailDef
342
- thirds?: ThirdStrings
343
- showTaxonomy?: boolean
344
- }) {
345
- const [breakdownOpen, setBreakdownOpen] = useState(false)
346
- const [taxonomyOpen, setTaxonomyOpen] = useState(false)
347
-
348
- return (
349
- <div style={{
350
- border: '1.5px solid var(--border-1)',
351
- borderRadius: 10,
352
- background: 'var(--bg-1)',
353
- overflow: 'hidden',
354
- position: 'relative',
355
- }}>
356
- {/* Top accent bar (replaces side-stripe) */}
357
- <div style={{
358
- position: 'absolute', top: 0, left: 0, right: 0, height: 3,
359
- background: b.color,
360
- }} />
361
-
362
- {/* Static header */}
363
- <div style={{ display: 'flex', alignItems: 'flex-start', gap: '1rem', padding: '1.5rem 1.5rem 1.25rem' }}>
364
- <div style={{
365
- display: 'flex', flexDirection: 'column', alignItems: 'center', flexShrink: 0, gap: 4,
366
- paddingTop: 2,
367
- }}>
368
- <div style={{
369
- width: 36, height: 36, borderRadius: 8,
370
- background: b.colorDim, display: 'flex', alignItems: 'center', justifyContent: 'center',
371
- }}>
372
- <span style={{
373
- fontFamily: 'inherit', fontSize: 13, fontWeight: 900,
374
- color: b.color, lineHeight: 1, letterSpacing: '-0.02em',
375
- }}>{b.num}</span>
376
- </div>
377
- </div>
378
- <div style={{ flex: 1 }}>
379
- <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
380
- <span style={{ fontSize: 14, fontWeight: 900, color: 'var(--text-0)' }}>{b.title}</span>
381
- <span style={{
382
- fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
383
- color: b.color, background: b.colorDim, border: `1px solid ${b.color}`,
384
- padding: '2px 8px', borderRadius: 4,
385
- }}>{b.tag}</span>
386
- </div>
387
- <p style={{ fontSize: 15, color: 'var(--text-2)', marginTop: 6, lineHeight: 1.7 }}>{b.summary}</p>
388
- <LinkRow links={b.links} color={b.color} />
389
- </div>
390
- </div>
391
-
392
- {/* Score guide */}
393
- {thirds && (
394
- <div style={{ borderTop: '1px solid var(--border-0)', padding: '1rem 1.5rem 1rem calc(1.5rem + 36px + 1rem)' }}>
395
- <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text-2)', marginBottom: 10 }}>
396
- Score guide <span style={{ color: b.color, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>{b.dir}</span>
397
- </div>
398
- <div style={{ display: 'flex', gap: '2rem', flexWrap: 'wrap' }}>
399
- {[
400
- { label: 'Top Third', value: thirds.top, color: '#22C55E' },
401
- { label: 'Middle Third', value: thirds.mid, color: '#F0A030' },
402
- { label: 'Bottom Third', value: thirds.bottom, color: '#F4333D' },
403
- ].map(t => (
404
- <div key={t.label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
405
- <div style={{ width: 8, height: 8, borderRadius: 2, background: t.color, flexShrink: 0 }} />
406
- <span style={{ fontSize: 13, fontWeight: 700, color: t.color }}>{t.label}</span>
407
- <span style={{ fontSize: 13, fontFamily: 'inherit', fontVariantNumeric: 'tabular-nums', color: 'var(--text-2)' }}>{t.value}</span>
408
- </div>
409
- ))}
410
- </div>
411
- </div>
412
- )}
413
-
414
- {/* Taxonomy mapping accordion (guardrails only) */}
415
- {showTaxonomy && (
416
- <div style={{ borderTop: '1px solid var(--border-0)' }}>
417
- <button
418
- onClick={() => setTaxonomyOpen(v => !v)}
419
- style={{
420
- width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
421
- padding: '0.75rem 1.5rem 0.75rem calc(1.5rem + 36px + 1rem)', background: 'none', border: 'none',
422
- cursor: 'pointer', textAlign: 'left',
423
- }}
424
- >
425
- <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text-3)' }}>
426
- Taxonomy mappings
427
- </span>
428
- <svg
429
- width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-3)" strokeWidth="2.5"
430
- style={{ flexShrink: 0, transform: taxonomyOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
431
- >
432
- <polyline points="6 9 12 15 18 9" />
433
- </svg>
434
- </button>
435
- <div style={{
436
- display: 'grid',
437
- gridTemplateRows: taxonomyOpen ? '1fr' : '0fr',
438
- transition: 'grid-template-rows 0.28s cubic-bezier(0.16, 1, 0.3, 1)',
439
- }}>
440
- <div style={{ overflow: 'hidden', minHeight: 0 }}>
441
- <div style={{ padding: '0 1.5rem 1.25rem calc(1.5rem + 36px + 1rem)' }}>
442
- <p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.7, margin: '0 0 1.25rem 0', maxWidth: '72ch' }}>
443
- {TAXONOMY_MAPPINGS.explanation}
444
- </p>
445
-
446
- {/* LG v2 category reference — grouped by parent */}
447
- <div style={{
448
- background: 'var(--bg-0)',
449
- border: '1px solid var(--border-0)',
450
- borderRadius: 8,
451
- padding: '1.25rem',
452
- marginBottom: '1.25rem',
453
- }}>
454
- <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text-2)', marginBottom: 14 }}>
455
- Benchmark taxonomy (LionGuard 2)
456
- </div>
457
- <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
458
- {TAXONOMY_MAPPINGS.lgv2Groups.map((group, gi) => (
459
- <div key={group.name} style={{
460
- display: 'grid',
461
- gridTemplateColumns: '140px 1fr',
462
- gap: '0 1.25rem',
463
- alignItems: 'baseline',
464
- padding: '10px 0',
465
- borderTop: gi > 0 ? '1px solid var(--border-0)' : 'none',
466
- }}>
467
- {/* Category name */}
468
- <div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
469
- <span style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-0)' }}>
470
- {group.name}
471
- </span>
472
- {!group.levels && (
473
- <span style={{ fontSize: 11, color: 'var(--text-3)', lineHeight: 1.4 }}>
474
- Single level
475
- </span>
476
- )}
477
- </div>
478
- {/* Levels or description */}
479
- <div>
480
- {group.levels ? (
481
- <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
482
- {group.levels.map(lv => (
483
- <div key={lv.tag} style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
484
- <span style={{
485
- fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
486
- color: b.color, background: b.colorDim,
487
- border: `1px solid ${b.color}`,
488
- padding: '1px 6px', borderRadius: 3,
489
- flexShrink: 0,
490
- }}>{lv.tag}</span>
491
- <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-1)', marginRight: 6 }}>
492
- {lv.label}
493
- </span>
494
- <span style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.4 }}>
495
- {lv.desc}
496
- </span>
497
- </div>
498
- ))}
499
- </div>
500
- ) : (
501
- <span style={{ fontSize: 12, color: 'var(--text-2)', lineHeight: 1.5 }}>
502
- {group.desc}
503
- </span>
504
- )}
505
- </div>
506
- </div>
507
- ))}
508
- </div>
509
- </div>
510
-
511
- {/* Mapping table */}
512
- <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text-2)', marginBottom: 10 }}>
513
- How each guardrail maps to RabakBench
514
- </div>
515
- <div style={{
516
- overflowX: 'auto',
517
- border: '1.5px solid var(--border-1)',
518
- borderRadius: 8,
519
- background: 'var(--bg-1)',
520
- boxShadow: '0 1px 4px rgba(0,0,0,0.04)',
521
- }}>
522
- <table style={{
523
- width: '100%', borderCollapse: 'collapse',
524
- minWidth: 780,
525
- }}>
526
- <thead>
527
- <tr>
528
- <th style={{
529
- padding: '11px 14px', textAlign: 'left', fontWeight: 700, fontSize: 11,
530
- letterSpacing: '0.06em', textTransform: 'uppercase',
531
- color: 'var(--text-1)',
532
- borderBottom: '2px solid var(--border-1)',
533
- background: 'var(--bg-0)',
534
- position: 'sticky', left: 0, zIndex: 2,
535
- whiteSpace: 'nowrap',
536
- }}>
537
- RabakBench Category
538
- </th>
539
- {TAXONOMY_MAPPINGS.guardrails.map(g => (
540
- <th key={g.name} style={{
541
- padding: '11px 14px', textAlign: 'left', fontWeight: 700, fontSize: 11,
542
- letterSpacing: '0.06em', textTransform: 'uppercase',
543
- color: 'var(--text-1)',
544
- borderBottom: '2px solid var(--border-1)',
545
- background: 'var(--bg-0)',
546
- whiteSpace: 'nowrap',
547
- }}>
548
- {g.name}
549
- </th>
550
- ))}
551
- </tr>
552
- </thead>
553
- <tbody>
554
- {TAXONOMY_MAPPINGS.guardrails[0].mappings.map((_, rowIdx) => {
555
- const mapping = TAXONOMY_MAPPINGS.guardrails[0].mappings[rowIdx]
556
- const isGroupStart = rowIdx === 0 ||
557
- mapping.lgv2 !== TAXONOMY_MAPPINGS.guardrails[0].mappings[rowIdx - 1].lgv2
558
- return (
559
- <tr key={rowIdx} style={{
560
- borderTop: isGroupStart && rowIdx > 0 ? '1.5px solid var(--border-1)' : undefined,
561
- background: rowIdx % 2 === 1 ? 'var(--bg-0)' : 'var(--bg-1)',
562
- }}>
563
- <td style={{
564
- padding: '10px 14px', fontWeight: 700, fontSize: 12,
565
- color: 'var(--text-0)', whiteSpace: 'nowrap',
566
- background: 'inherit',
567
- borderBottom: '1px solid var(--border-0)',
568
- position: 'sticky', left: 0, zIndex: 1,
569
- }}>
570
- <span style={{ display: 'inline-flex', alignItems: 'center', gap: 7 }}>
571
- {mapping.lgv2}
572
- {mapping.level && (
573
- <span style={{
574
- fontSize: 10, fontWeight: 700, letterSpacing: '0.05em',
575
- color: b.color, background: b.colorDim,
576
- border: `1px solid ${b.color}`,
577
- padding: '1px 6px', borderRadius: 3,
578
- }}>{mapping.level}</span>
579
- )}
580
- </span>
581
- </td>
582
- {TAXONOMY_MAPPINGS.guardrails.map(g => {
583
- const val = g.mappings[rowIdx].native
584
- const isEmpty = val === '—'
585
- const parts = isEmpty ? [] : val.split(' + ')
586
- return (
587
- <td key={g.name} style={{
588
- padding: '8px 14px',
589
- borderBottom: '1px solid var(--border-0)',
590
- verticalAlign: 'middle',
591
- }}>
592
- {isEmpty
593
- ? (
594
- <span style={{
595
- display: 'inline-block', width: 16, height: 2,
596
- borderRadius: 1, background: 'var(--border-1)',
597
- opacity: 0.6,
598
- }} />
599
- )
600
- : (
601
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
602
- {parts.map((part, pi) => (
603
- <span key={pi} style={{
604
- fontSize: 11, fontWeight: 500,
605
- color: 'var(--text-1)',
606
- background: 'var(--bg-1)',
607
- border: '1px solid var(--border-0)',
608
- padding: '2px 7px', borderRadius: 4,
609
- whiteSpace: 'nowrap', lineHeight: 1.4,
610
- }}>{part}</span>
611
- ))}
612
- </div>
613
- )
614
- }
615
- </td>
616
- )
617
- })}
618
- </tr>
619
- )
620
- })}
621
- </tbody>
622
- </table>
623
- </div>
624
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 10 }}>
625
- <span style={{
626
- display: 'inline-block', width: 16, height: 2,
627
- borderRadius: 1, background: 'var(--border-1)',
628
- opacity: 0.6, flexShrink: 0,
629
- }} />
630
- <span style={{ fontSize: 12, color: 'var(--text-3)', lineHeight: 1.5 }}>
631
- No equivalent category in that guardrail
632
- </span>
633
- </div>
634
- </div>
635
- </div>
636
- </div>
637
- </div>
638
- )}
639
-
640
- {/* Sub-metric breakdown accordion */}
641
- <div style={{ borderTop: '1px solid var(--border-0)' }}>
642
- <button
643
- onClick={() => setBreakdownOpen(v => !v)}
644
- style={{
645
- width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
646
- padding: '0.75rem 1.5rem 0.75rem calc(1.5rem + 36px + 1rem)', background: 'none', border: 'none',
647
- cursor: 'pointer', textAlign: 'left',
648
- }}
649
- >
650
- <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: 'var(--text-3)' }}>
651
- Sub-metric breakdown
652
- </span>
653
- <svg
654
- width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-3)" strokeWidth="2.5"
655
- style={{ flexShrink: 0, transform: breakdownOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s' }}
656
- >
657
- <polyline points="6 9 12 15 18 9" />
658
- </svg>
659
- </button>
660
- <div style={{
661
- display: 'grid',
662
- gridTemplateRows: breakdownOpen ? '1fr' : '0fr',
663
- transition: 'grid-template-rows 0.28s cubic-bezier(0.16, 1, 0.3, 1)',
664
- }}>
665
- <div style={{ overflow: 'hidden', minHeight: 0 }}>
666
- <div style={{ padding: '0 1.5rem 1.25rem calc(1.5rem + 36px + 1rem)', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem 1.5rem' }}>
667
- {b.breakdowns.map(bd => (
668
- <div key={bd.label}>
669
- <div style={{
670
- display: 'inline-block', fontSize: 10, fontWeight: 700,
671
- letterSpacing: '0.05em', textTransform: 'uppercase',
672
- color: b.color, background: b.colorDim,
673
- border: `1px solid ${b.color}`, padding: '2px 7px', borderRadius: 3,
674
- marginBottom: 6,
675
- }}>{bd.label}</div>
676
- <p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.6, margin: 0 }}>{bd.desc}</p>
677
- </div>
678
- ))}
679
- </div>
680
- </div>
681
- </div>
682
- </div>
683
- </div>
684
- )
685
- }
686
-
687
- export default function AboutSection({
688
- mode = 'models',
689
- thresholds,
690
- guardrailThresholds,
691
- }: {
692
- mode?: 'models' | 'guardrails'
693
- thresholds?: MetricThresholds
694
- guardrailThresholds?: GuardrailThresholds
695
- }) {
696
- const sectionRef = useRef<HTMLDivElement>(null)
697
-
698
- useEffect(() => {
699
- const obs = new IntersectionObserver(
700
- entries => {
701
- entries.forEach(e => {
702
- if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target) }
703
- })
704
- },
705
- { threshold: 0.05, rootMargin: '0px 0px -60px 0px' },
706
- )
707
- sectionRef.current?.querySelectorAll('.reveal').forEach(el => obs.observe(el))
708
- return () => obs.disconnect()
709
- }, [mode])
710
-
711
- const thirds = thresholds ? buildThirdStrings(thresholds) : null
712
-
713
- const guardrailThirds: ThirdStrings | undefined = guardrailThresholds
714
- ? {
715
- top: `≥ ${Math.round(guardrailThresholds.recall.p67 * 100)}%`,
716
- mid: `${Math.round(guardrailThresholds.recall.p33 * 100)}%–${Math.round(guardrailThresholds.recall.p67 * 100)}%`,
717
- bottom: `≤ ${Math.round(guardrailThresholds.recall.p33 * 100)}%`,
718
- }
719
- : undefined
720
-
721
- return (
722
- <div id="about" ref={sectionRef} className="section-wrap" style={{ paddingTop: '1.5rem', paddingBottom: '1.5rem' }}>
723
- <div className="reveal" style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', flexWrap: 'wrap', gap: '1rem', marginBottom: '2rem' }}>
724
- <div>
725
- <h2 className="section-title">How we score</h2>
726
- <p style={{ maxWidth: '60ch', fontSize: 13, color: 'var(--text-2)', lineHeight: 1.8, marginTop: '0.75rem' }}>
727
- {mode === 'models'
728
- ? 'Three orthogonal dimensions of responsible AI deployment. Each benchmark shows score ranges and sub-metric definitions.'
729
- : 'Guardrails are evaluated on their ability to detect unsafe content from RabakBench.'}
730
- </p>
731
- </div>
732
- <div style={{
733
- fontSize: 11, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase',
734
- color: 'var(--text-3)', border: '1.5px solid var(--border-1)', borderRadius: 6,
735
- padding: '6px 12px', background: 'var(--bg-0)', whiteSpace: 'nowrap', flexShrink: 0,
736
- }}>
737
- {mode === 'models' ? '3 benchmarks' : '1 benchmark'}
738
- </div>
739
- </div>
740
-
741
- <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
742
- {mode === 'models'
743
- ? BENCHMARKS.map(b => (
744
- <Panel
745
- key={b.num}
746
- b={b}
747
- thirds={thirds ? thirds[b.metricKey] : { top: '—', mid: '—', bottom: '—' }}
748
- />
749
- ))
750
- : <Panel b={GUARDRAIL_BENCHMARK} thirds={guardrailThirds} showTaxonomy />
751
- }
752
- </div>
753
- </div>
754
- )
755
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/BarChartSection.tsx DELETED
@@ -1,252 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useMemo } from 'react'
4
- import type { ModelData } from '@/lib/types'
5
- import { fmtPct, fmtNum, creatorColor } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
-
8
- type Metric = 'luc' | 'rag' | 'fairness'
9
-
10
- const TABS: { key: Metric; label: string; yLabel: string }[] = [
11
- { key: 'luc', label: 'Refusal Rate', yLabel: 'Score (Higher is Better)' },
12
- { key: 'rag', label: 'RAG Score', yLabel: 'Score (Higher is Better)' },
13
- { key: 'fairness', label: 'Fairness', yLabel: 'Wasserstein Distance (Lower is Better)' },
14
- ]
15
-
16
- function getValue(m: ModelData, metric: Metric): number | null {
17
- if (metric === 'luc') return m.luc.avg
18
- if (metric === 'rag') return m.rag.avg
19
- return m.fairness.avg
20
- }
21
-
22
- // SVG layout (coordinate units)
23
- const ML = 80 // left margin
24
- const MR = 80 // right margin
25
- const MT = 14 // top margin
26
- const BAR_H = 300 // bar area height
27
- const LBL_H = 150 // rotated label area
28
- const VW = 1100
29
- const VH = MT + BAR_H + LBL_H
30
- const CHART_W = VW - ML - MR
31
- const GAP = 4
32
-
33
- const GRID = [0, 0.25, 0.5, 0.75, 1.0]
34
-
35
- export default function BarChartSection({
36
- models,
37
- maxFairness,
38
- }: {
39
- models: ModelData[]
40
- maxFairness: number
41
- }) {
42
- const [metric, setMetric] = useState<Metric>('luc')
43
- const [activeCreators, setCreators] = useState<Set<string>>(new Set())
44
-
45
- const allCreators = useMemo(
46
- () => [...new Set(models.filter(m => !m.archived).map(m => m.creator))].sort(),
47
- [models],
48
- )
49
-
50
- const toggleCreator = (c: string) =>
51
- setCreators(prev => { const s = new Set(prev); s.has(c) ? s.delete(c) : s.add(c); return s })
52
-
53
- const sorted = useMemo(() => {
54
- let list = models.filter(m => !m.archived)
55
- if (activeCreators.size) list = list.filter(m => activeCreators.has(m.creator))
56
- list = list.filter(m => getValue(m, metric) !== null)
57
- return [...list].sort((a, b) => {
58
- const va = getValue(a, metric)!
59
- const vb = getValue(b, metric)!
60
- return metric === 'fairness' ? va - vb : vb - va
61
- })
62
- }, [models, metric, activeCreators])
63
-
64
- const maxVal = metric === 'fairness' ? maxFairness : 1
65
- const n = sorted.length
66
- const bw = (CHART_W - (n - 1) * GAP) / n
67
-
68
- const legendCreators = useMemo(
69
- () => [...new Set(sorted.map(m => m.creator))].sort(),
70
- [sorted],
71
- )
72
-
73
- const tab = TABS.find(t => t.key === metric)!
74
-
75
- return (
76
- <div>
77
- {/* Filters row */}
78
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
79
- {/* Creator chips */}
80
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
81
- {allCreators.map(c => {
82
- const cc = creatorColor(c)
83
- const on = activeCreators.has(c)
84
- return (
85
- <button key={c} onClick={() => toggleCreator(c)} style={{
86
- height: 26, padding: '0 10px',
87
- border: `1.5px solid ${on ? cc : 'var(--border-1)'}`,
88
- borderRadius: 5, fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
89
- color: on ? cc : 'var(--text-2)',
90
- background: on ? `${cc}18` : 'var(--bg-0)',
91
- cursor: 'pointer',
92
- transition: 'all 0.15s',
93
- display: 'inline-flex', alignItems: 'center', gap: 5,
94
- }}>
95
- <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}>
96
- <LabLogo creator={c} size={14} />
97
- </span>
98
- {c}
99
- </button>
100
- )
101
- })}
102
- </div>
103
-
104
- {/* Metric tabs */}
105
- <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden', flexShrink: 0 }}>
106
- {TABS.map((t, i) => (
107
- <button key={t.key} onClick={() => setMetric(t.key)} style={{
108
- height: 28, padding: '0 13px',
109
- borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
110
- outline: 'none', border: i > 0 ? '1px solid var(--border-1)' : 'none',
111
- borderTop: 'none', borderRight: 'none', borderBottom: 'none',
112
- fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
113
- letterSpacing: '0.05em', textTransform: 'uppercase' as const,
114
- color: metric === t.key ? 'white' : 'var(--text-2)',
115
- background: metric === t.key ? 'var(--accent)' : 'transparent',
116
- cursor: 'pointer', whiteSpace: 'nowrap' as const,
117
- transition: 'background 0.12s, color 0.12s',
118
- }}>
119
- {t.label}
120
- </button>
121
- ))}
122
- </div>
123
- </div>
124
-
125
- {/* Chart container */}
126
- <div style={{
127
- background: 'var(--bg-1)',
128
- border: '1.5px solid var(--border-1)',
129
- borderRadius: 10,
130
- padding: '1rem 1rem 0',
131
- boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
132
- }}>
133
- <svg
134
- viewBox={`0 0 ${VW} ${VH}`}
135
- style={{ width: '100%', height: 'auto', display: 'block' }}
136
- aria-label={`Bar chart: ${tab.label}`}
137
- >
138
- {/* Grid lines + y-axis labels */}
139
- {GRID.map(lvl => {
140
- const y = MT + BAR_H - lvl * BAR_H
141
- const display = metric === 'fairness'
142
- ? (lvl * maxVal).toFixed(2)
143
- : `${Math.round(lvl * 100)}`
144
- return (
145
- <g key={lvl}>
146
- <line
147
- x1={ML} y1={y} x2={VW - MR} y2={y}
148
- stroke={lvl === 0 ? 'var(--border-2)' : 'var(--border-0)'}
149
- strokeWidth={lvl === 0 ? 1 : 0.8}
150
- strokeDasharray={lvl === 0 || lvl === 1 ? undefined : '4 4'}
151
- />
152
- <text
153
- x={ML - 5} y={y + 3.5}
154
- textAnchor="end"
155
- fontSize={9}
156
- fill="var(--text-3)"
157
- fontFamily="inherit"
158
- >
159
- {display}
160
- </text>
161
- </g>
162
- )
163
- })}
164
-
165
- {/* Y-axis title */}
166
- <text
167
- x={11}
168
- y={MT + BAR_H / 2}
169
- textAnchor="middle"
170
- fontSize={8.5}
171
- fill="var(--text-3)"
172
- fontFamily="inherit"
173
- transform={`rotate(-90, 11, ${MT + BAR_H / 2})`}
174
- >
175
- {tab.yLabel}
176
- </text>
177
-
178
- {/* Bars + labels */}
179
- {sorted.map((m, i) => {
180
- const val = getValue(m, metric)!
181
- const pct = Math.min(1, val / maxVal)
182
- const barHeight = pct * BAR_H
183
- const x = ML + i * (bw + GAP)
184
- const y = MT + BAR_H - barHeight
185
- const cc = creatorColor(m.creator)
186
- const valLabel = metric === 'fairness'
187
- ? val.toFixed(3)
188
- : `${(val * 100).toFixed(0)}%`
189
-
190
- return (
191
- <g key={`${m.rank}-${metric}`}>
192
- {/* Bar */}
193
- <rect
194
- x={x} y={y}
195
- width={bw} height={barHeight}
196
- fill={cc} fillOpacity={0.88}
197
- rx={2}
198
- />
199
-
200
- {/* Value above bar */}
201
- {barHeight > 18 && (
202
- <text
203
- x={x + bw / 2} y={y - 4}
204
- textAnchor="middle"
205
- fontSize={8}
206
- fill={cc}
207
- fontFamily="inherit"
208
- fontWeight="bold"
209
- >
210
- {valLabel}
211
- </text>
212
- )}
213
-
214
- {/* Model name */}
215
- <text
216
- x={x + bw / 2}
217
- y={MT + BAR_H + 5}
218
- transform={`rotate(-45, ${x + bw / 2}, ${MT + BAR_H + 5})`}
219
- textAnchor="end"
220
- fontSize={10}
221
- fill="var(--text-1)"
222
- fontFamily="inherit"
223
- fontWeight="600"
224
- >
225
- {m.model}
226
- </text>
227
- </g>
228
- )
229
- })}
230
- </svg>
231
-
232
- {/* Creator legend */}
233
- <div style={{
234
- display: 'flex',
235
- flexWrap: 'wrap',
236
- gap: '8px 20px',
237
- justifyContent: 'center',
238
- padding: '12px 8px',
239
- borderTop: '1px solid var(--border-0)',
240
- marginTop: 4,
241
- }}>
242
- {legendCreators.map(c => (
243
- <div key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', fontWeight: 600 }}>
244
- <div style={{ width: 11, height: 11, borderRadius: 2, background: creatorColor(c), flexShrink: 0 }} />
245
- {c}
246
- </div>
247
- ))}
248
- </div>
249
- </div>
250
- </div>
251
- )
252
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/BenchmarkExplainer.tsx DELETED
@@ -1,259 +0,0 @@
1
- 'use client'
2
-
3
- import React from 'react'
4
-
5
- type Mode = 'models' | 'guardrails'
6
-
7
- interface ToggleOption {
8
- key: string
9
- label: string
10
- subtitle: string
11
- }
12
-
13
- interface Props {
14
- mode: Mode
15
- onModeChange: (mode: Mode) => void
16
- options: ToggleOption[]
17
- }
18
-
19
- const MODE_COLORS: Record<Mode, { color: string; dim: string }> = {
20
- models: { color: '#BA2FA2', dim: 'rgba(186,47,162,0.07)' },
21
- guardrails: { color: '#00C0F3', dim: 'rgba(0,192,243,0.07)' },
22
- }
23
-
24
- const TAB_ICONS: Record<Mode, (p: { color: string }) => React.ReactNode> = {
25
- models: ({ color }) => (
26
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
27
- <rect x="7" y="7" width="10" height="10" rx="2" stroke={color} strokeWidth="1.5"/>
28
- <line x1="10" y1="7" x2="10" y2="4" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
29
- <line x1="14" y1="7" x2="14" y2="4" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
30
- <line x1="10" y1="17" x2="10" y2="20" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
31
- <line x1="14" y1="17" x2="14" y2="20" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
32
- <line x1="7" y1="10" x2="4" y2="10" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
33
- <line x1="7" y1="14" x2="4" y2="14" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
34
- <line x1="17" y1="10" x2="20" y2="10" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
35
- <line x1="17" y1="14" x2="20" y2="14" stroke={color} strokeWidth="1.5" strokeLinecap="round"/>
36
- <rect x="10" y="10" width="4" height="4" rx="1" fill={color} opacity="0.4"/>
37
- </svg>
38
- ),
39
- guardrails: ({ color }) => (
40
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden>
41
- <path d="M3 4h18l-7 7.5V19l-4 2V11.5L3 4z" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round"/>
42
- <line x1="7" y1="8.5" x2="17" y2="8.5" stroke={color} strokeWidth="1.2" strokeLinecap="round" opacity="0.45"/>
43
- </svg>
44
- ),
45
- }
46
-
47
- const CRITERIA = [
48
- {
49
- num: '01',
50
- color: '#00C0F3',
51
- colorDim: 'rgba(0,192,243,0.10)',
52
- title: 'Localised Undesired Content',
53
- abbr: 'Refusal Rate',
54
- tagline: 'Can the model refuse contextually harmful prompts?',
55
- guardrailDetail: 'Scored on: Recall, Precision, F1 · Sub-tests: General, Physics, Career, JD',
56
- modes: ['models', 'guardrails'] as Mode[],
57
- Icon: ({ color }: { color: string }) => (
58
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none" aria-hidden>
59
- <path d="M14 2L4 6v8c0 5.5 4 10.4 10 12 6-1.6 10-6.5 10-12V6L14 2z"
60
- stroke={color} strokeWidth="1.4" strokeLinejoin="round" fill="none" opacity="0.9" />
61
- <line x1="9" y1="14" x2="13" y2="18" stroke={color} strokeWidth="1.6" strokeLinecap="round" />
62
- <line x1="13" y1="18" x2="20" y2="10" stroke={color} strokeWidth="1.6" strokeLinecap="round" />
63
- </svg>
64
- ),
65
- },
66
- {
67
- num: '02',
68
- color: '#6366F1',
69
- colorDim: 'rgba(99,102,241,0.08)',
70
- title: 'RAG Knowledge Robustness',
71
- abbr: 'Robustness',
72
- tagline: "Does it know what it doesn't know?",
73
- guardrailDetail: null,
74
- modes: ['models'] as Mode[],
75
- Icon: ({ color }: { color: string }) => (
76
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none" aria-hidden>
77
- <circle cx="14" cy="14" r="3" stroke={color} strokeWidth="1.4" fill="none" />
78
- <circle cx="6" cy="7" r="2" stroke={color} strokeWidth="1.2" fill="none" />
79
- <circle cx="22" cy="7" r="2" stroke={color} strokeWidth="1.2" fill="none" />
80
- <circle cx="22" cy="21" r="2" stroke={color} strokeWidth="1.2" fill="none" />
81
- <line x1="14" y1="11" x2="8" y2="9" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
82
- <line x1="14" y1="11" x2="20" y2="9" stroke={color} strokeWidth="1.2" strokeLinecap="round" />
83
- <line x1="14" y1="17" x2="20" y2="19" stroke={color} strokeWidth="1.2" strokeLinecap="round" strokeDasharray="2.5 2" opacity="0.5" />
84
- <circle cx="6" cy="22" r="2" stroke={color} strokeWidth="1.2" fill="none" opacity="0.3" />
85
- <line x1="14" y1="17" x2="7" y2="21" stroke={color} strokeWidth="1.2" strokeLinecap="round" strokeDasharray="2.5 2" opacity="0.25" />
86
- </svg>
87
- ),
88
- },
89
- {
90
- num: '03',
91
- color: '#BA2FA2',
92
- colorDim: 'rgba(186,47,162,0.10)',
93
- title: 'Demographic Fairness',
94
- abbr: 'Fairness Disparity',
95
- tagline: 'Does it produce equitable outputs for all users?',
96
- guardrailDetail: null,
97
- modes: ['models'] as Mode[],
98
- Icon: ({ color }: { color: string }) => (
99
- <svg width="22" height="22" viewBox="0 0 28 28" fill="none" aria-hidden>
100
- <line x1="14" y1="4" x2="14" y2="24" stroke={color} strokeWidth="1.3" strokeLinecap="round" opacity="0.6" />
101
- <line x1="5" y1="11" x2="23" y2="11" stroke={color} strokeWidth="1.5" strokeLinecap="round" />
102
- <path d="M5 11 Q5 18 9 18 Q13 18 13 11" stroke={color} strokeWidth="1.2" fill="none" />
103
- <path d="M15 11 Q15 18 19 18 Q23 18 23 11" stroke={color} strokeWidth="1.2" fill="none" />
104
- <line x1="10" y1="21" x2="18" y2="21" stroke={color} strokeWidth="1.2" strokeLinecap="round" opacity="0.5" />
105
- <line x1="11" y1="23" x2="17" y2="23" stroke={color} strokeWidth="1.2" strokeLinecap="round" opacity="0.3" />
106
- </svg>
107
- ),
108
- },
109
- ]
110
-
111
- export default function BenchmarkExplainer({ mode, onModeChange, options }: Props) {
112
- const visibleCriteria = CRITERIA.filter(c => c.modes.includes(mode))
113
-
114
- return (
115
- <section className="explainer-section" id="criteria">
116
- <div className="section-wrap" style={{ paddingBottom: '1.5rem' }}>
117
-
118
-
119
- {/* Mode switcher — full-width toggle */}
120
- <div className="mode-toggle-grid" style={{
121
- display: 'grid', gridTemplateColumns: '1fr 1fr',
122
- padding: 5,
123
- background: 'var(--bg-0)',
124
- border: '1.5px solid var(--border-1)',
125
- borderRadius: 14,
126
- marginBottom: '1.75rem',
127
- gap: 5,
128
- animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both',
129
- }}>
130
- {options.map(o => {
131
- const isActive = mode === o.key
132
- const { color, dim } = MODE_COLORS[o.key as Mode]
133
- const TabIcon = TAB_ICONS[o.key as Mode]
134
- return (
135
- <button
136
- key={o.key}
137
- className="mode-toggle-btn"
138
- onClick={() => onModeChange(o.key as Mode)}
139
- style={{
140
- position: 'relative',
141
- padding: '1.25rem 1.75rem',
142
- border: 'none',
143
- borderRadius: 10,
144
- background: isActive ? 'var(--bg-1)' : 'transparent',
145
- boxShadow: isActive ? '0 1px 4px rgba(0,0,0,0.08), 0 0 0 1px var(--border-1)' : 'none',
146
- cursor: 'pointer',
147
- textAlign: 'left',
148
- transition: 'background 0.25s, box-shadow 0.25s',
149
- display: 'block',
150
- }}
151
- >
152
- {/* Top accent bar */}
153
- <div style={{
154
- position: 'absolute', top: 0, left: 12, right: 12, height: 3,
155
- background: color, opacity: isActive ? 1 : 0,
156
- borderRadius: '0 0 2px 2px',
157
- transition: 'opacity 0.25s',
158
- }} />
159
-
160
- {/* Label row */}
161
- <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
162
- <div style={{
163
- width: 36, height: 36, borderRadius: 8, flexShrink: 0,
164
- display: 'flex', alignItems: 'center', justifyContent: 'center',
165
- background: isActive ? dim : 'var(--bg-0)',
166
- border: `1.5px solid ${isActive ? color : 'var(--border-1)'}`,
167
- transition: 'all 0.2s',
168
- }}>
169
- <TabIcon color={isActive ? color : 'var(--border-2)'} />
170
- </div>
171
- <span style={{
172
- fontSize: 18, fontWeight: 900, letterSpacing: '-0.01em',
173
- color: isActive ? color : 'var(--text-2)',
174
- fontFamily: 'var(--font-lato), sans-serif',
175
- transition: 'color 0.2s',
176
- }}>{o.label}</span>
177
- </div>
178
-
179
- {/* Subtitle */}
180
- <p className="mode-toggle-subtitle" style={{
181
- fontSize: 13, color: isActive ? 'var(--text-2)' : 'var(--text-3)',
182
- margin: 0, lineHeight: 1.6, paddingLeft: 48,
183
- transition: 'color 0.2s',
184
- }}>{o.subtitle}</p>
185
- </button>
186
- )
187
- })}
188
- </div>
189
-
190
- {/* Criteria rows — keyed by mode to trigger transition */}
191
- <div key={mode} style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both' }}>
192
- <div style={{ display: 'flex', flexDirection: 'column', gap: 0, border: '1.5px solid var(--border-1)', borderRadius: 10, overflow: 'hidden', background: 'var(--bg-1)' }}>
193
- {visibleCriteria.map((c, i) => (
194
- <div
195
- key={c.num}
196
- style={{
197
- display: 'flex',
198
- alignItems: 'center',
199
- gap: '1rem',
200
- padding: '1.1rem 1.5rem',
201
- borderBottom: i < visibleCriteria.length - 1 ? '1px solid var(--border-0)' : 'none',
202
- }}
203
- >
204
- {/* Numbered icon badge */}
205
- <div style={{
206
- width: 40, height: 40, borderRadius: 8, flexShrink: 0,
207
- display: 'flex', alignItems: 'center', justifyContent: 'center',
208
- background: c.colorDim, position: 'relative',
209
- }}>
210
- <c.Icon color={c.color} />
211
- <div style={{
212
- position: 'absolute', top: -5, right: -5,
213
- fontSize: 9, fontWeight: 900, fontFamily: 'inherit',
214
- color: '#fff', background: c.color,
215
- minWidth: 16, height: 16, borderRadius: 4,
216
- display: 'flex', alignItems: 'center', justifyContent: 'center',
217
- padding: '0 3px', lineHeight: 1,
218
- }}>{c.num}</div>
219
- </div>
220
-
221
- {/* Content */}
222
- <div style={{ flex: 1, minWidth: 0 }}>
223
- <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
224
- <span style={{ fontSize: 15, fontWeight: 900, color: 'var(--text-0)' }}>{c.title}</span>
225
- <span style={{
226
- fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase',
227
- color: c.color, background: c.colorDim, border: `1px solid ${c.color}`,
228
- padding: '2px 7px', borderRadius: 4,
229
- }}>{c.abbr}</span>
230
- </div>
231
- <p style={{ fontSize: 13, color: 'var(--text-2)', marginTop: 3, fontStyle: 'italic' }}>
232
- &ldquo;{c.tagline}&rdquo;
233
- </p>
234
- {mode === 'guardrails' && c.guardrailDetail && (
235
- <p style={{ fontSize: 11, color: 'var(--text-3)', marginTop: 4, fontStyle: 'normal' }}>
236
- {c.guardrailDetail}
237
- </p>
238
- )}
239
- </div>
240
- </div>
241
- ))}
242
- </div>
243
-
244
- {/* CTA */}
245
- <div className="explainer-cta" style={{ animation: 'fadeUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) 0.3s both' }}>
246
- <span className="explainer-cta-text">Want to understand what the scores mean?</span>
247
- <a href="#about" className="explainer-cta-link">
248
- Our methodology
249
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
250
- <line x1="12" y1="5" x2="12" y2="19" />
251
- <polyline points="19 12 12 19 5 12" />
252
- </svg>
253
- </a>
254
- </div>
255
- </div>
256
- </div>
257
- </section>
258
- )
259
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/EvaluationShell.tsx DELETED
@@ -1,120 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import BenchmarkExplainer from '@/components/BenchmarkExplainer'
5
- import LeaderboardTable from '@/components/LeaderboardTable'
6
- import GuardrailsTable from '@/components/GuardrailsTable'
7
- import GuardrailDistribution from '@/components/GuardrailDistribution'
8
- import AboutSection from '@/components/AboutSection'
9
- import ScoreDistribution from '@/components/ScoreDistribution'
10
- import InsightsSection from '@/components/InsightsSection'
11
- import ScrollReveal from '@/components/ScrollReveal'
12
- import type { ModelData, GuardrailData, MetricThresholds, GuardrailThresholds } from '@/lib/types'
13
-
14
- type Mode = 'models' | 'guardrails'
15
-
16
- const TOGGLE_OPTIONS = [
17
- { key: 'models', label: 'LLM Models', subtitle: '' },
18
- { key: 'guardrails', label: 'Safety Guardrails', subtitle: '' },
19
- ]
20
-
21
- interface Props {
22
- models: ModelData[]
23
- guardrails: GuardrailData[]
24
- maxFairness: number
25
- thresholds: MetricThresholds
26
- guardrailThresholds: GuardrailThresholds
27
- modelCount: number
28
- guardrailCount: number
29
- }
30
-
31
- export default function EvaluationShell({ models, guardrails, maxFairness, thresholds, guardrailThresholds, modelCount, guardrailCount }: Props) {
32
- const [mode, setMode] = useState<Mode>('models')
33
-
34
- const options = TOGGLE_OPTIONS.map(o => ({
35
- ...o,
36
- subtitle: o.key === 'models'
37
- ? `Evaluating ${modelCount} models across 3 dimensions`
38
- : `Evaluating ${guardrailCount} guardrails on localised content safety`,
39
- }))
40
-
41
- return (
42
- <>
43
- <BenchmarkExplainer mode={mode} onModeChange={setMode} options={options} />
44
-
45
- <section id="leaderboard" style={{ paddingTop: '1.5rem' }}>
46
- <div className="section-wrap" style={{ paddingTop: 0, paddingBottom: '1rem' }}>
47
- <ScrollReveal>
48
- <h2 className="section-title">Results</h2>
49
- <p style={{ fontSize: 15, color: 'var(--text-2)', marginTop: '0.4rem' }}>
50
- {mode === 'models'
51
- ? 'Score distribution across evaluation dimensions.'
52
- : 'Score distribution across detection metrics.'}
53
- </p>
54
- </ScrollReveal>
55
- </div>
56
-
57
- {mode === 'models' && (
58
- <div className="section-wrap" style={{ paddingTop: 0, paddingBottom: '1.5rem' }}>
59
- <ScrollReveal className="reveal-delay-1">
60
- <div className="insight-card">
61
- <div className="insight-card-heading">Where models cluster</div>
62
- <p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.65, marginBottom: '1.5rem', maxWidth: '64ch' }}>
63
- Each dot is one active model, coloured by lab. The dashed line marks the field average.
64
- Fairness is inverted: dots further right are more equitable.
65
- </p>
66
- <ScoreDistribution models={models} maxFairness={maxFairness} />
67
- </div>
68
- </ScrollReveal>
69
- </div>
70
- )}
71
-
72
- {mode === 'guardrails' && (
73
- <div className="section-wrap" style={{ paddingTop: 0, paddingBottom: '1.5rem' }}>
74
- <ScrollReveal className="reveal-delay-1">
75
- <div className="insight-card">
76
- <div className="insight-card-heading">Where guardrails cluster</div>
77
- <p style={{ fontSize: 13, color: 'var(--text-2)', lineHeight: 1.65, marginBottom: '1.5rem', maxWidth: '64ch' }}>
78
- Each dot is one guardrail, coloured by provider. The dashed line marks the field average.
79
- All metrics: higher is better.
80
- </p>
81
- <GuardrailDistribution guardrails={guardrails} />
82
- </div>
83
- </ScrollReveal>
84
- </div>
85
- )}
86
-
87
- </section>
88
-
89
- <section style={{ borderTop: '1px solid var(--border-0)' }}>
90
- <AboutSection mode={mode} thresholds={thresholds} guardrailThresholds={guardrailThresholds} />
91
- </section>
92
-
93
- <section style={{ borderTop: '1px solid var(--border-0)' }}>
94
- <div className="section-wrap" style={{ paddingTop: '1.5rem', paddingBottom: '1.5rem' }}>
95
- <ScrollReveal>
96
- <h2 className="section-title">Leaderboard</h2>
97
- <p style={{
98
- marginTop: '0.5rem',
99
- marginBottom: '1.25rem',
100
- fontSize: 13,
101
- color: 'var(--text-2)',
102
- maxWidth: '64ch',
103
- lineHeight: 1.7,
104
- }}>
105
- {mode === 'models'
106
- ? 'Click any row to expand the full metric breakdown. Click a creator badge to filter. Fairness: ↓ lower = more equitable.'
107
- : 'Click any row to expand the full metric breakdown. Click a provider badge to filter. Higher scores = better detection.'}
108
- </p>
109
- </ScrollReveal>
110
- {mode === 'models'
111
- ? <LeaderboardTable models={models} maxFairness={maxFairness} />
112
- : <GuardrailsTable guardrails={guardrails} />
113
- }
114
- </div>
115
- </section>
116
-
117
- <InsightsSection models={models} guardrails={guardrails} maxFairness={maxFairness} mode={mode} />
118
- </>
119
- )
120
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/GuardrailBarChart.tsx DELETED
@@ -1,224 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useMemo } from 'react'
4
- import type { GuardrailData } from '@/lib/types'
5
- import { guardrailCreatorColor } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
-
8
- type Metric = 'recall' | 'precision' | 'f1'
9
-
10
- const TABS: { key: Metric; label: string; yLabel: string }[] = [
11
- { key: 'recall', label: 'Recall', yLabel: 'Score (Higher is Better)' },
12
- { key: 'precision', label: 'Precision', yLabel: 'Score (Higher is Better)' },
13
- { key: 'f1', label: 'F1 Score', yLabel: 'Score (Higher is Better)' },
14
- ]
15
-
16
- const ML = 80
17
- const MR = 80
18
- const MT = 14
19
- const BAR_H = 300
20
- const LBL_H = 150
21
- const VW = 1100
22
- const VH = MT + BAR_H + LBL_H
23
- const CHART_W = VW - ML - MR
24
- const GAP = 4
25
-
26
- const GRID = [0, 0.25, 0.5, 0.75, 1.0]
27
-
28
- export default function GuardrailBarChart({ guardrails }: { guardrails: GuardrailData[] }) {
29
- const [metric, setMetric] = useState<Metric>('f1')
30
- const [activeCreators, setCreators] = useState<Set<string>>(new Set())
31
-
32
- const allCreators = useMemo(
33
- () => [...new Set(guardrails.map(g => g.creator))].sort(),
34
- [guardrails],
35
- )
36
-
37
- const toggleCreator = (c: string) =>
38
- setCreators(prev => { const s = new Set(prev); s.has(c) ? s.delete(c) : s.add(c); return s })
39
-
40
- const sorted = useMemo(() => {
41
- let list = [...guardrails]
42
- if (activeCreators.size) list = list.filter(g => activeCreators.has(g.creator))
43
- list = list.filter(g => g[metric] !== null)
44
- return list.sort((a, b) => (b[metric] ?? 0) - (a[metric] ?? 0))
45
- }, [guardrails, metric, activeCreators])
46
-
47
- const n = sorted.length
48
- const bw = n > 0 ? (CHART_W - (n - 1) * GAP) / n : 0
49
-
50
- const legendCreators = useMemo(
51
- () => [...new Set(sorted.map(g => g.creator))].sort(),
52
- [sorted],
53
- )
54
-
55
- const tab = TABS.find(t => t.key === metric)!
56
-
57
- return (
58
- <div>
59
- {/* Filters row */}
60
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', marginBottom: '1.25rem', flexWrap: 'wrap' }}>
61
- {/* Creator chips */}
62
- <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
63
- <span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase' as const, color: 'var(--text-3)', marginRight: 2 }}>Provider</span>
64
- {allCreators.map(c => {
65
- const cc = guardrailCreatorColor(c)
66
- const on = activeCreators.has(c)
67
- return (
68
- <button key={c} onClick={() => toggleCreator(c)} style={{
69
- height: 30, padding: '0 10px',
70
- border: `1.5px solid ${on ? cc : 'var(--border-1)'}`,
71
- borderRadius: 6, fontSize: 11, fontFamily: 'inherit', fontWeight: 700,
72
- color: on ? cc : 'var(--text-2)',
73
- background: on ? `${cc}12` : 'var(--bg-1)',
74
- cursor: 'pointer', whiteSpace: 'nowrap' as const,
75
- display: 'inline-flex', alignItems: 'center', gap: 5,
76
- }}>
77
- <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}>
78
- <LabLogo creator={c} />
79
- </span>
80
- {c}
81
- </button>
82
- )
83
- })}
84
- </div>
85
-
86
- {/* Metric tabs */}
87
- <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden', flexShrink: 0 }}>
88
- {TABS.map((t, i) => (
89
- <button key={t.key} onClick={() => setMetric(t.key)} style={{
90
- height: 30, padding: '0 13px',
91
- borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
92
- outline: 'none', border: i > 0 ? '1px solid var(--border-1)' : 'none',
93
- borderTop: 'none', borderRight: 'none', borderBottom: 'none',
94
- fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
95
- letterSpacing: '0.05em', textTransform: 'uppercase' as const,
96
- color: metric === t.key ? 'white' : 'var(--text-2)',
97
- background: metric === t.key ? 'var(--accent)' : 'transparent',
98
- cursor: 'pointer', whiteSpace: 'nowrap' as const,
99
- transition: 'background 0.12s, color 0.12s',
100
- }}>
101
- {t.label}
102
- </button>
103
- ))}
104
- </div>
105
- </div>
106
-
107
- {/* Chart */}
108
- <div style={{
109
- background: 'var(--bg-1)',
110
- border: '1.5px solid var(--border-1)',
111
- borderRadius: 10,
112
- padding: '1rem 1rem 0',
113
- boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
114
- }}>
115
- <svg
116
- viewBox={`0 0 ${VW} ${VH}`}
117
- style={{ width: '100%', height: 'auto', display: 'block' }}
118
- aria-label={`Bar chart: ${tab.label}`}
119
- >
120
- {/* Grid lines + y-axis labels */}
121
- {GRID.map(lvl => {
122
- const y = MT + BAR_H - lvl * BAR_H
123
- return (
124
- <g key={lvl}>
125
- <line
126
- x1={ML} y1={y} x2={VW - MR} y2={y}
127
- stroke={lvl === 0 ? 'var(--border-2)' : 'var(--border-0)'}
128
- strokeWidth={lvl === 0 ? 1 : 0.8}
129
- strokeDasharray={lvl === 0 || lvl === 1 ? undefined : '4 4'}
130
- />
131
- <text
132
- x={ML - 5} y={y + 3.5}
133
- textAnchor="end"
134
- fontSize={9}
135
- fill="var(--text-3)"
136
- fontFamily="inherit"
137
- >
138
- {`${Math.round(lvl * 100)}`}
139
- </text>
140
- </g>
141
- )
142
- })}
143
-
144
- {/* Y-axis title */}
145
- <text
146
- x={11}
147
- y={MT + BAR_H / 2}
148
- textAnchor="middle"
149
- fontSize={8.5}
150
- fill="var(--text-3)"
151
- fontFamily="inherit"
152
- transform={`rotate(-90, 11, ${MT + BAR_H / 2})`}
153
- >
154
- {tab.yLabel}
155
- </text>
156
-
157
- {/* Bars */}
158
- {sorted.map((g, i) => {
159
- const val = g[metric]!
160
- const barHeight = val * BAR_H
161
- const x = ML + i * (bw + GAP)
162
- const y = MT + BAR_H - barHeight
163
- const cc = guardrailCreatorColor(g.creator)
164
-
165
- return (
166
- <g key={`${g.rank}-${metric}`}>
167
- <rect
168
- x={x} y={y}
169
- width={bw} height={barHeight}
170
- fill={cc} fillOpacity={0.88}
171
- rx={2}
172
- />
173
-
174
- {barHeight > 18 && (
175
- <text
176
- x={x + bw / 2} y={y - 4}
177
- textAnchor="middle"
178
- fontSize={8}
179
- fill={cc}
180
- fontFamily="inherit"
181
- fontWeight="bold"
182
- >
183
- {`${(val * 100).toFixed(0)}%`}
184
- </text>
185
- )}
186
-
187
- <text
188
- x={x + bw / 2}
189
- y={MT + BAR_H + 5}
190
- transform={`rotate(-45, ${x + bw / 2}, ${MT + BAR_H + 5})`}
191
- textAnchor="end"
192
- fontSize={10}
193
- fill="var(--text-1)"
194
- fontFamily="inherit"
195
- fontWeight="600"
196
- >
197
- {g.guardrail}
198
- </text>
199
- </g>
200
- )
201
- })}
202
- </svg>
203
-
204
- {/* Legend */}
205
- <div style={{
206
- display: 'flex',
207
- flexWrap: 'wrap',
208
- gap: '8px 20px',
209
- justifyContent: 'center',
210
- padding: '12px 8px',
211
- borderTop: '1px solid var(--border-0)',
212
- marginTop: 4,
213
- }}>
214
- {legendCreators.map(c => (
215
- <div key={c} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: 'var(--text-2)', fontWeight: 600 }}>
216
- <div style={{ width: 11, height: 11, borderRadius: 2, background: guardrailCreatorColor(c), flexShrink: 0 }} />
217
- {c}
218
- </div>
219
- ))}
220
- </div>
221
- </div>
222
- </div>
223
- )
224
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/GuardrailDistribution.tsx DELETED
@@ -1,219 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import type { GuardrailData } from '@/lib/types'
5
- import { LabLogo } from '@/components/LabLogo'
6
- import { guardrailCreatorColor } from '@/lib/utils'
7
-
8
- const STRIP_ROWS = [
9
- { key: 'recall' as const, label: 'Recall', color: '#00C0F3', fmt: (v: number) => `${(v * 100).toFixed(0)}%` },
10
- { key: 'f1' as const, label: 'F1 Score', color: '#F4333D', fmt: (v: number) => `${(v * 100).toFixed(0)}%` },
11
- ]
12
-
13
- function getVal(g: GuardrailData, key: 'recall' | 'f1'): number | null {
14
- return g[key]
15
- }
16
-
17
- export default function GuardrailDistribution({ guardrails }: { guardrails: GuardrailData[] }) {
18
- const [tooltip, setTooltip] = useState<{
19
- x: number; y: number; guardrail: string; val: string; creator: string
20
- } | null>(null)
21
-
22
- const VW = 800
23
- const ROW_H = 120
24
- const LBL_W = 85
25
- const PAD_R = 85
26
- const AXIS_W = VW - LBL_W - PAD_R
27
- const DOT_R = 7
28
- const VH = STRIP_ROWS.length * ROW_H
29
- const TT_W = 200
30
- const TT_H = 36
31
- const VIOLIN_H = 40
32
- const VIOLIN_BW = AXIS_W * 0.07
33
-
34
- return (
35
- <div style={{ position: 'relative', width: '100%' }}>
36
- <svg
37
- viewBox={`0 0 ${VW} ${VH}`}
38
- style={{ width: '100%', height: 'auto', display: 'block' }}
39
- aria-label="Guardrail score distribution"
40
- onMouseLeave={() => setTooltip(null)}
41
- >
42
- {STRIP_ROWS.map((row, ri) => {
43
- const y0 = ri * ROW_H
44
- const yMid = y0 + ROW_H / 2
45
-
46
- const vals = guardrails
47
- .map(g => ({ val: getVal(g, row.key), creator: g.creator, guardrail: g.guardrail }))
48
- .filter(d => d.val !== null) as { val: number; creator: string; guardrail: string }[]
49
-
50
- const mean = vals.reduce((s, d) => s + d.val, 0) / vals.length
51
-
52
- const toX = (v: number) => LBL_W + (v) * AXIS_W
53
-
54
- const meanX = toX(mean)
55
-
56
- return (
57
- <g key={row.key}>
58
- {ri % 2 === 1 && (
59
- <rect x={0} y={y0} width={VW} height={ROW_H} fill="var(--bg-0)" fillOpacity={0.5} />
60
- )}
61
-
62
- {ri > 0 && (
63
- <line x1={0} y1={y0} x2={VW} y2={y0} stroke="var(--border-0)" strokeWidth={1} />
64
- )}
65
-
66
- <line x1={LBL_W} y1={yMid} x2={VW - PAD_R} y2={yMid}
67
- stroke="var(--border-1)" strokeWidth={1} />
68
-
69
- {/* Half violin */}
70
- {vals.length > 1 && (() => {
71
- const N = 90
72
- const sampleXs = Array.from({ length: N }, (_, i) => LBL_W + (i / (N - 1)) * AXIS_W)
73
- const densities = sampleXs.map(px =>
74
- vals.reduce((s, d) => s + Math.exp(-0.5 * ((px - toX(d.val)) / VIOLIN_BW) ** 2), 0) /
75
- (vals.length * VIOLIN_BW * Math.sqrt(2 * Math.PI))
76
- )
77
- const maxD = Math.max(...densities)
78
- if (maxD <= 0) return null
79
- const pts = sampleXs.map((px, i) =>
80
- `L${px.toFixed(1)},${(yMid - (densities[i] / maxD) * VIOLIN_H).toFixed(1)}`
81
- ).join(' ')
82
- const d = `M${LBL_W},${yMid} ${pts} L${VW - PAD_R},${yMid} Z`
83
- return (
84
- <path
85
- key="violin"
86
- d={d}
87
- fill="var(--text-3)"
88
- fillOpacity={0.15}
89
- stroke="var(--text-3)"
90
- strokeOpacity={0.45}
91
- strokeWidth={1.2}
92
- strokeLinejoin="round"
93
- />
94
- )
95
- })()}
96
-
97
- {/* Tick marks */}
98
- {[0, 0.25, 0.5, 0.75, 1].map(t => {
99
- const tx = LBL_W + t * AXIS_W
100
- return (
101
- <g key={t}>
102
- <line x1={tx} y1={yMid - 5} x2={tx} y2={yMid + 5}
103
- stroke="var(--border-2)" strokeWidth={1} />
104
- <text x={tx} y={y0 + ROW_H - 8}
105
- textAnchor="middle" fontSize={9} fill="var(--text-3)" fontFamily="inherit">
106
- {`${Math.round(t * 100)}%`}
107
- </text>
108
- </g>
109
- )
110
- })}
111
-
112
- {/* Mean line */}
113
- <line x1={meanX} y1={y0 + 10} x2={meanX} y2={y0 + ROW_H - 20}
114
- stroke="var(--text-3)" strokeOpacity={0.9} strokeWidth={1.2} strokeDasharray="4 3" />
115
- <text x={meanX + 4} y={y0 + 20} fontSize={8} fill="var(--text-3)" fillOpacity={0.9} fontFamily="inherit">
116
- avg {row.fmt(mean)}
117
- </text>
118
-
119
- {/* Dots */}
120
- {[...vals].sort((a, b) => {
121
- const aH = tooltip?.guardrail === a.guardrail ? 1 : 0
122
- const bH = tooltip?.guardrail === b.guardrail ? 1 : 0
123
- return aH - bH
124
- }).map((d) => {
125
- const isHovered = tooltip?.guardrail === d.guardrail
126
- const dimmed = tooltip !== null && !isHovered
127
- const cx = toX(d.val)
128
- return (
129
- <g key={d.guardrail}>
130
- <circle
131
- cx={cx}
132
- cy={yMid}
133
- r={isHovered ? 20 : DOT_R}
134
- fill={isHovered ? 'var(--bg-1)' : guardrailCreatorColor(d.creator)}
135
- fillOpacity={dimmed ? 0.35 : 1}
136
- stroke={isHovered ? guardrailCreatorColor(d.creator) : 'var(--bg-1)'}
137
- strokeWidth={isHovered ? 2.5 : 1.2}
138
- style={{
139
- cursor: 'pointer',
140
- transition: 'r 0.15s, fill-opacity 0.15s',
141
- filter: dimmed ? 'saturate(0.15)' : undefined,
142
- }}
143
- onMouseEnter={() => setTooltip({
144
- x: cx,
145
- y: yMid,
146
- guardrail: d.guardrail,
147
- val: row.fmt(d.val),
148
- creator: d.creator,
149
- })}
150
- />
151
- </g>
152
- )
153
- })}
154
-
155
- {/* Row label */}
156
- <text x={LBL_W - 16} y={yMid + 5}
157
- textAnchor="end" fontSize={12} fontWeight="700"
158
- fill="var(--text-1)" fontFamily="inherit">
159
- {row.label}
160
- </text>
161
-
162
- </g>
163
- )
164
- })}
165
-
166
- {/* Tooltip */}
167
- {tooltip && (() => {
168
- const ttX = Math.max(0, Math.min(tooltip.x - TT_W / 2, VW - TT_W))
169
- const above = tooltip.y - DOT_R - TT_H - 8
170
- const ttY = above < 2 ? tooltip.y + DOT_R + 6 : above
171
- const cc = guardrailCreatorColor(tooltip.creator)
172
- return (
173
- <g style={{ pointerEvents: 'none' }}>
174
- <rect x={ttX} y={ttY} width={TT_W} height={TT_H} rx={5}
175
- fill="var(--bg-1)" stroke="var(--border-2)" strokeWidth={1.2} />
176
- <rect x={ttX} y={ttY} width={3} height={TT_H} rx={2} fill={cc} />
177
- <text x={ttX + 10} y={ttY + 13}
178
- fontSize={10} fontWeight="700" fill="var(--text-0)" fontFamily="inherit">
179
- {tooltip.guardrail}
180
- </text>
181
- <text x={ttX + 10} y={ttY + 27}
182
- fontSize={9} fill={cc} fontFamily="inherit">
183
- {tooltip.val}
184
- </text>
185
- </g>
186
- )
187
- })()}
188
- </svg>
189
-
190
- {/* Logo overlays — one per row for the hovered guardrail */}
191
- {tooltip && STRIP_ROWS.map((row, ri) => {
192
- const g = guardrails.find(g => g.guardrail === tooltip.guardrail)
193
- if (!g) return null
194
- const val = getVal(g, row.key)
195
- if (val === null) return null
196
- const cx = LBL_W + val * AXIS_W
197
- const yMid = ri * ROW_H + ROW_H / 2
198
- const pctX = cx / VW * 100
199
- const pctY = yMid / VH * 100
200
- return (
201
- <div key={row.key} style={{
202
- position: 'absolute',
203
- left: `${pctX}%`,
204
- top: `${pctY}%`,
205
- transform: 'translate(-50%, -50%)',
206
- width: 40,
207
- height: 40,
208
- display: 'flex',
209
- alignItems: 'center',
210
- justifyContent: 'center',
211
- pointerEvents: 'none',
212
- }}>
213
- <LabLogo creator={tooltip.creator} size={32} />
214
- </div>
215
- )
216
- })}
217
- </div>
218
- )
219
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/GuardrailsTable.tsx DELETED
@@ -1,258 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useMemo, useCallback } from 'react'
4
- import type { GuardrailData } from '@/lib/types'
5
- import { fmtPct, guardrailCreatorColor } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
-
8
- type SortKey = 'rank' | 'recall' | 'precision' | 'f1'
9
-
10
- interface Props {
11
- guardrails: GuardrailData[]
12
- }
13
-
14
- function scoreColor(v: number | null): string {
15
- if (v === null) return 'var(--text-3)'
16
- if (v >= 0.9) return '#22C55E'
17
- if (v >= 0.75) return '#4ADE80'
18
- if (v >= 0.6) return '#F0A030'
19
- return '#F4333D'
20
- }
21
-
22
- function ScoreCell({
23
- value,
24
- color,
25
- format,
26
- }: {
27
- value: number | null
28
- color: string
29
- format: (v: number | null) => string
30
- }) {
31
- const barWidth = value === null ? 0 : value
32
-
33
- return (
34
- <td className="score-cell">
35
- {value === null ? (
36
- <span className="score-null">-</span>
37
- ) : (
38
- <>
39
- <div className="score-val" style={{ color }}>{format(value)}</div>
40
- <div className="score-bar-track">
41
- <div
42
- className="score-bar-fill"
43
- style={{ width: `${barWidth * 100}%`, background: color }}
44
- />
45
- </div>
46
- </>
47
- )}
48
- </td>
49
- )
50
- }
51
-
52
- function ExpandedRow({ guardrail }: { guardrail: GuardrailData }) {
53
- return (
54
- <tr className="lb-expand-row">
55
- <td colSpan={7}>
56
- <div style={{ display: 'flex', alignItems: 'center', gap: '2rem', padding: '0.75rem 1.5rem' }}>
57
- <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
58
- Detections: <span style={{ fontWeight: 700, color: 'var(--text-1)', fontFamily: 'inherit', fontVariantNumeric: 'tabular-nums' }}>{guardrail.detections}</span>
59
- </span>
60
- <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
61
- Last Run: <span style={{ color: 'var(--text-2)' }}>{guardrail.lastRun}</span>
62
- </span>
63
- </div>
64
- </td>
65
- </tr>
66
- )
67
- }
68
-
69
- export default function GuardrailsTable({ guardrails }: Props) {
70
- const [sortKey, setSortKey] = useState<SortKey>('recall')
71
- const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
72
- const [query, setQuery] = useState('')
73
- const [expandedRank, setExpandedRank] = useState<number | null>(null)
74
- const [activeCreator, setActiveCreator] = useState<string | null>(null)
75
-
76
- const creators = useMemo(() => [...new Set(guardrails.map(g => g.creator))].sort(), [guardrails])
77
-
78
- const handleSort = useCallback((key: SortKey) => {
79
- if (sortKey === key) {
80
- setSortDir(d => (d === 'asc' ? 'desc' : 'asc'))
81
- } else {
82
- setSortKey(key)
83
- setSortDir('desc')
84
- }
85
- }, [sortKey])
86
-
87
- const sorted = useMemo(() => {
88
- let list = [...guardrails]
89
-
90
- if (activeCreator) list = list.filter(g => g.creator === activeCreator)
91
- if (query.trim()) {
92
- const q = query.toLowerCase()
93
- list = list.filter(
94
- g => g.guardrail.toLowerCase().includes(q) || g.creator.toLowerCase().includes(q),
95
- )
96
- }
97
-
98
- list.sort((a, b) => {
99
- let va: number | null, vb: number | null
100
- if (sortKey === 'rank') { va = a.rank; vb = b.rank }
101
- else if (sortKey === 'recall') { va = a.recall; vb = b.recall }
102
- else if (sortKey === 'precision') { va = a.precision; vb = b.precision }
103
- else { va = a.f1; vb = b.f1 }
104
-
105
- if (va === null && vb === null) return 0
106
- if (va === null) return 1
107
- if (vb === null) return -1
108
- return sortDir === 'asc' ? va - vb : vb - va
109
- })
110
-
111
- return list
112
- }, [guardrails, activeCreator, query, sortKey, sortDir])
113
-
114
- function SortArrow({ k }: { k: SortKey }) {
115
- if (sortKey !== k) return <span style={{ color: 'var(--border-2)', marginLeft: 4 }}>↕</span>
116
- return <span style={{ color: 'var(--accent)', marginLeft: 4 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>
117
- }
118
-
119
- return (
120
- <div>
121
- {/* Controls */}
122
- <div className="lb-controls">
123
- <div className="lb-search-wrap">
124
- <svg className="lb-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
125
- <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
126
- </svg>
127
- <input
128
- className="lb-search"
129
- placeholder="Search guardrails..."
130
- value={query}
131
- onChange={e => setQuery(e.target.value)}
132
- />
133
- </div>
134
-
135
- <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
136
- {creators.map(c => {
137
- const cc = guardrailCreatorColor(c)
138
- return (
139
- <button
140
- key={c}
141
- className={`filter-chip ${activeCreator === c ? 'active' : ''}`}
142
- style={activeCreator === c ? {
143
- borderColor: cc,
144
- color: cc,
145
- background: `${cc}12`,
146
- } : {}}
147
- onClick={() => setActiveCreator(prev => prev === c ? null : c)}
148
- >
149
- <span className="filter-chip-logo"><LabLogo creator={c} /></span>
150
- {c}
151
- </button>
152
- )
153
- })}
154
- </div>
155
- </div>
156
-
157
- {/* Table */}
158
- <div className="lb-table-wrap">
159
- <table className="lb-table">
160
- <thead>
161
- <tr>
162
- <th onClick={() => handleSort('rank')} className={sortKey === 'rank' ? 'sort-active' : ''} style={{ width: 48 }}>
163
- # <SortArrow k="rank" />
164
- </th>
165
- <th style={{ width: 130 }}>Creator</th>
166
- <th>Guardrail</th>
167
- <th onClick={() => handleSort('recall')} className={sortKey === 'recall' ? 'sort-active' : ''}>
168
- Recall <SortArrow k="recall" />
169
- </th>
170
- <th onClick={() => handleSort('precision')} className={sortKey === 'precision' ? 'sort-active' : ''}>
171
- Precision <SortArrow k="precision" />
172
- </th>
173
- <th onClick={() => handleSort('f1')} className={sortKey === 'f1' ? 'sort-active' : ''}>
174
- F1 <SortArrow k="f1" />
175
- </th>
176
- </tr>
177
- </thead>
178
- <tbody>
179
- {sorted.map((g, i) => {
180
- const isExpanded = expandedRank === g.rank
181
- const cc = guardrailCreatorColor(g.creator)
182
-
183
- return [
184
- <tr
185
- key={`row-${g.rank}`}
186
- className={isExpanded ? 'row-expanded' : ''}
187
- style={{ background: `${cc}08` }}
188
- >
189
- <td>
190
- <span className={`rank-cell ${i < 3 && !activeCreator && !query ? 'rank-top3' : ''}`}>
191
- {g.rank}
192
- </span>
193
- </td>
194
- <td>
195
- <button
196
- className="creator-badge"
197
- style={{ background: `${cc}18`, color: cc }}
198
- onClick={() => setActiveCreator(prev => prev === g.creator ? null : g.creator)}
199
- >
200
- <span className="creator-badge-logo">
201
- <LabLogo creator={g.creator} />
202
- </span>
203
- {g.creator}
204
- </button>
205
- </td>
206
- <td>
207
- <button
208
- className="model-name"
209
- onClick={() => setExpandedRank(isExpanded ? null : g.rank)}
210
- >
211
- <svg
212
- className={`expand-chevron ${isExpanded ? 'open' : ''}`}
213
- width="12"
214
- height="12"
215
- viewBox="0 0 24 24"
216
- fill="none"
217
- stroke="currentColor"
218
- strokeWidth="2.5"
219
- >
220
- <polyline points="9 18 15 12 9 6" />
221
- </svg>
222
- {g.guardrail}
223
- </button>
224
- </td>
225
- <ScoreCell
226
- value={g.recall}
227
- color={scoreColor(g.recall)}
228
- format={v => fmtPct(v)}
229
- />
230
- <ScoreCell
231
- value={g.precision}
232
- color={scoreColor(g.precision)}
233
- format={v => fmtPct(v)}
234
- />
235
- <ScoreCell
236
- value={g.f1}
237
- color={scoreColor(g.f1)}
238
- format={v => fmtPct(v)}
239
- />
240
- </tr>,
241
-
242
- isExpanded && (
243
- <ExpandedRow key={`expand-${g.rank}`} guardrail={g} />
244
- ),
245
- ]
246
- })}
247
- </tbody>
248
- </table>
249
- </div>
250
-
251
- <div style={{ marginTop: 12 }}>
252
- <span style={{ fontSize: 12, color: 'var(--text-3)' }}>
253
- {sorted.length} of {guardrails.length} guardrails
254
- </span>
255
- </div>
256
- </div>
257
- )
258
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/Hero.tsx DELETED
@@ -1,42 +0,0 @@
1
- import type { BenchmarkStats } from '@/lib/types'
2
-
3
- export default function Hero({ stats, guardrailCount }: { stats: BenchmarkStats; guardrailCount: number }) {
4
- return (
5
- <div className="page-intro">
6
- <div className="page-intro-inner">
7
- <h1 id="hero-title" style={{
8
- fontFamily: 'var(--font-lato), sans-serif',
9
- fontSize: 'clamp(1.6rem, 3.2vw, 2.4rem)',
10
- fontWeight: 900,
11
- lineHeight: 1.1,
12
- letterSpacing: '-0.02em',
13
- color: 'var(--text-0)',
14
- margin: 0,
15
- }}>
16
- Responsible AI Bench
17
- </h1>
18
- <div className="page-intro-row">
19
- <p className="page-intro-desc">
20
- Independent evaluation of large language models and safety guardrails on localised safety,
21
- RAG knowledge robustness, and demographic fairness —
22
- benchmarked for Singapore deployment contexts.
23
- </p>
24
- <div className="page-intro-stats">
25
- <div className="pi-stat">
26
- <span className="pi-num">{stats.totalModels}</span>
27
- <span className="pi-label">Models</span>
28
- </div>
29
- <div className="pi-stat">
30
- <span className="pi-num">{guardrailCount}</span>
31
- <span className="pi-label">Guardrails</span>
32
- </div>
33
- <div className="pi-stat">
34
- <span className="pi-num">{stats.lastUpdated}</span>
35
- <span className="pi-label">Last Run</span>
36
- </div>
37
- </div>
38
- </div>
39
- </div>
40
- </div>
41
- )
42
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/InsightsSection.tsx DELETED
@@ -1,385 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useRef, useState, useMemo } from 'react'
4
- import type { ModelData, GuardrailData } from '@/lib/types'
5
- import { creatorColor, inferModelSize } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
- import GuardrailBarChart from '@/components/GuardrailBarChart'
8
-
9
- const SIZE_COLORS = { Small: '#2BBFB3', Medium: '#E8892B', Large: '#9B72CF' } as const
10
- const SIZE_ORDER = { Small: 0, Medium: 1, Large: 2 } as const
11
- type ModelSize = keyof typeof SIZE_COLORS
12
-
13
- function getAvg(m: ModelData, key: 'luc' | 'rag' | 'fairness'): number | null {
14
- if (key === 'luc') return m.luc.avg
15
- if (key === 'rag') return m.rag.avg
16
- return m.fairness.avg
17
- }
18
-
19
-
20
- function LabSizeBreakdown({ models, maxFairness }: { models: ModelData[]; maxFairness: number }) {
21
- const active = useMemo(() => models.filter(m => !m.archived), [models])
22
- const allCreators = useMemo(() => [...new Set(active.map(m => m.creator))].sort(), [active])
23
-
24
- const [creator, setCreator] = useState<string>(() => allCreators[0] ?? '')
25
- const [metric, setMetric] = useState<'luc' | 'rag' | 'fairness'>('luc')
26
- const [hoveredModel, setHoveredModel] = useState<string | null>(null)
27
-
28
- const sel = allCreators.includes(creator) ? creator : (allCreators[0] ?? '')
29
-
30
- const rows = useMemo(() => {
31
- return active
32
- .filter(m => m.creator === sel && getAvg(m, metric) !== null)
33
- .map(m => ({ m, size: inferModelSize(m.model) as ModelSize }))
34
- .sort((a, b) => {
35
- const sd = SIZE_ORDER[a.size] - SIZE_ORDER[b.size]
36
- if (sd !== 0) return sd
37
- const va = getAvg(a.m, metric)!, vb = getAvg(b.m, metric)!
38
- return metric === 'fairness' ? va - vb : vb - va
39
- })
40
- }, [active, sel, metric])
41
-
42
- const groups = useMemo(() =>
43
- (['Small', 'Medium', 'Large'] as ModelSize[])
44
- .map(sz => ({ sz, items: rows.filter(r => r.size === sz) }))
45
- .filter(g => g.items.length > 0),
46
- [rows],
47
- )
48
-
49
- const maxVal = metric === 'fairness' ? maxFairness : 1
50
-
51
- const fieldAvg = (() => {
52
- const vals = active.filter(m => getAvg(m, metric) !== null).map(m => getAvg(m, metric)!)
53
- return vals.length > 0 ? vals.reduce((s, v) => s + v, 0) / vals.length : null
54
- })()
55
-
56
- const METRIC_LABELS: Record<'luc'|'rag'|'fairness', string> = {
57
- luc: 'Refusal Rate', rag: 'RAG Score', fairness: 'Fairness',
58
- }
59
-
60
- const fmtVal = (val: number) =>
61
- metric === 'fairness' ? val.toFixed(3) : `${(val * 100).toFixed(0)}%`
62
-
63
- const fieldAvgPct = fieldAvg !== null ? Math.min(1, fieldAvg / maxVal) : null
64
-
65
- const n = rows.length
66
- const ROW_H = 32
67
- const GROUP_HEADER_H = 28
68
- const GROUP_GAP = 12
69
-
70
- return (
71
- <div>
72
- {/* Controls */}
73
- <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
74
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
75
- {allCreators.map(c => {
76
- const on = c === sel
77
- return (
78
- <button key={c} onClick={() => setCreator(c)} style={{
79
- height: 26, padding: '0 10px',
80
- border: `1.5px solid ${on ? creatorColor(c) : 'var(--border-1)'}`,
81
- borderRadius: 5, fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
82
- color: on ? creatorColor(c) : 'var(--text-2)',
83
- background: on ? `${creatorColor(c)}18` : 'var(--bg-0)',
84
- cursor: 'pointer',
85
- transition: 'all 0.15s',
86
- display: 'inline-flex', alignItems: 'center', gap: 5,
87
- }}>
88
- <span style={{ display: 'inline-flex', alignItems: 'center', lineHeight: 0 }}>
89
- <LabLogo creator={c} size={14} />
90
- </span>
91
- {c}
92
- </button>
93
- )
94
- })}
95
- </div>
96
-
97
- <div style={{ display: 'inline-flex', border: '1.5px solid var(--border-1)', borderRadius: 7, overflow: 'hidden', flexShrink: 0 }}>
98
- {(['luc', 'rag', 'fairness'] as const).map((m, i) => {
99
- const on = m === metric
100
- return (
101
- <button key={m} onClick={() => setMetric(m)} style={{
102
- height: 28, padding: '0 13px',
103
- borderLeft: i > 0 ? '1px solid var(--border-1)' : 'none',
104
- outline: 'none', border: i > 0 ? '1px solid var(--border-1)' : 'none',
105
- borderTop: 'none', borderRight: 'none', borderBottom: 'none',
106
- fontSize: 10, fontFamily: 'inherit', fontWeight: 700,
107
- letterSpacing: '0.05em', textTransform: 'uppercase' as const,
108
- color: on ? 'white' : 'var(--text-2)',
109
- background: on ? 'var(--accent)' : 'transparent',
110
- cursor: 'pointer', whiteSpace: 'nowrap' as const,
111
- transition: 'background 0.12s, color 0.12s',
112
- }}>
113
- {METRIC_LABELS[m]}
114
- </button>
115
- )
116
- })}
117
- </div>
118
- </div>
119
-
120
- {n === 0 ? (
121
- <div style={{
122
- height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center',
123
- color: 'var(--text-3)', fontSize: 13,
124
- border: '1px dashed var(--border-1)', borderRadius: 8,
125
- }}>
126
- No data for {sel}
127
- </div>
128
- ) : (
129
- <div style={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
130
- {/* Axis scale */}
131
- <div style={{ display: 'flex', alignItems: 'center', marginBottom: 6, paddingLeft: 0 }}>
132
- <div className="insights-chart-name" style={{ width: 170, flexShrink: 0 }} />
133
- <div style={{ flex: 1, position: 'relative', height: 16 }}>
134
- {[0, 0.25, 0.5, 0.75, 1].map(t => (
135
- <span key={t} style={{
136
- position: 'absolute',
137
- left: `${t * 100}%`,
138
- transform: 'translateX(-50%)',
139
- fontSize: 9,
140
- fontFamily: 'inherit',
141
- fontVariantNumeric: 'tabular-nums',
142
- color: 'var(--text-3)',
143
- whiteSpace: 'nowrap',
144
- }}>
145
- {metric === 'fairness' ? (t * maxVal).toFixed(2) : `${Math.round(t * 100)}%`}
146
- </span>
147
- ))}
148
- </div>
149
- <div style={{ width: 56, flexShrink: 0 }} />
150
- </div>
151
-
152
- {/* Groups */}
153
- {groups.map((g, gi) => (
154
- <div key={g.sz} style={{ marginTop: gi > 0 ? GROUP_GAP : 0 }}>
155
- {/* Group header */}
156
- <div style={{
157
- height: GROUP_HEADER_H,
158
- display: 'flex',
159
- alignItems: 'center',
160
- gap: 8,
161
- marginBottom: 2,
162
- }}>
163
- <div style={{
164
- width: 6, height: 6, borderRadius: '50%',
165
- background: SIZE_COLORS[g.sz],
166
- flexShrink: 0,
167
- }} />
168
- <span style={{
169
- fontSize: 10,
170
- fontWeight: 700,
171
- letterSpacing: '0.08em',
172
- textTransform: 'uppercase' as const,
173
- color: SIZE_COLORS[g.sz],
174
- }}>
175
- {g.sz}
176
- </span>
177
- <span style={{
178
- fontSize: 10,
179
- color: 'var(--text-3)',
180
- fontFamily: 'inherit',
181
- fontVariantNumeric: 'tabular-nums',
182
- }}>
183
- {g.items.length} model{g.items.length !== 1 ? 's' : ''}
184
- </span>
185
- <div style={{ flex: 1, height: 1, background: 'var(--border-0)', marginLeft: 4 }} />
186
- </div>
187
-
188
- {/* Rows */}
189
- {g.items.map(r => {
190
- const val = getAvg(r.m, metric)!
191
- const pct = Math.min(1, val / maxVal)
192
- const isHovered = hoveredModel === r.m.model
193
- const cc = SIZE_COLORS[r.size]
194
-
195
- return (
196
- <div
197
- key={r.m.model}
198
- onMouseEnter={() => setHoveredModel(r.m.model)}
199
- onMouseLeave={() => setHoveredModel(null)}
200
- style={{
201
- display: 'flex',
202
- alignItems: 'center',
203
- height: ROW_H,
204
- gap: 0,
205
- borderRadius: 4,
206
- background: isHovered ? `${cc}0A` : 'transparent',
207
- transition: 'background 0.12s',
208
- cursor: 'default',
209
- }}
210
- >
211
- {/* Model name */}
212
- <div className="insights-chart-name" style={{
213
- width: 170,
214
- flexShrink: 0,
215
- paddingLeft: 20,
216
- paddingRight: 12,
217
- overflow: 'hidden',
218
- }}>
219
- <span style={{
220
- fontSize: 11.5,
221
- fontWeight: isHovered ? 700 : 500,
222
- color: isHovered ? 'var(--text-0)' : 'var(--text-1)',
223
- whiteSpace: 'nowrap',
224
- transition: 'color 0.12s, font-weight 0.12s',
225
- display: 'block',
226
- overflow: 'hidden',
227
- textOverflow: 'ellipsis',
228
- }}
229
- title={r.m.model}
230
- >
231
- {r.m.model}
232
- </span>
233
- </div>
234
-
235
- {/* Bar track */}
236
- <div style={{
237
- flex: 1,
238
- height: 10,
239
- background: 'var(--border-0)',
240
- borderRadius: 3,
241
- overflow: 'visible',
242
- position: 'relative',
243
- }}>
244
- {/* Field average marker */}
245
- {fieldAvgPct !== null && (
246
- <div style={{
247
- position: 'absolute',
248
- left: `${fieldAvgPct * 100}%`,
249
- top: -4,
250
- bottom: -4,
251
- width: 1,
252
- background: 'var(--text-3)',
253
- opacity: 0.5,
254
- zIndex: 1,
255
- }} />
256
- )}
257
-
258
- {/* Bar fill */}
259
- <div style={{
260
- width: `${pct * 100}%`,
261
- height: '100%',
262
- background: cc,
263
- borderRadius: 3,
264
- opacity: isHovered ? 1 : 0.78,
265
- transition: 'opacity 0.12s',
266
- position: 'relative',
267
- }} />
268
- </div>
269
-
270
- {/* Value */}
271
- <div style={{
272
- width: 56,
273
- flexShrink: 0,
274
- textAlign: 'right',
275
- paddingRight: 4,
276
- paddingLeft: 8,
277
- }}>
278
- <span style={{
279
- fontSize: 11,
280
- fontFamily: 'inherit',
281
- fontVariantNumeric: 'tabular-nums',
282
- fontWeight: 600,
283
- color: isHovered ? cc : 'var(--text-2)',
284
- transition: 'color 0.12s',
285
- }}>
286
- {fmtVal(val)}
287
- </span>
288
- </div>
289
- </div>
290
- )
291
- })}
292
- </div>
293
- ))}
294
-
295
- {/* Field average legend */}
296
- {fieldAvg !== null && (
297
- <div className="insights-chart-legend" style={{
298
- display: 'flex', alignItems: 'center', gap: 6,
299
- marginTop: 10, paddingLeft: 170,
300
- }}>
301
- <div style={{
302
- width: 16, height: 1,
303
- background: 'var(--text-3)', opacity: 0.5,
304
- }} />
305
- <span style={{
306
- fontSize: 9,
307
- fontFamily: 'inherit',
308
- fontVariantNumeric: 'tabular-nums',
309
- color: 'var(--text-3)',
310
- }}>
311
- field avg {fmtVal(fieldAvg)}
312
- </span>
313
- </div>
314
- )}
315
- </div>
316
- )}
317
- </div>
318
- )
319
- }
320
-
321
- interface Props {
322
- models: ModelData[]
323
- guardrails: GuardrailData[]
324
- maxFairness: number
325
- mode: 'models' | 'guardrails'
326
- }
327
-
328
- export default function InsightsSection({ models, guardrails, maxFairness, mode }: Props) {
329
- const ref = useRef<HTMLDivElement>(null)
330
-
331
- useEffect(() => {
332
- const obs = new IntersectionObserver(
333
- entries => {
334
- entries.forEach(e => {
335
- if (e.isIntersecting) { e.target.classList.add('visible'); obs.unobserve(e.target) }
336
- })
337
- },
338
- { threshold: 0.05, rootMargin: '0px 0px -60px 0px' },
339
- )
340
- ref.current?.querySelectorAll('.reveal').forEach(el => obs.observe(el))
341
- return () => obs.disconnect()
342
- }, [mode])
343
-
344
- return (
345
- <section
346
- id="insights"
347
- ref={ref}
348
- style={{ borderTop: '1px solid var(--border-0)' }}
349
- >
350
- <div className="section-wrap" style={{ paddingTop: '1.5rem', paddingBottom: '1.5rem' }}>
351
- <div className="reveal">
352
- <h2 className="section-title">Data in context</h2>
353
- </div>
354
-
355
- {mode === 'guardrails' && (
356
- <div className="insight-card reveal reveal-delay-1" style={{ marginTop: '2rem' }}>
357
- <div className="insight-card-title">Metric Comparison</div>
358
- <div className="insight-card-heading">How guardrails rank across metrics</div>
359
- <GuardrailBarChart guardrails={guardrails} />
360
- </div>
361
- )}
362
-
363
- {mode === 'models' && <div className="insight-card reveal reveal-delay-1" style={{ marginTop: '2rem' }}>
364
- <div className="insight-card-title">Lab × Size Breakdown</div>
365
- <div className="insight-card-heading" style={{ marginBottom: '0.75rem' }}>How size affects performance within each lab</div>
366
- <p style={{ fontSize: 15, color: 'var(--text-2)', lineHeight: 1.65, marginBottom: '0.5rem' }}>
367
- Models are grouped by approximate size: <strong style={{ color: SIZE_COLORS.Small }}>Small</strong> (≤&thinsp;10 B params),{' '}
368
- <strong style={{ color: SIZE_COLORS.Medium }}>Medium</strong> (11–40 B), and{' '}
369
- <strong style={{ color: SIZE_COLORS.Large }}>Large</strong> (40 B+).
370
- Commercial closed-weight models are classified by model-family tier
371
- (e.g. Haiku → Small, Sonnet/Flash → Medium, Opus/Pro → Large).
372
- </p>
373
- <p style={{ fontSize: 13, color: 'var(--text-3)', lineHeight: 1.55, marginBottom: '1.25rem', fontStyle: 'italic' }}>
374
- Size classifications are approximate and inherently subjective — exact
375
- parameter counts are not published for most commercial models.
376
- Categorisations are based on publicly available information and
377
- model-family naming conventions at the time of evaluation; they may not
378
- reflect the true underlying model size.
379
- </p>
380
- <LabSizeBreakdown models={models} maxFairness={maxFairness} />
381
- </div>}
382
- </div>
383
- </section>
384
- )
385
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/LabLogo.tsx DELETED
@@ -1,33 +0,0 @@
1
- 'use client'
2
-
3
- import dynamic from 'next/dynamic'
4
- import type { ComponentType } from 'react'
5
-
6
- interface IconProps {
7
- size?: number
8
- }
9
-
10
- /* Dynamic imports — each icon is 'use client' and tree-shaken */
11
- const icons: Record<string, ComponentType<IconProps>> = {
12
- Anthropic: dynamic(() => import('@lobehub/icons/es/Anthropic').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
13
- Google: dynamic(() => import('@lobehub/icons/es/Google').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
14
- OpenAI: dynamic(() => import('@lobehub/icons/es/OpenAI').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
15
- Meta: dynamic(() => import('@lobehub/icons/es/Meta').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
16
- xAI: dynamic(() => import('@lobehub/icons/es/XAI').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
17
- DeepSeek: dynamic(() => import('@lobehub/icons/es/DeepSeek').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
18
- Moonshot: dynamic(() => import('@lobehub/icons/es/Moonshot').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
19
- Alibaba: dynamic(() => import('@lobehub/icons/es/Alibaba').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
20
- 'MiniMax AI': dynamic(() => import('@lobehub/icons/es/Minimax').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
21
- zAI: dynamic(() => import('@lobehub/icons/es/Zhipu').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
22
- Microsoft: dynamic(() => import('@lobehub/icons/es/Microsoft').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
23
- Amazon: dynamic(() => import('@lobehub/icons/es/Aws').then(m => ({ default: m.default.Avatar as unknown as ComponentType<IconProps> }))),
24
- GovTech: dynamic(() => Promise.resolve({ default: ({ size = 18 }: IconProps) => (
25
- <img src="/govtech-icon.png" alt="GovTech" width={size} height={size} style={{ objectFit: 'contain', borderRadius: '50%' }} />
26
- )})),
27
- }
28
-
29
- export function LabLogo({ creator, size = 18 }: { creator: string; size?: number }) {
30
- const Icon = icons[creator]
31
- if (!Icon) return null
32
- return <Icon size={size} />
33
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/LeaderboardTable.tsx DELETED
@@ -1,349 +0,0 @@
1
- 'use client'
2
-
3
- import { useState, useMemo, useCallback } from 'react'
4
- import type { ModelData } from '@/lib/types'
5
- import { fmtPct, fmtNum, lucColor, ragColor, fairnessColor, creatorColor, CREATOR_COLORS } from '@/lib/utils'
6
- import { LabLogo } from '@/components/LabLogo'
7
-
8
- type SortKey = 'rank' | 'luc' | 'rag' | 'fairness'
9
-
10
- interface Props {
11
- models: ModelData[]
12
- maxFairness: number
13
- }
14
-
15
- function ScoreCell({
16
- value,
17
- color,
18
- max = 1,
19
- format,
20
- invert = false,
21
- }: {
22
- value: number | null
23
- color: string
24
- max?: number
25
- format: (v: number | null) => string
26
- invert?: boolean
27
- }) {
28
- const barWidth = value === null ? 0 : invert ? Math.max(0, 1 - value / max) : value / max
29
-
30
- return (
31
- <td className="score-cell">
32
- {value === null ? (
33
- <span className="score-null">—</span>
34
- ) : (
35
- <>
36
- <div className="score-val" style={{ color }}>{format(value)}</div>
37
- <div className="score-bar-track">
38
- <div
39
- className="score-bar-fill"
40
- style={{ width: `${barWidth * 100}%`, background: color }}
41
- />
42
- </div>
43
- </>
44
- )}
45
- </td>
46
- )
47
- }
48
-
49
- function ExpandedRow({ model, maxFairness }: { model: ModelData; maxFairness: number }) {
50
- const lucMetrics = [
51
- { label: 'General', val: model.luc.general, color: lucColor(model.luc.general) },
52
- { label: 'Physics misuse', val: model.luc.physics, color: lucColor(model.luc.physics) },
53
- { label: 'Career scams', val: model.luc.career, color: lucColor(model.luc.career) },
54
- { label: 'Job discrimination', val: model.luc.jd, color: lucColor(model.luc.jd) },
55
- ]
56
- const ragMetrics = [
57
- { label: 'Long In-Context Abstractive', val: model.rag.lcAbs, color: ragColor(model.rag.lcAbs) },
58
- { label: 'Long In-Context Factual', val: model.rag.lcFact, color: ragColor(model.rag.lcFact) },
59
- { label: 'HyDE RAG Abstractive', val: model.rag.hyAbs, color: ragColor(model.rag.hyAbs) },
60
- { label: 'HyDE RAG Factual', val: model.rag.hyFact, color: ragColor(model.rag.hyFact) },
61
- ]
62
- const fairMetrics = [
63
- { label: 'Style disparity', val: model.fairness.style, invert: true },
64
- { label: 'Content disparity', val: model.fairness.con, invert: true },
65
- ]
66
-
67
- return (
68
- <tr className="lb-expand-row">
69
- <td colSpan={6}>
70
- <div className="lb-expand-inner">
71
- {/* LUC */}
72
- <div>
73
- <div className="expand-group-title">Localised Undesired Content</div>
74
- {lucMetrics.map(m => (
75
- <div className="expand-metric" key={m.label}>
76
- <div className="expand-metric-label">{m.label}</div>
77
- <div className="expand-metric-row">
78
- <span className="expand-metric-val" style={{ color: m.color }}>
79
- {fmtPct(m.val)}
80
- </span>
81
- <div className="mini-bar-track">
82
- <div
83
- className="mini-bar-fill"
84
- style={{
85
- width: m.val !== null ? `${m.val * 100}%` : '0%',
86
- background: m.color,
87
- }}
88
- />
89
- </div>
90
- </div>
91
- </div>
92
- ))}
93
- </div>
94
-
95
- {/* RAG */}
96
- <div>
97
- <div className="expand-group-title">RAG Robustness</div>
98
- {ragMetrics.map(m => (
99
- <div className="expand-metric" key={m.label}>
100
- <div className="expand-metric-label">{m.label}</div>
101
- <div className="expand-metric-row">
102
- <span className="expand-metric-val" style={{ color: m.color }}>
103
- {fmtPct(m.val)}
104
- </span>
105
- <div className="mini-bar-track">
106
- <div
107
- className="mini-bar-fill"
108
- style={{
109
- width: m.val !== null ? `${m.val * 100}%` : '0%',
110
- background: m.color,
111
- }}
112
- />
113
- </div>
114
- </div>
115
- </div>
116
- ))}
117
- </div>
118
-
119
- {/* Fairness */}
120
- <div>
121
- <div className="expand-group-title">Fairness Disparity</div>
122
- {fairMetrics.map(m => {
123
- const fc = fairnessColor(m.val)
124
- const pct = m.val !== null ? Math.min(100, (m.val / maxFairness) * 100) : 0
125
- return (
126
- <div className="expand-metric" key={m.label}>
127
- <div className="expand-metric-label">{m.label}</div>
128
- <div className="expand-metric-row">
129
- <span className="expand-metric-val" style={{ color: fc }}>
130
- {fmtNum(m.val, 4)}
131
- </span>
132
- <div className="mini-bar-track">
133
- <div
134
- className="mini-bar-fill"
135
- style={{
136
- width: `${100 - pct}%`,
137
- background: fc,
138
- }}
139
- />
140
- </div>
141
- </div>
142
- </div>
143
- )
144
- })}
145
- </div>
146
- </div>
147
- </td>
148
- </tr>
149
- )
150
- }
151
-
152
- export default function LeaderboardTable({ models, maxFairness }: Props) {
153
- const [sortKey, setSortKey] = useState<SortKey>('luc')
154
- const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc')
155
- const [query, setQuery] = useState('')
156
- const [expandedRank, setExpandedRank] = useState<number | null>(null)
157
- const [activeCreator, setActiveCreator] = useState<string | null>(null)
158
-
159
- const creators = useMemo(() => [...new Set(models.map(m => m.creator))].sort(), [models])
160
-
161
- const handleSort = useCallback((key: SortKey) => {
162
- if (sortKey === key) {
163
- setSortDir(d => (d === 'asc' ? 'desc' : 'asc'))
164
- } else {
165
- setSortKey(key)
166
- setSortDir(key === 'fairness' ? 'asc' : 'desc')
167
- }
168
- }, [sortKey])
169
-
170
- const sortedFiltered = useMemo(() => {
171
- let list = models.filter(m => !m.archived)
172
-
173
- if (activeCreator) list = list.filter(m => m.creator === activeCreator)
174
- if (query.trim()) {
175
- const q = query.toLowerCase()
176
- list = list.filter(
177
- m => m.model.toLowerCase().includes(q) || m.creator.toLowerCase().includes(q),
178
- )
179
- }
180
-
181
- list.sort((a, b) => {
182
- let va: number | null, vb: number | null
183
- if (sortKey === 'rank') { va = a.rank; vb = b.rank }
184
- else if (sortKey === 'luc') { va = a.luc.avg; vb = b.luc.avg }
185
- else if (sortKey === 'rag') { va = a.rag.avg; vb = b.rag.avg }
186
- else { va = a.fairness.avg; vb = b.fairness.avg }
187
-
188
- if (va === null && vb === null) return 0
189
- if (va === null) return 1
190
- if (vb === null) return -1
191
-
192
- return sortDir === 'asc' ? va - vb : vb - va
193
- })
194
-
195
- return list
196
- }, [models, activeCreator, query, sortKey, sortDir])
197
-
198
- function SortArrow({ k }: { k: SortKey }) {
199
- if (sortKey !== k) return <span style={{ color: 'var(--border-2)', marginLeft: 4 }}>↕</span>
200
- return <span style={{ color: 'var(--accent)', marginLeft: 4 }}>{sortDir === 'asc' ? '↑' : '↓'}</span>
201
- }
202
-
203
- return (
204
- <div>
205
- {/* Controls */}
206
- <div className="lb-controls">
207
- <div className="lb-search-wrap">
208
- <svg className="lb-search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
209
- <circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
210
- </svg>
211
- <input
212
- className="lb-search"
213
- placeholder="Search models..."
214
- value={query}
215
- onChange={e => setQuery(e.target.value)}
216
- />
217
- </div>
218
-
219
- <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
220
- {creators.map(c => (
221
- <button
222
- key={c}
223
- className={`filter-chip ${activeCreator === c ? 'active' : ''}`}
224
- style={activeCreator === c ? {
225
- borderColor: creatorColor(c),
226
- color: creatorColor(c),
227
- background: `${creatorColor(c)}12`,
228
- } : {}}
229
- onClick={() => setActiveCreator(prev => prev === c ? null : c)}
230
- >
231
- <span className="filter-chip-logo"><LabLogo creator={c} /></span>
232
- {c}
233
- </button>
234
- ))}
235
- </div>
236
- </div>
237
-
238
- {/* Table */}
239
- <div className="lb-table-wrap">
240
- <table className="lb-table">
241
- <thead>
242
- <tr>
243
- <th onClick={() => handleSort('rank')} className={sortKey === 'rank' ? 'sort-active' : ''} style={{ width: 48 }}>
244
- # <SortArrow k="rank" />
245
- </th>
246
- <th style={{ width: 130 }}>Creator</th>
247
- <th>Model</th>
248
- <th onClick={() => handleSort('luc')} className={sortKey === 'luc' ? 'sort-active' : ''}>
249
- Refusal Rate <SortArrow k="luc" />
250
- </th>
251
- <th onClick={() => handleSort('rag')} className={sortKey === 'rag' ? 'sort-active' : ''}>
252
- RAG Score <SortArrow k="rag" />
253
- </th>
254
- <th onClick={() => handleSort('fairness')} className={sortKey === 'fairness' ? 'sort-active' : ''}>
255
- Fairness ↓ <SortArrow k="fairness" />
256
- </th>
257
- </tr>
258
- </thead>
259
- <tbody>
260
- {sortedFiltered.map((model, i) => {
261
- const isExpanded = expandedRank === model.rank
262
- const cc = creatorColor(model.creator)
263
-
264
- return [
265
- <tr
266
- key={`row-${model.rank}`}
267
- className={isExpanded ? 'row-expanded' : ''}
268
- style={{ background: `${cc}08` }}
269
- >
270
- <td>
271
- <span className={`rank-cell ${i < 3 && !activeCreator && !query ? 'rank-top3' : ''}`}>
272
- {i + 1}
273
- </span>
274
- </td>
275
- <td>
276
- <button
277
- className="creator-badge"
278
- style={{ background: `${cc}18`, color: cc }}
279
- onClick={() => setActiveCreator(prev => prev === model.creator ? null : model.creator)}
280
- >
281
- <span className="creator-badge-logo">
282
- <LabLogo creator={model.creator} />
283
- </span>
284
- {model.creator}
285
- </button>
286
- </td>
287
- <td>
288
- <button
289
- className="model-name"
290
- onClick={() => setExpandedRank(isExpanded ? null : model.rank)}
291
- >
292
- <svg
293
- className={`expand-chevron ${isExpanded ? 'open' : ''}`}
294
- width="12"
295
- height="12"
296
- viewBox="0 0 24 24"
297
- fill="none"
298
- stroke="currentColor"
299
- strokeWidth="2.5"
300
- >
301
- <polyline points="9 18 15 12 9 6" />
302
- </svg>
303
- {model.model}
304
- </button>
305
- </td>
306
- <ScoreCell
307
- value={model.luc.avg}
308
- color={lucColor(model.luc.avg)}
309
- format={v => fmtPct(v)}
310
- />
311
- <ScoreCell
312
- value={model.rag.avg}
313
- color={ragColor(model.rag.avg)}
314
- format={v => fmtPct(v)}
315
- />
316
- <ScoreCell
317
- value={model.fairness.avg}
318
- color={fairnessColor(model.fairness.avg)}
319
- max={maxFairness}
320
- format={v => fmtNum(v, 4)}
321
- invert
322
- />
323
- </tr>,
324
-
325
- isExpanded && (
326
- <ExpandedRow key={`expand-${model.rank}`} model={model} maxFairness={maxFairness} />
327
- ),
328
- ]
329
- })}
330
- </tbody>
331
- </table>
332
- </div>
333
-
334
- <div style={{
335
- marginTop: 12,
336
- display: 'flex',
337
- justifyContent: 'space-between',
338
- alignItems: 'center',
339
- }}>
340
- <span style={{
341
- fontSize: 12,
342
- color: 'var(--text-3)',
343
- }}>
344
- {sortedFiltered.length} models · ↓ lower fairness score = more equitable
345
- </span>
346
- </div>
347
- </div>
348
- )
349
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/Nav.tsx DELETED
@@ -1,127 +0,0 @@
1
- 'use client'
2
-
3
- import Image from 'next/image'
4
- import { useEffect, useState, useCallback } from 'react'
5
-
6
- const NAV_SECTIONS = ['criteria', 'leaderboard', 'about', 'insights'] as const
7
- const NAV_LABELS: Record<string, string> = {
8
- criteria: 'Overview',
9
- leaderboard: 'Results',
10
- about: 'Methodology',
11
- insights: 'Insights',
12
- }
13
-
14
- export default function Nav({ modelCount, lastUpdated }: { modelCount: number; lastUpdated: string }) {
15
- const [scrolled, setScrolled] = useState(false)
16
- const [showTitle, setShowTitle] = useState(false)
17
- const [activeSection, setActiveSection] = useState<string | null>(null)
18
- const [menuOpen, setMenuOpen] = useState(false)
19
-
20
- useEffect(() => {
21
- const update = () => {
22
- setScrolled(window.scrollY > 20)
23
-
24
- const heroTitle = document.getElementById('hero-title')
25
- if (heroTitle) {
26
- const rect = heroTitle.getBoundingClientRect()
27
- setShowTitle(rect.bottom < 0)
28
- }
29
-
30
- const scrollMid = window.scrollY + window.innerHeight * 0.35
31
- let current: string | null = null
32
- for (const id of NAV_SECTIONS) {
33
- const el = document.getElementById(id)
34
- if (el && el.offsetTop <= scrollMid) current = id
35
- }
36
- setActiveSection(current)
37
- }
38
-
39
- update()
40
- window.addEventListener('scroll', update, { passive: true })
41
- return () => window.removeEventListener('scroll', update)
42
- }, [])
43
-
44
- const closeMenu = useCallback(() => setMenuOpen(false), [])
45
-
46
- return (
47
- <>
48
- <nav
49
- className="nav-bar"
50
- style={{ boxShadow: scrolled ? '0 1px 8px rgba(0,0,0,0.08)' : 'none' }}
51
- >
52
- <div className="nav-logo">
53
- <Image
54
- src="/govtech-logo.jpg"
55
- alt="GovTech Singapore"
56
- height={26}
57
- width={104}
58
- style={{ objectFit: 'contain', objectPosition: 'left center', width: 'auto', height: 26 }}
59
- priority
60
- />
61
- <span
62
- className="nav-logo-sep"
63
- style={{
64
- opacity: showTitle ? 1 : 0,
65
- transition: 'opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
66
- }}
67
- />
68
- <span
69
- className="nav-logo-bench"
70
- style={{
71
- opacity: showTitle ? 1 : 0,
72
- transform: showTitle ? 'translateY(0)' : 'translateY(4px)',
73
- transition: 'opacity 0.35s cubic-bezier(0.16, 1, 0.3, 1), transform 0.35s cubic-bezier(0.16, 1, 0.3, 1)',
74
- }}
75
- >
76
- Responsible AI Bench
77
- </span>
78
- </div>
79
-
80
- <ul className="nav-links">
81
- {NAV_SECTIONS.map(id => (
82
- <li key={id}>
83
- <a href={`#${id}`} className={activeSection === id ? 'nav-active' : ''}>
84
- {NAV_LABELS[id]}
85
- </a>
86
- </li>
87
- ))}
88
- </ul>
89
-
90
- <button
91
- className="nav-hamburger"
92
- onClick={() => setMenuOpen(v => !v)}
93
- aria-label="Toggle navigation menu"
94
- aria-expanded={menuOpen}
95
- >
96
- <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
97
- {menuOpen ? (
98
- <>
99
- <line x1="6" y1="6" x2="18" y2="18" />
100
- <line x1="6" y1="18" x2="18" y2="6" />
101
- </>
102
- ) : (
103
- <>
104
- <line x1="4" y1="7" x2="20" y2="7" />
105
- <line x1="4" y1="12" x2="20" y2="12" />
106
- <line x1="4" y1="17" x2="20" y2="17" />
107
- </>
108
- )}
109
- </svg>
110
- </button>
111
- </nav>
112
-
113
- <div className={`nav-mobile-menu ${menuOpen ? 'open' : ''}`}>
114
- {NAV_SECTIONS.map(id => (
115
- <a
116
- key={id}
117
- href={`#${id}`}
118
- className={activeSection === id ? 'nav-active' : ''}
119
- onClick={closeMenu}
120
- >
121
- {NAV_LABELS[id]}
122
- </a>
123
- ))}
124
- </div>
125
- </>
126
- )
127
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/ScoreDistribution.tsx DELETED
@@ -1,234 +0,0 @@
1
- 'use client'
2
-
3
- import { useState } from 'react'
4
- import type { ModelData } from '@/lib/types'
5
- import { LabLogo } from '@/components/LabLogo'
6
- import { creatorColor } from '@/lib/utils'
7
-
8
- const STRIP_ROWS = [
9
- { key: 'luc' as const, label: 'Refusal Rate', invert: false, color: '#00C0F3', fmt: (v: number) => `${(v * 100).toFixed(0)}%` },
10
- { key: 'rag' as const, label: 'RAG Score', invert: false, color: '#F4333D', fmt: (v: number) => `${(v * 100).toFixed(0)}%` },
11
- { key: 'fairness' as const, label: 'Fairness', invert: true, color: '#BA2FA2', fmt: (v: number) => v.toFixed(3) },
12
- ]
13
-
14
- function getAvg(m: ModelData, key: 'luc' | 'rag' | 'fairness'): number | null {
15
- if (key === 'luc') return m.luc.avg
16
- if (key === 'rag') return m.rag.avg
17
- return m.fairness.avg
18
- }
19
-
20
- export default function ScoreDistribution({ models, maxFairness }: { models: ModelData[]; maxFairness: number }) {
21
- const active = models.filter(m => !m.archived)
22
- const [tooltip, setTooltip] = useState<{
23
- x: number; y: number; model: string; val: string; creator: string
24
- } | null>(null)
25
-
26
- const VW = 800
27
- const ROW_H = 120
28
- const LBL_W = 85
29
- const PAD_R = 85
30
- const AXIS_W = VW - LBL_W - PAD_R
31
- const DOT_R = 7
32
- const VH = STRIP_ROWS.length * ROW_H
33
- const TT_W = 168
34
- const TT_H = 36
35
- const VIOLIN_H = 40
36
- const VIOLIN_BW = AXIS_W * 0.07
37
-
38
- return (
39
- <div style={{ position: 'relative', width: '100%' }}>
40
- <svg
41
- viewBox={`0 0 ${VW} ${VH}`}
42
- style={{ width: '100%', height: 'auto', display: 'block' }}
43
- aria-label="Score distribution across all models"
44
- onMouseLeave={() => setTooltip(null)}
45
- >
46
- {STRIP_ROWS.map((row, ri) => {
47
- const y0 = ri * ROW_H
48
- const yMid = y0 + ROW_H / 2
49
-
50
- const vals = active
51
- .map(m => ({ val: getAvg(m, row.key), creator: m.creator, model: m.model }))
52
- .filter(d => d.val !== null) as { val: number; creator: string; model: string }[]
53
-
54
- const rawMax = row.key === 'fairness' ? maxFairness : 1
55
- const mean = vals.reduce((s, d) => s + d.val, 0) / vals.length
56
-
57
- const toX = (v: number) => {
58
- const pct = row.invert ? 1 - v / rawMax : v / rawMax
59
- return LBL_W + pct * AXIS_W
60
- }
61
- const meanX = toX(mean)
62
-
63
- return (
64
- <g key={row.key}>
65
- {/* Alternating row bg */}
66
- {ri % 2 === 1 && (
67
- <rect x={0} y={y0} width={VW} height={ROW_H} fill="var(--bg-0)" fillOpacity={0.5} />
68
- )}
69
-
70
- {/* Row divider */}
71
- {ri > 0 && (
72
- <line x1={0} y1={y0} x2={VW} y2={y0} stroke="var(--border-0)" strokeWidth={1} />
73
- )}
74
-
75
- {/* Axis line */}
76
- <line x1={LBL_W} y1={yMid} x2={VW - PAD_R} y2={yMid}
77
- stroke="var(--border-1)" strokeWidth={1} />
78
-
79
- {/* Half violin */}
80
- {vals.length > 1 && (() => {
81
- const N = 90
82
- const sampleXs = Array.from({ length: N }, (_, i) => LBL_W + (i / (N - 1)) * AXIS_W)
83
- const densities = sampleXs.map(px =>
84
- vals.reduce((s, d) => s + Math.exp(-0.5 * ((px - toX(d.val)) / VIOLIN_BW) ** 2), 0) /
85
- (vals.length * VIOLIN_BW * Math.sqrt(2 * Math.PI))
86
- )
87
- const maxD = Math.max(...densities)
88
- if (maxD <= 0) return null
89
- const pts = sampleXs.map((px, i) =>
90
- `L${px.toFixed(1)},${(yMid - (densities[i] / maxD) * VIOLIN_H).toFixed(1)}`
91
- ).join(' ')
92
- const d = `M${LBL_W},${yMid} ${pts} L${VW - PAD_R},${yMid} Z`
93
- return (
94
- <path
95
- key="violin"
96
- d={d}
97
- fill="var(--text-3)"
98
- fillOpacity={0.15}
99
- stroke="var(--text-3)"
100
- strokeOpacity={0.45}
101
- strokeWidth={1.2}
102
- strokeLinejoin="round"
103
- />
104
- )
105
- })()}
106
-
107
- {/* Tick marks + labels */}
108
- {[0, 0.25, 0.5, 0.75, 1].map(t => {
109
- const tx = LBL_W + t * AXIS_W
110
- const tickLabel = row.invert
111
- ? (t === 0 ? rawMax.toFixed(2) : t === 1 ? '0' : (rawMax * (1 - t)).toFixed(2))
112
- : `${Math.round(t * 100)}%`
113
- return (
114
- <g key={t}>
115
- <line x1={tx} y1={yMid - 5} x2={tx} y2={yMid + 5}
116
- stroke="var(--border-2)" strokeWidth={1} />
117
- <text x={tx} y={y0 + ROW_H - 8}
118
- textAnchor="middle" fontSize={9} fill="var(--text-3)" fontFamily="inherit">
119
- {tickLabel}
120
- </text>
121
- </g>
122
- )
123
- })}
124
-
125
- {/* Mean line */}
126
- <line x1={meanX} y1={y0 + 10} x2={meanX} y2={y0 + ROW_H - 20}
127
- stroke="var(--text-3)" strokeOpacity={0.9} strokeWidth={1.2} strokeDasharray="4 3" />
128
- <text x={meanX + 4} y={y0 + 20} fontSize={8} fill="var(--text-3)" fillOpacity={0.9} fontFamily="inherit">
129
- avg {row.fmt(mean)}
130
- </text>
131
-
132
- {/* Dots — render hovered dot last so it stays on top */}
133
- {[...vals].sort((a, b) => {
134
- const aH = tooltip?.model === a.model ? 1 : 0
135
- const bH = tooltip?.model === b.model ? 1 : 0
136
- return aH - bH
137
- }).map((d, i) => {
138
- const isHovered = tooltip?.model === d.model
139
- const dimmed = tooltip !== null && !isHovered
140
- const cx = toX(d.val)
141
- return (
142
- <g key={d.model}>
143
- <circle
144
- cx={cx}
145
- cy={yMid}
146
- r={isHovered ? 20 : DOT_R}
147
- fill={isHovered ? 'var(--bg-1)' : creatorColor(d.creator)}
148
- fillOpacity={dimmed ? 0.35 : 1}
149
- stroke={isHovered ? creatorColor(d.creator) : 'var(--bg-1)'}
150
- strokeWidth={isHovered ? 2.5 : 1.2}
151
- style={{
152
- cursor: 'pointer',
153
- transition: 'r 0.15s, fill-opacity 0.15s',
154
- filter: dimmed ? 'saturate(0.15)' : undefined,
155
- }}
156
- onMouseEnter={() => setTooltip({
157
- x: cx,
158
- y: yMid,
159
- model: d.model,
160
- val: row.fmt(d.val),
161
- creator: d.creator,
162
- })}
163
- />
164
- </g>
165
- )
166
- })}
167
-
168
- {/* Row label */}
169
- <text x={LBL_W - 16} y={yMid + 5}
170
- textAnchor="end" fontSize={12} fontWeight="700"
171
- fill="var(--text-1)" fontFamily="inherit">
172
- {row.label}
173
- </text>
174
-
175
- </g>
176
- )
177
- })}
178
-
179
- {/* Tooltip — rendered last so it appears above all dots */}
180
- {tooltip && (() => {
181
- const ttX = Math.max(0, Math.min(tooltip.x - TT_W / 2, VW - TT_W))
182
- const above = tooltip.y - DOT_R - TT_H - 8
183
- const ttY = above < 2 ? tooltip.y + DOT_R + 6 : above
184
- const cc = creatorColor(tooltip.creator)
185
- return (
186
- <g style={{ pointerEvents: 'none' }}>
187
- <rect x={ttX} y={ttY} width={TT_W} height={TT_H} rx={5}
188
- fill="var(--bg-1)" stroke="var(--border-2)" strokeWidth={1.2} />
189
- <rect x={ttX} y={ttY} width={3} height={TT_H} rx={2} fill={cc} />
190
- <text x={ttX + 10} y={ttY + 13}
191
- fontSize={10} fontWeight="700" fill="var(--text-0)" fontFamily="inherit">
192
- {tooltip.model}
193
- </text>
194
- <text x={ttX + 10} y={ttY + 27}
195
- fontSize={9} fill={cc} fontFamily="inherit">
196
- {tooltip.val}
197
- </text>
198
- </g>
199
- )
200
- })()}
201
- </svg>
202
-
203
- {/* Logo overlays — one per row for the hovered model */}
204
- {tooltip && STRIP_ROWS.map((row, ri) => {
205
- const m = active.find(m => m.model === tooltip.model)
206
- if (!m) return null
207
- const val = getAvg(m, row.key)
208
- if (val === null) return null
209
- const rawMax = row.key === 'fairness' ? maxFairness : 1
210
- const pct = row.invert ? 1 - val / rawMax : val / rawMax
211
- const cx = LBL_W + pct * AXIS_W
212
- const yMid = ri * ROW_H + ROW_H / 2
213
- const pctX = cx / VW * 100
214
- const pctY = yMid / VH * 100
215
- return (
216
- <div key={row.key} style={{
217
- position: 'absolute',
218
- left: `${pctX}%`,
219
- top: `${pctY}%`,
220
- transform: 'translate(-50%, -50%)',
221
- width: 40,
222
- height: 40,
223
- display: 'flex',
224
- alignItems: 'center',
225
- justifyContent: 'center',
226
- pointerEvents: 'none',
227
- }}>
228
- <LabLogo creator={tooltip.creator} size={32} />
229
- </div>
230
- )
231
- })}
232
- </div>
233
- )
234
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/ScrollReveal.tsx DELETED
@@ -1,31 +0,0 @@
1
- 'use client'
2
-
3
- import { useEffect, useRef } from 'react'
4
-
5
- export default function ScrollReveal({ children, className = '' }: { children: React.ReactNode; className?: string }) {
6
- const ref = useRef<HTMLDivElement>(null)
7
-
8
- useEffect(() => {
9
- const obs = new IntersectionObserver(
10
- entries => {
11
- entries.forEach(e => {
12
- if (e.isIntersecting) {
13
- e.target.classList.add('visible')
14
- obs.unobserve(e.target)
15
- }
16
- })
17
- },
18
- { threshold: 0.05, rootMargin: '0px 0px -60px 0px' },
19
- )
20
-
21
- const el = ref.current
22
- if (el) obs.observe(el)
23
- return () => obs.disconnect()
24
- }, [])
25
-
26
- return (
27
- <div ref={ref} className={`reveal ${className}`}>
28
- {children}
29
- </div>
30
- )
31
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/components/SegmentedToggle.tsx DELETED
@@ -1,34 +0,0 @@
1
- 'use client'
2
-
3
- interface Option {
4
- key: string
5
- label: string
6
- subtitle: string
7
- }
8
-
9
- interface Props {
10
- options: Option[]
11
- active: string
12
- onChange: (key: string) => void
13
- }
14
-
15
- export default function SegmentedToggle({ options, active, onChange }: Props) {
16
- return (
17
- <div className="seg-toggle-wrap" style={{ alignItems: 'flex-start' }}>
18
- <div className="seg-toggle">
19
- {options.map(o => (
20
- <button
21
- key={o.key}
22
- className={`seg-toggle-btn ${active === o.key ? 'seg-active' : ''}`}
23
- onClick={() => onChange(o.key)}
24
- >
25
- {o.label}
26
- </button>
27
- ))}
28
- </div>
29
- <p className="seg-toggle-subtitle">
30
- {options.find(o => o.key === active)?.subtitle}
31
- </p>
32
- </div>
33
- )
34
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/parseData.ts DELETED
@@ -1,103 +0,0 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import type { ModelData, BenchmarkStats, MetricThresholds } from './types'
4
-
5
- function pct(val: string): number | null {
6
- const s = val?.trim()
7
- if (!s) return null
8
- const n = parseFloat(s.replace('%', ''))
9
- return isNaN(n) ? null : n / 100
10
- }
11
-
12
- function num(val: string): number | null {
13
- const s = val?.trim()
14
- if (!s) return null
15
- const n = parseFloat(s)
16
- return isNaN(n) ? null : n
17
- }
18
-
19
- export function loadData(): ModelData[] {
20
- const filePath = path.join(process.cwd(), 'public', 'data.csv')
21
- const text = fs.readFileSync(filePath, 'utf-8')
22
- const lines = text.split('\n').filter(l => l.trim()).slice(2)
23
-
24
- return lines
25
- .map(line => {
26
- const cols = line.split(',')
27
- return {
28
- rank: parseInt(cols[0]) || 0,
29
- creator: cols[1]?.trim() || '',
30
- model: cols[2]?.trim() || '',
31
- luc: {
32
- general: pct(cols[3]),
33
- physics: pct(cols[4]),
34
- career: pct(cols[5]),
35
- jd: pct(cols[6]),
36
- avg: pct(cols[7]),
37
- },
38
- rag: {
39
- lcAbs: pct(cols[8]),
40
- lcFact: pct(cols[9]),
41
- hyAbs: pct(cols[10]),
42
- hyFact: pct(cols[11]),
43
- avg: pct(cols[12]),
44
- },
45
- fairness: {
46
- style: num(cols[13]),
47
- con: num(cols[14]),
48
- avg: num(cols[15]),
49
- },
50
- archived: cols[16]?.trim() === 'true',
51
- }
52
- })
53
- .filter(m => m.creator && m.model)
54
- }
55
-
56
- export function computeStats(models: ModelData[]): BenchmarkStats {
57
- const active = models.filter(m => !m.archived)
58
- const creators = new Set(models.map(m => m.creator))
59
-
60
- const lucScores = active.map(m => m.luc.avg).filter((v): v is number => v !== null)
61
- const ragScores = active.map(m => m.rag.avg).filter((v): v is number => v !== null)
62
-
63
- const avgLUC = lucScores.reduce((a, b) => a + b, 0) / lucScores.length
64
- const avgRAG = ragScores.reduce((a, b) => a + b, 0) / ragScores.length
65
-
66
- const topModel = active
67
- .filter(m => m.luc.avg !== null)
68
- .sort((a, b) => (b.luc.avg ?? 0) - (a.luc.avg ?? 0))[0]?.model ?? ''
69
-
70
- return {
71
- totalModels: models.length,
72
- totalCreators: creators.size,
73
- avgLUC,
74
- avgRAG,
75
- topModel,
76
- lastUpdated: new Date().toLocaleDateString('en-SG', { month: 'short', year: 'numeric' }),
77
- }
78
- }
79
-
80
- export function getMaxFairnessAvg(models: ModelData[]): number {
81
- const vals = models.map(m => m.fairness.avg).filter((v): v is number => v !== null)
82
- return Math.max(...vals)
83
- }
84
-
85
- function empiricalPercentile(vals: number[], p: number): number {
86
- const sorted = [...vals].sort((a, b) => a - b)
87
- const idx = (p / 100) * (sorted.length - 1)
88
- const lo = Math.floor(idx)
89
- const hi = Math.ceil(idx)
90
- return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo])
91
- }
92
-
93
- export function computeThresholds(models: ModelData[]): MetricThresholds {
94
- const active = models.filter(m => !m.archived)
95
- const luc = active.map(m => m.luc.avg).filter((v): v is number => v !== null)
96
- const rag = active.map(m => m.rag.avg).filter((v): v is number => v !== null)
97
- const fair = active.map(m => m.fairness.avg).filter((v): v is number => v !== null)
98
- return {
99
- luc: { p33: empiricalPercentile(luc, 33), p67: empiricalPercentile(luc, 67) },
100
- rag: { p33: empiricalPercentile(rag, 33), p67: empiricalPercentile(rag, 67) },
101
- fairness: { p33: empiricalPercentile(fair, 33), p67: empiricalPercentile(fair, 67) },
102
- }
103
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/parseGuardrails.ts DELETED
@@ -1,50 +0,0 @@
1
- import fs from 'fs'
2
- import path from 'path'
3
- import type { GuardrailData, GuardrailThresholds } from './types'
4
-
5
- function pct(val: string): number | null {
6
- const s = val?.trim()
7
- if (!s) return null
8
- const n = parseFloat(s.replace('%', ''))
9
- return isNaN(n) ? null : n / 100
10
- }
11
-
12
- export function loadGuardrails(): GuardrailData[] {
13
- const filePath = path.join(process.cwd(), 'public', 'guardrails_data.csv')
14
- if (!fs.existsSync(filePath)) return []
15
- const text = fs.readFileSync(filePath, 'utf-8')
16
- const lines = text.split('\n').filter(l => l.trim()).slice(1)
17
-
18
- return lines
19
- .map(line => {
20
- const cols = line.split(',')
21
- return {
22
- rank: parseInt(cols[0]) || 0,
23
- creator: cols[1]?.trim() || '',
24
- guardrail: cols[2]?.trim() || '',
25
- recall: pct(cols[3]),
26
- precision: pct(cols[4]),
27
- f1: pct(cols[5]),
28
- detections: cols[7]?.trim() || '',
29
- lastRun: cols[8]?.trim() || '',
30
- }
31
- })
32
- .filter(g => g.creator && g.guardrail)
33
- }
34
-
35
- function empiricalPercentile(vals: number[], p: number): number {
36
- const sorted = [...vals].sort((a, b) => a - b)
37
- const idx = (p / 100) * (sorted.length - 1)
38
- const lo = Math.floor(idx)
39
- const hi = Math.ceil(idx)
40
- return sorted[lo] + (idx - lo) * (sorted[hi] - sorted[lo])
41
- }
42
-
43
- export function computeGuardrailThresholds(guardrails: GuardrailData[]): GuardrailThresholds {
44
- const recalls = guardrails.map(g => g.recall).filter((v): v is number => v !== null)
45
- const f1s = guardrails.map(g => g.f1).filter((v): v is number => v !== null)
46
- return {
47
- recall: { p33: empiricalPercentile(recalls, 33), p67: empiricalPercentile(recalls, 67) },
48
- f1: { p33: empiricalPercentile(f1s, 33), p67: empiricalPercentile(f1s, 67) },
49
- }
50
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/types.ts DELETED
@@ -1,62 +0,0 @@
1
- export interface LUCScores {
2
- general: number | null
3
- physics: number | null
4
- career: number | null
5
- jd: number | null
6
- avg: number | null
7
- }
8
-
9
- export interface RAGScores {
10
- lcAbs: number | null
11
- lcFact: number | null
12
- hyAbs: number | null
13
- hyFact: number | null
14
- avg: number | null
15
- }
16
-
17
- export interface FairnessScores {
18
- style: number | null
19
- con: number | null
20
- avg: number | null
21
- }
22
-
23
- export interface ModelData {
24
- rank: number
25
- creator: string
26
- model: string
27
- luc: LUCScores
28
- rag: RAGScores
29
- fairness: FairnessScores
30
- archived: boolean
31
- }
32
-
33
- export interface BenchmarkStats {
34
- totalModels: number
35
- totalCreators: number
36
- avgLUC: number
37
- avgRAG: number
38
- topModel: string
39
- lastUpdated: string
40
- }
41
-
42
- export interface MetricThresholds {
43
- luc: { p33: number; p67: number }
44
- rag: { p33: number; p67: number }
45
- fairness: { p33: number; p67: number }
46
- }
47
-
48
- export interface GuardrailThresholds {
49
- recall: { p33: number; p67: number }
50
- f1: { p33: number; p67: number }
51
- }
52
-
53
- export interface GuardrailData {
54
- rank: number
55
- creator: string
56
- guardrail: string
57
- recall: number | null
58
- precision: number | null
59
- f1: number | null
60
- detections: string
61
- lastRun: string
62
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/utils.ts DELETED
@@ -1,90 +0,0 @@
1
- export function fmtPct(v: number | null, decimals = 0): string {
2
- if (v === null) return '—'
3
- return `${(v * 100).toFixed(decimals)}%`
4
- }
5
-
6
- export function fmtNum(v: number | null, decimals = 4): string {
7
- if (v === null) return '—'
8
- return v.toFixed(decimals)
9
- }
10
-
11
- /* Score scale: green → light green → amber → red */
12
- export function lucColor(v: number | null): string {
13
- if (v === null) return 'var(--text-3)'
14
- if (v >= 0.9) return '#22C55E'
15
- if (v >= 0.75) return '#4ADE80'
16
- if (v >= 0.6) return '#F0A030'
17
- return '#F4333D'
18
- }
19
-
20
- export function ragColor(v: number | null): string {
21
- if (v === null) return 'var(--text-3)'
22
- if (v >= 0.65) return '#22C55E'
23
- if (v >= 0.5) return '#4ADE80'
24
- if (v >= 0.35) return '#F0A030'
25
- return '#F4333D'
26
- }
27
-
28
- export function fairnessColor(avg: number | null): string {
29
- if (avg === null) return 'var(--text-3)'
30
- if (avg <= 0.1) return '#22C55E'
31
- if (avg <= 0.2) return '#4ADE80'
32
- if (avg <= 0.4) return '#F0A030'
33
- return '#F4333D'
34
- }
35
-
36
- export const CREATOR_COLORS: Record<string, string> = {
37
- Anthropic: '#e07040',
38
- Google: '#4285f5',
39
- OpenAI: '#5ac8a8',
40
- xAI: '#000000',
41
- Meta: '#1877f2',
42
- DeepSeek: '#8b7fff',
43
- Moonshot: '#ff9bb0',
44
- Alibaba: '#ff8800',
45
- zAI: '#3B5CEB',
46
- 'MiniMax AI': '#E8441A',
47
- }
48
-
49
- export function creatorColor(creator: string): string {
50
- return CREATOR_COLORS[creator] ?? '#6b7280'
51
- }
52
-
53
- export const GUARDRAIL_CREATOR_COLORS: Record<string, string> = {
54
- GovTech: '#CC2984',
55
- Microsoft: '#00A4EF',
56
- Amazon: '#FF9900',
57
- Meta: '#1877f2',
58
- OpenAI: '#5ac8a8',
59
- }
60
-
61
- export function guardrailCreatorColor(creator: string): string {
62
- return GUARDRAIL_CREATOR_COLORS[creator] ?? CREATOR_COLORS[creator] ?? '#6b7280'
63
- }
64
-
65
- export function inferModelSize(model: string): 'Small' | 'Medium' | 'Large' {
66
- const n = model.toLowerCase()
67
-
68
- // Small: lightweight / edge tier (≤10B open-weight, or Haiku/Nano/Lite families)
69
- if (/haiku|nano|\blite\b|\bsmall\b/.test(n)) return 'Small'
70
-
71
- // Explicit parameter count in the name (e.g. "70B", "235B", "20B")
72
- const paramMatch = n.match(/(\d+)b\b/)
73
- if (paramMatch) {
74
- const params = parseInt(paramMatch[1], 10)
75
- if (params <= 10) return 'Small'
76
- if (params <= 40) return 'Medium'
77
- return 'Large'
78
- }
79
-
80
- // Medium: mid-tier commercial families or mid-size open-weight
81
- if (/\bmini\b|sonnet|\bflash\b|\bplus\b|\bchat\b|\bfast\b|gemma/.test(n)) return 'Medium'
82
-
83
- // Large: flagship / pro / opus, or any unmatched model (defaults to Large)
84
- return 'Large'
85
- }
86
-
87
- export function normalizedFairness(avg: number | null, maxAvg: number): number | null {
88
- if (avg === null || maxAvg === 0) return null
89
- return Math.max(0, 1 - avg / maxAvg)
90
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
style.css ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ body {
2
+ padding: 2rem;
3
+ font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
+ }
5
+
6
+ h1 {
7
+ font-size: 16px;
8
+ margin-top: 0;
9
+ }
10
+
11
+ p {
12
+ color: rgb(107, 114, 128);
13
+ font-size: 15px;
14
+ margin-bottom: 10px;
15
+ margin-top: 5px;
16
+ }
17
+
18
+ .card {
19
+ max-width: 620px;
20
+ margin: 0 auto;
21
+ padding: 16px;
22
+ border: 1px solid lightgray;
23
+ border-radius: 16px;
24
+ }
25
+
26
+ .card p:last-child {
27
+ margin-bottom: 0;
28
+ }
tsconfig.json DELETED
@@ -1,34 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2017",
4
- "lib": ["dom", "dom.iterable", "esnext"],
5
- "allowJs": true,
6
- "skipLibCheck": true,
7
- "strict": true,
8
- "noEmit": true,
9
- "esModuleInterop": true,
10
- "module": "esnext",
11
- "moduleResolution": "bundler",
12
- "resolveJsonModule": true,
13
- "isolatedModules": true,
14
- "jsx": "react-jsx",
15
- "incremental": true,
16
- "plugins": [
17
- {
18
- "name": "next"
19
- }
20
- ],
21
- "paths": {
22
- "@/*": ["./src/*"]
23
- }
24
- },
25
- "include": [
26
- "next-env.d.ts",
27
- "**/*.ts",
28
- "**/*.tsx",
29
- ".next/types/**/*.ts",
30
- ".next/dev/types/**/*.ts",
31
- "**/*.mts"
32
- ],
33
- "exclude": ["node_modules"]
34
- }