@nucel/ui 0.3.0 → 0.11.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 (63) hide show
  1. package/package.json +27 -34
  2. package/src/lib/components/BottomSheet.svelte +96 -0
  3. package/src/lib/components/Breadcrumbs.svelte +57 -0
  4. package/src/lib/components/Checkbox.svelte +64 -0
  5. package/src/lib/components/CodeBlock.svelte +264 -0
  6. package/src/lib/components/CodeEditor.svelte +175 -0
  7. package/src/lib/components/ColorInput.svelte +41 -0
  8. package/src/lib/components/ColorInput.test.ts +126 -0
  9. package/src/lib/components/Combobox.svelte +103 -0
  10. package/src/lib/components/CommandPalette.svelte +135 -0
  11. package/src/lib/components/CopyButton.svelte +95 -0
  12. package/src/lib/components/CopyButton.test.ts +213 -0
  13. package/src/lib/components/DataTable.svelte +202 -0
  14. package/src/lib/components/DateRangePicker.svelte +185 -0
  15. package/src/lib/components/DiffEditor.svelte +174 -0
  16. package/src/lib/components/Drawer.svelte +69 -0
  17. package/src/lib/components/Fab.svelte +59 -0
  18. package/src/lib/components/Form.svelte +38 -0
  19. package/src/lib/components/FormField.svelte +51 -0
  20. package/src/lib/components/IconButton.svelte +86 -0
  21. package/src/lib/components/IconButton.test.ts +139 -0
  22. package/src/lib/components/InlineCode.svelte +28 -0
  23. package/src/lib/components/Pagination.svelte +65 -0
  24. package/src/lib/components/Radio.svelte +60 -0
  25. package/src/lib/components/RadioGroup.svelte +26 -0
  26. package/src/lib/components/SearchInput.svelte +77 -0
  27. package/src/lib/components/Skeleton.svelte +76 -0
  28. package/src/lib/components/StatCard.svelte +97 -0
  29. package/src/lib/components/ThemeProvider.svelte +157 -0
  30. package/src/lib/components/ThemeToggle.svelte +68 -0
  31. package/src/lib/components/ThreeWayMerge.svelte +185 -0
  32. package/src/lib/components/ui/Alert.svelte +1 -1
  33. package/src/lib/components/ui/MarkdownRenderer.svelte +126 -8
  34. package/src/lib/components/ui/Sparkline.svelte +1 -1
  35. package/src/lib/components/ui/StatusBadge.svelte +6 -3
  36. package/src/lib/components/ui/StatusDot.svelte +3 -3
  37. package/src/lib/components/ui/table/Table.svelte +1 -1
  38. package/src/lib/components/ui/table/TableBody.svelte +1 -1
  39. package/src/lib/components/ui/table/TableCaption.svelte +1 -1
  40. package/src/lib/components/ui/table/TableCell.svelte +1 -1
  41. package/src/lib/components/ui/table/TableHead.svelte +1 -1
  42. package/src/lib/components/ui/table/TableHeader.svelte +1 -1
  43. package/src/lib/components/ui/table/TableRow.svelte +1 -1
  44. package/src/lib/index.ts +161 -61
  45. package/src/lib/utils/cn.test.ts +993 -0
  46. package/src/lib/utils/detectLanguage.ts +187 -0
  47. package/src/lib/utils/monaco-workers.d.ts +32 -0
  48. package/src/lib/utils/monacoLoader.ts +167 -0
  49. package/src/lib/utils/shikiHighlighter.ts +78 -0
  50. package/src/styles.css +100 -32
  51. package/src/lib/components/ui/AppShell.svelte +0 -14
  52. package/src/lib/components/ui/AppSidebar.svelte +0 -45
  53. package/src/lib/components/ui/CodeBlock.svelte +0 -92
  54. package/src/lib/components/ui/CopyButton.svelte +0 -43
  55. package/src/lib/components/ui/CostDisplay.svelte +0 -26
  56. package/src/lib/components/ui/FilterBar.svelte +0 -63
  57. package/src/lib/components/ui/FormField.svelte +0 -34
  58. package/src/lib/components/ui/MetricCard.svelte +0 -79
  59. package/src/lib/components/ui/NavItem.svelte +0 -42
  60. package/src/lib/components/ui/NavSection.svelte +0 -17
  61. package/src/lib/components/ui/Pagination.svelte +0 -85
  62. package/src/lib/components/ui/StatCard.svelte +0 -19
  63. package/src/lib/components/ui/Timeline.svelte +0 -85
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nucel/ui",
3
- "version": "0.3.0",
3
+ "version": "0.11.0",
4
4
  "description": "A comprehensive Svelte 5 UI component library for Nucel projects",
