@heyramzi/ui 6.0.1 → 7.0.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 (114) hide show
  1. package/COMPONENTS.md +7 -26
  2. package/README.md +12 -5
  3. package/package.json +14 -16
  4. package/scripts/sync-shadcn.mjs +12 -3
  5. package/src/agents/agent-avatar.svelte +639 -0
  6. package/src/agents/agent-cast.svelte +173 -0
  7. package/src/agents/demo.svelte +78 -0
  8. package/src/agents/index.ts +11 -0
  9. package/src/agents/types.ts +72 -0
  10. package/{LICENSE → src/agents/vendor/LICENSE} +1 -1
  11. package/src/agents/vendor/color.ts +155 -0
  12. package/src/agents/vendor/draw.ts +840 -0
  13. package/src/agents/vendor/engine.ts +845 -0
  14. package/src/agents/vendor/mesh.ts +838 -0
  15. package/src/agents/vendor/plastic.ts +4958 -0
  16. package/src/agents/vendor/presets.ts +103 -0
  17. package/src/agents/vendor/shapes.ts +41 -0
  18. package/src/agents/vendor/ticker.ts +67 -0
  19. package/src/agents/vendor/types.ts +261 -0
  20. package/src/agents/vendor/wear.ts +1699 -0
  21. package/src/ai/index.ts +0 -18
  22. package/src/animations/README.md +3 -3
  23. package/src/lib/sliding-pill.ts +68 -0
  24. package/src/lib/theme-transition.ts +4 -2
  25. package/src/shadcn/code-block/code-block-actions.svelte +23 -0
  26. package/src/shadcn/code-block/code-block-content.svelte +55 -0
  27. package/src/shadcn/code-block/code-block-copy-button.svelte +59 -0
  28. package/src/shadcn/code-block/code-block-footer.svelte +20 -0
  29. package/src/shadcn/code-block/code-block-header.svelte +23 -0
  30. package/src/shadcn/code-block/code-block-line.svelte +71 -0
  31. package/src/shadcn/code-block/code-block-title.svelte +23 -0
  32. package/src/shadcn/code-block/code-block.svelte +47 -0
  33. package/src/shadcn/code-block/index.ts +42 -0
  34. package/src/shadcn/hooks/highlighter.ts +132 -0
  35. package/src/shadcn/hooks/use-highlighted-code.svelte.ts +98 -0
  36. package/src/shadcn/kbd/index.ts +10 -0
  37. package/src/shadcn/kbd/kbd-group.svelte +20 -0
  38. package/src/shadcn/kbd/kbd.svelte +23 -0
  39. package/src/shadcn/message-scroller/index.ts +28 -0
  40. package/src/shadcn/message-scroller/message-scroller-button.svelte +52 -0
  41. package/src/shadcn/message-scroller/message-scroller-content.svelte +17 -0
  42. package/src/shadcn/message-scroller/message-scroller-item.svelte +22 -0
  43. package/src/shadcn/message-scroller/message-scroller-provider.svelte +7 -0
  44. package/src/shadcn/message-scroller/message-scroller-viewport.svelte +20 -0
  45. package/src/shadcn/message-scroller/message-scroller.svelte +20 -0
  46. package/src/shadcn/tabs/tabs-list.svelte +13 -2
  47. package/src/shadcn/tabs/tabs-trigger.svelte +1 -1
  48. package/src/shadcn/trace/index.ts +29 -0
  49. package/src/shadcn/trace/trace-collapsible-content.svelte +29 -0
  50. package/src/shadcn/trace/trace-collapsible-trigger.svelte +29 -0
  51. package/src/shadcn/trace/trace-collapsible.svelte +20 -0
  52. package/src/shadcn/trace/trace-description.svelte +20 -0
  53. package/src/shadcn/trace/trace-icon.svelte +24 -0
  54. package/src/shadcn/trace/trace-line.svelte +31 -0
  55. package/src/shadcn/trace/trace-title.svelte +20 -0
  56. package/src/shadcn/trace/trace.svelte +23 -0
  57. package/src/showcase/AiSection.svelte +0 -7
  58. package/src/showcase/demos/shadcn/code-block.svelte +80 -0
  59. package/src/showcase/demos/shadcn/index.ts +24 -0
  60. package/src/showcase/demos/shadcn/message-scroller.svelte +44 -0
  61. package/src/showcase/demos/shadcn/trace.svelte +51 -0
  62. package/src/showcase/kit.ts +1 -2
  63. package/src/ai/conversation/Conversation.svelte +0 -28
  64. package/src/ai/conversation/ConversationContent.svelte +0 -35
  65. package/src/ai/conversation/ConversationEmptyState.svelte +0 -46
  66. package/src/ai/conversation/ConversationScrollButton.svelte +0 -51
  67. package/src/ai/conversation/index.ts +0 -33
  68. package/src/ai/conversation/stick-to-bottom-context.svelte.ts +0 -131
  69. package/src/charts/animated-value.css +0 -7
  70. package/src/charts/animated-value.svelte +0 -110
  71. package/src/charts/area-chart.css +0 -29
  72. package/src/charts/area-chart.svelte +0 -195
  73. package/src/charts/bar-chart.css +0 -119
  74. package/src/charts/bar-chart.svelte +0 -269
  75. package/src/charts/charts.css +0 -201
  76. package/src/charts/crosshair.svelte +0 -35
  77. package/src/charts/donut-chart.css +0 -56
  78. package/src/charts/donut-chart.svelte +0 -158
  79. package/src/charts/edge-fade.svelte +0 -43
  80. package/src/charts/edge-fade.ts +0 -16
  81. package/src/charts/figure.css +0 -100
  82. package/src/charts/figure.svelte +0 -122
  83. package/src/charts/gauge-chart.css +0 -94
  84. package/src/charts/gauge-chart.svelte +0 -303
  85. package/src/charts/hover-band.test.ts +0 -30
  86. package/src/charts/hover-band.ts +0 -21
  87. package/src/charts/hover-focus.svelte +0 -85
  88. package/src/charts/index.ts +0 -30
  89. package/src/charts/legend.css +0 -131
  90. package/src/charts/legend.svelte +0 -112
  91. package/src/charts/line-chart.css +0 -26
  92. package/src/charts/line-chart.svelte +0 -256
  93. package/src/charts/motion.ts +0 -19
  94. package/src/charts/number-chart.css +0 -168
  95. package/src/charts/number-chart.svelte +0 -177
  96. package/src/charts/primitives/chart-container.svelte +0 -57
  97. package/src/charts/primitives/chart-style.svelte +0 -36
  98. package/src/charts/primitives/chart-tooltip.svelte +0 -161
  99. package/src/charts/primitives/chart-utils.ts +0 -54
  100. package/src/charts/primitives/index.ts +0 -11
  101. package/src/charts/reveal.svelte +0 -61
  102. package/src/charts/ring-chart.css +0 -96
  103. package/src/charts/ring-chart.svelte +0 -158
  104. package/src/charts/sparkline.css +0 -45
  105. package/src/charts/sparkline.svelte +0 -140
  106. package/src/charts/stat-card.css +0 -103
  107. package/src/charts/stat-card.svelte +0 -267
  108. package/src/charts/time-series-chart.css +0 -28
  109. package/src/charts/time-series-chart.svelte +0 -179
  110. package/src/charts/trend-pill.css +0 -38
  111. package/src/charts/trend-pill.svelte +0 -48
  112. package/src/lib/hooks/is-mobile.svelte.ts +0 -11
  113. package/src/showcase/ChartsSection.svelte +0 -458
  114. package/src/showcase/demos/ai/conversation.svelte +0 -59
