veslx 0.0.1

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 (83) hide show
  1. package/README.md +3 -0
  2. package/bin/lib/import-config.ts +13 -0
  3. package/bin/lib/init.ts +31 -0
  4. package/bin/lib/serve.ts +35 -0
  5. package/bin/lib/start.ts +40 -0
  6. package/bin/lib/stop.ts +24 -0
  7. package/bin/vesl.ts +41 -0
  8. package/components.json +20 -0
  9. package/eslint.config.js +23 -0
  10. package/index.html +17 -0
  11. package/package.json +89 -0
  12. package/plugin/README.md +21 -0
  13. package/plugin/package.json +26 -0
  14. package/plugin/src/cli.ts +30 -0
  15. package/plugin/src/client.tsx +224 -0
  16. package/plugin/src/lib.ts +268 -0
  17. package/plugin/src/plugin.ts +109 -0
  18. package/postcss.config.js +5 -0
  19. package/public/logo_dark.png +0 -0
  20. package/public/logo_light.png +0 -0
  21. package/src/App.tsx +21 -0
  22. package/src/components/front-matter.tsx +53 -0
  23. package/src/components/gallery/components/figure-caption.tsx +15 -0
  24. package/src/components/gallery/components/figure-header.tsx +20 -0
  25. package/src/components/gallery/components/lightbox.tsx +106 -0
  26. package/src/components/gallery/components/loading-image.tsx +48 -0
  27. package/src/components/gallery/hooks/use-gallery-images.ts +103 -0
  28. package/src/components/gallery/hooks/use-lightbox.ts +40 -0
  29. package/src/components/gallery/index.tsx +134 -0
  30. package/src/components/gallery/lib/render-math-in-text.tsx +47 -0
  31. package/src/components/header.tsx +68 -0
  32. package/src/components/index.ts +5 -0
  33. package/src/components/loading.tsx +16 -0
  34. package/src/components/mdx-components.tsx +163 -0
  35. package/src/components/mode-toggle.tsx +44 -0
  36. package/src/components/page-error.tsx +59 -0
  37. package/src/components/parameter-badge.tsx +78 -0
  38. package/src/components/parameter-table.tsx +420 -0
  39. package/src/components/post-list.tsx +148 -0
  40. package/src/components/running-bar.tsx +21 -0
  41. package/src/components/runtime-mdx.tsx +82 -0
  42. package/src/components/slide.tsx +11 -0
  43. package/src/components/theme-provider.tsx +6 -0
  44. package/src/components/ui/badge.tsx +36 -0
  45. package/src/components/ui/breadcrumb.tsx +115 -0
  46. package/src/components/ui/button.tsx +56 -0
  47. package/src/components/ui/card.tsx +79 -0
  48. package/src/components/ui/carousel.tsx +260 -0
  49. package/src/components/ui/dropdown-menu.tsx +198 -0
  50. package/src/components/ui/input.tsx +22 -0
  51. package/src/components/ui/kbd.tsx +22 -0
  52. package/src/components/ui/select.tsx +158 -0
  53. package/src/components/ui/separator.tsx +29 -0
  54. package/src/components/ui/shadcn-io/code-block/index.tsx +620 -0
  55. package/src/components/ui/shadcn-io/code-block/server.tsx +63 -0
  56. package/src/components/ui/sheet.tsx +140 -0
  57. package/src/components/ui/sidebar.tsx +771 -0
  58. package/src/components/ui/skeleton.tsx +15 -0
  59. package/src/components/ui/spinner.tsx +16 -0
  60. package/src/components/ui/tooltip.tsx +28 -0
  61. package/src/components/welcome.tsx +21 -0
  62. package/src/hooks/use-key-bindings.ts +72 -0
  63. package/src/hooks/use-mobile.tsx +19 -0
  64. package/src/index.css +279 -0
  65. package/src/lib/constants.ts +10 -0
  66. package/src/lib/format-date.tsx +6 -0
  67. package/src/lib/format-file-size.ts +10 -0
  68. package/src/lib/parameter-utils.ts +134 -0
  69. package/src/lib/utils.ts +6 -0
  70. package/src/main.tsx +10 -0
  71. package/src/pages/home.tsx +39 -0
  72. package/src/pages/post.tsx +65 -0
  73. package/src/pages/slides.tsx +173 -0
  74. package/tailwind.config.js +136 -0
  75. package/test-content/.vesl.json +49 -0
  76. package/test-content/README.md +33 -0
  77. package/test-content/test-post/README.mdx +7 -0
  78. package/test-content/test-slides/SLIDES.mdx +8 -0
  79. package/tsconfig.app.json +32 -0
  80. package/tsconfig.json +15 -0
  81. package/tsconfig.node.json +25 -0
  82. package/vesl.config.ts +4 -0
  83. package/vite.config.ts +54 -0
