free-coding-models 0.5.1 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +31 -5
  2. package/changelog/v0.5.4.md +24 -0
  3. package/package.json +5 -2
  4. package/sources.js +2 -0
  5. package/src/tui/key-handler.js +1 -1
  6. package/web/assets/providers/aistudio/aistudio-text.svg +1 -0
  7. package/web/assets/providers/aistudio/aistudio.svg +1 -0
  8. package/web/assets/providers/cerebras/cerebras-brand-color.svg +1 -0
  9. package/web/assets/providers/cerebras/cerebras-brand.svg +1 -0
  10. package/web/assets/providers/cerebras/cerebras-color.svg +1 -0
  11. package/web/assets/providers/cerebras/cerebras-text.svg +1 -0
  12. package/web/assets/providers/cerebras/cerebras.svg +1 -0
  13. package/web/assets/providers/cloudflare/cloudflare-color.svg +1 -0
  14. package/web/assets/providers/cloudflare/cloudflare-text.svg +1 -0
  15. package/web/assets/providers/cloudflare/cloudflare.svg +1 -0
  16. package/web/assets/providers/gemini/gemini-color.svg +1 -0
  17. package/web/assets/providers/gemini/gemini-text.svg +1 -0
  18. package/web/assets/providers/gemini/gemini.svg +1 -0
  19. package/web/assets/providers/github/github-text.svg +1 -0
  20. package/web/assets/providers/github/github.svg +1 -0
  21. package/web/assets/providers/groq/groq-text.svg +1 -0
  22. package/web/assets/providers/groq/groq.svg +1 -0
  23. package/web/assets/providers/mistral/mistral-color.svg +1 -0
  24. package/web/assets/providers/mistral/mistral-text.svg +1 -0
  25. package/web/assets/providers/mistral/mistral.svg +1 -0
  26. package/web/assets/providers/nvidia/nvidia-color.svg +1 -0
  27. package/web/assets/providers/nvidia/nvidia-text.svg +1 -0
  28. package/web/assets/providers/nvidia/nvidia.svg +1 -0
  29. package/web/assets/providers/opencode/opencode-text.svg +1 -0
  30. package/web/assets/providers/opencode/opencode.svg +1 -0
  31. package/web/assets/providers/openrouter/openrouter (1).svg +1 -0
  32. package/web/assets/providers/openrouter/openrouter-text.svg +1 -0
  33. package/web/assets/providers/openrouter/openrouter.svg +1 -0
  34. package/web/assets/providers/ovhcloud/ovhcloud.svg +12 -0
  35. package/web/assets/providers/qwen/qwen-color.svg +1 -0
  36. package/web/assets/providers/qwen/qwen-text.svg +1 -0
  37. package/web/assets/providers/qwen/qwen.svg +1 -0
  38. package/web/assets/providers/sambanova/sambanova-color.svg +1 -0
  39. package/web/assets/providers/sambanova/sambanova-text.svg +1 -0
  40. package/web/assets/providers/sambanova/sambanova.svg +1 -0
  41. package/web/assets/providers/scalewaylogo/ScalewayLogo.svg +13 -0
  42. package/web/assets/providers/zai/zai-text.svg +1 -0
  43. package/web/assets/providers/zai/zai.svg +1 -0
  44. package/web/dist/assets/index-BoWmUveV.js +39 -0
  45. package/web/dist/assets/index-BrpHevg4.css +1 -0
  46. package/web/dist/favicon.ico +0 -0
  47. package/web/dist/favicons/apple-touch-icon.png +0 -0
  48. package/web/dist/favicons/browserconfig.xml +12 -0
  49. package/web/dist/favicons/favicon-16x16.png +0 -0
  50. package/web/dist/favicons/favicon-192x192.png +0 -0
  51. package/web/dist/favicons/favicon-32x32.png +0 -0
  52. package/web/dist/favicons/favicon-48x48.png +0 -0
  53. package/web/dist/favicons/favicon-512x512.png +0 -0
  54. package/web/dist/favicons/favicon-96x96.png +0 -0
  55. package/web/dist/favicons/favicon.ico +0 -0
  56. package/web/dist/favicons/mstile-150x150.png +0 -0
  57. package/web/dist/favicons/mstile-310x150.png +0 -0
  58. package/web/dist/favicons/mstile-310x310.png +0 -0
  59. package/web/dist/favicons/mstile-512x512.png +0 -0
  60. package/web/dist/favicons/mstile-70x70.png +0 -0
  61. package/web/dist/favicons/site.webmanifest +25 -0
  62. package/web/dist/index.html +42 -3
  63. package/web/index.html +40 -1
  64. package/web/public/favicon.ico +0 -0
  65. package/web/public/favicons/apple-touch-icon.png +0 -0
  66. package/web/public/favicons/browserconfig.xml +12 -0
  67. package/web/public/favicons/favicon-16x16.png +0 -0
  68. package/web/public/favicons/favicon-192x192.png +0 -0
  69. package/web/public/favicons/favicon-32x32.png +0 -0
  70. package/web/public/favicons/favicon-48x48.png +0 -0
  71. package/web/public/favicons/favicon-512x512.png +0 -0
  72. package/web/public/favicons/favicon-96x96.png +0 -0
  73. package/web/public/favicons/favicon.ico +0 -0
  74. package/web/public/favicons/mstile-150x150.png +0 -0
  75. package/web/public/favicons/mstile-310x150.png +0 -0
  76. package/web/public/favicons/mstile-310x310.png +0 -0
  77. package/web/public/favicons/mstile-512x512.png +0 -0
  78. package/web/public/favicons/mstile-70x70.png +0 -0
  79. package/web/public/favicons/site.webmanifest +25 -0
  80. package/web/server.js +71 -1
  81. package/web/src/App.jsx +209 -89
  82. package/web/src/components/analytics/AnalyticsView.jsx +30 -2
  83. package/web/src/components/analytics/AnalyticsView.module.css +4 -0
  84. package/web/src/components/atoms/AILatencyCell.module.css +9 -1
  85. package/web/src/components/atoms/HealthCell.jsx +113 -28
  86. package/web/src/components/atoms/HealthCell.module.css +43 -2
  87. package/web/src/components/atoms/LastPingCell.module.css +10 -0
  88. package/web/src/components/atoms/NoKeyIcon.jsx +65 -0
  89. package/web/src/components/atoms/NoKeyIcon.module.css +30 -0
  90. package/web/src/components/atoms/ProviderLogo.jsx +188 -0
  91. package/web/src/components/atoms/ProviderLogo.module.css +103 -0
  92. package/web/src/components/atoms/Sparkline.jsx +21 -2
  93. package/web/src/components/atoms/StabilityCell.module.css +5 -0
  94. package/web/src/components/atoms/StatusDot.module.css +7 -0
  95. package/web/src/components/atoms/TPSCell.module.css +7 -1
  96. package/web/src/components/atoms/TierBadge.module.css +11 -0
  97. package/web/src/components/atoms/Toast.module.css +6 -0
  98. package/web/src/components/atoms/VerdictBadge.module.css +12 -0
  99. package/web/src/components/dashboard/DetailPanel.jsx +63 -2
  100. package/web/src/components/dashboard/DetailPanel.module.css +130 -0
  101. package/web/src/components/dashboard/FilterBar.jsx +146 -53
  102. package/web/src/components/dashboard/FilterBar.module.css +138 -58
  103. package/web/src/components/dashboard/ModelTable.jsx +235 -30
  104. package/web/src/components/dashboard/ModelTable.module.css +205 -16
  105. package/web/src/components/layout/Header.jsx +140 -12
  106. package/web/src/components/layout/Header.module.css +191 -27
  107. package/web/src/components/palette/CommandPalette.jsx +146 -0
  108. package/web/src/components/palette/CommandPalette.module.css +121 -0
  109. package/web/src/global.css +52 -15
  110. package/web/src/hooks/useColumnSizing.js +211 -0
  111. package/web/src/hooks/useFavorites.js +150 -0
  112. package/web/src/hooks/useFilter.js +157 -24
  113. package/web/src/hooks/useTheme.js +72 -16
  114. package/web/src/hooks/useUrlState.js +93 -0
  115. package/web/dist/assets/index-ByGf4Kq-.js +0 -14
  116. package/web/dist/assets/index-Ds7wmHBv.css +0 -1
  117. package/web/src/components/dashboard/StatsBar.jsx +0 -42
  118. package/web/src/components/dashboard/StatsBar.module.css +0 -28
  119. package/web/src/components/layout/Sidebar.jsx +0 -50
  120. package/web/src/components/layout/Sidebar.module.css +0 -86
  121. package/web/src/components/map/MapView.jsx +0 -17
  122. package/web/src/components/map/MapView.module.css +0 -25
