@glossarist/concept-browser 0.1.0

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 (68) hide show
  1. package/README.md +319 -0
  2. package/cli/index.mjs +119 -0
  3. package/env.d.ts +7 -0
  4. package/index.html +16 -0
  5. package/package.json +78 -0
  6. package/postcss.config.js +6 -0
  7. package/scripts/build-edges.js +112 -0
  8. package/scripts/fetch-datasets.mjs +195 -0
  9. package/scripts/generate-404.js +15 -0
  10. package/scripts/generate-data.mjs +606 -0
  11. package/scripts/load-site-config.mjs +56 -0
  12. package/src/App.vue +98 -0
  13. package/src/__tests__/data-integration.test.ts +135 -0
  14. package/src/__tests__/data-integrity.test.ts +101 -0
  15. package/src/__tests__/dataset-adapter.test.ts +336 -0
  16. package/src/__tests__/dataset-style.test.ts +37 -0
  17. package/src/__tests__/graph.test.ts +187 -0
  18. package/src/__tests__/lang.test.ts +29 -0
  19. package/src/__tests__/math.test.ts +113 -0
  20. package/src/__tests__/reference-resolver.test.ts +122 -0
  21. package/src/__tests__/site-config.test.ts +52 -0
  22. package/src/__tests__/uri-router.test.ts +76 -0
  23. package/src/adapters/DatasetAdapter.ts +270 -0
  24. package/src/adapters/ReferenceResolver.ts +95 -0
  25. package/src/adapters/UriRouter.ts +41 -0
  26. package/src/adapters/factory.ts +78 -0
  27. package/src/adapters/types.ts +162 -0
  28. package/src/components/AppHeader.vue +99 -0
  29. package/src/components/AppSidebar.vue +133 -0
  30. package/src/components/ConceptCard.vue +65 -0
  31. package/src/components/ConceptDetail.vue +540 -0
  32. package/src/components/ConceptTimeline.vue +410 -0
  33. package/src/components/FormatDownloads.vue +46 -0
  34. package/src/components/GraphPanel.vue +499 -0
  35. package/src/components/LanguageDetail.vue +211 -0
  36. package/src/components/NavIcon.vue +20 -0
  37. package/src/components/SearchBar.vue +241 -0
  38. package/src/composables/use-dataset-loader.ts +27 -0
  39. package/src/config/types.ts +130 -0
  40. package/src/config/use-site-config.ts +144 -0
  41. package/src/graph/GraphEngine.ts +137 -0
  42. package/src/graph/index.ts +1 -0
  43. package/src/main.ts +11 -0
  44. package/src/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  45. package/src/router/index.ts +43 -0
  46. package/src/router/page-routes.ts +35 -0
  47. package/src/stores/ui.ts +59 -0
  48. package/src/stores/vocabulary.ts +309 -0
  49. package/src/style.css +314 -0
  50. package/src/utils/asciidoc-lite.ts +123 -0
  51. package/src/utils/concept-formats.ts +157 -0
  52. package/src/utils/dataset-style.ts +54 -0
  53. package/src/utils/index.ts +1 -0
  54. package/src/utils/lang.ts +32 -0
  55. package/src/utils/math.ts +100 -0
  56. package/src/views/AboutView.vue +122 -0
  57. package/src/views/ConceptView.vue +119 -0
  58. package/src/views/ContributorsView.vue +110 -0
  59. package/src/views/DatasetView.vue +249 -0
  60. package/src/views/GraphView.vue +65 -0
  61. package/src/views/HomeView.vue +168 -0
  62. package/src/views/NewsView.vue +146 -0
  63. package/src/views/ResolveView.vue +63 -0
  64. package/src/views/SearchView.vue +33 -0
  65. package/src/views/StatsView.vue +121 -0
  66. package/tailwind.config.js +43 -0
  67. package/tsconfig.json +24 -0
  68. package/vite.config.ts +27 -0