5
5
  "type": "module",
6
6
  "svelte": "./src/lib/index.ts",
@@ -13,14 +13,11 @@
13
13
  },
14
14
  "files": [
15
15
  "src/lib",
16
- "!src/**/*.test.ts",
17
- "!src/**/*.spec.ts",
18
- "!src/tests",
19
16
  "src/styles.css"
20
17
  ],
21
18
  "scripts": {
22
19
  "dev": "vite",
23
- "build": "vite build",
20
+ "build": "svelte-check --tsconfig ./tsconfig.json",
24
21
  "check": "svelte-check --tsconfig ./tsconfig.json",
25
22
  "lint": "eslint .",
26
23
  "format": "prettier --write .",
@@ -33,7 +30,7 @@
33
30
  },
34
31
  "repository": {
35
32
  "type": "git",
36
- "url": "https://github.com/nucel/ui.git"
33
+ "url": "https://github.com/nucel-dev/ui.git"
37
34
  },
38
35
  "keywords": [
39
36
  "svelte",
@@ -49,32 +46,32 @@
49
46
  "license": "MIT",
50
47
  "dependencies": {
51
48
  "@lucide/svelte": "^0.564.0",
52
- "@number-flow/svelte": "^0.3.7",
53
- "@tiptap/core": "^3.22.2",
54
- "@tiptap/extension-character-count": "^3.22.2",
55
- "@tiptap/extension-color": "^3.22.2",
56
- "@tiptap/extension-highlight": "^3.22.2",
57
- "@tiptap/extension-link": "^3.22.2",
58
- "@tiptap/extension-mention": "^3.22.2",
59
- "@tiptap/extension-placeholder": "^3.22.2",
60
- "@tiptap/extension-subscript": "^3.22.2",
61
- "@tiptap/extension-superscript": "^3.22.2",
62
- "@tiptap/extension-table": "^3.22.2",
63
- "@tiptap/extension-table-cell": "^3.22.2",
64
- "@tiptap/extension-table-header": "^3.22.2",
65
- "@tiptap/extension-table-row": "^3.22.2",
66
- "@tiptap/extension-task-item": "^3.22.2",
67
- "@tiptap/extension-task-list": "^3.22.2",
68
- "@tiptap/extension-text-align": "^3.22.2",
69
- "@tiptap/extension-text-style": "^3.22.2",
70
- "@tiptap/extension-typography": "^3.22.2",
71
- "@tiptap/extension-underline": "^3.22.2",
72
- "@tiptap/starter-kit": "^3.22.2",
49
+ "@tiptap/core": "^3.23.6",
50
+ "@tiptap/extension-character-count": "^3.23.6",
51
+ "@tiptap/extension-color": "^3.23.6",
52
+ "@tiptap/extension-highlight": "^3.23.6",
53
+ "@tiptap/extension-link": "^3.23.6",
54
+ "@tiptap/extension-mention": "^3.23.6",
55
+ "@tiptap/extension-placeholder": "^3.23.6",
56
+ "@tiptap/extension-subscript": "^3.23.6",
57
+ "@tiptap/extension-superscript": "^3.23.6",
58
+ "@tiptap/extension-table": "^3.23.6",
59
+ "@tiptap/extension-table-cell": "^3.23.6",
60
+ "@tiptap/extension-table-header": "^3.23.6",
61
+ "@tiptap/extension-table-row": "^3.23.6",
62
+ "@tiptap/extension-task-item": "^3.23.6",
63
+ "@tiptap/extension-task-list": "^3.23.6",
64
+ "@tiptap/extension-text-align": "^3.23.6",
65
+ "@tiptap/extension-text-style": "^3.23.6",
66
+ "@tiptap/extension-typography": "^3.23.6",
67
+ "@tiptap/extension-underline": "^3.23.6",
68
+ "@tiptap/starter-kit": "^3.23.6",
73
69
  "bits-ui": "^2.16.1",
74
70
  "clsx": "^2.1.1",
75
71
  "dompurify": "^3.3.3",
76
72
  "marked": "^17.0.5",
77
- "shiki": "^4.0.2",
73
+ "monaco-editor": "^0.55.1",
74
+ "shiki": "^4.1.0",
78
75
  "svelte-sonner": "^1.0.7",
79
76
  "tailwind-merge": "^3.5.0",
80
77
  "tailwind-variants": "^3.2.2",
@@ -89,17 +86,13 @@
89
86
  "@storybook/svelte-vite": "^10.3.4",
90
87
  "@sveltejs/vite-plugin-svelte": "^6.2.4",
91
88
  "@tailwindcss/vite": "^4.1.18",
92
- "@testing-library/jest-dom": "^6.9.1",
93
89
  "@testing-library/svelte": "^5.3.1",
94
90
  "@types/dompurify": "^3.2.0",
95
- "@vitest/browser": "4.1.5",
96
- "@vitest/browser-playwright": "^4.1.5",
97
91
  "eslint": "^9.39.2",
98
92
  "eslint-config-prettier": "^10.1.8",
99
93
  "eslint-plugin-svelte": "^3.14.0",
100
94
  "globals": "^17.3.0",
101
- "jsdom": "^28.1.0",
102
- "playwright": "^1.59.1",
95
+ "jsdom": "^29.1.1",
103
96
  "prettier": "^3.8.1",
104
97
  "prettier-plugin-svelte": "^3.4.1",
105
98
  "prettier-plugin-tailwindcss": "^0.7.2",
@@ -111,7 +104,7 @@
111
104
  "typescript": "^5.8.0",
112
105
  "typescript-eslint": "^8.54.0",
113
106
  "vite": "^8.0.0",
114
- "vitest": "4.1.5"
107
+ "vitest": "^4.0.18"
115
108
  },
116
109
  "peerDependencies": {
117
110
  "svelte": "^5.0.0",
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ /**
3
+ * BottomSheet — mobile-first bottom-anchored sheet.
4
+ *
5
+ * Built on Sheet primitive with `side="bottom"` plus a few mobile
6
+ * affordances: a top grabber handle, larger touch targets in the
7
+ * header, and an explicit safe-area inset on the bottom so the
8
+ * close button isn't eaten by the home indicator.
9
+ *
10
+ * Use for: mobile filter panels, action menus, "more options"
11
+ * surfaces that would be a popover on desktop.
12
+ *
13
+ * Pair with a Tailwind `md:hidden` wrapper on the trigger to keep
14
+ * the desktop dropdown menu unchanged.
15
+ */
16
+ import type { Snippet } from 'svelte';
17
+ import Sheet from './ui/sheet/sheet.svelte';
18
+ import SheetTrigger from './ui/sheet/sheet-trigger.svelte';
19
+ import SheetContent from './ui/sheet/sheet-content.svelte';
20
+ import SheetHeader from './ui/sheet/sheet-header.svelte';
21
+ import SheetTitle from './ui/sheet/sheet-title.svelte';
22
+ import SheetDescription from './ui/sheet/sheet-description.svelte';
23
+ import SheetFooter from './ui/sheet/sheet-footer.svelte';
24
+ import { cn } from '../utils.js';
25
+
26
+ type Props = {
27
+ open?: boolean;
28
+ title?: string;
29
+ description?: string;
30
+ /** Show the grabber bar at the top. Defaults to true. */
31
+ grabber?: boolean;
32
+ class?: string;
33
+ trigger?: Snippet;
34
+ header?: Snippet;
35
+ footer?: Snippet;
36
+ children?: Snippet;
37
+ };
38
+
39
+ let {
40
+ open = $bindable(false),
41
+ title,
42
+ description,
43
+ grabber = true,
44
+ class: className,
45
+ trigger,
46
+ header,
47
+ footer,
48
+ children,
49
+ }: Props = $props();
50
+ </script>
51
+
52
+ <Sheet bind:open>
53
+ {#if trigger}
54
+ <SheetTrigger>
55
+ {@render trigger()}
56
+ </SheetTrigger>
57
+ {/if}
58
+ <SheetContent
59
+ side="bottom"
60
+ class={cn(
61
+ 'flex max-h-[90dvh] flex-col rounded-t-xl px-0 pb-[env(safe-area-inset-bottom,0px)]',
62
+ className,
63
+ )}
64
+ >
65
+ {#if grabber}
66
+ <div class="flex justify-center pt-2 pb-1" aria-hidden="true">
67
+ <div class="h-1.5 w-10 rounded-full bg-muted-foreground/30"></div>
68
+ </div>
69
+ {/if}
70
+
71
+ {#if header}
72
+ <div class="px-4">
73
+ {@render header()}
74
+ </div>
75
+ {:else if title || description}
76
+ <SheetHeader class="px-4">
77
+ {#if title}
78
+ <SheetTitle>{title}</SheetTitle>
79
+ {/if}
80
+ {#if description}
81
+ <SheetDescription>{description}</SheetDescription>
82
+ {/if}
83
+ </SheetHeader>
84
+ {/if}
85
+
86
+ <div class="flex-1 overflow-y-auto px-4 py-2">
87
+ {#if children}{@render children()}{/if}
88
+ </div>
89
+
90
+ {#if footer}
91
+ <SheetFooter class="px-4">
92
+ {@render footer()}
93
+ </SheetFooter>
94
+ {/if}
95
+ </SheetContent>
96
+ </Sheet>
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import { ChevronRightIcon } from '@lucide/svelte';
3
+ import { cn } from '../utils.js';
4
+
5
+ type Item = {
6
+ label: string;
7
+ href?: string;
8
+ };
9
+
10
+ let {
11
+ items,
12
+ separator,
13
+ class: className,
14
+ ariaLabel = 'breadcrumb',
15
+ }: {
16
+ items: Item[];
17
+ separator?: string;
18
+ class?: string;
19
+ ariaLabel?: string;
20
+ } = $props();
21
+ </script>
22
+
23
+ <nav data-slot="breadcrumbs" aria-label={ariaLabel} class={cn('w-full', className)}>
24
+ <ol class="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm">
25
+ {#each items as item, i (i)}
26
+ {@const isLast = i === items.length - 1}
27
+ <li class="inline-flex items-center gap-1.5">
28
+ {#if isLast || !item.href}
29
+ <span
30
+ class={cn(
31
+ isLast ? 'text-foreground font-medium' : 'text-muted-foreground',
32
+ )}
33
+ aria-current={isLast ? 'page' : undefined}
34
+ >
35
+ {item.label}
36
+ </span>
37
+ {:else}
38
+ <a
39
+ href={item.href}
40
+ class="hover:text-foreground transition-colors"
41
+ >
42
+ {item.label}
43
+ </a>
44
+ {/if}
45
+ </li>
46
+ {#if !isLast}
47
+ <li class="text-muted-foreground/60 inline-flex items-center" aria-hidden="true">
48
+ {#if separator}
49
+ <span>{separator}</span>
50
+ {:else}
51
+ <ChevronRightIcon class="size-3.5" />
52
+ {/if}
53
+ </li>
54
+ {/if}
55
+ {/each}
56
+ </ol>
57
+ </nav>
@@ -0,0 +1,64 @@
1
+ <script lang="ts">
2
+ import { Checkbox as CheckboxPrimitive } from 'bits-ui';
3
+ import { CheckIcon, MinusIcon } from '@lucide/svelte';
4
+ import { cn, type WithoutChildrenOrChild } from '../utils.js';
5
+
6
+ type Props = WithoutChildrenOrChild<CheckboxPrimitive.RootProps> & {
7
+ label?: string;
8
+ class?: string;
9
+ };
10
+
11
+ let {
12
+ ref = $bindable(null),
13
+ checked = $bindable(false),
14
+ indeterminate = $bindable(false),
15
+ disabled,
16
+ label,
17
+ class: className,
18
+ id,
19
+ ...restProps
20
+ }: Props = $props();
21
+
22
+ const uid = $props.id();
23
+ const checkboxId = $derived(id ?? `checkbox-${uid}`);
24
+
25
+ const boxClass =
26
+ 'peer border-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary inline-flex size-4 shrink-0 items-center justify-center rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50';
27
+ </script>
28
+
29
+ {#snippet box()}
30
+ <CheckboxPrimitive.Root
31
+ bind:ref
32
+ bind:checked
33
+ bind:indeterminate
34
+ id={checkboxId}
35
+ data-slot="checkbox"
36
+ {disabled}
37
+ class={cn(boxClass, !label && className)}
38
+ {...restProps}
39
+ >
40
+ {#snippet children({ checked: isChecked, indeterminate: isIndeterminate })}
41
+ {#if isIndeterminate}
42
+ <MinusIcon class="size-3.5" />
43
+ {:else if isChecked}
44
+ <CheckIcon class="size-3.5" />
45
+ {/if}
46
+ {/snippet}
47
+ </CheckboxPrimitive.Root>
48
+ {/snippet}
49
+
50
+ {#if label}
51
+ <label
52
+ for={checkboxId}
53
+ class={cn(
54
+ 'inline-flex items-center gap-2 text-sm leading-none font-medium select-none',
55
+ disabled && 'cursor-not-allowed opacity-50',
56
+ className,
57
+ )}
58
+ >
59
+ {@render box()}
60
+ <span>{label}</span>
61
+ </label>
62
+ {:else}
63
+ {@render box()}
64
+ {/if}
@@ -0,0 +1,264 @@
1
+ <script lang="ts">
2
+ import { CheckIcon, CopyIcon } from '@lucide/svelte';
3
+ import { getContext, onMount } from 'svelte';
4
+ import { cn } from '../utils.js';
5
+ import {
6
+ getHighlighter,
7
+ loadLanguage,
8
+ resolveLang,
9
+ SHIKI_LIGHT_THEME,
10
+ SHIKI_DARK_THEME,
11
+ } from '../utils/shikiHighlighter.js';
12
+ import type { ThemeContext } from './ThemeProvider.svelte';
13
+
14
+ type Theme = 'light' | 'dark' | 'auto';
15
+
16
+ type Props = {
17
+ /** Source code to render. */
18
+ code: string;
19
+ /** Shiki language id. Unknown values fall back to `plaintext`. */
20
+ language?: string;
21
+ /** Theme. `auto` follows the nearest `<ThemeProvider>`. Defaults to `auto`. */
22
+ theme?: Theme;
23
+ /** Optional filename shown in the header. */
24
+ filename?: string;
25
+ /** Render gutter line numbers (default `false`). */
26
+ showLineNumbers?: boolean;
27
+ /** Soft-wrap long lines (default `false` — horizontal scroll). */
28
+ wrap?: boolean;
29
+ /** Show the copy button (default `true`). */
30
+ copyable?: boolean;
31
+ /**
32
+ * Backwards-compat alias for `copyable`. Older callers (0.5/0.6/0.7) still
33
+ * use `showCopy`; honour it but prefer `copyable` going forward.
34
+ */
35
+ showCopy?: boolean;
36
+ /** Max height before the body becomes scrollable. Default `60vh`. */
37
+ maxHeight?: string;
38
+ class?: string;
39
+ };
40
+
41
+ let {
42
+ code,
43
+ language = 'plaintext',
44
+ theme = 'auto',
45
+ filename,
46
+ showLineNumbers = false,
47
+ wrap = false,
48
+ copyable = true,
49
+ showCopy,
50
+ maxHeight = '60vh',
51
+ class: className,
52
+ }: Props = $props();
53
+
54
+ // Honor the legacy `showCopy` prop if a caller still passes it.
55
+ const showCopyButton = $derived(showCopy === undefined ? copyable : showCopy);
56
+
57
+ // Read the ThemeProvider context if present. Doesn't throw — CodeBlock
58
+ // renders just fine outside a ThemeProvider, it just won't auto-switch.
59
+ const themeCtx = getContext<ThemeContext | undefined>(Symbol.for('@nucel/ui:theme')) as
60
+ | ThemeContext
61
+ | undefined;
62
+ // Note: the ThemeProvider uses its own private symbol; we can't read it
63
+ // directly. Fall back to inspecting the `.dark` class on <html> at render
64
+ // time — that's the contract ThemeProvider commits to.
65
+ let domDark = $state(false);
66
+
67
+ const resolvedTheme = $derived<'light' | 'dark'>(
68
+ theme === 'light' ? 'light' : theme === 'dark' ? 'dark' : domDark ? 'dark' : 'light',
69
+ );
70
+
71
+ const shikiTheme = $derived(
72
+ resolvedTheme === 'dark' ? SHIKI_DARK_THEME : SHIKI_LIGHT_THEME,
73
+ );
74
+
75
+ let highlightedHtml = $state<string | null>(null);
76
+ let highlighterReady = $state(false);
77
+ let copied = $state(false);
78
+
79
+ // Derived snapshot of the props that affect highlighting output. When any
80
+ // of them change we re-run Shiki.
81
+ const highlightKey = $derived(
82
+ [code, language, shikiTheme, showLineNumbers ? '1' : '0'].join(''),
83
+ );
84
+
85
+ onMount(() => {
86
+ // Initial dark-class snapshot + observe for ThemeProvider toggles.
87
+ const html = document.documentElement;
88
+ const sync = () => {
89
+ domDark = html.classList.contains('dark');
90
+ };
91
+ sync();
92
+
93
+ const mo = new MutationObserver(sync);
94
+ mo.observe(html, { attributes: true, attributeFilter: ['class'] });
95
+
96
+ // Kick off the highlighter ASAP — subsequent CodeBlocks reuse the cache.
97
+ (async () => {
98
+ await getHighlighter();
99
+ highlighterReady = true;
100
+ })();
101
+
102
+ return () => mo.disconnect();
103
+ });
104
+
105
+ // Re-render whenever the highlight inputs change.
106
+ $effect(() => {
107
+ // Track all reactive deps.
108
+ void highlightKey;
109
+ if (typeof window === 'undefined') return;
110
+
111
+ let cancelled = false;
112
+ (async () => {
113
+ try {
114
+ await loadLanguage(language);
115
+ const hl = await getHighlighter();
116
+ if (cancelled) return;
117
+ const html = hl.codeToHtml(code, {
118
+ lang: resolveLang(language),
119
+ theme: shikiTheme,
120
+ transformers: [
121
+ {
122
+ name: 'nucel:line-attrs',
123
+ line(node, line) {
124
+ node.properties = node.properties ?? {};
125
+ (node.properties as Record<string, string | number>)['data-line'] =
126
+ line;
127
+ },
128
+ },
129
+ ],
130
+ });
131
+ if (!cancelled) highlightedHtml = html;
132
+ } catch (err) {
133
+ // Highlighting should never break the page — fall back to plain rendering.
134
+ console.warn('[@nucel/ui CodeBlock] Shiki render failed:', err);
135
+ if (!cancelled) highlightedHtml = null;
136
+ }
137
+ })();
138
+
139
+ return () => {
140
+ cancelled = true;
141
+ };
142
+ });
143
+
144
+ async function copy() {
145
+ try {
146
+ await navigator.clipboard.writeText(code);
147
+ copied = true;
148
+ setTimeout(() => (copied = false), 1500);
149
+ } catch {
150
+ // Clipboard API can fail in insecure contexts — silently ignore.
151
+ }
152
+ }
153
+
154
+ const lines = $derived(code.split('\n'));
155
+ </script>
156
+
157
+ <div
158
+ data-slot="code-block"
159
+ data-theme={resolvedTheme}
160
+ class={cn(
161
+ 'bg-muted/40 border-border relative overflow-hidden rounded-lg border text-sm',
162
+ className,
163
+ )}
164
+ >
165
+ {#if filename || showCopyButton || language}
166
+ <div
167
+ class="border-border bg-muted/60 flex items-center justify-between gap-2 border-b px-3 py-1.5"
168
+ >
169
+ <div class="text-muted-foreground flex items-center gap-2 text-xs font-medium">
170
+ {#if filename}
171
+ <span class="truncate">{filename}</span>
172
+ {/if}
173
+ {#if language && language !== 'plaintext'}
174
+ <span class="bg-background rounded px-1.5 py-0.5 font-mono text-[10px] uppercase">
175
+ {language}
176
+ </span>
177
+ {/if}
178
+ </div>
179
+ {#if showCopyButton}
180
+ <button
181
+ type="button"
182
+ onclick={copy}
183
+ aria-label="Copy code"
184
+ class="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs outline-none focus-visible:ring-2"
185
+ >
186
+ {#if copied}
187
+ <CheckIcon class="size-3.5" /> Copied
188
+ {:else}
189
+ <CopyIcon class="size-3.5" /> Copy
190
+ {/if}
191
+ </button>
192
+ {/if}
193
+ </div>
194
+ {/if}
195
+
196
+ <div
197
+ class="nucel-code-body overflow-auto"
198
+ class:nucel-code-wrap={wrap}
199
+ style:max-height={maxHeight}
200
+ >
201
+ {#if highlightedHtml}
202
+ <!-- Shiki-rendered output. {@html} is safe here: Shiki escapes all source. -->
203
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
204
+ <div class="nucel-code-shiki" class:with-line-numbers={showLineNumbers}>
205
+ {@html highlightedHtml}
206
+ </div>
207
+ {:else}
208
+ <!-- Plain fallback while Shiki loads / on SSR / on error. -->
209
+ <pre
210
+ class="text-foreground p-4 font-mono text-[13px] leading-relaxed"
211
+ class:whitespace-pre-wrap={wrap}><code
212
+ data-language={language}
213
+ class={cn('block', `language-${language}`)}
214
+ >{#if showLineNumbers}{#each lines as line, i (i)}<span
215
+ class="text-muted-foreground/60 mr-4 inline-block w-6 text-right select-none"
216
+ >{i + 1}</span
217
+ >{line}{#if i < lines.length - 1}{'\n'}{/if}{/each}{:else}{code}{/if}</code
218
+ ></pre>
219
+ {/if}
220
+ </div>
221
+ </div>
222
+
223
+ <style>
224
+ /* Shiki emits a <pre class="shiki ..."><code><span class="line">…</span></code></pre>.
225
+ We restyle its container only — the inline colour spans are Shiki's.
226
+ Theme-aware: when the consumer flips to dark we swap to the dark stylesheet. */
227
+ .nucel-code-shiki :global(pre.shiki) {
228
+ margin: 0;
229
+ padding: 1rem;
230
+ background: transparent !important;
231
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace);
232
+ font-size: 13px;
233
+ line-height: 1.55;
234
+ tab-size: 2;
235
+ }
236
+ .nucel-code-shiki :global(pre.shiki code) {
237
+ display: block;
238
+ min-width: max-content;
239
+ }
240
+ .nucel-code-shiki.with-line-numbers :global(pre.shiki) {
241
+ counter-reset: shiki-line;
242
+ padding-left: 0;
243
+ }
244
+ .nucel-code-shiki.with-line-numbers :global(pre.shiki .line)::before {
245
+ counter-increment: shiki-line;
246
+ content: counter(shiki-line);
247
+ display: inline-block;
248
+ width: 2.5rem;
249
+ margin-right: 1rem;
250
+ padding-right: 0.5rem;
251
+ text-align: right;
252
+ color: var(--muted-foreground);
253
+ opacity: 0.6;
254
+ user-select: none;
255
+ border-right: 1px solid var(--border);
256
+ }
257
+ .nucel-code-wrap :global(pre.shiki) {
258
+ white-space: pre-wrap;
259
+ word-break: break-word;
260
+ }
261
+ .nucel-code-wrap :global(pre.shiki code) {
262
+ min-width: 0;
263
+ }
264
+ </style>