package/src/ai/index.ts CHANGED
@@ -1,21 +1,3 @@
1
- // =========================
2
- // CONVERSATION (scroll container)
3
- // =========================
4
-
5
- export {
6
- Conversation,
7
- ConversationContent,
8
- ConversationEmptyState,
9
- ConversationScrollButton,
10
- StickToBottomContext,
11
- getStickToBottomContext,
12
- setStickToBottomContext,
13
- type ConversationProps,
14
- type ConversationContentProps,
15
- type ConversationEmptyStateProps,
16
- type ConversationScrollButtonProps,
17
- } from "./conversation/index.js";
18
-
19
1
  // =========================
20
2
  // COMPOSER
21
3
  // =========================
@@ -53,8 +53,8 @@ One behaviour, one implementation. Where a component already did the job, the up
53
53
 
54
54
  | Upstream | Where it lives instead |
55
55
  | --- | --- |
56
- | `toggle` | `shadcn/switch/switch.css` — the thumb slide, on our switch. |
57
- | `checkbox-check` | `shadcn/checkbox/checkbox.css` — the mark draw, on our checkbox. |
56
+ | `toggle` | `shadcn/switch/switch.svelte` — the thumb slide, on our switch. |
57
+ | `checkbox-check` | `shadcn/checkbox/checkbox.svelte` — the fill and border, on our checkbox. |
58
58
  | `toast-open-close` | `shadcn/sonner` — svelte-sonner owns toast motion. |