@@ -0,0 +1,309 @@
1
+ import { defineStore } from 'pinia';
2
+ import { ref, computed, toRaw } from 'vue';
3
+ import { getFactory } from '../adapters/factory';
4
+ import type { DatasetAdapter } from '../adapters/DatasetAdapter';
5
+ import type { Manifest, ConceptDocument, SearchHit, GraphEdge } from '../adapters/types';
6
+ import { GraphEngine } from '../graph';
7
+
8
+ export const useVocabularyStore = defineStore('vocabulary', () => {
9
+ // State
10
+ const datasets = ref<Map<string, DatasetAdapter>>(new Map());
11
+ const manifests = ref<Map<string, Manifest>>(new Map());
12
+ const currentConcept = ref<ConceptDocument | null>(null);
13
+ const currentRegisterId = ref<string>('');
14
+ const currentConceptId = ref<string>('');
15
+ const loading = ref(false);
16
+ const error = ref<string | null>(null);
17
+ const graph = ref(new GraphEngine());
18
+ const conceptEdges = ref<GraphEdge[]>([]);
19
+ const initialized = ref(false);
20
+
21
+ // Graph reactivity: increment to trigger computed updates
22
+ const graphVersion = ref(0);
23
+ function touchGraph() { graphVersion.value++; }
24
+
25
+ // Edge loading status
26
+ const edgeStatus = ref<Record<string, { loaded: boolean; count: number }>>({});
27
+
28
+ const factory = getFactory();
29
+
30
+ // Computed
31
+ const currentManifest = computed(() =>
32
+ manifests.value.get(currentRegisterId.value)
33
+ );
34
+
35
+ const currentAdapter = computed(() =>
36
+ datasets.value.get(currentRegisterId.value)
37
+ );
38
+
39
+ const datasetList = computed(() => {
40
+ const list: { id: string; manifest: Manifest; adapter: DatasetAdapter }[] = [];
41
+ for (const [id, adapter] of datasets.value) {
42
+ const m = manifests.value.get(id);
43
+ if (m) list.push({ id, manifest: m, adapter: adapter as DatasetAdapter });
44
+ }
45
+ return list;
46
+ });
47
+
48
+ // Actions
49
+ async function discoverDatasets() {
50
+ loading.value = true;
51
+ error.value = null;
52
+ try {
53
+ const adapters = await factory.discoverDatasets('/datasets.json');
54
+ for (const adapter of adapters) {
55
+ datasets.value.set(adapter.registerId, adapter);
56
+ if (adapter.manifest) {
57
+ manifests.value.set(adapter.registerId, adapter.manifest);
58
+ }
59
+ }
60
+ initialized.value = true;
61
+ } catch (e: any) {
62
+ error.value = `Failed to discover datasets: ${e.message}`;
63
+ } finally {
64
+ loading.value = false;
65
+ }
66
+ }
67
+
68
+ async function loadDataset(registerId: string) {
69
+ error.value = null;
70
+ try {
71
+ const adapter = await factory.loadDataset(registerId);
72
+ datasets.value.set(registerId, adapter);
73
+ if (adapter.manifest) {
74
+ manifests.value.set(registerId, adapter.manifest);
75
+ }
76
+
77
+ // Load pre-computed edges (lightweight)
78
+ await loadEdges(adapter);
79
+
80
+ touchGraph();
81
+
82
+ // Seed graph nodes lazily — don't block UI for large datasets
83
+ seedGraphNodes(registerId, adapter);
84
+ } catch (e: any) {
85
+ error.value = `Failed to load dataset ${registerId}: ${e.message}`;
86
+ throw e;
87
+ }
88
+ }
89
+
90
+ function seedGraphNodes(registerId: string, adapter: DatasetAdapter, sync = false) {
91
+ const entries = adapter.getConcepts();
92
+
93
+ if (sync) {
94
+ for (const entry of entries) {
95
+ if (!entry) continue;
96
+ graph.value.addNode({
97
+ uri: factory.router.buildUri(registerId, entry.id),
98
+ register: registerId,
99
+ conceptId: entry.id,
100
+ designations: entry.eng ? { eng: entry.eng } : {},
101
+ status: entry.status,
102
+ loaded: false,
103
+ });
104
+ }
105
+ touchGraph();
106
+ return;
107
+ }
108
+
109
+ const batchSize = 500;
110
+ let offset = 0;
111
+ const schedule = typeof requestIdleCallback !== 'undefined'
112
+ ? requestIdleCallback
113
+ : (cb: () => void) => setTimeout(cb, 0);
114
+
115
+ function processBatch() {
116
+ const end = Math.min(offset + batchSize, entries.length);
117
+ for (let i = offset; i < end; i++) {
118
+ const entry = entries[i];
119
+ if (!entry) continue;
120
+ graph.value.addNode({
121
+ uri: factory.router.buildUri(registerId, entry.id),
122
+ register: registerId,
123
+ conceptId: entry.id,
124
+ designations: entry.eng ? { eng: entry.eng } : {},
125
+ status: entry.status,
126
+ loaded: false,
127
+ });
128
+ }
129
+ offset = end;
130
+ if (offset < entries.length) {
131
+ schedule(processBatch);
132
+ } else {
133
+ touchGraph();
134
+ }
135
+ }
136
+
137
+ schedule(processBatch);
138
+ }
139
+
140
+ async function loadAllGraphData() {
141
+ const engine = toRaw(graph.value);
142
+ const adapters = factory.getAdapters();
143
+
144
+ await Promise.allSettled(adapters.map(async (adapter) => {
145
+ try {
146
+ const [nodeResult, edgeResult] = await Promise.allSettled([
147
+ adapter.loadGraphNodes(),
148
+ !edgeStatus.value[adapter.registerId]?.loaded
149
+ ? adapter.loadEdgeIndex()
150
+ : Promise.resolve([] as GraphEdge[]),
151
+ ]);
152
+
153
+ if (nodeResult.status === 'fulfilled') {
154
+ const { uriPrefix, nodes } = nodeResult.value;
155
+ for (const [id, term, lang, status] of nodes) {
156
+ engine.addNode({
157
+ uri: uriPrefix + id,
158
+ register: adapter.registerId,
159
+ conceptId: id,
160
+ designations: term ? { [lang || 'eng']: term } : {},
161
+ status,
162
+ loaded: false,
163
+ });
164
+ }
165
+ }
166
+
167
+ if (edgeResult.status === 'fulfilled' && Array.isArray(edgeResult.value)) {
168
+ for (const edge of edgeResult.value) {
169
+ engine.addEdge(edge);
170
+ }
171
+ edgeStatus.value[adapter.registerId] = { loaded: true, count: edgeResult.value.length };
172
+ }
173
+ } catch {
174
+ // Individual adapter failures are non-critical for graph view
175
+ }
176
+ }));
177
+
178
+ touchGraph();
179
+ }
180
+
181
+ async function loadEdges(adapter: DatasetAdapter) {
182
+ try {
183
+ const edges = await adapter.loadEdgeIndex();
184
+ for (const edge of edges) {
185
+ // Mark source node as having edges
186
+ graph.value.addEdge(edge);
187
+ }
188
+ edgeStatus.value[adapter.registerId] = { loaded: true, count: edges.length };
189
+ } catch {
190
+ edgeStatus.value[adapter.registerId] = { loaded: false, count: 0 };
191
+ }
192
+ }
193
+
194
+ async function viewConcept(registerId: string, conceptId: string) {
195
+ error.value = null;
196
+ currentRegisterId.value = registerId;
197
+ currentConceptId.value = conceptId;
198
+
199
+ try {
200
+ const adapter = datasets.value.get(registerId);
201
+ if (!adapter) throw new Error(`Dataset ${registerId} not loaded`);
202
+
203
+ const concept = await adapter.fetchConcept(conceptId);
204
+ currentConcept.value = concept;
205
+
206
+ // Extract and register edges for this specific concept
207
+ const edges = adapter.extractEdges(concept);
208
+ const uri = concept['@id'];
209
+
210
+ // Update graph node with full data
211
+ graph.value.addNode({
212
+ uri,
213
+ register: registerId,
214
+ conceptId,
215
+ designations: (() => {
216
+ const d: Record<string, string> = {};
217
+ const entry = adapter.getIndexEntry(conceptId);
218
+ if (entry?.eng) d.eng = entry.eng;
219
+ for (const [lang, lc] of Object.entries(concept['gl:localizedConcept'] || {})) {
220
+ const preferred = lc['gl:designation']?.find(
221
+ (dd: any) => dd['gl:normativeStatus'] === 'preferred'
222
+ );
223
+ if (preferred?.['gl:term']) d[lang] = preferred['gl:term'];
224
+ }
225
+ return d;
226
+ })(),
227
+ status: adapter.getIndexEntry(conceptId)?.status ?? 'unknown',
228
+ loaded: true,
229
+ });
230
+
231
+ for (const edge of edges) {
232
+ graph.value.addEdge(edge);
233
+ }
234
+
235
+ touchGraph();
236
+ conceptEdges.value = graph.value.getEdges(uri);
237
+ } catch (e: any) {
238
+ error.value = `Failed to load concept ${conceptId}: ${e.message}`;
239
+ currentConcept.value = null;
240
+ throw e;
241
+ }
242
+ }
243
+
244
+ async function navigateToUri(uri: string) {
245
+ const resolution = factory.resolve(uri);
246
+
247
+ if (resolution.type !== 'internal') {
248
+ error.value = `Cannot resolve URI: ${uri}`;
249
+ return;
250
+ }
251
+
252
+ if (!datasets.value.has(resolution.registerId)) {
253
+ await loadDataset(resolution.registerId);
254
+ }
255
+
256
+ try {
257
+ await viewConcept(resolution.registerId, resolution.conceptId);
258
+ } catch {
259
+ // viewConcept already sets error.value
260
+ }
261
+ }
262
+
263
+ async function searchAcrossDatasets(query: string, lang: string = 'eng'): Promise<SearchHit[]> {
264
+ const hits: SearchHit[] = [];
265
+ for (const adapter of datasets.value.values()) {
266
+ if (adapter.index || adapter.manifest) {
267
+ await adapter.ensureAllChunksLoaded();
268
+ hits.push(...adapter.search(query, lang));
269
+ }
270
+ }
271
+ return hits;
272
+ }
273
+
274
+ async function getRandomConcept(): Promise<{ registerId: string; conceptId: string } | null> {
275
+ const loaded = [...datasets.value.values()].filter(a => a.index);
276
+ if (!loaded.length) return null;
277
+ const adapter = loaded[Math.floor(Math.random() * loaded.length)];
278
+ const concepts = adapter.getConcepts() as (import('../adapters/types').ConceptSummary | undefined)[];
279
+ const dense = concepts.filter((c): c is import('../adapters/types').ConceptSummary => c != null);
280
+ if (!dense.length) return null;
281
+ const pick = dense[Math.floor(Math.random() * dense.length)];
282
+ return { registerId: adapter.registerId, conceptId: pick.id };
283
+ }
284
+
285
+ return {
286
+ datasets,
287
+ manifests,
288
+ currentConcept,
289
+ currentRegisterId,
290
+ currentConceptId,
291
+ loading,
292
+ error,
293
+ graph,
294
+ graphVersion,
295
+ conceptEdges,
296
+ initialized,
297
+ edgeStatus,
298
+ currentManifest,
299
+ currentAdapter,
300
+ datasetList,
301
+ discoverDatasets,
302
+ loadDataset,
303
+ viewConcept,
304
+ navigateToUri,
305
+ searchAcrossDatasets,
306
+ loadAllGraphData,
307
+ getRandomConcept,
308
+ };
309
+ });
package/src/style.css ADDED
@@ -0,0 +1,314 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ --brand-primary: #2563eb;
7
+ --brand-primary-rgb: 37, 99, 235;
8
+ --brand-dark: #1e293b;
9
+ --font-header: 'DM Serif Display', Georgia, serif;
10
+ --font-body: 'DM Sans', system-ui, sans-serif;
11
+ }
12
+
13
+ @layer base {
14
+ body {
15
+ font-family: var(--font-body);
16
+ @apply antialiased bg-surface text-ink-800;
17
+ -webkit-font-smoothing: antialiased;
18
+ -moz-osx-font-smoothing: grayscale;
19
+ }
20
+
21
+ h1, h2, h3, .font-serif {
22
+ font-family: var(--font-header);
23
+ }
24
+
25
+ code, pre, .font-mono {
26
+ font-family: 'JetBrains Mono', monospace;
27
+ }
28
+ }
29
+
30
+ @layer components {
31
+ /* Cards */
32
+ .card {
33
+ @apply bg-surface-raised rounded-xl border border-ink-100/60;
34
+ box-shadow: 0 1px 3px rgba(26, 27, 46, 0.04), 0 1px 2px rgba(26, 27, 46, 0.02);
35
+ }
36
+
37
+ .card-hover {
38
+ @apply card transition-all duration-200;
39
+ }
40
+ .card-hover:hover {
41
+ box-shadow: 0 4px 16px rgba(26, 27, 46, 0.08), 0 2px 4px rgba(26, 27, 46, 0.04);
42
+ @apply border-ink-200;
43
+ transform: translateY(-1px);
44
+ }
45
+ .card-hover:active {
46
+ transform: translateY(0);
47
+ box-shadow: 0 1px 3px rgba(26, 27, 46, 0.04);
48
+ }
49
+
50
+ /* Buttons */
51
+ .btn-primary {
52
+ @apply text-white px-5 py-2 rounded-lg font-medium text-sm transition-all duration-150;
53
+ background-color: var(--brand-dark);
54
+ box-shadow: 0 1px 2px rgba(26, 27, 46, 0.12);
55
+ }
56
+ .btn-primary:hover {
57
+ background-color: var(--brand-primary);
58
+ box-shadow: 0 2px 4px rgba(26, 27, 46, 0.16);
59
+ }
60
+ .btn-primary:disabled {
61
+ @apply bg-ink-300 cursor-not-allowed;
62
+ box-shadow: none;
63
+ }
64
+
65
+ .btn-secondary {
66
+ @apply bg-surface-raised text-ink-700 px-5 py-2 rounded-lg font-medium text-sm border border-ink-100 transition-all duration-150;
67
+ }
68
+ .btn-secondary:hover {
69
+ @apply bg-surface-alt border-ink-200;
70
+ }
71
+
72
+ .btn-ghost {
73
+ @apply text-ink-600 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors duration-150;
74
+ }
75
+ .btn-ghost:hover {
76
+ @apply bg-ink-50 text-ink-800;
77
+ }
78
+ .btn-ghost.active {
79
+ @apply bg-ink-50 text-ink-800 font-semibold;
80
+ }
81
+
82
+ /* Badges */
83
+ .badge {
84
+ @apply inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium tracking-wide;
85
+ }
86
+ .badge-blue {
87
+ @apply badge bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300;
88
+ }
89
+ .badge-green {
90
+ @apply badge bg-emerald-50 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300;
91
+ }
92
+ .badge-yellow {
93
+ @apply badge bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300;
94
+ }
95
+ .badge-gray {
96
+ @apply badge bg-ink-50 text-ink-600;
97
+ }
98
+ .badge-purple {
99
+ @apply badge bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300;
100
+ }
101
+
102
+ /* Brand badge — uses primary color from CSS var */
103
+ .badge-brand {
104
+ @apply badge;
105
+ background-color: rgba(var(--brand-primary-rgb), 0.1);
106
+ color: var(--brand-primary);
107
+ }
108
+
109
+ /* Links */
110
+ .concept-link {
111
+ color: var(--brand-primary);
112
+ @apply underline-offset-2 hover:underline cursor-pointer transition-colors;
113
+ }
114
+ .concept-link:hover {
115
+ filter: brightness(0.8);
116
+ }
117
+
118
+ .xref-link {
119
+ color: var(--brand-primary);
120
+ @apply underline-offset-2 hover:underline cursor-pointer transition-colors;
121
+ font-weight: 500;
122
+ }
123
+
124
+ /* Concept definition lists */
125
+ .concept-list {
126
+ list-style: disc;
127
+ padding-left: 1.25rem;
128
+ margin: 0.5rem 0;
129
+ }
130
+ .concept-list li {
131
+ margin: 0.25rem 0;
132
+ }
133
+
134
+ /* Smooth collapse for language sections */
135
+ .lang-content {
136
+ overflow: hidden;
137
+ transition: max-height 250ms ease-out, opacity 200ms ease-out;
138
+ }
139
+
140
+ /* Section labels */
141
+ .section-label {
142
+ @apply text-[11px] font-semibold uppercase tracking-widest text-ink-400 mb-2;
143
+ font-family: var(--font-body);
144
+ }
145
+
146
+ /* Math */
147
+ .math-inline .katex {
148
+ font-size: 1.05em;
149
+ }
150
+ .math-bold .katex .mord,
151
+ .math-bold .katex .mbin,
152
+ .math-bold .katex .mrel {
153
+ font-weight: bold;
154
+ }
155
+ .math-fallback {
156
+ @apply bg-ink-50 text-ink-600 px-1 rounded text-xs font-mono;
157
+ }
158
+
159
+ /* Prose content (news posts, etc.) */
160
+ .prose-news {
161
+ @apply text-sm text-ink-700 leading-relaxed;
162
+ }
163
+ .prose-news h2 {
164
+ @apply font-serif text-xl text-ink-800 mt-6 mb-3;
165
+ }
166
+ .prose-news h3 {
167
+ @apply font-serif text-lg text-ink-800 mt-5 mb-2;
168
+ }
169
+ .prose-news h4 {
170
+ @apply font-semibold text-ink-800 mt-4 mb-2;
171
+ }
172
+ .prose-news p {
173
+ @apply mb-4;
174
+ }
175
+ .prose-news ul, .prose-news ol {
176
+ @apply mb-4 pl-5;
177
+ }
178
+ .prose-news ul {
179
+ @apply list-disc;
180
+ }
181
+ .prose-news ol {
182
+ @apply list-decimal;
183
+ }
184
+ .prose-news li {
185
+ @apply mb-1;
186
+ }
187
+ .prose-news a {
188
+ @apply concept-link;
189
+ }
190
+ .prose-news strong {
191
+ @apply font-semibold text-ink-800;
192
+ }
193
+ .prose-news em {
194
+ @apply italic;
195
+ }
196
+ .prose-news code {
197
+ @apply bg-ink-50 text-ink-600 px-1 rounded text-xs font-mono;
198
+ }
199
+ .prose-news pre {
200
+ @apply bg-ink-50 rounded-lg p-4 mb-4 overflow-x-auto text-xs font-mono;
201
+ }
202
+ .prose-news pre code {
203
+ @apply bg-transparent px-0 text-ink-700;
204
+ }
205
+ }
206
+
207
+ @layer utilities {
208
+ .text-balance {
209
+ text-wrap: balance;
210
+ }
211
+ }
212
+
213
+ /* Route transitions */
214
+ .page-enter-active,
215
+ .page-leave-active {
216
+ transition: opacity 150ms ease;
217
+ }
218
+ .page-enter-from,
219
+ .page-leave-to {
220
+ opacity: 0;
221
+ }
222
+
223
+ /* Staggered entrance for card grids */
224
+ @keyframes fadeSlideIn {
225
+ from {
226
+ opacity: 0;
227
+ transform: translateY(8px);
228
+ }
229
+ to {
230
+ opacity: 1;
231
+ transform: translateY(0);
232
+ }
233
+ }
234
+ .animate-entrance {
235
+ animation: fadeSlideIn 300ms ease both;
236
+ }
237
+
238
+ /* Skeleton shimmer */
239
+ @keyframes shimmer {
240
+ 0% { background-position: -200% 0; }
241
+ 100% { background-position: 200% 0; }
242
+ }
243
+ .skeleton {
244
+ background: linear-gradient(90deg, #f0f0f4 25%, #f7f7f9 50%, #f0f0f4 75%);
245
+ background-size: 200% 100%;
246
+ animation: shimmer 1.5s ease-in-out infinite;
247
+ border-radius: 6px;
248
+ }
249
+
250
+ /* ===== Dark mode ===== */
251
+ .dark body {
252
+ background-color: #0f1020;
253
+ color: #dddde6;
254
+ }
255
+
256
+ /* Surfaces */
257
+ .dark .bg-surface { background-color: #0f1020 !important; }
258
+ .dark .bg-surface-alt { background-color: #161728 !important; }
259
+ .dark .bg-surface-raised { background-color: #1a1b2e !important; }
260
+
261
+ /* Text */
262
+ .dark .text-ink { color: #dddde6 !important; }
263
+ .dark .text-ink-800 { color: #dddde6 !important; }
264
+ .dark .text-ink-700 { color: #b8b9cc !important; }
265
+ .dark .text-ink-600 { color: #8d8faa !important; }
266
+ .dark .text-ink-500 { color: #8d8faa !important; }
267
+ .dark .text-ink-400 { color: #636588 !important; }
268
+ .dark .text-ink-300 { color: #636588 !important; }
269
+ .dark .text-ink-200 { color: #484a6e !important; }
270
+ .dark .text-ink-100 { color: #484a6e !important; }
271
+
272
+ /* Backgrounds */
273
+ .dark .bg-ink-50 { background-color: #161728 !important; }
274
+ .dark .bg-ink-100 { background-color: #1e1f34 !important; }
275
+
276
+ /* Borders */
277
+ .dark .border-ink-100 { border-color: #2c2e4a !important; }
278
+ .dark .border-ink-100\/60 { border-color: rgba(44, 46, 74, 0.6) !important; }
279
+ .dark .border-ink-100\/80 { border-color: rgba(44, 46, 74, 0.8) !important; }
280
+ .dark .border-ink-200 { border-color: #36385a !important; }
281
+
282
+ /* Focus rings */
283
+ .dark .focus\:ring-ink-200:focus { --tw-ring-color: #36385a !important; }
284
+ .dark .focus\:ring-ink-200\/30:focus { --tw-ring-color: rgba(54, 56, 90, 0.3) !important; }
285
+ .dark .focus\:border-ink-400:focus { border-color: #636588 !important; }
286
+
287
+ /* Cards */
288
+ .dark .card {
289
+ background-color: #1e1f34 !important;
290
+ border-color: rgba(44, 46, 74, 0.6) !important;
291
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2) !important;
292
+ }
293
+
294
+ /* Inputs */
295
+ .dark input {
296
+ background-color: #161728 !important;
297
+ border-color: #2c2e4a !important;
298
+ color: #dddde6 !important;
299
+ }
300
+ .dark input::placeholder { color: #484a6e !important; }
301
+
302
+ /* Skeleton */
303
+ .dark .skeleton {
304
+ background: linear-gradient(90deg, #1e1f34 25%, #2c2e4a 50%, #1e1f34 75%) !important;
305
+ }
306
+
307
+ /* Hover states */
308
+ .dark .hover\:bg-ink-50:hover { background-color: #161728 !important; }
309
+ .dark .hover\:bg-surface-alt:hover { background-color: #161728 !important; }
310
+ .dark .hover\:text-ink-700:hover { color: #b8b9cc !important; }
311
+ .dark .hover\:text-ink-800:hover { color: #dddde6 !important; }
312
+
313
+ /* Placeholder text */
314
+ .dark .placeholder\:text-ink-300::placeholder { color: #484a6e !important; }