@@ -1,8 +1,16 @@
1
+ /**
2
+ * @file web/src/components/atoms/HealthCell.module.css
3
+ * @description Styles for the HealthCell atom — matches CLI Health column typography.
4
+ * 📖 "NO KEY" is rendered as a louder, bold, warning-yellow chip with the NoKeyIcon
5
+ * 📖 sitting inline next to the label, so a missing credential jumps out at the user.
6
+ */
1
7
  .cell {
2
8
  font-family: var(--font-mono);
3
9
  font-size: 11px;
4
10
  font-weight: 600;
5
- display: inline-block;
11
+ display: inline-flex;
12
+ align-items: center;
13
+ gap: 5px;
6
14
  letter-spacing: 0.2px;
7
15
  white-space: nowrap;
8
16
  overflow: hidden;
@@ -12,4 +20,37 @@
12
20
  .warning { color: #ffaa00; }
13
21
  .error { color: #ff4444; }
14
22
  .errorBold { color: #ff4444; font-weight: 800; }
15
- .dim { color: var(--color-text-dim); }
23
+ .dim { color: var(--color-text-dim); }
24
+
25
+ /* 📖 The "NO KEY" treatment is intentionally louder than the other states:
26
+ 📖 - yellow chip background instead of plain text
27
+ 📖 - 13px font (vs 11px elsewhere in the column)
28
+ 📖 - bold weight, slightly more letter-spacing
29
+ 📖 - dark text on yellow for AA contrast in dark + light themes */
30
+ .noKey {
31
+ background: rgba(255, 170, 0, 0.18);
32
+ color: #ffaa00;
33
+ border: 1px solid rgba(255, 170, 0, 0.45);
34
+ padding: 1px 7px;
35
+ border-radius: 4px;
36
+ font-size: 12px;
37
+ font-weight: 700;
38
+ letter-spacing: 0.4px;
39
+ }
40
+ .noKeyText {
41
+ /* 📖 Inherits the surrounding font, just bumps the weight and tracking for the
42
+ 📖 label so the chip reads as a single badge even at narrow column widths. */
43
+ font-weight: 700;
44
+ letter-spacing: 0.4px;
45
+ }
46
+
47
+ :global([data-theme="light"]) .noKey {
48
+ background: rgba(255, 170, 0, 0.25);
49
+ border-color: rgba(255, 170, 0, 0.55);
50
+ color: #b86b00;
51
+ }
52
+ :global([data-theme="light"]) .up { color: #008f4d; }
53
+ :global([data-theme="light"]) .warning { color: #b86b00; }
54
+ :global([data-theme="light"]) .error,
55
+ :global([data-theme="light"]) .errorBold { color: #c02323; }
56
+ :global([data-theme="light"]) .dim { color: #888; }
@@ -32,4 +32,14 @@
32
32
  border-radius: 50%;
33
33
  animation: pingRing 0.8s linear infinite;
34
34
  flex-shrink: 0;
35
+ }
36
+
37
+ /* 📖 Light theme: the neon green ring is unreadable on white, swap to a deep
38
+ 📖 green ring on a soft neutral track so the spinner stays visible. */
39
+ :global([data-theme="light"]) .fast { color: #008f4d; }
40
+ :global([data-theme="light"]) .medium { color: #b86b00; }
41
+ :global([data-theme="light"]) .slow { color: #c8143a; }
42
+ :global([data-theme="light"]) .spinner {
43
+ border-color: rgba(0, 0, 0, 0.12);
44
+ border-top-color: #008f4d;
35
45
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file web/src/components/atoms/NoKeyIcon.jsx
3
+ * @description Compact "no API key" indicator — yellow key with a red diagonal strikethrough.
4
+ *
5
+ * 📖 Used in two places to surface the missing-key state without duplicating the
6
+ * 📖 verbose "NO KEY" pill that used to live next to the model label:
7
+ * 📖 1. Inline next to the model name in the Model cell (replaces the old orange pill)
8
+ * 📖 2. Inside the Health cell, sitting next to the "NO KEY" text label
9
+ *
10
+ * 📖 The strikethrough is rendered as an inline SVG `<line>` on top of the Tabler
11
+ * 📖 `IconKey`. Doing it in SVG (instead of CSS) keeps the line aligned with the
12
+ * 📖 icon at any size and avoids the CSS overflow quirks that come with rotating
13
+ * 📖 a pseudo-element inside a flex parent.
14
+ *
15
+ * 📖 `size` defaults to 14px which is the same as the Tabler icons used in Settings;
16
+ * 📖 pass a smaller value (e.g. 11) for inline use next to model names where space is tight.
17
+ *
18
+ * @functions
19
+ * → NoKeyIcon — renders a yellow key with a red strikethrough
20
+ * @exports NoKeyIcon
21
+ */
22
+ import { IconKey } from '@tabler/icons-react'
23
+ import styles from './NoKeyIcon.module.css'
24
+
25
+ export default function NoKeyIcon({ size = 14, title = 'No API key configured' }) {
26
+ // 📖 Stroke width follows the rest of the dashboard's icon style (1.5).
27
+ // 📖 The key is yellow (warning palette); the slash is a bright red so it reads
28
+ // 📖 as a "blocked" state at a glance even in peripheral vision.
29
+ const strokeWidth = 1.5
30
+ const slashPadding = Math.max(1, Math.round(size * 0.07))
31
+
32
+ return (
33
+ <span
34
+ className={styles.wrapper}
35
+ style={{ width: `${size}px`, height: `${size}px` }}
36
+ role="img"
37
+ aria-label={title}
38
+ title={title}
39
+ >
40
+ <IconKey
41
+ size={size}
42
+ stroke={strokeWidth}
43
+ className={styles.key}
44
+ />
45
+ <svg
46
+ className={styles.slash}
47
+ width={size + slashPadding * 2}
48
+ height={size + slashPadding * 2}
49
+ viewBox={`0 0 ${size + slashPadding * 2} ${size + slashPadding * 2}`}
50
+ aria-hidden="true"
51
+ focusable="false"
52
+ >
53
+ <line
54
+ x1={slashPadding + 1}
55
+ y1={slashPadding + 1}
56
+ x2={slashPadding + size - 1}
57
+ y2={slashPadding + size - 1}
58
+ stroke="var(--color-danger, #ff4444)"
59
+ strokeWidth="1.1"
60
+ strokeLinecap="round"
61
+ />
62
+ </svg>
63
+ </span>
64
+ )
65
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * @file web/src/components/atoms/NoKeyIcon.module.css
3
+ * @description Styles for the NoKeyIcon atom — yellow key with a red diagonal slash.
4
+ * 📖 The wrapper is a fixed-size box so the absolutely-positioned slash SVG can
5
+ * 📖 center itself over the icon regardless of the parent's flex alignment.
6
+ */
7
+ .wrapper {
8
+ display: inline-flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ position: relative;
12
+ flex-shrink: 0;
13
+ line-height: 0;
14
+ vertical-align: middle;
15
+ }
16
+
17
+ .key {
18
+ color: #ffaa00; /* 📖 warning yellow — same family as the rest of the dashboard */
19
+ position: relative;
20
+ z-index: 1;
21
+ filter: drop-shadow(0 0 1px rgba(255, 170, 0, 0.35));
22
+ }
23
+
24
+ .slash {
25
+ position: absolute;
26
+ inset: 0;
27
+ margin: auto;
28
+ pointer-events: none;
29
+ z-index: 2;
30
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * @file web/src/components/atoms/ProviderLogo.jsx
3
+ * @description Renders a provider cell as [icon + text-wordmark] on a single line.
4
+ *
5
+ * @details
6
+ * - Looks up the provider's assets under `web/assets/providers/<folder>/`.
7
+ * - Prefers the **color** icon when available (recognizable + lively in both themes);
8
+ * falls back to the **mono** (currentColor) icon when no color variant exists.
9
+ * - Renders the **text** wordmark (`<id>-text.svg`) next to the icon when available.
10
+ * Text wordmarks use `fill="currentColor"`, so they automatically adapt to the
11
+ * active theme (white in dark mode, black in light mode) without any JS.
12
+ * - For providers without a text wordmark (legacy OVH / Scaleway SVGs, or models
13
+ * like Codestral that share a parent's brand), shows the graphic icon + the
14
+ * human-readable caption as plain text.
15
+ * - Inlines the SVGs as raw strings via `import.meta.glob(..., { as: 'raw' })` so
16
+ * `currentColor` resolves against the cell's text color. Using `<img src>` would
17
+ * sandbox the SVG and break the theme inheritance.
18
+ * - Constrained to a single row height (~22px) via `--pl-max-h` so the cell never
19
+ * grows taller than the rest of the table, regardless of which variant is shown.
20
+ *
21
+ * @param {object} props
22
+ * @param {string} props.providerKey - The kebab-case key from sources.js (e.g. 'nvidia', 'github-models', 'opencode-zen', 'googleai').
23
+ * @param {string} props.origin - Human-readable name from sources.js, used as fallback text / tooltip.
24
+ * @functions
25
+ * → ProviderLogo (default export)
26
+ * → getSvg (internal) – raw-string lookup keyed by asset path
27
+ * @see web/src/components/dashboard/ModelTable.jsx (consumer)
28
+ * @see web/assets/providers/ (asset source)
29
+ * @see https://lobehub.com/icons (variant inventory source)
30
+ */
31
+ import styles from './ProviderLogo.module.css'
32
+
33
+ // 📖 Eager raw import of every provider SVG. The key is a relative path from this
34
+ // 📖 file; the value is the SVG markup as a string. Vite bundles it at build time.
35
+ // 📖 Path resolves to `web/assets/providers/**/*.svg` (3 levels up from atoms/).
36
+ const svgModules = import.meta.glob('../../../assets/providers/**/*.svg', {
37
+ query: '?raw',
38
+ import: 'default',
39
+ eager: true,
40
+ })
41
+
42
+ // 📖 Provider key → asset folder + variant availability.
43
+ // 📖 `folder` MUST match the directory name under web/assets/providers/.
44
+ // 📖 `hasColor` / `hasText` are derived from the lobe-icons static-svg inventory
45
+ // 📖 (see https://lobehub.com/icons). `legacy` marks providers that ship with a
46
+ // 📖 single graphic SVG and no wordmark — for those we render icon + caption.
47
+ // 📖 Keys MUST match the `providerKey` field sent by the backend (kebab-case for
48
+ // 📖 compound names like `github-models` / `opencode-zen` — see sources.js).
49
+ const PROVIDER_CONFIG = {
50
+ nvidia: { folder: 'nvidia', hasColor: true, hasText: true },
51
+ groq: { folder: 'groq', hasColor: false, hasText: true },
52
+ cerebras: { folder: 'cerebras', hasColor: true, hasText: true },
53
+ googleai: { folder: 'aistudio', hasColor: false, hasText: true },
54
+ 'github-models': { folder: 'github', hasColor: false, hasText: true },
55
+ mistral: { folder: 'mistral', hasColor: true, hasText: true },
56
+ cloudflare: { folder: 'cloudflare', hasColor: true, hasText: true },
57
+ openrouter: { folder: 'openrouter', hasColor: false, hasText: true },
58
+ sambanova: { folder: 'sambanova', hasColor: true, hasText: true },
59
+ // 📖 OVH's logo is itself a full wordmark ("OVHcloud") — adding the origin
60
+ // 📖 name "OVHcloud AI" as a caption would be redundant. `noCaption` skips
61
+ // 📖 the text label and lets the wordmark stand on its own. The user pointed
62
+ // 📖 us to https://www.ovhcloud.com/.../OVHcloud_logo.svg#ocmscloud_logo for
63
+ // 📖 the international wordmark; we extract the inner <symbol> and save it as
64
+ // 📖 a renderable <svg> with the same viewBox.
65
+ ovhcloud: { folder: 'ovhcloud', hasColor: false, hasText: false, legacy: true, noCaption: true },
66
+ // 📖 Scaleway's pre-existing asset is `ScalewayLogo.svg` (capitalized) inside
67
+ // 📖 `scalewaylogo/`. The default filename lookup would look for
68
+ // 📖 `scalewaylogo/scalewaylogo.svg` which doesn't exist, so `iconFile`
69
+ // 📖 overrides it. The wordmark IS the brand name ("Scaleway") so adding
70
+ // 📖 the origin name as a separate caption is redundant — `noCaption` skips it.
71
+ // 📖 `iconClass: 'scalewayLogo'` attaches a global class so CSS can swap to
72
+ // 📖 a lighter purple in dark mode (the brand #521094 is barely visible on
73
+ // 📖 the near-black background). SVG paths were rewritten to use
74
+ // 📖 `fill="currentColor"` for this CSS control.
75
+ scaleway: { folder: 'scalewaylogo', iconFile: 'ScalewayLogo', iconClass: 'scalewayLogo', hasColor: false, hasText: false, legacy: true, noCaption: true },
76
+ zai: { folder: 'zai', hasColor: false, hasText: true },
77
+ qwen: { folder: 'qwen', hasColor: true, hasText: true },
78
+ gemini: { folder: 'gemini', hasColor: true, hasText: true },
79
+ 'opencode-zen': { folder: 'opencode', hasColor: false, hasText: true },
80
+ // 📖 Codestral intentionally reuses Mistral's branding — the colored Mistral
81
+ // 📖 icon (so the brand association is visible) + the literal caption
82
+ // 📖 "Codestral" as plain text. `iconFile` overrides the default filename
83
+ // 📖 lookup (we want mistral-color, not codestral-color which doesn't exist),
84
+ // 📖 and `caption` overrides the origin so we don't show "Codestral" twice.
85
+ codestral: { folder: 'mistral', iconFile: 'mistral-color', hasColor: true, hasText: false, legacy: true, caption: 'Codestral' },
86
+ }
87
+
88
+ function getSvg(folder, filename) {
89
+ if (!folder || !filename) return null
90
+ return svgModules[`../../../assets/providers/${folder}/${filename}.svg`] || null
91
+ }
92
+
93
+ export default function ProviderLogo({ providerKey, origin }) {
94
+ const cfg = PROVIDER_CONFIG[providerKey]
95
+
96
+ // 📖 Unknown / unmapped provider: fall back to plain text. Same as the old
97
+ // 📖 `.providerPill` so we don't visually regress on rows we couldn't classify.
98
+ if (!cfg) {
99
+ return <span className={styles.fallback}>{origin}</span>
100
+ }
101
+
102
+ // 📖 Pick the best icon: prefer the colorful variant, fall back to mono.
103
+ // 📖 Mono uses currentColor → inherits the cell's text color → theme-adaptive.
104
+ // 📖 `cfg.iconFile` overrides the default filename so providers like Codestral
105
+ // 📖 can borrow another provider's icon (e.g. mistral-color for brand parity).
106
+ const iconSvg =
107
+ (cfg.iconFile && getSvg(cfg.folder, cfg.iconFile)) ||
108
+ (cfg.hasColor && getSvg(cfg.folder, `${cfg.folder}-color`)) ||
109
+ getSvg(cfg.folder, cfg.folder)
110
+
111
+ // 📖 Pick the text wordmark (currentColor-based, theme-adaptive).
112
+ const textSvg = cfg.hasText ? getSvg(cfg.folder, `${cfg.folder}-text`) : null
113
+
114
+ // ─── Layouts ─────────────────────────────────────────────────────────────
115
+ // 📖 Single horizontal row: [icon | gap | wordmark], all in --pl-max-h tall.
116
+ // 📖 `overflow: hidden` on the row is a safety net for the rare case where
117
+ // 📖 a particularly wide wordmark (e.g. cloudflare ~145px) still overflows
118
+ // 📖 the column even at 12px height — better clipped than warping the table.
119
+ if (iconSvg && textSvg) {
120
+ return (
121
+ <span className={styles.row} title={origin}>
122
+ <span
123
+ className={styles.icon}
124
+ aria-hidden="true"
125
+ dangerouslySetInnerHTML={{ __html: iconSvg }}
126
+ />
127
+ <span
128
+ className={styles.textLogo}
129
+ aria-hidden="true"
130
+ dangerouslySetInnerHTML={{ __html: textSvg }}
131
+ />
132
+ </span>
133
+ )
134
+ }
135
+
136
+ // 📖 Legacy providers (Scaleway, OVH by default): graphic only, no wordmark SVG.
137
+ // 📖 Show the graphic + a caption as plain text so the cell stays scannable
138
+ // 📖 in the table. `cfg.caption` overrides the origin name (Codestral wants
139
+ // 📖 its own label, not whatever sources.js returns). `cfg.noCaption` skips
140
+ // 📖 the caption entirely (OVH's graphic is itself a full wordmark, and
141
+ // 📖 Scaleway's wordmark already contains the brand name).
142
+ // 📖 `cfg.iconClass` (when set) is appended to the icon span — used to attach
143
+ // 📖 a global class for per-theme color overrides (e.g. Scaleway's lighter
144
+ // 📖 purple in dark mode).
145
+ if (iconSvg) {
146
+ const captionText = cfg.caption ?? origin
147
+ if (cfg.noCaption) {
148
+ const wordmarkClass = cfg.iconClass
149
+ ? `${styles.iconLegacyWordmark} ${cfg.iconClass}`
150
+ : styles.iconLegacyWordmark
151
+ return (
152
+ <span className={styles.row} title={origin}>
153
+ <span
154
+ className={wordmarkClass}
155
+ aria-hidden="true"
156
+ dangerouslySetInnerHTML={{ __html: iconSvg }}
157
+ />
158
+ </span>
159
+ )
160
+ }
161
+ return (
162
+ <span className={styles.row} title={origin}>
163
+ <span
164
+ className={styles.icon}
165
+ aria-hidden="true"
166
+ dangerouslySetInnerHTML={{ __html: iconSvg }}
167
+ />
168
+ <span className={styles.fallback}>{captionText}</span>
169
+ </span>
170
+ )
171
+ }
172
+
173
+ // 📖 Icon-only fallback (shouldn't happen with current assets, but safe).
174
+ if (textSvg) {
175
+ return (
176
+ <span className={styles.row} title={origin}>
177
+ <span
178
+ className={styles.textLogo}
179
+ aria-hidden="true"
180
+ dangerouslySetInnerHTML={{ __html: textSvg }}
181
+ />
182
+ </span>
183
+ )
184
+ }
185
+
186
+ // 📖 No assets at all (e.g. codestral without icon). Plain text fallback.
187
+ return <span className={styles.fallback}>{origin}</span>
188
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * @file web/src/components/atoms/ProviderLogo.module.css
3
+ * @description Styles for the inline [icon + wordmark] provider cell.
4
+ *
5
+ * - Single horizontal row, max-height pinned so the cell never exceeds the
6
+ * table's row height (matches the 7px vertical padding on .td).
7
+ * - `color: var(--color-text)` propagates the active theme's text color into
8
+ * inlined SVGs that use `fill="currentColor"` (mono icons + all wordmarks).
9
+ * - Sized to fit within a ~150px column at default row height. Cloudflare
10
+ * is the widest wordmark (~145px at 12px high) — it fits but is tight.
11
+ */
12
+
13
+ .row {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 6px;
17
+ max-width: 100%;
18
+ max-height: var(--pl-max-h, 22px);
19
+ overflow: hidden;
20
+ /* 📖 Inherited by the inlined SVGs that use `fill="currentColor"`. Set on
21
+ 📖 the row so both the mono icon and the wordmark pick up the theme. */
22
+ color: var(--color-text);
23
+ line-height: 1;
24
+ vertical-align: middle;
25
+ }
26
+
27
+ .icon {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ height: var(--pl-icon-h, 16px);
32
+ width: auto;
33
+ flex-shrink: 0;
34
+ }
35
+ /* 📖 The inlined <svg> from lobe-icons ships with `width="1em" height="1em"`.
36
+ 📖 We override both so the wrapper's height controls the rendered size, and
37
+ 📖 let the viewBox dictate the width (preserves each icon's aspect ratio). */
38
+ .icon svg {
39
+ height: 100%;
40
+ width: auto;
41
+ display: block;
42
+ }
43
+
44
+ .textLogo {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ height: var(--pl-text-h, 12px);
49
+ width: auto;
50
+ flex-shrink: 0;
51
+ }
52
+ .textLogo svg {
53
+ height: 100%;
54
+ width: auto;
55
+ display: block;
56
+ }
57
+
58
+ /* 📖 Wordmark-only variant used by OVH (and any future provider whose "icon"
59
+ 📖 asset is itself a full horizontal wordmark). Sized to a slightly tighter
60
+ 📖 max height than the square icons so the wordmark doesn't visually crowd
61
+ 📖 the row. The viewBox aspect ratio of the OVH wordmark is ~6.3:1, so at
62
+ 📖 14px high it renders ~88px wide — fits comfortably in 150px. */
63
+ .iconLegacyWordmark {
64
+ display: inline-flex;
65
+ align-items: center;
66
+ justify-content: center;
67
+ height: var(--pl-icon-h, 14px);
68
+ width: auto;
69
+ flex-shrink: 0;
70
+ /* 📖 Default text color is inherited from the row. Wordmarks that use
71
+ 📖 `fill="currentColor"` (OVH) follow the theme automatically. Wordmarks
72
+ 📖 with hardcoded fills (Scaleway) need a per-theme override — see
73
+ 📖 `.scalewayLogo` below. */
74
+ color: var(--color-text);
75
+ }
76
+ .iconLegacyWordmark svg {
77
+ height: 100%;
78
+ width: auto;
79
+ display: block;
80
+ }
81
+
82
+ /* 📖 Scaleway: brand purple #521094 is the official color. It looks great on
83
+ 📖 white but is barely visible on the dark background — the contrast is
84
+ 📖 too low (dark purple on near-black). Bump to a lighter, more saturated
85
+ 📖 shade in dark mode so the wordmark reads cleanly without losing brand
86
+ 📖 identity. Paths were rewritten to use `fill="currentColor"` so this
87
+ 📖 single CSS color property controls the whole wordmark. */
88
+ :global(.scalewayLogo) {
89
+ color: #521094;
90
+ transition: color 200ms var(--ease-out, ease);
91
+ }
92
+ :global([data-theme="dark"]) :global(.scalewayLogo) {
93
+ color: #a78bfa;
94
+ }
95
+
96
+ .fallback {
97
+ font-size: 11px;
98
+ font-weight: 500;
99
+ color: var(--color-text);
100
+ white-space: nowrap;
101
+ /* 📖 Slight tracking so the origin name reads cleanly next to the icon. */
102
+ letter-spacing: 0.2px;
103
+ }
@@ -2,9 +2,28 @@
2
2
  * @file web/src/components/atoms/Sparkline.jsx
3
3
  * @description SVG sparkline chart for ping history trend visualization.
4
4
  * 📖 Renders a polyline with gradient area fill and endpoint dot.
5
+ * 📖 Colors are theme-aware: reads CSS custom properties from :root so the
6
+ * 📖 stroke/dot swap to the AA-contrast palette in light mode automatically.
5
7
  */
6
8
  import { useMemo } from 'react'
7
9
 
10
+ // 📖 Read CSS custom properties at module scope; falls back to dark-mode neon
11
+ // 📖 values when running outside the browser (e.g. during server rendering).
12
+ function readCssVar(name, fallback) {
13
+ if (typeof window === 'undefined' || !window.document?.documentElement) return fallback
14
+ const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
15
+ return v || fallback
16
+ }
17
+
18
+ // 📖 Threshold-based color picker that returns the current theme's variant of
19
+ // 📖 each color. Reading the variables on every render is fine — they only
20
+ // 📖 change when the user toggles the theme, and the memo cache key catches it.
21
+ function pickSparkColor(lastVal) {
22
+ if (lastVal < 500) return readCssVar('--color-success', '#00ff88')
23
+ if (lastVal < 1500) return readCssVar('--color-warning', '#ffaa00')
24
+ return readCssVar('--color-danger', '#ff4444')
25
+ }
26
+
8
27
  export default function Sparkline({ history }) {
9
28
  const svg = useMemo(() => {
10
29
  if (!history || history.length < 2) return null
@@ -28,13 +47,13 @@ export default function Sparkline({ history }) {
28
47
  .join(' ')
29
48
 
30
49
  const lastVal = values[values.length - 1]
31
- const color = lastVal < 500 ? '#00ff88' : lastVal < 1500 ? '#ffaa00' : '#ff4444'
50
+ const color = pickSparkColor(lastVal)
32
51
  const lastX = ((values.length - 1) * step).toFixed(1)
33
52
  const lastY = (h - ((lastVal - min) / range) * (h - 4) - 2).toFixed(1)
34
53
 
35
54
  return (
36
55
  <svg className="sparkline-svg" width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
37
- <polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.8" />
56
+ <polyline fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" strokeLinecap="round" points={points} opacity="0.85" />
38
57
  <circle cx={lastX} cy={lastY} r="2.5" fill={color} />
39
58
  </svg>
40
59
  )
@@ -6,3 +6,8 @@
6
6
  .low { background: linear-gradient(90deg, #ff4444, #cc0000); }
7
7
  .value { font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600; }
8
8
  .none { color: var(--color-text-dim); }
9
+
10
+ /* 📖 Light theme: drop the neon gradients to deep, saturated versions. */
11
+ :global([data-theme="light"]) .high { background: linear-gradient(90deg, #008f4d, #4a7a00); }
12
+ :global([data-theme="light"]) .mid { background: linear-gradient(90deg, #b86b00, #c45a00); }
13
+ :global([data-theme="light"]) .low { background: linear-gradient(90deg, #c8143a, #990000); }
@@ -15,3 +15,10 @@
15
15
  0%, 100% { box-shadow: 0 0 0 0 rgba(118,185,0,0.25); }
16
16
  50% { box-shadow: 0 0 0 6px transparent; }
17
17
  }
18
+
19
+ /* 📖 Light theme: deep green/red for the dot so it still reads as "up"/"down"
20
+ 📖 on a white card; the glow halo is dimmed so it doesn't blow out. */
21
+ :global([data-theme="light"]) .up { background: #008f4d; box-shadow: 0 0 4px rgba(0,143,77,0.35); }
22
+ :global([data-theme="light"]) .down,
23
+ :global([data-theme="light"]) .timeout { background: #c8143a; }
24
+ :global([data-theme="light"]) .pending { background: #888899; }
@@ -35,4 +35,10 @@
35
35
  border-radius: 50%;
36
36
  animation: miniSpin 0.6s linear infinite;
37
37
  flex-shrink: 0;
38
- }
38
+ }
39
+
40
+ /* 📖 Light theme overrides — match AILatencyCell. */
41
+ :global([data-theme="light"]) .fast { color: #008f4d; }
42
+ :global([data-theme="light"]) .slow { color: #c8143a; }
43
+ :global([data-theme="light"]) .badge { color: #0e7490; }
44
+ :global([data-theme="light"]) .runningCell { background: rgba(125, 0, 180, 0.08); }
@@ -16,3 +16,14 @@
16
16
  .tier_bplus { background: rgba(168,168,200,0.1); color: #a8a8c8; border: 1px solid rgba(168,168,200,0.15); }
17
17
  .tier_b { background: rgba(128,128,152,0.1); color: #808098; border: 1px solid rgba(128,128,152,0.15); }
18
18
  .tier_c { background: rgba(96,96,120,0.1); color: #606078; border: 1px solid rgba(96,96,120,0.15); }
19
+
20
+ /* 📖 Light theme: darker, AA-contrast tier text colors. Borders stay the same
21
+ 📖 hue so the badge still feels "tier-colored", just more legible. */
22
+ :global([data-theme="light"]) .tier_splus { color: #7a5e00; border-color: rgba(176,136,0,0.4); }
23
+ :global([data-theme="light"]) .tier_s { color: #a14a00; border-color: rgba(196,90,0,0.4); }
24
+ :global([data-theme="light"]) .tier_aplus { color: #005d8a; border-color: rgba(0,115,168,0.4); }
25
+ :global([data-theme="light"]) .tier_a { color: #1f7a3e; border-color: rgba(31,122,62,0.4); }
26
+ :global([data-theme="light"]) .tier_aminus { color: #2f7a2f; border-color: rgba(47,122,47,0.4); }
27
+ :global([data-theme="light"]) .tier_bplus { color: #4a4a72; border-color: rgba(90,90,138,0.4); }
28
+ :global([data-theme="light"]) .tier_b { color: #555566; border-color: rgba(85,85,102,0.4); }
29
+ :global([data-theme="light"]) .tier_c { color: #5a5a6a; border-color: rgba(106,106,122,0.4); }
@@ -18,6 +18,12 @@
18
18
  .error { border-left: 4px solid #ff4444; }
19
19
  .warning { border-left: 4px solid #ffaa00; }
20
20
  .info { border-left: 4px solid #06b6d4; }
21
+
22
+ /* 📖 Light theme: deeper accent bars so they pop on the white toast card. */
23
+ :global([data-theme="light"]) .success { border-left-color: #008f4d; }
24
+ :global([data-theme="light"]) .error { border-left-color: #c8143a; }
25
+ :global([data-theme="light"]) .warning { border-left-color: #b86b00; }
26
+ :global([data-theme="light"]) .info { border-left-color: #0e7490; }
21
27
  .icon { font-size: 18px; flex-shrink: 0; }
22
28
  .message { flex: 1; }
23
29
  .close {
@@ -26,3 +26,15 @@
26
26
  .unstable { background: rgba(204,0,0,0.12); color: #cc0000; }
27
27
  .notactive { background: rgba(85,85,112,0.12); color: #555570; }
28
28
  .pending { background: rgba(68,68,96,0.1); color: #444460; }
29
+
30
+ /* 📖 Light theme: deeper, more saturated text colors. Backgrounds stay
31
+ 📖 the same since they are translucent and adapt to the white card. */
32
+ :global([data-theme="light"]) .perfect { color: #008f4d; background: rgba(0,143,77,0.10); }
33
+ :global([data-theme="light"]) .normal { color: #4a7a00; background: rgba(74,122,0,0.10); }
34
+ :global([data-theme="light"]) .slow { color: #b86b00; background: rgba(184,107,0,0.10); }
35
+ :global([data-theme="light"]) .spiky { color: #c45000; background: rgba(196,80,0,0.10); }
36
+ :global([data-theme="light"]) .veryslow { color: #c8143a; background: rgba(200,20,58,0.10); }
37
+ :global([data-theme="light"]) .overloaded { color: #b8101f; background: rgba(184,16,31,0.10); }
38
+ :global([data-theme="light"]) .unstable { color: #990000; background: rgba(153,0,0,0.10); }
39
+ :global([data-theme="light"]) .notactive { color: #5a5a72; }
40
+ :global([data-theme="light"]) .pending { color: #5a5a8a; }