59
59
  | `banner-stacking` | `shadcn/sonner` — stacking and hover spread are what it does. |
60
60
  | `matrix-dot-loader` | `ai/loading-state` — `PixelLoader` is the dot-grid loader. |
@@ -81,4 +81,4 @@ Every transition reads semantic CSS variables (`--modal-open-dur`, `--tilt-follo
81
81
 
82
82
  So the scale is single-sourced when the design layer is imported, and the transition still runs at the shipped value when it is not. A value the scale has no token for — a 1000ms tilt return, a 90ms reel stagger — stays a literal rather than being rounded onto the nearest rung. Override any of them at `:root` or per-scope to retune without forking the CSS.
83
83
 
84
- See the [transitions.dev SKILL](../../../../ai-doc/skills/design/motion/transitions-dev/SKILL.md) for the full token list and the per-transition variables.
84
+ See the `transitions-dev` skill in vibe-kit (`ai-doc/skills/design/motion/transitions-dev/SKILL.md`) for the full token list and the per-transition variables.
@@ -0,0 +1,68 @@
1
+ import type { Attachment } from "svelte/attachments";
2
+
3
+ /** The travelling highlight behind the active tab. Attach it to a span inside the list. */
4
+
5
+ // WHY attach to the pill, not the list: the pill finds its list via
6
+ // `parentElement`, so any strip that renders one span gets the motion.
7
+
8
+ // WHY measure: the active item's width is its label. The selector covers the
9
+ // three ways a strip marks it: a tab, a nav link, a toggle.
10
+
11
+ // Per transitions.dev `tabs-sliding`: first paint and resizes write the
12
+ // position with the transition off, so the pill snaps in instead of flying.
13
+ const ACTIVE = '[data-state="active"],[aria-current="page"],[aria-pressed="true"]';
14
+
15
+ export const slidingPill: Attachment<HTMLElement> = (pill) => {
16
+ const list = pill.parentElement;
17
+ if (!list) return;
18
+
19
+ let settled = false;
20
+
21
+ function move(animate: boolean) {
22
+ const active = list!.querySelector<HTMLElement>(ACTIVE);
23
+ if (!active) {
24
+ pill.style.opacity = "0";
25
+ return;
26
+ }
27
+ pill.style.opacity = "1";
28
+ if (animate) {
29
+ pill.style.transform = `translateX(${active.offsetLeft}px)`;
30
+ pill.style.width = `${active.offsetWidth}px`;
31
+ return;
32
+ }
33
+ // Suspend, write, reflow, restore. Without the reflow the browser
34
+ // coalesces the two writes and the snap animates anyway.
35
+ pill.style.transition = "none";
36
+ pill.style.transform = `translateX(${active.offsetLeft}px)`;
37
+ pill.style.width = `${active.offsetWidth}px`;
38
+ void pill.offsetWidth;
39
+ pill.style.transition = "";
40
+ }
41
+
42
+ // Position first, THEN hand the highlight over. Swapped, one frame draws both
43
+ // the trigger's own background and a zero-width pill: a flicker on hydration.
44
+ move(false);
45
+ list.dataset.pill = "on";
46
+ settled = true;
47
+
48
+ // The selection lives in an attribute bits-ui owns, and a badge arriving
49
+ // changes a trigger's width without any attribute changing.
50
+ const attributes = new MutationObserver(() => move(settled));
51
+ attributes.observe(list, {
52
+ subtree: true,
53
+ childList: true,
54
+ attributes: true,
55
+ attributeFilter: ["data-state", "aria-current", "aria-pressed"],
56
+ });
57
+
58
+ // A list that reflows (a rail collapsing, a font landing) re-measures
59
+ // without motion: nothing was chosen, so nothing should travel.
60
+ const resize = new ResizeObserver(() => move(false));
61
+ resize.observe(list);
62
+
63
+ return () => {
64
+ attributes.disconnect();
65
+ resize.disconnect();
66
+ delete list.dataset.pill;
67
+ };
68
+ };
@@ -3,6 +3,8 @@ export interface RevealThemeOptions {
3
3
  skip?: boolean;
4
4
  /** Wipe length in ms. */
5
5
  duration?: number;
6
+ /** Wipe easing, any CSS easing value. */
7
+ easing?: string;
6
8
  }
7
9
 
8
10
  const DEFAULT_DURATION = 400;
@@ -20,7 +22,7 @@ export function revealTheme(
20
22
  apply: () => void,
21
23
  options: RevealThemeOptions = {},
22
24
  ): void {
23
- const { skip = false, duration = DEFAULT_DURATION } = options;
25
+ const { skip = false, duration = DEFAULT_DURATION, easing = "ease-in-out" } = options;
24
26
  const origin = event.currentTarget;
25
27
 
26
28
  const prefersReduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
@@ -45,7 +47,7 @@ export function revealTheme(
45
47
  transition.ready.then(() => {
46
48
  document.documentElement.animate(
47
49
  { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`] },
