Skip to content
edmi-ui

Code Block

Syntax-highlighted code with header, copy, language select and line numbers. The canonical code block for the kit.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
With header + line numbers
keeper.ts
import { rebalance } from "@/lib/keeper"
export async function run(index: string) {  // only when drift is above the limit  const drift = await getDrift(index)  if (drift < 0.02) return  return rebalance(index, { slippage: 0.01 })}
No header
const w = { NVDAx: 0.32, MSFTx: 0.28 }
With header + line numbers
keeper.ts
import { rebalance } from "@/lib/keeper"
export async function run(index: string) {  // only when drift is above the limit  const drift = await getDrift(index)  if (drift < 0.02) return  return rebalance(index, { slippage: 0.01 })}
No header
const w = { NVDAx: 0.32, MSFTx: 0.28 }
With header + line numbers
keeper.ts
import { rebalance } from "@/lib/keeper"
export async function run(index: string) {  // only when drift is above the limit  const drift = await getDrift(index)  if (drift < 0.02) return  return rebalance(index, { slippage: 0.01 })}
No header
const w = { NVDAx: 0.32, MSFTx: 0.28 }

The canonical code block of the kit: the body sits on card, the header on muted. Built on button and select; highlighting uses Shiki’s CSS-variables theme mapped onto chart tokens (keyword chart-2, string success-text, number chart-3, function chart-4, comment muted-foreground), so it follows mode, base and theme.

Terminal window
npx shadcn@latest add @edmi-ui/ai-code-block
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-code-block
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-code-block
Terminal window
bunx shadcn@latest add @edmi-ui/ai-code-block

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-code-block
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-code-block
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-code-block
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-code-block

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-code-block.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-code-block.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-code-block.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-code-block.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { CodeBlock } from "@/components/ai/code-block";
<div className="flex w-full max-w-xl flex-col gap-6">
<div className="flex flex-col gap-2">
<span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">
With header + line numbers
</span>
{/* … */}
</div>
<div className="flex flex-col gap-2">
<span className="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">
No header
</span>
<CodeBlock
className="max-w-md"
code="const w = { NVDAx: 0.32, MSFTx: 0.28 }"
language="typescript"
/>
</div>
</div>
<script setup lang="ts">
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopyButton,
CodeBlockFilename,
CodeBlockHeader,
CodeBlockLanguageSelector,
CodeBlockLanguageSelectorContent,
CodeBlockLanguageSelectorItem,
CodeBlockLanguageSelectorTrigger,
CodeBlockLanguageSelectorValue,
CodeBlockTitle,
} from "@/components/ai/code-block";
</script>
<template>
<div class="flex w-full max-w-xl flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">With header + line numbers</span>
<CodeBlock show-line-numbers>
<CodeBlockHeader>
<CodeBlockTitle>
<FileCodeIcon class="size-3.5" />
<CodeBlockFilename>keeper.ts</CodeBlockFilename>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockLanguageSelector>
<CodeBlockLanguageSelectorTrigger>
<CodeBlockLanguageSelectorValue />
</CodeBlockLanguageSelectorTrigger>
<CodeBlockLanguageSelectorContent>
<CodeBlockLanguageSelectorItem :key="l.value" :value="l.value">
{{ l.label }}
</CodeBlockLanguageSelectorItem>
</CodeBlockLanguageSelectorContent>
</CodeBlockLanguageSelector>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
</div>
<!-- … -->
</div>
</template>
<script lang="ts">
import { CodeBlock } from "$lib/components/ai/code-block";
</script>
<div class="flex w-full max-w-xl flex-col gap-6">
<div class="flex flex-col gap-2">
<span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase"
>With header + line numbers</span
>
<!-- … -->
</div>
<div class="flex flex-col gap-2">
<span class="font-mono text-[10.5px] tracking-wide text-muted-foreground uppercase">No header</span>
<CodeBlock
class="max-w-md"
code="const w = &#123; NVDAx: 0.32, MSFTx: 0.28 &#125;"
language="typescript"
/>
</div>
</div>
registry/components/ai/code-block.tsx
"use client";
import { cn } from "cn";
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type {
BundledLanguage,
HighlighterGeneric,
ThemedToken,
ThemeRegistration,
} from "shiki";
import { createCssVariablesTheme, createHighlighter } from "shiki";
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/edmi/ui/select";
// The canonical code block of the kit (DESIGN §5b): body on --card, header on --muted,
// highlight colours come from chart tokens so they follow mode, base and theme.
// Shiki's `css-variables` theme emits `var(--shiki-token-*)`; the container maps those onto tokens.
const SHIKI_TOKEN_VARS = cn(
"[--shiki-foreground:var(--foreground)] [--shiki-background:transparent]",
"[--shiki-token-keyword:var(--chart-2)] [--shiki-token-string:var(--success-text)]",
"[--shiki-token-string-expression:var(--success-text)] [--shiki-token-constant:var(--chart-3)]",
"[--shiki-token-function:var(--chart-4)] [--shiki-token-comment:var(--muted-foreground)]",
"[--shiki-token-parameter:var(--foreground)] [--shiki-token-punctuation:var(--foreground)]",
"[--shiki-token-link:var(--info-text)]",
);
const THEME_NAME = "edmi-css-variables";
const edmiTheme: ThemeRegistration = createCssVariablesTheme({
name: THEME_NAME,
variablePrefix: "--shiki-",
fontStyle: true,
});
// Shiki font styles are bitflags: 1 = italic, 2 = bold, 4 = underline.
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
const isUnderline = (fontStyle: number | undefined) =>
fontStyle && fontStyle & 4;
interface KeyedToken {
token: ThemedToken;
key: string;
}
interface KeyedLine {
tokens: KeyedToken[];
key: string;
}
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
lines.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
key: `line-${lineIdx}-${tokenIdx}`,
token,
})),
}));
const TokenSpan = ({ token }: { token: ThemedToken }) => (
<span
style={
{
color: token.color,
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
...token.htmlStyle,
} as CSSProperties
}
>
{token.content}
</span>
);
// Line numbers use CSS counters: 38px gutter, number right-aligned 14px from the code (board AI 05).
const LINE_NUMBER_CLASSES = cn(
"block",
"before:inline-block before:w-[38px] before:pr-3.5 before:text-right",
"before:content-[counter(line)] before:[counter-increment:line]",
"before:font-mono before:text-muted-foreground-2 before:select-none",
);
const LineSpan = ({
keyedLine,
showLineNumbers,
}: {
keyedLine: KeyedLine;
showLineNumbers: boolean;
}) => (
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
{keyedLine.tokens.length === 0
? "\n"
: keyedLine.tokens.map(({ token, key }) => (
<TokenSpan key={key} token={token} />
))}
</span>
);
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
};
interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
interface CodeBlockContextType {
code: string;
}
const CodeBlockContext = createContext<CodeBlockContextType>({ code: "" });
// Highlighter cache (singleton per language), token cache and async subscribers.
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, string>>
>();
const tokensCache = new Map<string, TokenizedCode>();
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (language: BundledLanguage) => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const promise = createHighlighter({
langs: [language],
themes: [edmiTheme],
}) as Promise<HighlighterGeneric<BundledLanguage, string>>;
highlighterCache.set(language, promise);
return promise;
};
// Plain tokens for immediate display while highlighting loads.
const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
color: "inherit",
content: line,
} as ThemedToken,
],
),
});
/** Tokenizes `code`; returns the cached result synchronously or `null` and calls `callback` once ready. */
export const highlightCode = (
code: string,
language: BundledLanguage,
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const key = getTokensCacheKey(code, language);
const cached = tokensCache.get(key);
if (cached) {
return cached;
}
if (callback) {
if (!subscribers.has(key)) {
subscribers.set(key, new Set());
}
subscribers.get(key)?.add(callback);
}
getHighlighter(language)
.then((highlighter) => {
const loaded = highlighter.getLoadedLanguages();
const lang = loaded.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang,
theme: THEME_NAME,
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
tokensCache.set(key, tokenized);
const subs = subscribers.get(key);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(key);
}
})
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(key);
});
return null;
};
const CodeBlockBody = memo(
({
tokenized,
showLineNumbers,
className,
}: {
tokenized: TokenizedCode;
showLineNumbers: boolean;
className?: string;
}) => {
const keyedLines = useMemo(
() => addKeysToTokens(tokenized.tokens),
[tokenized.tokens],
);
return (
<pre
className={cn(
"m-0 py-2.5 font-mono text-[12.5px] leading-[1.7]",
showLineNumbers ? "pr-4" : "px-4",
className,
)}
>
<code
className={cn(
"font-mono",
showLineNumbers &&
"[counter-increment:line_0] [counter-reset:line]",
)}
>
{keyedLines.map((keyedLine) => (
<LineSpan
key={keyedLine.key}
keyedLine={keyedLine}
showLineNumbers={showLineNumbers}
/>
))}
</code>
</pre>
);
},
(prev, next) =>
prev.tokenized === next.tokenized &&
prev.showLineNumbers === next.showLineNumbers &&
prev.className === next.className,
);
CodeBlockBody.displayName = "CodeBlockBody";
export const CodeBlockContainer = ({
className,
language,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
<div
data-slot="ai-code-block"
className={cn(
"group relative w-full overflow-hidden rounded-xl border border-border bg-card text-foreground",
SHIKI_TOKEN_VARS,
className,
)}
data-language={language}
style={{
containIntrinsicSize: "auto 200px",
contentVisibility: "auto",
...style,
}}
{...props}
/>
);
export const CodeBlockHeader = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
data-slot="ai-code-block-header"
className={cn(
"flex h-[38px] items-center justify-between gap-2 border-b border-border bg-muted pr-1.5 pl-3 text-[12.5px] text-muted-foreground",
className,
)}
{...props}
>
{children}
</div>
);
export const CodeBlockTitle = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-2", className)} {...props}>
{children}
</div>
);
export const CodeBlockFilename = ({
children,
className,
...props
}: HTMLAttributes<HTMLSpanElement>) => (
<span className={cn("font-mono text-xs", className)} {...props}>
{children}
</span>
);
export const CodeBlockActions = ({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export const CodeBlockContent = ({
code,
language,
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
}) => {
const rawTokens = useMemo(() => createRawTokens(code), [code]);
const syncTokens = useMemo(
() => highlightCode(code, language) ?? rawTokens,
[code, language, rawTokens],
);
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
const asyncKeyRef = useRef({ code, language });
// Invalidate stale async tokens synchronously during render.
if (
asyncKeyRef.current.code !== code ||
asyncKeyRef.current.language !== language
) {
asyncKeyRef.current = { code, language };
setAsyncTokens(null);
}
useEffect(() => {
let cancelled = false;
highlightCode(code, language, (result) => {
if (!cancelled) {
setAsyncTokens(result);
}
});
return () => {
cancelled = true;
};
}, [code, language]);
// Server and hydration render the plain tokens; the (possibly cached) highlighted ones only after mount.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const tokenized = asyncTokens ?? (mounted ? syncTokens : rawTokens);
return (
<div className="relative overflow-auto">
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
</div>
);
};
export const CodeBlock = ({
code,
language,
showLineNumbers = false,
className,
children,
...props
}: CodeBlockProps) => {
const contextValue = useMemo(() => ({ code }), [code]);
return (
<CodeBlockContext.Provider value={contextValue}>
<CodeBlockContainer className={className} language={language} {...props}>
{children}
<CodeBlockContent
code={code}
language={language}
showLineNumbers={showLineNumbers}
/>
</CodeBlockContainer>
</CodeBlockContext.Provider>
);
};
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
};
export const CodeBlockCopyButton = ({
onCopy,
onError,
timeout = 2000,
children,
className,
...props
}: CodeBlockCopyButtonProps) => {
const [isCopied, setIsCopied] = useState(false);
const timeoutRef = useRef<number>(0);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = useCallback(async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
if (!isCopied) {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
timeoutRef.current = window.setTimeout(
() => setIsCopied(false),
timeout,
);
}
} catch (error) {
onError?.(error as Error);
}
}, [code, onCopy, onError, timeout, isCopied]);
useEffect(
() => () => {
window.clearTimeout(timeoutRef.current);
},
[],
);
return (
<Button
aria-label="Copy code"
className={cn("shrink-0", className)}
onClick={copyToClipboard}
size="icon-xs"
variant="ghost"
{...props}
>
{children ??
(isCopied ? (
<CheckIcon
/>
) : (
<CopyIcon
/>
))}
</Button>
);
};
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
export const CodeBlockLanguageSelector = (
props: CodeBlockLanguageSelectorProps,
) => <Select {...props} />;
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
typeof SelectTrigger
>;
/** Ghost trigger (board: a ghost xs button with a chevron). */
export const CodeBlockLanguageSelectorTrigger = ({
className,
...props
}: CodeBlockLanguageSelectorTriggerProps) => (
<SelectTrigger
className={cn(
"h-6 gap-1 rounded-md border-transparent data-[size=sm]:h-6 data-[size=sm]:rounded-md bg-transparent px-2 text-xs font-normal text-foreground shadow-none hover:bg-accent",
className,
)}
size="sm"
{...props}
/>
);
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
typeof SelectValue
>;
export const CodeBlockLanguageSelectorValue = (
props: CodeBlockLanguageSelectorValueProps,
) => <SelectValue {...props} />;
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
typeof SelectContent
>;
export const CodeBlockLanguageSelectorContent = ({
align = "end",
...props
}: CodeBlockLanguageSelectorContentProps) => (
<SelectContent align={align} {...props} />
);
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
typeof SelectItem
>;
export const CodeBlockLanguageSelectorItem = (
props: CodeBlockLanguageSelectorItemProps,
) => <SelectItem {...props} />;
registry/components/ai/code-block/CodeBlock.vue
<script setup lang="ts">
import type { BundledLanguage } from "shiki"
import type { HTMLAttributes } from "vue"
import { computed, provide } from "vue"
import CodeBlockContainer from "./CodeBlockContainer.vue"
import CodeBlockContent from "./CodeBlockContent.vue"
import { CodeBlockKey } from "./context"
const props = withDefaults(
defineProps<{
code: string
language: BundledLanguage
showLineNumbers?: boolean
class?: HTMLAttributes["class"]
}>(),
{
showLineNumbers: false,
},
)
provide(CodeBlockKey, {
code: computed(() => props.code),
})
</script>
<template>
<CodeBlockContainer :class="props.class" :language="props.language">
<slot />
<CodeBlockContent
:code="props.code"
:language="props.language"
:show-line-numbers="props.showLineNumbers"
/>
</CodeBlockContainer>
</template>
registry/components/ai/code-block/CodeBlockActions.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center gap-1', props.class)">
<slot />
</div>
</template>
registry/components/ai/code-block/CodeBlockContainer.vue
<script setup lang="ts">
import type { CSSProperties, HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
// The canonical code block of the kit (DESIGN 5b): body on --card, header on --muted.
// Highlight colours come from chart tokens through Shiki's css-variables theme.
const SHIKI_TOKEN_VARS = cn(
"[--shiki-foreground:var(--foreground)] [--shiki-background:transparent]",
"[--shiki-token-keyword:var(--chart-2)] [--shiki-token-string:var(--success-text)]",
"[--shiki-token-string-expression:var(--success-text)] [--shiki-token-constant:var(--chart-3)]",
"[--shiki-token-function:var(--chart-4)] [--shiki-token-comment:var(--muted-foreground)]",
"[--shiki-token-parameter:var(--foreground)] [--shiki-token-punctuation:var(--foreground)]",
"[--shiki-token-link:var(--info-text)]",
)
const props = defineProps<{
class?: HTMLAttributes["class"]
language: string
}>()
const style: CSSProperties = {
contentVisibility: "auto",
containIntrinsicSize: "auto 200px",
}
</script>
<template>
<div
data-slot="ai-code-block"
:class="cn(
'group relative w-full overflow-hidden rounded-xl border border-border bg-card text-foreground',
SHIKI_TOKEN_VARS,
props.class,
)"
:data-language="props.language"
:style="style"
>
<slot />
</div>
</template>
registry/components/ai/code-block/CodeBlockContent.vue
<script setup lang="ts">
import type { BundledLanguage } from "shiki"
import type { TokenizedCode } from "./utils"
import { computed, ref, watch } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { createRawTokens, highlightCode, isBold, isItalic, isUnderline } from "./utils"
const props = withDefaults(
defineProps<{
code: string
language: BundledLanguage
showLineNumbers?: boolean
}>(),
{
showLineNumbers: false,
},
)
const rawTokens = computed(() => createRawTokens(props.code))
// Cached result when there is one, raw tokens until highlighting finishes.
const tokenized = ref<TokenizedCode>(highlightCode(props.code, props.language) ?? rawTokens.value)
watch(
() => [props.code, props.language],
() => {
tokenized.value = highlightCode(props.code, props.language) ?? rawTokens.value
highlightCode(props.code, props.language, (result) => {
tokenized.value = result
})
},
{ immediate: true },
)
const keyedLines = computed(() =>
tokenized.value.tokens.map((line, lineIdx) => ({
key: `line-${lineIdx}`,
tokens: line.map((token, tokenIdx) => ({
token,
key: `line-${lineIdx}-${tokenIdx}`,
})),
})),
)
// Line numbers use CSS counters: 38px gutter, number right-aligned 14px from the code (board AI 05).
const lineNumberClasses = cn(
"block",
"before:inline-block before:w-[38px] before:pr-3.5 before:text-right",
"before:content-[counter(line)] before:[counter-increment:line]",
"before:font-mono before:text-muted-foreground-2 before:select-none",
)
</script>
<template>
<div class="relative overflow-auto">
<pre
:class="cn('m-0 py-2.5 font-mono text-[12.5px] leading-[1.7]', showLineNumbers ? 'pr-4' : 'px-4')"
><code
:class="cn(
'font-mono',
showLineNumbers && '[counter-increment:line_0] [counter-reset:line]',
)"
><template v-for="line in keyedLines" :key="line.key"><span :class="showLineNumbers ? lineNumberClasses : 'block'"><template v-if="line.tokens.length === 0">{{ "\n" }}</template><template v-else><span
v-for="tokenObj in line.tokens"
:key="tokenObj.key"
:style="{
color: tokenObj.token.color,
...tokenObj.token.htmlStyle,
fontStyle: isItalic(tokenObj.token.fontStyle) ? 'italic' : undefined,
fontWeight: isBold(tokenObj.token.fontStyle) ? 'bold' : undefined,
textDecoration: isUnderline(tokenObj.token.fontStyle) ? 'underline' : undefined,
}"
>{{ tokenObj.token.content }}</span></template></span></template></code></pre>
</div>
</template>
registry/components/ai/code-block/CodeBlockCopyButton.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { CheckIcon, CopyIcon } from "@lucide/vue"
import { onBeforeUnmount, ref } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { useCodeBlockContext } from "./context"
const props = withDefaults(defineProps<{
timeout?: number
class?: HTMLAttributes["class"]
}>(), {
timeout: 2000,
})
const emit = defineEmits<{
(event: "copy"): void
(event: "error", error: Error): void
}>()
const { code } = useCodeBlockContext()
const isCopied = ref(false)
let resetTimer: ReturnType<typeof setTimeout> | undefined
async function copyToClipboard() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
emit("error", new Error("Clipboard API not available"))
return
}
try {
await navigator.clipboard.writeText(code.value)
isCopied.value = true
emit("copy")
if (resetTimer) {
clearTimeout(resetTimer)
}
resetTimer = setTimeout(() => {
isCopied.value = false
}, props.timeout)
}
catch (error) {
emit("error", error instanceof Error ? error : new Error("Copy failed"))
}
}
onBeforeUnmount(() => {
if (resetTimer) {
clearTimeout(resetTimer)
}
})
</script>
<template>
<Button
data-slot="ai-code-block-copy"
aria-label="Copy code"
:class="cn('shrink-0', props.class)"
size="icon-xs"
type="button"
variant="ghost"
@click="copyToClipboard"
>
<slot>
<CheckIcon v-if="isCopied" />
<CopyIcon v-else />
</slot>
</Button>
</template>
registry/components/ai/code-block/CodeBlockFilename.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<span :class="cn('font-mono text-xs', props.class)">
<slot />
</span>
</template>
registry/components/ai/code-block/CodeBlockHeader.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="ai-code-block-header"
:class="cn(
'flex h-[38px] items-center justify-between gap-2 border-b border-border bg-muted pr-1.5 pl-3 text-[12.5px] text-muted-foreground',
props.class,
)"
>
<slot />
</div>
</template>
registry/components/ai/code-block/CodeBlockLanguageSelector.vue
<script setup lang="ts">
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
import { useForwardPropsEmits } from "reka-ui"
import { Select } from "@/registry/edmi/ui/select"
const props = defineProps<SelectRootProps>()
const emits = defineEmits<SelectRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<Select v-bind="forwarded">
<slot />
</Select>
</template>
registry/components/ai/code-block/CodeBlockLanguageSelectorContent.vue
<script setup lang="ts">
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardPropsEmits } from "reka-ui"
import { SelectContent } from "@/registry/edmi/ui/select"
const props = withDefaults(
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
{ align: "end" },
)
const emits = defineEmits<SelectContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<SelectContent v-bind="forwarded" :class="props.class">
<slot />
</SelectContent>
</template>
registry/components/ai/code-block/CodeBlockLanguageSelectorItem.vue
<script setup lang="ts">
import type { SelectItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectItem } from "@/registry/edmi/ui/select"
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectItem v-bind="props">
<slot />
</SelectItem>
</template>
registry/components/ai/code-block/CodeBlockLanguageSelectorTrigger.vue
<script setup lang="ts">
import type { SelectTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { useForwardProps } from "reka-ui"
import { cn } from "@/registry/edmi/lib/utils"
import { SelectTrigger } from "@/registry/edmi/ui/select"
// Ghost trigger (board: a ghost xs button with a chevron).
const props = defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<SelectTrigger
v-bind="forwardedProps"
:class="cn(
'h-6 gap-1 rounded-md border-transparent bg-transparent px-2 text-xs font-normal text-foreground shadow-none hover:bg-accent data-[size=sm]:h-6 data-[size=sm]:rounded-md',
props.class,
)"
size="sm"
>
<slot />
</SelectTrigger>
</template>
registry/components/ai/code-block/CodeBlockLanguageSelectorValue.vue
<script setup lang="ts">
import type { SelectValueProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { SelectValue } from "@/registry/edmi/ui/select"
const props = defineProps<SelectValueProps & { class?: HTMLAttributes["class"] }>()
</script>
<template>
<SelectValue v-bind="props">
<slot />
</SelectValue>
</template>
registry/components/ai/code-block/CodeBlockTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div :class="cn('flex items-center gap-2', props.class)">
<slot />
</div>
</template>
registry/components/ai/code-block/context.ts
import type { ComputedRef, InjectionKey } from "vue";
import { inject } from "vue";
export interface CodeBlockContext {
code: ComputedRef<string>;
}
export const CodeBlockKey: InjectionKey<CodeBlockContext> = Symbol("CodeBlock");
export function useCodeBlockContext() {
const context = inject(CodeBlockKey);
if (!context)
throw new Error("CodeBlockCopyButton must be used within a <CodeBlock />");
return context;
}
registry/components/ai/code-block/utils.ts
import type { BundledLanguage, HighlighterGeneric, ThemedToken } from "shiki";
import { createCssVariablesTheme, createHighlighter } from "shiki";
// Shiki's `css-variables` theme emits `var(--shiki-token-*)`; CodeBlockContainer maps those onto
// chart tokens, so highlighting follows mode, base and theme (DESIGN 5b).
export const THEME_NAME = "edmi-css-variables";
const edmiTheme = createCssVariablesTheme({
name: THEME_NAME,
variablePrefix: "--shiki-",
fontStyle: true,
});
// Shiki font styles are bitflags: 1 = italic, 2 = bold, 4 = underline.
export const isItalic = (fontStyle: number | undefined) =>
fontStyle && fontStyle & 1;
export const isBold = (fontStyle: number | undefined) =>
fontStyle && fontStyle & 2;
export function isUnderline(fontStyle: number | undefined) {
return fontStyle && fontStyle & 4;
}
export interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
// Highlighter cache (singleton per language), token cache and async subscribers.
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, string>>
>();
const tokensCache = new Map<string, TokenizedCode>();
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
function getTokensCacheKey(code: string, language: BundledLanguage) {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
}
function getHighlighter(language: BundledLanguage) {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const promise = createHighlighter({
themes: [edmiTheme],
langs: [language],
}) as Promise<HighlighterGeneric<BundledLanguage, string>>;
highlighterCache.set(language, promise);
return promise;
}
// Plain tokens for immediate display while highlighting loads.
export function createRawTokens(code: string): TokenizedCode {
return {
tokens: code.split("\n").map((line) =>
line === ""
? []
: [
{
content: line,
color: "inherit",
} as ThemedToken,
],
),
fg: "inherit",
bg: "transparent",
};
}
/** Tokenizes `code`; returns the cached result synchronously or `null` and calls `callback` once ready. */
export function highlightCode(
code: string,
language: BundledLanguage,
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null {
const key = getTokensCacheKey(code, language);
const cached = tokensCache.get(key);
if (cached) {
return cached;
}
if (callback) {
if (!subscribers.has(key)) {
subscribers.set(key, new Set());
}
subscribers.get(key)?.add(callback);
}
getHighlighter(language)
.then((highlighter) => {
const loaded = highlighter.getLoadedLanguages();
const lang = loaded.includes(language) ? language : "text";
const result = highlighter.codeToTokens(code, {
lang,
theme: THEME_NAME,
});
const tokenized: TokenizedCode = {
tokens: result.tokens,
fg: result.fg ?? "inherit",
bg: result.bg ?? "transparent",
};
tokensCache.set(key, tokenized);
const subs = subscribers.get(key);
if (subs) {
for (const sub of subs) {
sub(tokenized);
}
subscribers.delete(key);
}
})
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(key);
});
return null;
}
registry/components/ai/code-block/index.ts
export { default as CodeBlock } from "./CodeBlock.vue";
export { default as CodeBlockActions } from "./CodeBlockActions.vue";
export { default as CodeBlockContainer } from "./CodeBlockContainer.vue";
export { default as CodeBlockContent } from "./CodeBlockContent.vue";
export { default as CodeBlockCopyButton } from "./CodeBlockCopyButton.vue";
export { default as CodeBlockFilename } from "./CodeBlockFilename.vue";
export { default as CodeBlockHeader } from "./CodeBlockHeader.vue";
export { default as CodeBlockLanguageSelector } from "./CodeBlockLanguageSelector.vue";
export { default as CodeBlockLanguageSelectorContent } from "./CodeBlockLanguageSelectorContent.vue";
export { default as CodeBlockLanguageSelectorItem } from "./CodeBlockLanguageSelectorItem.vue";
export { default as CodeBlockLanguageSelectorTrigger } from "./CodeBlockLanguageSelectorTrigger.vue";
export { default as CodeBlockLanguageSelectorValue } from "./CodeBlockLanguageSelectorValue.vue";
export { default as CodeBlockTitle } from "./CodeBlockTitle.vue";
export * from "./context";
export { highlightCode } from "./utils";
src/lib/registry/ai/code-block/code-block-actions.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
</script>
<div class={cn("flex items-center gap-1", className)} {...restProps}>
{@render children?.()}
</div>
src/lib/registry/ai/code-block/code-block-content.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { BundledLanguage } from "shiki";
import {
createRawTokens,
highlightCode,
isBold,
isItalic,
isUnderline,
type TokenizedCode,
} from "./utils.js";
let {
code,
language,
showLineNumbers = false,
}: { code: string; language: BundledLanguage; showLineNumbers?: boolean } = $props();
// Line numbers use CSS counters: 38px gutter, number right-aligned 14px from the code (board AI 05).
const LINE_NUMBER_CLASSES = cn(
"block",
"before:inline-block before:w-[38px] before:pr-3.5 before:text-right",
"before:content-[counter(line)] before:[counter-increment:line]",
"before:font-mono before:text-muted-foreground-2 before:select-none"
);
let highlighted = $state<TokenizedCode | null>(null);
const tokenized = $derived(
highlighted ?? highlightCode(code, language) ?? createRawTokens(code)
);
$effect(() => {
const current = { code, language };
highlighted = null;
let cancelled = false;
highlightCode(current.code, current.language, (result) => {
if (!cancelled) highlighted = result;
});
return () => {
cancelled = true;
};
});
</script>
<div class="relative overflow-auto">
<pre class={cn("m-0 py-2.5 font-mono text-[12.5px] leading-[1.7]", showLineNumbers ? "pr-4" : "px-4")}><code
class={cn("font-mono", showLineNumbers && "[counter-increment:line_0] [counter-reset:line]")}
>{#each tokenized.tokens as line, i (i)}<span class={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}
>{#if line.length === 0}{"\n"}{:else}{#each line as token, j (j)}<span
style:color={token.color}
style:font-style={isItalic(token.fontStyle) ? "italic" : undefined}
style:font-weight={isBold(token.fontStyle) ? "bold" : undefined}
style:text-decoration={isUnderline(token.fontStyle) ? "underline" : undefined}
>{token.content}</span
>{/each}{/if}</span
>{/each}</code
></pre>
</div>
src/lib/registry/ai/code-block/code-block-copy-button.svelte
<script lang="ts">
import CheckIcon from "phosphor-svelte/lib/Check";
import CopyIcon from "phosphor-svelte/lib/Copy";
import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { onDestroy } from "svelte";
import { useCodeBlockContext } from "./use-code-block.svelte.js";
let {
onCopy,
onError,
timeout = 2000,
class: className,
children,
...restProps
}: Omit<ButtonProps, "onclick" | "href"> & {
onCopy?: () => void;
onError?: (error: Error) => void;
timeout?: number;
} = $props();
const block = useCodeBlockContext();
let isCopied = $state(false);
let timer: ReturnType<typeof setTimeout> | undefined;
async function copy() {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(block.code);
isCopied = true;
onCopy?.();
clearTimeout(timer);
timer = setTimeout(() => (isCopied = false), timeout);
} catch (error) {
onError?.(error as Error);
}
}
onDestroy(() => clearTimeout(timer));
</script>
<Button
data-slot="ai-code-block-copy"
aria-label="Copy code"
class={cn("shrink-0", className)}
onclick={copy}
size="icon-xs"
type="button"
variant="ghost"
{...restProps}
>
{#if children}
{@render children()}
{:else if isCopied}
<CheckIcon
/>
{:else}
<CopyIcon
/>
{/if}
</Button>
src/lib/registry/ai/code-block/code-block-filename.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
children,
...restProps
}: HTMLAttributes<HTMLSpanElement> & { children?: Snippet } = $props();
</script>
<span class={cn("font-mono text-xs", className)} {...restProps}>
{@render children?.()}
</span>
src/lib/registry/ai/code-block/code-block-header.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
</script>
<div
data-slot="ai-code-block-header"
class={cn(
"flex h-[38px] items-center justify-between gap-2 border-b border-border bg-muted pr-1.5 pl-3 text-[12.5px] text-muted-foreground",
className
)}
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ai/code-block/code-block-language-selector-trigger.svelte
<script lang="ts">
import { SelectTrigger } from "$lib/registry/ui/select/index.js";
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
// Ghost trigger (board: a ghost xs button with a chevron).
let { class: className, children, ...restProps }: ComponentProps<typeof SelectTrigger> = $props();
</script>
<SelectTrigger
size="sm"
class={cn(
"h-6 gap-1 rounded-md border-transparent bg-transparent px-2 text-xs font-normal text-foreground shadow-none hover:bg-accent data-[size=sm]:h-6 data-[size=sm]:rounded-md",
className
)}
{...restProps}
>
{@render children?.()}
</SelectTrigger>
src/lib/registry/ai/code-block/code-block-title.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
let {
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();
</script>
<div class={cn("flex items-center gap-2", className)} {...restProps}>
{@render children?.()}
</div>
src/lib/registry/ai/code-block/code-block.svelte
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { Snippet } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { BundledLanguage } from "shiki";
import CodeBlockContent from "./code-block-content.svelte";
import { setCodeBlockContext } from "./use-code-block.svelte.js";
// The canonical code block of the kit (DESIGN 5b): body on --card, header on --muted. Highlight
// colours come from chart tokens through Shiki's css-variables theme.
const SHIKI_TOKEN_VARS = cn(
"[--shiki-foreground:var(--foreground)] [--shiki-background:transparent]",
"[--shiki-token-keyword:var(--chart-2)] [--shiki-token-string:var(--success-text)]",
"[--shiki-token-string-expression:var(--success-text)] [--shiki-token-constant:var(--chart-3)]",
"[--shiki-token-function:var(--chart-4)] [--shiki-token-comment:var(--muted-foreground)]",
"[--shiki-token-parameter:var(--foreground)] [--shiki-token-punctuation:var(--foreground)]",
"[--shiki-token-link:var(--info-text)]"
);
let {
code,
language,
showLineNumbers = false,
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
showLineNumbers?: boolean;
children?: Snippet;
} = $props();
setCodeBlockContext({
get code() {
return code;
},
});
</script>
<div
data-slot="ai-code-block"
data-language={language}
class={cn(
"group relative w-full overflow-hidden rounded-xl border border-border bg-card text-foreground",
SHIKI_TOKEN_VARS,
className
)}
style="content-visibility:auto;contain-intrinsic-size:auto 200px"
{...restProps}
>
{@render children?.()}
<CodeBlockContent {code} {language} {showLineNumbers} />
</div>
src/lib/registry/ai/code-block/use-code-block.svelte.ts
import { getContext, setContext } from "svelte";
/** Getter object keeps the context reactive. */
export interface CodeBlockContextValue {
readonly code: string;
}
const KEY = Symbol("ai-code-block");
export function setCodeBlockContext(value: CodeBlockContextValue) {
return setContext(KEY, value);
}
export function useCodeBlockContext(): CodeBlockContextValue {
const ctx = getContext<CodeBlockContextValue | undefined>(KEY);
if (!ctx) {
throw new Error("CodeBlockCopyButton must be used within <CodeBlock>");
}
return ctx;
}
src/lib/registry/ai/code-block/utils.ts
import type { BundledLanguage, HighlighterGeneric, ThemedToken } from "shiki";
import { createCssVariablesTheme, createHighlighter } from "shiki";
// Shiki's `css-variables` theme emits `var(--shiki-token-*)`; the container maps those onto chart
// tokens, so highlighting follows mode, base and theme (DESIGN 5b).
export const THEME_NAME = "edmi-css-variables";
const edmiTheme = createCssVariablesTheme({
name: THEME_NAME,
variablePrefix: "--shiki-",
fontStyle: true,
});
// Shiki font styles are bitflags: 1 = italic, 2 = bold, 4 = underline.
export const isItalic = (fontStyle: number | undefined) =>
Boolean(fontStyle && fontStyle & 1);
export const isBold = (fontStyle: number | undefined) =>
Boolean(fontStyle && fontStyle & 2);
export const isUnderline = (fontStyle: number | undefined) =>
Boolean(fontStyle && fontStyle & 4);
export interface TokenizedCode {
tokens: ThemedToken[][];
fg: string;
bg: string;
}
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, string>>
>();
const tokensCache = new Map<string, TokenizedCode>();
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getKey = (code: string, language: BundledLanguage) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (language: BundledLanguage) => {
const cached = highlighterCache.get(language);
if (cached) {
return cached;
}
const promise = createHighlighter({
langs: [language],
themes: [edmiTheme],
}) as Promise<HighlighterGeneric<BundledLanguage, string>>;
highlighterCache.set(language, promise);
return promise;
};
/** Plain tokens for immediate display while highlighting loads. */
export const createRawTokens = (code: string): TokenizedCode => ({
bg: "transparent",
fg: "inherit",
tokens: code
.split("\n")
.map((line) =>
line === "" ? [] : [{ color: "inherit", content: line } as ThemedToken],
),
});
/** Tokenizes `code`; returns the cached result synchronously or `null` and calls `callback` once ready. */
export function highlightCode(
code: string,
language: BundledLanguage,
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null {
const key = getKey(code, language);
const cached = tokensCache.get(key);
if (cached) {
return cached;
}
if (callback) {
if (!subscribers.has(key)) {
subscribers.set(key, new Set());
}
subscribers.get(key)?.add(callback);
}
getHighlighter(language)
.then((highlighter) => {
const lang = highlighter.getLoadedLanguages().includes(language)
? language
: "text";
const result = highlighter.codeToTokens(code, {
lang,
theme: THEME_NAME,
});
const tokenized: TokenizedCode = {
bg: result.bg ?? "transparent",
fg: result.fg ?? "inherit",
tokens: result.tokens,
};
tokensCache.set(key, tokenized);
for (const sub of subscribers.get(key) ?? []) {
sub(tokenized);
}
subscribers.delete(key);
})
.catch((error) => {
console.error("Failed to highlight code:", error);
subscribers.delete(key);
});
return null;
}
src/lib/registry/ai/code-block/index.ts
import {
Root as Select,
Content as SelectContent,
Item as SelectItem,
Value as SelectValue,
} from "$lib/registry/ui/select/index.js";
import Root from "./code-block.svelte";
import Actions from "./code-block-actions.svelte";
import Content from "./code-block-content.svelte";
import CopyButton from "./code-block-copy-button.svelte";
import Filename from "./code-block-filename.svelte";
import Header from "./code-block-header.svelte";
import LanguageSelectorTrigger from "./code-block-language-selector-trigger.svelte";
import Title from "./code-block-title.svelte";
export { useCodeBlockContext } from "./use-code-block.svelte.js";
export { highlightCode } from "./utils.js";
export {
Actions as CodeBlockActions,
Content as CodeBlockContent,
CopyButton as CodeBlockCopyButton,
Filename as CodeBlockFilename,
Header as CodeBlockHeader,
LanguageSelectorTrigger as CodeBlockLanguageSelectorTrigger,
Root as CodeBlock,
Select as CodeBlockLanguageSelector,
SelectContent as CodeBlockLanguageSelectorContent,
SelectItem as CodeBlockLanguageSelectorItem,
SelectValue as CodeBlockLanguageSelectorValue,
Title as CodeBlockTitle,
};
Part Prop Type Default
CodeBlock code, language, showLineNumbers string, BundledLanguage, boolean false
CodeBlockCopyButton onCopy, onError, timeout 2000
CodeBlockLanguageSelector Select props (items, value, onValueChange)
  • Highlight colours come from chart tokens instead of a fixed Shiki theme.