@@ -0,0 +1,78 @@
1
+ import { useFileContent } from "../../plugin/src/client";
2
+ import { useMemo } from "react";
3
+ import { cn } from "@/lib/utils";
4
+ import {
5
+ type ParameterValue,
6
+ extractPath,
7
+ getValueType,
8
+ formatValue,
9
+ parseConfigFile,
10
+ deriveLabelFromPath,
11
+ } from "@/lib/parameter-utils";
12
+
13
+ interface ParameterBadgeProps {
14
+ /** Path to the YAML or JSON file */
15
+ path: string;
16
+ /** jq-like path to the value (e.g., ".base.N_E") */
17
+ keyPath: string;
18
+ /** Optional label override (defaults to last segment of keyPath) */
19
+ label?: string;
20
+ /** Optional unit suffix (e.g., "ms", "Hz") */
21
+ unit?: string;
22
+ }
23
+
24
+ export function ParameterBadge({ path, keyPath, label, unit }: ParameterBadgeProps) {
25
+ const { content, loading, error } = useFileContent(path);
26
+
27
+ const { value, displayLabel } = useMemo(() => {
28
+ if (!content) return { value: undefined, displayLabel: "" };
29
+
30
+ const data = parseConfigFile(content, path);
31
+ if (!data) return { value: undefined, displayLabel: "" };
32
+
33
+ const extracted = extractPath(data, keyPath);
34
+ const derivedLabel = label || deriveLabelFromPath(keyPath);
35
+
36
+ return { value: extracted, displayLabel: derivedLabel };
37
+ }, [content, path, keyPath, label]);
38
+
39
+ if (loading) {
40
+ return (
41
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-muted/50 border border-border/50">
42
+ <span className="w-2 h-2 border border-muted-foreground/40 border-t-transparent rounded-full animate-spin" />
43
+ </span>
44
+ );
45
+ }
46
+
47
+ if (error || value === undefined) {
48
+ return (
49
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-destructive/10 border border-destructive/30">
50
+ <span className="text-[10px] font-mono text-destructive">—</span>
51
+ </span>
52
+ );
53
+ }
54
+
55
+ const type = getValueType(value);
56
+ const formattedValue = formatValue(value);
57
+
58
+ return (
59
+ <span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md font-mono text-[11px]">
60
+ <span className="text-muted-foreground">{displayLabel}</span>
61
+ <span className="text-muted-foreground/40">=</span>
62
+ <span
63
+ className={cn(
64
+ "font-medium tabular-nums",
65
+ type === "number" && "text-foreground",
66
+ type === "string" && "text-amber-600 dark:text-amber-500",
67
+ type === "boolean" && "text-cyan-600 dark:text-cyan-500",
68
+ type === "null" && "text-muted-foreground/50",
69
+ type === "array" && "text-purple-600 dark:text-purple-400",
70
+ type === "object" && "text-purple-600 dark:text-purple-400"
71
+ )}
72
+ >
73
+ {type === "string" ? `"${formattedValue}"` : formattedValue}
74
+ </span>
75
+ {unit && <span className="text-muted-foreground/60">{unit}</span>}
76
+ </span>
77
+ );
78
+ }
@@ -0,0 +1,420 @@
1
+ import { useFileContent } from "../../plugin/src/client";
2
+ import { useMemo, useState } from "react";
3
+ import { cn } from "@/lib/utils";
4
+ import {
5
+ type ParameterValue,
6
+ extractPath,
7
+ getValueType,
8
+ formatValue,
9
+ parseConfigFile,
10
+ } from "@/lib/parameter-utils";
11
+
12
+ /**
13
+ * Build a filtered data object from an array of jq-like paths.
14
+ * Each path extracts data and places it in the result under the final key name.
15
+ */
16
+ function filterData(
17
+ data: Record<string, ParameterValue>,
18
+ keys: string[]
19
+ ): Record<string, ParameterValue> {
20
+ const result: Record<string, ParameterValue> = {};
21
+
22
+ for (const keyPath of keys) {
23
+ const extracted = extractPath(data, keyPath);
24
+ if (extracted === undefined) continue;
25
+
26
+ const cleanPath = keyPath.startsWith(".") ? keyPath.slice(1) : keyPath;
27
+
28
+ // For simple paths like .base.N_E, use "N_E" as key
29
+ // For paths with [], preserve more context
30
+ let keyName: string;
31
+ if (cleanPath.includes("[")) {
32
+ keyName = cleanPath.replace(/\[\]/g, "").replace(/\[(\d+)\]/g, "_$1");
33
+ } else {
34
+ const parts = cleanPath.split(".");
35
+ keyName = parts[parts.length - 1];
36
+ }
37
+
38
+ result[keyName] = extracted;
39
+ }
40
+
41
+ return result;
42
+ }
43
+
44
+ // Renders a flat section of key-value pairs in a dense grid
45
+ function ParameterGrid({ entries }: { entries: [string, ParameterValue][] }) {
46
+ if (entries.length === 0) return null;
47
+
48
+ return (
49
+ <div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-x-6 gap-y-px">
50
+ {entries.map(([key, value]) => {
51
+ const type = getValueType(value);
52
+ return (
53
+ <div
54
+ key={key}
55
+ className="flex items-baseline justify-between gap-2 py-1 group hover:bg-muted/30 -mx-1.5 px-1.5 rounded-sm transition-colors"
56
+ >
57
+ <span className="text-[11px] text-muted-foreground font-mono truncate">
58
+ {key}
59
+ </span>
60
+ <span
61
+ className={cn(
62
+ "text-[11px] font-mono tabular-nums font-medium shrink-0",
63
+ type === "number" && "text-foreground",
64
+ type === "string" && "text-amber-600 dark:text-amber-500",
65
+ type === "boolean" && "text-cyan-600 dark:text-cyan-500",
66
+ type === "null" && "text-muted-foreground/50"
67
+ )}
68
+ >
69
+ {type === "string" ? `"${formatValue(value)}"` : formatValue(value)}
70
+ </span>
71
+ </div>
72
+ );
73
+ })}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ // Renders a nested section with its own header
79
+ function ParameterSection({
80
+ name,
81
+ data,
82
+ depth = 0
83
+ }: {
84
+ name: string;
85
+ data: Record<string, ParameterValue>;
86
+ depth?: number;
87
+ }) {
88
+ const [isCollapsed, setIsCollapsed] = useState(false);
89
+
90
+ const entries = Object.entries(data);
91
+ const leafEntries = entries.filter(([, v]) => {
92
+ const t = getValueType(v);
93
+ return t !== "object" && t !== "array";
94
+ });
95
+ const nestedEntries = entries.filter(([, v]) => {
96
+ const t = getValueType(v);
97
+ return t === "object" || t === "array";
98
+ });
99
+
100
+ return (
101
+ <div className={cn(depth === 0 && "mb-4 last:mb-0")}>
102
+ <button
103
+ onClick={() => setIsCollapsed(!isCollapsed)}
104
+ className={cn(
105
+ "flex items-center gap-2 w-full text-left group mb-1.5",
106
+ depth === 0 && "pb-1 border-b border-border/50"
107
+ )}
108
+ >
109
+ <span className={cn(
110
+ "text-[10px] text-muted-foreground/60 transition-transform duration-150 select-none",
111
+ isCollapsed && "-rotate-90"
112
+ )}>
113
+ {isCollapsed ? "+" : "-"}
114
+ </span>
115
+
116
+ <span className={cn(
117
+ "font-mono text-[11px] uppercase tracking-widest",
118
+ depth === 0
119
+ ? "text-foreground/80 font-semibold"
120
+ : "text-muted-foreground/70"
121
+ )}>
122
+ {name.replace(/_/g, " ")}
123
+ </span>
124
+
125
+ <span className="text-[9px] font-mono text-muted-foreground/40 ml-auto">
126
+ {entries.length}
127
+ </span>
128
+ </button>
129
+
130
+ {!isCollapsed && (
131
+ <div className={cn(
132
+ depth > 0 && "pl-3 ml-1 border-l border-border/40"
133
+ )}>
134
+ {leafEntries.length > 0 && (
135
+ <div className={cn(nestedEntries.length > 0 && "mb-3")}>
136
+ <ParameterGrid entries={leafEntries} />
137
+ </div>
138
+ )}
139
+
140
+ {nestedEntries.map(([key, value]) => {
141
+ const type = getValueType(value);
142
+ if (type === "array") {
143
+ const arr = value as ParameterValue[];
144
+ return (
145
+ <div key={key} className="mb-2 last:mb-0">
146
+ <div className="text-[10px] font-mono text-muted-foreground/60 uppercase tracking-wider mb-1">
147
+ {key} [{arr.length}]
148
+ </div>
149
+ <div className="pl-3 ml-1 border-l border-border/40">
150
+ {arr.map((item, i) => {
151
+ const itemType = getValueType(item);
152
+ if (itemType === "object") {
153
+ return (
154
+ <ParameterSection
155
+ key={i}
156
+ name={`${i}`}
157
+ data={item as Record<string, ParameterValue>}
158
+ depth={depth + 1}
159
+ />
160
+ );
161
+ }
162
+ return (
163
+ <div key={i} className="text-[11px] font-mono text-foreground py-0.5">
164
+ [{i}] {formatValue(item)}
165
+ </div>
166
+ );
167
+ })}
168
+ </div>
169
+ </div>
170
+ );
171
+ }
172
+ return (
173
+ <ParameterSection
174
+ key={key}
175
+ name={key}
176
+ data={value as Record<string, ParameterValue>}
177
+ depth={depth + 1}
178
+ />
179
+ );
180
+ })}
181
+ </div>
182
+ )}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ interface ParameterTableProps {
188
+ /** Path to the YAML or JSON file */
189
+ path: string;
190
+ /**
191
+ * Optional array of jq-like paths to filter which parameters to show.
192
+ * Examples:
193
+ * - [".base.N_E", ".base.N_I"] → show only N_E and N_I from base
194
+ * - [".base"] → show entire base section
195
+ * - [".default_inputs", ".base.dt"] → show default_inputs section and dt from base
196
+ */
197
+ keys?: string[];
198
+ }
199
+
200
+ /**
201
+ * Estimate the height contribution of a data structure.
202
+ */
203
+ function estimateHeight(data: Record<string, ParameterValue>, depth = 0): number {
204
+ const entries = Object.entries(data);
205
+ let height = 0;
206
+
207
+ for (const [, value] of entries) {
208
+ const type = getValueType(value);
209
+ if (type === "object") {
210
+ height += 28 + estimateHeight(value as Record<string, ParameterValue>, depth + 1);
211
+ } else if (type === "array") {
212
+ const arr = value as ParameterValue[];
213
+ height += 28;
214
+ for (const item of arr) {
215
+ if (getValueType(item) === "object") {
216
+ height += 24 + estimateHeight(item as Record<string, ParameterValue>, depth + 1);
217
+ } else {
218
+ height += 24;
219
+ }
220
+ }
221
+ } else {
222
+ height += 24;
223
+ }
224
+ }
225
+
226
+ return height;
227
+ }
228
+
229
+ /**
230
+ * Split entries into balanced columns based on estimated height.
231
+ */
232
+ function splitIntoColumns<T extends [string, ParameterValue]>(
233
+ entries: T[],
234
+ numColumns: number
235
+ ): T[][] {
236
+ if (numColumns <= 1) return [entries];
237
+
238
+ const entryHeights = entries.map(([, value]) => {
239
+ const type = getValueType(value);
240
+ if (type === "object") {
241
+ return 28 + estimateHeight(value as Record<string, ParameterValue>);
242
+ } else if (type === "array") {
243
+ const arr = value as ParameterValue[];
244
+ let h = 28;
245
+ for (const item of arr) {
246
+ if (getValueType(item) === "object") {
247
+ h += 24 + estimateHeight(item as Record<string, ParameterValue>);
248
+ } else {
249
+ h += 24;
250
+ }
251
+ }
252
+ return h;
253
+ }
254
+ return 24;
255
+ });
256
+
257
+ const totalHeight = entryHeights.reduce((a, b) => a + b, 0);
258
+ const targetPerColumn = totalHeight / numColumns;
259
+
260
+ const columns: T[][] = [];
261
+ let currentColumn: T[] = [];
262
+ let currentHeight = 0;
263
+
264
+ for (let i = 0; i < entries.length; i++) {
265
+ const entry = entries[i];
266
+ const entryHeight = entryHeights[i];
267
+
268
+ if (currentHeight >= targetPerColumn && columns.length < numColumns - 1 && currentColumn.length > 0) {
269
+ columns.push(currentColumn);
270
+ currentColumn = [];
271
+ currentHeight = 0;
272
+ }
273
+
274
+ currentColumn.push(entry);
275
+ currentHeight += entryHeight;
276
+ }
277
+
278
+ if (currentColumn.length > 0) {
279
+ columns.push(currentColumn);
280
+ }
281
+
282
+ return columns;
283
+ }
284
+
285
+ export function ParameterTable({ path, keys }: ParameterTableProps) {
286
+ const { content, loading, error } = useFileContent(path);
287
+
288
+ const parsed = useMemo(() => {
289
+ if (!content) return null;
290
+
291
+ const data = parseConfigFile(content, path);
292
+ if (!data) return null;
293
+
294
+ if (keys && keys.length > 0) {
295
+ return filterData(data, keys);
296
+ }
297
+
298
+ return data;
299
+ }, [content, path, keys]);
300
+
301
+ if (loading) {
302
+ return (
303
+ <div className="my-6 p-4 rounded border border-border/50 bg-card/30">
304
+ <div className="flex items-center gap-2 text-muted-foreground/60">
305
+ <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
306
+ <span className="text-[11px] font-mono">loading parameters...</span>
307
+ </div>
308
+ </div>
309
+ );
310
+ }
311
+
312
+ if (error) {
313
+ return (
314
+ <div className="my-6 p-3 rounded border border-destructive/30 bg-destructive/5">
315
+ <p className="text-[11px] font-mono text-destructive">{error}</p>
316
+ </div>
317
+ );
318
+ }
319
+
320
+ if (!parsed) {
321
+ return (
322
+ <div className="my-6 p-3 rounded border border-border/50 bg-card/30">
323
+ <p className="text-[11px] font-mono text-muted-foreground">unable to parse config</p>
324
+ </div>
325
+ );
326
+ }
327
+
328
+ const entries = Object.entries(parsed);
329
+
330
+ const topLeaves = entries.filter(([, v]) => {
331
+ const t = getValueType(v);
332
+ return t !== "object" && t !== "array";
333
+ });
334
+ const topNested = entries.filter(([, v]) => {
335
+ const t = getValueType(v);
336
+ return t === "object" || t === "array";
337
+ });
338
+
339
+ const estHeight = estimateHeight(parsed);
340
+ const HEIGHT_THRESHOLD = 500;
341
+ const numColumns = estHeight > HEIGHT_THRESHOLD ? Math.min(Math.ceil(estHeight / HEIGHT_THRESHOLD), 3) : 1;
342
+ const useColumns = numColumns > 1 && topNested.length > 1;
343
+
344
+ const columns = useColumns
345
+ ? splitIntoColumns(topNested as [string, ParameterValue][], numColumns)
346
+ : [topNested];
347
+
348
+ const filename = path.split("/").pop() || path;
349
+
350
+ const renderNestedEntry = ([key, value]: [string, ParameterValue]) => {
351
+ const type = getValueType(value);
352
+ if (type === "array") {
353
+ const arr = value as ParameterValue[];
354
+ return (
355
+ <div key={key} className="mb-4 last:mb-0">
356
+ <div className="text-[11px] font-mono text-foreground/80 uppercase tracking-widest font-semibold mb-1.5 pb-1 border-b border-border/50">
357
+ {key.replace(/_/g, " ")} [{arr.length}]
358
+ </div>
359
+ <div className="pl-3 ml-1 border-l border-border/40">
360
+ {arr.map((item, i) => {
361
+ const itemType = getValueType(item);
362
+ if (itemType === "object") {
363
+ return (
364
+ <ParameterSection
365
+ key={i}
366
+ name={`${i}`}
367
+ data={item as Record<string, ParameterValue>}
368
+ depth={1}
369
+ />
370
+ );
371
+ }
372
+ return (
373
+ <div key={i} className="text-[11px] font-mono text-foreground py-0.5">
374
+ [{i}] {formatValue(item)}
375
+ </div>
376
+ );
377
+ })}
378
+ </div>
379
+ </div>
380
+ );
381
+ }
382
+ return (
383
+ <ParameterSection
384
+ key={key}
385
+ name={key}
386
+ data={value as Record<string, ParameterValue>}
387
+ depth={0}
388
+ />
389
+ );
390
+ };
391
+
392
+ return (
393
+ <div className="my-6 not-prose">
394
+ <div className="rounded border border-border/60 bg-card/20 p-3 overflow-hidden">
395
+ {topLeaves.length > 0 && (
396
+ <div className={cn(topNested.length > 0 && "mb-4 pb-3 border-b border-border/30")}>
397
+ <ParameterGrid entries={topLeaves} />
398
+ </div>
399
+ )}
400
+
401
+ {useColumns ? (
402
+ <div
403
+ className="grid gap-6"
404
+ style={{ gridTemplateColumns: `repeat(${columns.length}, 1fr)` }}
405
+ >
406
+ {columns.map((columnEntries, colIndex) => (
407
+ <div key={colIndex} className={cn(
408
+ colIndex > 0 && "border-l border-border/30 pl-6"
409
+ )}>
410
+ {columnEntries.map(renderNestedEntry)}
411
+ </div>
412
+ ))}
413
+ </div>
414
+ ) : (
415
+ topNested.map(renderNestedEntry)
416
+ )}
417
+ </div>
418
+ </div>
419
+ );
420
+ }
@@ -0,0 +1,148 @@
1
+ import { Link } from "react-router-dom";
2
+ import { cn } from "@/lib/utils";
3
+ import { DirectoryEntry } from "../../plugin/src/lib";
4
+ import { findReadme, findSlides } from "../../plugin/src/client";
5
+ import { formatDate } from "@/lib/format-date";
6
+ import { ArrowRight } from "lucide-react";
7
+
8
+ export default function PostList({ directory }: { directory: DirectoryEntry }) {
9
+ const folders = directory.children.filter((c): c is DirectoryEntry => c.type === "directory");
10
+
11
+ if (folders.length === 0) {
12
+ return (
13
+ <div className="py-24 text-center">
14
+ <p className="text-muted-foreground font-mono text-sm tracking-wide">no entries</p>
15
+ </div>
16
+ );
17
+ }
18
+
19
+ let posts = folders.map((folder) => {
20
+ const readme = findReadme(folder);
21
+ const slides = findSlides(folder);
22
+ return {
23
+ ...folder,
24
+ readme,
25
+ slides,
26
+ }
27
+ })
28
+
29
+ posts = posts.filter((post) => {
30
+ return post.readme?.frontmatter?.visibility !== "hidden";
31
+ });
32
+
33
+ posts = posts.sort((a, b) => {
34
+ let aDate = a.readme?.frontmatter?.date ? new Date(a.readme.frontmatter.date as string) : null;
35
+ let bDate = b.readme?.frontmatter?.date ? new Date(b.readme.frontmatter.date as string) : null;
36
+
37
+ if (!aDate && a.slides) {
38
+ aDate = a.slides.frontmatter?.date ? new Date(a.slides.frontmatter.date as string) : null;
39
+ }
40
+ if (!bDate && b.slides) {
41
+ bDate = b.slides.frontmatter?.date ? new Date(b.slides.frontmatter.date as string) : null;
42
+ }
43
+
44
+ if (aDate && bDate) {
45
+ return bDate.getTime() - aDate.getTime();
46
+ } else if (aDate) {
47
+ return -1;
48
+ } else if (bDate) {
49
+ return 1;
50
+ } else {
51
+ return a.name.localeCompare(b.name);
52
+ }
53
+ });
54
+
55
+ const postsGroupedByMonthAndYear: { [key: string]: typeof posts } = {};
56
+ posts.forEach((post) => {
57
+ let date = post.readme?.frontmatter?.date ? new Date(post.readme.frontmatter.date as string) : null;
58
+ if (!date && post.slides) {
59
+ date = post.slides.frontmatter?.date ? new Date(post.slides.frontmatter.date as string) : null;
60
+ }
61
+ const monthYear = date ? `${date.getFullYear()}-${date.getMonth() + 1}` : "unknown";
62
+ if (!postsGroupedByMonthAndYear[monthYear]) {
63
+ postsGroupedByMonthAndYear[monthYear] = [];
64
+ }
65
+ postsGroupedByMonthAndYear[monthYear].push(post);
66
+ });
67
+
68
+ return (
69
+ <div className="space-y-8">
70
+ {Object.entries(postsGroupedByMonthAndYear)
71
+ .sort(([a], [b]) => {
72
+ if (a === "unknown") return 1;
73
+ if (b === "unknown") return -1;
74
+ return b.localeCompare(a);
75
+ })
76
+ .map(([monthYear, monthPosts]) => {
77
+ const [year, month] = monthYear.split("-");
78
+ const displayDate = monthYear === "unknown"
79
+ ? "Unknown Date"
80
+ : new Date(parseInt(year), parseInt(month) - 1).toLocaleDateString("en-US", {
81
+ year: "numeric",
82
+ month: "long"
83
+ });
84
+
85
+ return (
86
+ <div key={monthYear}>
87
+ <h2 className="text-xs font-mono uppercase tracking-wider text-muted-foreground mb-3">
88
+ {displayDate}
89
+ </h2>
90
+ <div className="space-y-1">
91
+ {monthPosts.map((post) => {
92
+ let frontmatter = post.readme?.frontmatter;
93
+
94
+ if (!post.readme && post.slides) {
95
+ frontmatter = post.slides.frontmatter;
96
+ }
97
+
98
+ const title = (frontmatter?.title as string) || post.name;
99
+ const description = frontmatter?.description as string | undefined;
100
+ const date = frontmatter?.date ? new Date(frontmatter.date as string) : null;
101
+
102
+ return (
103
+ <Link
104
+ key={post.path}
105
+ to={(post.slides && !post.readme) ? `/${post.slides.path}` : `/${post.readme.path}`}
106
+ className={cn(
107
+ "group block py-3 px-3 -mx-3 rounded-md",
108
+ "transition-colors duration-150",
109
+ // "hover:bg-accent"
110
+ )}
111
+ >
112
+ <article className="flex items-start gap-4">
113
+ {/* Date - left side, fixed width */}
114
+ <time
115
+ dateTime={date?.toISOString()}
116
+ className="font-mono text-xs text-muted-foreground tabular-nums w-20 flex-shrink-0 pt-0.5"
117
+ >
118
+ {date ? formatDate(date) : <span className="text-muted-foreground/30">—</span>}
119
+ </time>
120
+
121
+ {/* Main content */}
122
+ <div className="flex-1 min-w-0">
123
+ <h3 className={cn(
124
+ "text-sm font-medium text-foreground",
125
+ "group-hover:underline",
126
+ "flex items-center gap-2"
127
+ )}>
128
+ <span>{title}</span>
129
+ <ArrowRight className="h-3 w-3 opacity-0 -translate-x-1 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-200 text-primary" />
130
+ </h3>
131
+
132
+ {description && (
133
+ <p className="text-sm text-muted-foreground line-clamp-1 mt-0.5">
134
+ {description}
135
+ </p>
136
+ )}
137
+ </div>
138
+ </article>
139
+ </Link>
140
+ );
141
+ })}
142
+ </div>
143
+ </div>
144
+ );
145
+ })}
146
+ </div>
147
+ );
148
+ }
@@ -0,0 +1,21 @@
1
+ import { isSimulationRunning } from "../../plugin/src/client";
2
+
3
+
4
+ export function RunningBar() {
5
+ const isRunning = isSimulationRunning();
6
+
7
+ return (
8
+ <>
9
+ {isRunning && (
10
+ // this should stay red not another color
11
+ <div className="sticky top-0 z-50 px-[var(--page-padding)] py-2 bg-red-500 text-primary-foreground font-mono text-xs text-center tracking-wide">
12
+ <span className="inline-flex items-center gap-3">
13
+ <span className="h-1.5 w-1.5 rounded-full bg-current animate-pulse" />
14
+ <span className="uppercase tracking-widest">simulation running</span>
15
+ <span className="text-primary-foreground/60">Page will auto-refresh on completion</span>
16
+ </span>
17
+ </div>
18
+ )}
19
+ </>
20
+ )
21
+ }