48
- { duration, easing: "ease-in-out", pseudoElement: "::view-transition-new(root)" },
50
+ { duration, easing, pseudoElement: "::view-transition-new(root)" },
49
51
  );
50
52
  });
51
53
  }
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef } from "@heyramzi/utils";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ class: className,
8
+ children,
9
+ ...restProps
10
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11
+ </script>
12
+
13
+ <div
14
+ bind:this={ref}
15
+ data-slot="code-block-actions"
16
+ class={cn(
17
+ "-my-1 -me-1.5 gap-1 [[data-slot=code-block]>&]:m-0 [[data-slot=code-block]>&]:top-3 [[data-slot=code-block]>&]:end-3 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:top-2 group-data-[size=sm]/code-block:[[data-slot=code-block]>&]:end-2 [[data-slot=code-block]>&]:*:[button]:bg-background [[data-slot=code-block]>&]:*:[button]:ring-1 [[data-slot=code-block]>&]:*:[button]:ring-foreground/10 ms-auto flex shrink-0 items-center [[data-slot=code-block]>&]:absolute [[data-slot=code-block]>&]:z-10 [[data-slot=code-block]>&]:opacity-0 [[data-slot=code-block]>&]:transition-opacity [[data-slot=code-block]>&]:group-focus-within/code-block:opacity-100 [[data-slot=code-block]>&]:group-hover/code-block:opacity-100 [[data-slot=code-block]>&]:pointer-coarse:opacity-100",
18
+ className
19
+ )}
20
+ {...restProps}
21
+ >
22
+ {@render children?.()}
23
+ </div>
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef } from "@heyramzi/utils";
3
+ import CodeBlockLine from "./code-block-line.svelte";
4
+ import type { HTMLAttributes } from "svelte/elements";
5
+
6
+ let {
7
+ ref = $bindable(null),
8
+ class: className,
9
+ style,
10
+ code,
11
+ wrap = false,
12
+ lineNumbers = false,
13
+ startLine = 1,
14
+ children,
15
+ ...restProps
16
+ }: WithElementRef<HTMLAttributes<HTMLPreElement>> & {
17
+ /** Plain text to render. Split into lines when `lineNumbers` is set. */
18
+ code?: string;
19
+ wrap?: boolean;
20
+ lineNumbers?: boolean;
21
+ startLine?: number;
22
+ } = $props();
23
+
24
+ // A `code` string is split into lines so each one can be numbered.
25
+ const lines = $derived(
26
+ code !== undefined && lineNumbers ? code.replace(/\n$/, "").split("\n") : null
27
+ );
28
+ </script>
29
+
30
+ {#snippet content()}
31
+ <code
32
+ data-slot="code-block-code"class=" grid min-h-lh min-w-max content-start in-data-numbered:grid-cols-[auto_1fr] in-data-wrap:min-w-0">
33
+ {#if lines}
34
+ {#each lines as line, index (index)}
35
+ <CodeBlockLine code={line} />
36
+ {/each}
37
+ {:else if code !== undefined}
38
+ {code}
39
+ {:else}
40
+ {@render children?.()}
41
+ {/if}
42
+ </code>
43
+ {/snippet}
44
+
45
+ <!-- Whitespace inside a <pre> is rendered, so its content is a snippet defined outside it, where Svelte trims whitespace as usual. -->
46
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
47
+ <pre
48
+ bind:this={ref}
49
+ data-slot="code-block-content"data-wrap={wrap || undefined}
50
+ data-numbered={lineNumbers || undefined}
51
+ dir="ltr"tabindex={0}
52
+ style="counter-reset: line {startLine - 1};{style ?? ''}"class={cn("px-4 py-4 leading-relaxed group-has-data-[slot=code-block-header]/code-block:pt-2 group-has-data-[slot=code-block-footer]/code-block:pb-2 group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:py-3 group-data-[size=sm]/code-block:group-has-data-[slot=code-block-header]/code-block:pt-1.5 min-h-0 min-w-0 scroll-fade-x scrollbar-thin overflow-auto text-start font-mono outline-none data-wrap:scroll-fade-y data-wrap:wrap-break-word data-wrap:whitespace-pre-wrap",
53
+ className
54
+ )}
55
+ {...restProps}>{@render content()}</pre>
@@ -0,0 +1,59 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from "svelte";
3
+ import CheckIcon from "@lucide/svelte/icons/check";
4
+ import CopyIcon from "@lucide/svelte/icons/copy";
5
+ import { Button, type ButtonProps } from "../button/index.js";
6
+ import { cn } from "@heyramzi/utils";
7
+
8
+ let {
9
+ ref = $bindable(null),
10
+ class: className,
11
+ value,
12
+ variant = "ghost",
13
+ size = "sm",
14
+ onclick,
15
+ ...restProps
16
+ }: Omit<ButtonProps, "onclick"> & {
17
+ value: string;
18
+ onclick?: (e: MouseEvent) => void;
19
+ } = $props();
20
+
21
+ let copied = $state(false);
22
+ let timeout: ReturnType<typeof setTimeout> | undefined;
23
+
24
+ function copy() {
25
+ navigator.clipboard
26
+ .writeText(value)
27
+ .then(() => {
28
+ copied = true;
29
+ clearTimeout(timeout);
30
+ timeout = setTimeout(() => (copied = false), 2000);
31
+ })
32
+ .catch(() => {});
33
+ }
34
+
35
+ onDestroy(() => clearTimeout(timeout));
36
+ </script>
37
+
38
+ <Button
39
+ bind:ref
40
+ data-slot="code-block-copy-button"
41
+ data-copied={copied || undefined}
42
+ {variant}
43
+ {size}
44
+ icon
45
+ aria-label={copied ? "Copied" : "Copy"}
46
+ class={cn(className)}
47
+ onclick={(e) => {
48
+ onclick?.(e);
49
+ copy();
50
+ }}
51
+ {...restProps}
52
+ >
53
+ {#if copied}
54
+ <CheckIcon />
55
+ {:else}
56
+ <CopyIcon />
57
+ {/if}
58
+ </Button>
59
+ <span aria-live="polite" class="sr-only">{copied ? "Copied" : ""}</span>
@@ -0,0 +1,20 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef } from "@heyramzi/utils";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ class: className,
8
+ children,
9
+ ...restProps
10
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11
+ </script>
12
+
13
+ <div
14
+ bind:this={ref}
15
+ data-slot="code-block-footer"
16
+ class={cn("gap-2 px-4 pb-4 text-muted-foreground group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:pb-3 flex min-w-0 shrink-0 items-center", className)}
17
+ {...restProps}
18
+ >
19
+ {@render children?.()}
20
+ </div>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef } from "@heyramzi/utils";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ class: className,
8
+ children,
9
+ ...restProps
10
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11
+ </script>
12
+
13
+ <div
14
+ bind:this={ref}
15
+ data-slot="code-block-header"
16
+ class={cn(
17
+ "gap-2 px-4 pt-4 text-muted-foreground group-data-[variant=destructive]/code-block:text-destructive group-data-[size=sm]/code-block:px-3 group-data-[size=sm]/code-block:pt-3 flex min-w-0 shrink-0 items-center *:has-[>[data-slot=code-block-title]]:min-w-0",
18
+ className
19
+ )}
20
+ {...restProps}
21
+ >
22
+ {@render children?.()}
23
+ </div>
@@ -0,0 +1,71 @@
1
+ <script lang="ts" module>
2
+ import { tv, type VariantProps } from "tailwind-variants";
3
+
4
+ export const codeBlockLineVariants = tv({
5
+ base: "-mx-4 px-4 in-data-numbered:gap-x-3 before:min-w-6 before:text-end before:text-muted-foreground after:start-1 after:text-muted-foreground group-data-[size=sm]/code-block:-mx-3 group-data-[size=sm]/code-block:px-3 relative block min-h-lh after:absolute after:top-0 after:select-none in-data-numbered:col-span-2 in-data-numbered:grid in-data-numbered:grid-cols-subgrid in-data-numbered:[counter-increment:line] in-data-numbered:before:content-[counter(line)] in-data-numbered:before:select-none in-data-numbered:supports-[content:counter(line)_/_'']:before:content-[counter(line)_/_'']",
6
+ variants: {
7
+ variant: {
8
+ default: "",
9
+ added: "bg-muted after:content-['+']",
10
+ removed: "bg-destructive/10 after:text-destructive after:content-['−']",
11
+ highlighted: "bg-muted",
12
+ },
13
+ },
14
+ defaultVariants: {
15
+ variant: "default",
16
+ },
17
+ });
18
+
19
+ export type CodeBlockLineVariant = VariantProps<typeof codeBlockLineVariants>["variant"];
20
+
21
+ // One highlighted token, such as one returned by `useHighlightedCode`.
22
+ export type CodeBlockLineToken = {
23
+ content: string;
24
+ style?: string;
25
+ class?: string;
26
+ };
27
+ </script>
28
+
29
+ <script lang="ts">
30
+ import { cn, type WithElementRef } from "@heyramzi/utils";
31
+ import type { HTMLAttributes } from "svelte/elements";
32
+
33
+ let {
34
+ ref = $bindable(null),
35
+ class: className,
36
+ variant = "default",
37
+ code,
38
+ children,
39
+ ...restProps
40
+ }: WithElementRef<HTMLAttributes<HTMLSpanElement>> & {
41
+ variant?: CodeBlockLineVariant;
42
+ /** The line as plain text, or as highlighted tokens. When it is set, `children` is ignored. */
43
+ code?: string | CodeBlockLineToken[];
44
+ } = $props();
45
+
46
+ const newline = "\n";
47
+ </script>
48
+
49
+ <!-- An empty line holds a newline so copying a selection keeps blank lines. -->
50
+ <span
51
+ bind:this={ref}
52
+ data-slot="code-block-line"data-variant={variant}
53
+ class={cn(codeBlockLineVariants({ variant }), className)}
54
+ {...restProps}
55
+ >
56
+ <span data-slot="code-block-line-content"class="min-w-0">
57
+ {#if typeof code ==="string"}
58
+ {code || newline}
59
+ {:else if code?.length}
60
+ {#each code as token, index (index)}
61
+ <span style={token.style} class={token.class}>{token.content}</span>
62
+ {/each}
63
+ {:else if code}
64
+ {newline}
65
+ {:else if children}
66
+ {@render children()}
67
+ {:else}
68
+ {newline}
69
+ {/if}
70
+ </span>
71
+ </span>
@@ -0,0 +1,23 @@
1
+ <script lang="ts">
2
+ import { cn, type WithElementRef } from "@heyramzi/utils";
3
+ import type { HTMLAttributes } from "svelte/elements";
4
+
5
+ let {
6
+ ref = $bindable(null),
7
+ class: className,
8
+ children,
9
+ ...restProps
10
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11
+ </script>
12
+
13
+ <div
14
+ bind:this={ref}
15
+ data-slot="code-block-title"
16
+ class={cn(
17
+ "font-sans text-sm font-medium text-foreground group-data-[variant=destructive]/code-block:text-destructive *:[svg]:me-1.5 [&_svg:not([class*='size-'])]:size-4 group-data-[size=sm]/code-block:text-xs group-data-[size=sm]/code-block:[&_svg:not([class*='size-'])]:size-3.5 min-w-0 truncate [&_svg]:pointer-events-none [&_svg]:inline-block [&_svg]:align-middle",
18
+ className
19
+ )}
20
+ {...restProps}
21
+ >
22
+ {@render children?.()}
23
+ </div>
@@ -0,0 +1,47 @@
1
+ <script lang="ts" module>
2
+ import { tv, type VariantProps } from "tailwind-variants";
3
+
4
+ export const codeBlockVariants = tv({
5
+ base: "rounded-lg text-sm data-[size=sm]:text-xs has-[>[data-slot=code-block-content]:focus-visible]:ring-3 has-[>[data-slot=code-block-content]:focus-visible]:ring-ring/50 group/code-block relative flex w-full min-w-0 flex-col overflow-hidden",
6
+ variants: {
7
+ variant: {
8
+ default: "border border-input text-foreground dark:bg-input/30 has-[>[data-slot=code-block-content]:focus-visible]:border-ring",
9
+ destructive: "border border-destructive/30 text-destructive dark:border-destructive/40",
10
+ },
11
+ },
12
+ defaultVariants: {
13
+ variant: "default",
14
+ },
15
+ });
16
+
17
+ export type CodeBlockVariant = VariantProps<typeof codeBlockVariants>["variant"];
18
+ export type CodeBlockSize = "default" | "sm";
19
+ </script>
20
+
21
+ <script lang="ts">
22
+ import { cn, type WithElementRef } from "@heyramzi/utils";
23
+ import type { HTMLAttributes } from "svelte/elements";
24
+
25
+ let {
26
+ ref = $bindable(null),
27
+ class: className,
28
+ variant = "default",
29
+ size = "default",
30
+ children,
31
+ ...restProps
32
+ }: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
33
+ variant?: CodeBlockVariant;
34
+ size?: CodeBlockSize;
35
+ } = $props();
36
+ </script>
37
+
38
+ <div
39
+ bind:this={ref}
40
+ data-slot="code-block"
41
+ data-variant={variant}
42
+ data-size={size}
43
+ class={cn(codeBlockVariants({ variant }), className)}
44
+ {...restProps}
45
+ >
46
+ {@render children?.()}
47
+ </div>
@@ -0,0 +1,42 @@
1
+ import Actions from "./code-block-actions.svelte";
2
+ import Content from "./code-block-content.svelte";
3
+ import CopyButton from "./code-block-copy-button.svelte";
4
+ import Footer from "./code-block-footer.svelte";
5
+ import Header from "./code-block-header.svelte";
6
+ import Title from "./code-block-title.svelte";
7
+ import Line, {
8
+ codeBlockLineVariants,
9
+ type CodeBlockLineToken,
10
+ type CodeBlockLineVariant,
11
+ } from "./code-block-line.svelte";
12
+ import Root, {
13
+ codeBlockVariants,
14
+ type CodeBlockSize,
15
+ type CodeBlockVariant,
16
+ } from "./code-block.svelte";
17
+
18
+ export {
19
+ Root,
20
+ Header,
21
+ Title,
22
+ Actions,
23
+ CopyButton,
24
+ Content,
25
+ Line,
26
+ Footer,
27
+ codeBlockVariants,
28
+ codeBlockLineVariants,
29
+ type CodeBlockVariant,
30
+ type CodeBlockSize,
31
+ type CodeBlockLineVariant,
32
+ type CodeBlockLineToken,
33
+ //
34
+ Root as CodeBlock,
35
+ Header as CodeBlockHeader,
36
+ Title as CodeBlockTitle,
37
+ Actions as CodeBlockActions,
38
+ CopyButton as CodeBlockCopyButton,
39
+ Content as CodeBlockContent,
40
+ Line as CodeBlockLine,
41
+ Footer as CodeBlockFooter,
42
+ };
@@ -0,0 +1,132 @@
1
+ import {
2
+ createHighlighterCore,
3
+ type GrammarState,
4
+ type HighlighterCore,
5
+ type ThemedToken,
6
+ } from "shiki/core";
7
+ import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
8
+
9
+ // Add the languages you need. Each one loads the first time it is used.
10
+ const languages = {
11
+ bash: () => import("shiki/langs/shellscript.mjs"),
12
+ css: () => import("shiki/langs/css.mjs"),
13
+ diff: () => import("shiki/langs/diff.mjs"),
14
+ html: () => import("shiki/langs/html.mjs"),
15
+ javascript: () => import("shiki/langs/javascript.mjs"),
16
+ json: () => import("shiki/langs/json.mjs"),
17
+ jsx: () => import("shiki/langs/jsx.mjs"),
18
+ markdown: () => import("shiki/langs/markdown.mjs"),
19
+ python: () => import("shiki/langs/python.mjs"),
20
+ sql: () => import("shiki/langs/sql.mjs"),
21
+ svelte: () => import("shiki/langs/svelte.mjs"),
22
+ tsx: () => import("shiki/langs/tsx.mjs"),
23
+ typescript: () => import("shiki/langs/typescript.mjs"),
24
+ yaml: () => import("shiki/langs/yaml.mjs"),
25
+ };
26
+
27
+ const aliases: Record<string, keyof typeof languages> = {
28
+ js: "javascript",
29
+ md: "markdown",
30
+ py: "python",
31
+ sh: "bash",
32
+ shell: "bash",
33
+ shellscript: "bash",
34
+ ts: "typescript",
35
+ yml: "yaml",
36
+ zsh: "bash",
37
+ };
38
+
39
+ let highlighter: Promise<HighlighterCore> | undefined;
40
+
41
+ function getHighlighter() {
42
+ highlighter ??= createHighlighterCore({
43
+ themes: [import("shiki/themes/github-light.mjs"), import("shiki/themes/github-dark.mjs")],
44
+ langs: [],
45
+ engine: createJavaScriptRegexEngine({ forgiving: true }),
46
+ });
47
+
48
+ return highlighter;
49
+ }
50
+
51
+ function getLanguage(language: string) {
52
+ const name = language.toLowerCase();
53
+
54
+ if (name === "ansi") {
55
+ return name;
56
+ }
57
+
58
+ if (name in languages) {
59
+ return name as keyof typeof languages;
60
+ }
61
+
62
+ return aliases[name] ?? null;
63
+ }
64
+
65
+ // One highlighted token. Render it as a `<span>` with this style and class.
66
+ export type HighlightedToken = {
67
+ content: string;
68
+ style?: string;
69
+ class?: string;
70
+ };
71
+
72
+ // A line of plain text, or the highlighted tokens of a line.
73
+ export type HighlightedLine = string | HighlightedToken[];
74
+
75
+ function toHighlightedLine(tokens: ThemedToken[]): HighlightedToken[] {
76
+ return tokens.map((token) => ({
77
+ content: token.content,
78
+ style: Object.entries(token.htmlStyle ?? {})
79
+ .map(([property, value]) => `${property}:${value}`)
80
+ .join(";"),
81
+ class: "text-(--shiki-light) dark:text-(--shiki-dark)",
82
+ }));
83
+ }
84
+
85
+ export type Highlighter = NonNullable<Awaited<ReturnType<typeof loadHighlighter>>>;
86
+
87
+ // Loads a language and returns a highlighter for it, or null for a language
88
+ // that is not listed.
89
+ export async function loadHighlighter(language: string) {
90
+ const lang = getLanguage(language);
91
+
92
+ if (!lang) {
93
+ return null;
94
+ }
95
+
96
+ const shiki = await getHighlighter();
97
+
98
+ if (lang !== "ansi" && !shiki.getLoadedLanguages().includes(lang)) {
99
+ await shiki.loadLanguage(languages[lang]);
100
+ }
101
+
102
+ return {
103
+ // Returns the tokens of each line, and the grammar state after the last
104
+ // line. Pass that state back to continue where the code left off.
105
+ highlight(code: string, state?: GrammarState) {
106
+ const tokenize = (text: string) =>
107
+ shiki.codeToTokens(text, {
108
+ lang,
109
+ themes: { light: "github-light", dark: "github-dark" },
110
+ defaultColor: false,
111
+ grammarState: state,
112
+ }).tokens;
113
+
114
+ // ANSI has no grammar state, so each line is tokenized on its own and
115
+ // gets the same colors whether it arrives in one piece or streams.
116
+ const tokens =
117
+ lang === "ansi" ? code.split("\n").flatMap((line) => tokenize(line)) : tokenize(code);
118
+
119
+ return {
120
+ lines: tokens.map((line) => toHighlightedLine(line)),
121
+ state: shiki.getLastGrammarState(tokens),
122
+ };
123
+ },
124
+ };
125
+ }
126
+
127
+ // Returns the tokens of each line, or null for a language that is not listed.
128
+ export async function highlightCode(code: string, language: string) {
129
+ const highlighter = await loadHighlighter(language);
130
+
131
+ return highlighter ? highlighter.highlight(code).lines : null;
132
+ }