diff --git a/app/docs/changelog/page.tsx b/app/docs/changelog/page.tsx index bdbed4a..a18a5ae 100644 --- a/app/docs/changelog/page.tsx +++ b/app/docs/changelog/page.tsx @@ -4,7 +4,7 @@ import { join } from "node:path" import Link from "next/link" import { docsNav } from "@/lib/docs" import { getRegistryItem } from "@/lib/registry" -import { ChangelogImage } from "@/components/changelog-image" +import { ThemeImage } from "@/components/docs/theme-image" export const metadata: Metadata = { title: "Changelog", @@ -134,16 +134,16 @@ export default function ChangelogPage() { {entry.hasImage && ( - + )}
View documentation → diff --git a/app/docs/components/activity-graph/page.tsx b/app/docs/components/activity-graph/page.tsx index eae9325..af9e185 100644 --- a/app/docs/components/activity-graph/page.tsx +++ b/app/docs/components/activity-graph/page.tsx @@ -144,7 +144,7 @@ export default async function ActivityGraphPage() {

-
+

Examples

diff --git a/app/docs/components/ai-copy-button/page.tsx b/app/docs/components/ai-copy-button/page.tsx index 63954af..f761f86 100644 --- a/app/docs/components/ai-copy-button/page.tsx +++ b/app/docs/components/ai-copy-button/page.tsx @@ -43,7 +43,7 @@ export default async function AiCopyButtonPage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/components/api-ref-table/page.tsx b/app/docs/components/api-ref-table/page.tsx index 0d97655..7e718e1 100644 --- a/app/docs/components/api-ref-table/page.tsx +++ b/app/docs/components/api-ref-table/page.tsx @@ -79,7 +79,7 @@ export default async function ApiRefTablePage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/components/code-block-command/page.tsx b/app/docs/components/code-block-command/page.tsx index 07f0830..de0d6b7 100644 --- a/app/docs/components/code-block-command/page.tsx +++ b/app/docs/components/code-block-command/page.tsx @@ -58,7 +58,7 @@ export default function CodeBlockCommandPage() { } > {/* Examples */} -
+

Examples

@@ -186,7 +186,7 @@ export default function CodeBlockCommandPage() {
{/* Color themes */} -
+

Color themes

Pass a{" "} diff --git a/app/docs/components/code-block/page.tsx b/app/docs/components/code-block/page.tsx index c2bffe2..b201567 100644 --- a/app/docs/components/code-block/page.tsx +++ b/app/docs/components/code-block/page.tsx @@ -90,7 +90,7 @@ export default async function CodeBlockPage() { } > {/* Examples */} -

+

Examples

@@ -213,7 +213,7 @@ export default async function CodeBlockPage() {
{/* Color themes */} -
+

Color themes

Pass a{" "} diff --git a/app/docs/components/code-line/page.tsx b/app/docs/components/code-line/page.tsx index d490806..e151ad2 100644 --- a/app/docs/components/code-line/page.tsx +++ b/app/docs/components/code-line/page.tsx @@ -66,7 +66,7 @@ export default async function CodeLinePage() { } > {/* Examples */} -

+

Examples

@@ -173,7 +173,7 @@ export default async function CodeLinePage() {
{/* Color themes */} -
+

Color themes

Pass a{" "} diff --git a/app/docs/components/cron-schedule/page.tsx b/app/docs/components/cron-schedule/page.tsx index c71b3bd..30cc10e 100644 --- a/app/docs/components/cron-schedule/page.tsx +++ b/app/docs/components/cron-schedule/page.tsx @@ -70,7 +70,7 @@ export default function CronSchedulePage() { } > {/* Examples */} -

+

Examples

diff --git a/app/docs/components/diff-viewer/page.tsx b/app/docs/components/diff-viewer/page.tsx new file mode 100644 index 0000000..1ca3a72 --- /dev/null +++ b/app/docs/components/diff-viewer/page.tsx @@ -0,0 +1,260 @@ +import type { Metadata } from "next" +import { DiffViewer } from "@/registry/diff-viewer/diff-viewer" +import { ApiRefTable } from "@/registry/api-ref-table/api-ref-table" +import { ComponentDocsPage } from "@/components/docs/component-docs-page" +import { VariantGrid } from "@/components/docs/variant-grid" +import { CodeLine } from "@/registry/code-line/code-line" + +export const metadata: Metadata = { + title: "Diff Viewer", + description: + "Code diff viewer with line numbers and add/remove coloring. Supports unified and split layouts.", +} + +const sourceFiles = [ + "registry/diff-viewer/diff-viewer.tsx", + "registry/diff-viewer/diff-viewer-client.tsx", +] + +const oldCode = `import { useState } from "react" + +function Counter() { + const [count, setCount] = useState(0) + + return ( + + ) +}` + +const newCode = `import { useState, useCallback } from "react" + +function Counter({ initial = 0 }) { + const [count, setCount] = useState(initial) + + const increment = useCallback(() => { + setCount((prev) => prev + 1) + }, []) + + return ( + + ) +}` + +const configOld = `{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "strict": true + } +}` + +const configNew = `{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true + } +}` + +export default function DiffViewerPage() { + return ( + + +
+ } + usage={ + <> + + `} + /> + + } + > + {/* Layouts */} +
+

Layouts

+ + ), + code: ``, + }, + { + label: "Split", + preview: ( + + ), + code: ``, + }, + ]} + files={sourceFiles} + columns={1} + fullWidth + registryName="diff-viewer" + /> +
+ + {/* Examples */} +
+

Examples

+ + ), + code: ``, + }, + { + label: "No header", + preview: ( + + ), + code: ``, + }, + ]} + files={sourceFiles} + columns={1} + fullWidth + registryName="diff-viewer" + /> +
+ + {/* API Reference */} +
+

API Reference

+ +
+ + {/* Notes */} +
+

Notes

+
    +
  • + Two input modes.{" "} + Pass{" "} + + oldCode + {" "} + +{" "} + + newCode + {" "} + to compute the diff, or pass a{" "} + + patch + {" "} + string if you already have one. +
  • +
  • + Icon library.{" "} + Uses{" "} + + Lucide + {" "} + icons by default. Since this is copy-paste code, you can swap the + imports if your project uses a different icon library. +
  • +
+
+ + ) +} diff --git a/app/docs/components/env-table/page.tsx b/app/docs/components/env-table/page.tsx index fd96e08..31aaad3 100644 --- a/app/docs/components/env-table/page.tsx +++ b/app/docs/components/env-table/page.tsx @@ -154,7 +154,7 @@ export default function EnvTablePage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/components/file-tree/page.tsx b/app/docs/components/file-tree/page.tsx index 44512d0..826b794 100644 --- a/app/docs/components/file-tree/page.tsx +++ b/app/docs/components/file-tree/page.tsx @@ -169,7 +169,7 @@ export default function FileTreePage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/components/github-button-group/page.tsx b/app/docs/components/github-button-group/page.tsx index 7482c94..3c4fb09 100644 --- a/app/docs/components/github-button-group/page.tsx +++ b/app/docs/components/github-button-group/page.tsx @@ -63,7 +63,7 @@ export default async function GitHubButtonGroupPage() { )} {/* Examples */} -
+

Examples

diff --git a/app/docs/components/github-stars-button/page.tsx b/app/docs/components/github-stars-button/page.tsx index defe7fe..48e4721 100644 --- a/app/docs/components/github-stars-button/page.tsx +++ b/app/docs/components/github-stars-button/page.tsx @@ -56,7 +56,7 @@ export default async function GitHubStarsButtonPage() {
{/* Examples */} -
+

Examples

diff --git a/app/docs/components/json-viewer/page.tsx b/app/docs/components/json-viewer/page.tsx index 3d1bfc8..ef42dbf 100644 --- a/app/docs/components/json-viewer/page.tsx +++ b/app/docs/components/json-viewer/page.tsx @@ -147,7 +147,7 @@ export default function JsonViewerPage() { } > {/* Examples */} -
+

Examples

@@ -298,7 +298,7 @@ export default function JsonViewerPage() {
{/* Color themes */} -
+

Color themes

Pass a{" "} diff --git a/app/docs/components/kbd/page.tsx b/app/docs/components/kbd/page.tsx new file mode 100644 index 0000000..aea1f67 --- /dev/null +++ b/app/docs/components/kbd/page.tsx @@ -0,0 +1,357 @@ +import type { Metadata } from "next" +import { Kbd, KbdCombo, builtInSchemes, type BuiltInColorScheme } from "@/registry/kbd/kbd" +import { ApiRefTable } from "@/registry/api-ref-table/api-ref-table" +import { ComponentDocsPage } from "@/components/docs/component-docs-page" +import { VariantGrid } from "@/components/docs/variant-grid" +import { CodeLine } from "@/registry/code-line/code-line" +import { KbdPlayground } from "./playground" + +export const metadata: Metadata = { + title: "Kbd", + description: + "Keyboard shortcut key rendered as a styled keycap. Three visual profiles: flat, raised, and sculpted.", +} + +const sourceFiles = ["registry/kbd/kbd.tsx"] + +export default function KbdPage() { + return ( + + + + +

+ } + usage={ + <> + + ⌘`} /> + `} + /> + + } + > + {/* Playground */} +
+

Playground

+ +
+ + {/* Variants */} +
+

Variants

+ + Esc + + +
+ ), + code: `Esc`, + }, + { + label: "Raised", + preview: ( +
+ Esc + + +
+ ), + code: `Esc`, + }, + { + label: "Sculpted", + preview: ( +
+ Esc + + +
+ ), + code: `Esc`, + }, + ]} + files={sourceFiles} + columns={1} + registryName="kbd" + /> +
+ + {/* Sizes */} +
+

Sizes

+ + + + Open command palette + +
+ ), + code: ``, + }, + { + label: "Medium", + preview: ( +
+ + + Open command palette + +
+ ), + code: ``, + }, + { + label: "Large", + preview: ( +
+ + + Open command palette + +
+ ), + code: ``, + }, + ]} + files={sourceFiles} + columns={3} + registryName="kbd" + /> +
+ + {/* Examples */} +
+

Examples

+ + ), + code: ``, + }, + { + label: "Arrow keys", + preview: ( +
+ + + + +
+ ), + code: ``, + }, + { + label: "Inline with text", + preview: ( +

+ Press{" "} + {" "} + to open the command palette +

+ ), + code: `

Press to open the command palette

`, + }, + ]} + files={sourceFiles} + columns={3} + registryName="kbd" + /> +
+ + {/* Color Schemes */} +
+

Color Schemes

+

+ Built-in color palettes inspired by popular keycap sets. Pass a name + string or a custom{" "} + + {"{ bg, text, border }"} + {" "} + object. +

+
+ {(Object.keys(builtInSchemes) as BuiltInColorScheme[]).map((name) => ( +
+
+ + K +
+ + {name} + +
+ ))} +
+ + ), + code: ``, + }, + { + label: "Olivia", + preview: ( + + ), + code: ``, + }, + { + label: "Botanical", + preview: ( + + ), + code: ``, + }, + { + label: "Laser", + preview: ( + + ), + code: ``, + }, + { + label: "Custom colors", + preview: ( + + ), + code: ``, + }, + ]} + files={sourceFiles} + columns={3} + registryName="kbd" + /> +
+ + {/* API Reference */} +
+

API Reference

+ + +
+ + ) +} diff --git a/app/docs/components/kbd/playground.tsx b/app/docs/components/kbd/playground.tsx new file mode 100644 index 0000000..6f04e91 --- /dev/null +++ b/app/docs/components/kbd/playground.tsx @@ -0,0 +1,83 @@ +"use client" + +import { KbdCombo, type BuiltInColorScheme } from "@/registry/kbd/kbd" +import { + ComponentPlayground, + type PlaygroundControl, +} from "@/components/docs/component-playground" + +const colorSchemeOptions = [ + "none", + "dolch", + "olivia", + "botanical", + "oblivion", + "8008", + "laser", + "mizu", + "dracula", + "hammerhead", + "wob", + "bow", + "cream", +] + +const controls: PlaygroundControl[] = [ + { + name: "variant", + type: "select", + options: ["flat", "raised", "sculpted"], + default: "raised", + }, + { + name: "size", + type: "select", + options: ["sm", "md", "lg"], + default: "md", + }, + { + name: "colorScheme", + type: "select", + label: "colorScheme", + options: colorSchemeOptions, + default: "none", + }, + { + name: "keys", + type: "preset", + label: "Keys", + presets: [ + { label: "⌘ K", value: ["⌘", "K"], code: '["⌘", "K"]' }, + { label: "Ctrl Shift P", value: ["Ctrl", "Shift", "P"], code: '["Ctrl", "Shift", "P"]' }, + { label: "⌥ ↑", value: ["⌥", "↑"], code: '["⌥", "↑"]' }, + { label: "← ↑ ↓ →", value: ["←", "↑", "↓", "→"], code: '["←", "↑", "↓", "→"]' }, + { label: "Esc", value: ["Esc"], code: '["Esc"]' }, + ], + default: "⌘ K", + }, +] + +export function KbdPlayground() { + return ( + { + const variant = (props.variant as string) ?? "raised" + const size = (props.size as string) ?? "md" + const colorScheme = props.colorScheme === "none" ? undefined : (props.colorScheme as BuiltInColorScheme) + const keys = (props.keys as string[]) ?? ["⌘", "K"] + + return ( + + ) + }} + /> + ) +} diff --git a/app/docs/components/log-viewer/page.tsx b/app/docs/components/log-viewer/page.tsx index 214c924..5a002da 100644 --- a/app/docs/components/log-viewer/page.tsx +++ b/app/docs/components/log-viewer/page.tsx @@ -130,7 +130,7 @@ export default function LogViewerPage() {
{/* Variants */} -
+

Variants

@@ -240,7 +240,7 @@ export default function LogViewerPage() {
{/* Custom colors */} -
+

Custom colors

Pass a colorScale to diff --git a/app/docs/components/npm-badge/page.tsx b/app/docs/components/npm-badge/page.tsx index df91833..93fda61 100644 --- a/app/docs/components/npm-badge/page.tsx +++ b/app/docs/components/npm-badge/page.tsx @@ -45,7 +45,7 @@ export default async function NpmBadgePage() { } > {/* Layouts */} -

+

Layouts

@@ -160,7 +160,7 @@ export default async function NpmBadgePage() {
{/* Variants (inline) */} -
+

Examples

diff --git a/app/docs/components/producthunt-button/page.tsx b/app/docs/components/producthunt-button/page.tsx index c30559f..529c35f 100644 --- a/app/docs/components/producthunt-button/page.tsx +++ b/app/docs/components/producthunt-button/page.tsx @@ -5,6 +5,7 @@ import { ComponentDocsPage } from "@/components/docs/component-docs-page" import { VariantGrid } from "@/components/docs/variant-grid" import { ProductHuntButtonPlayground } from "./playground" import { CodeLine } from "@/registry/code-line/code-line" +import { Stepper, StepperItem } from "@/registry/stepper/stepper" export const metadata: Metadata = { title: "Product Hunt Button", @@ -78,7 +79,7 @@ export default async function ProductHuntButtonPage() {
{/* Examples */} -
+

Examples

@@ -431,33 +432,49 @@ export default async function ProductHuntButtonPage() { The component fetches live data from the Product Hunt GraphQL API. To enable this, you need a developer token.

-
    -
  1. - Go to the{" "} - - Product Hunt API Dashboard - - . -
  2. -
  3. - Click Add an Application. - Enter any name and redirect URI (e.g. your site URL). -
  4. -
  5. - After creating the app, scroll down to the{" "} - Developer Token section. - Copy the token value. -
  6. -
  7. - Add it to your environment: -
  8. -
- + + +

+ Go to the{" "} + + Product Hunt API Dashboard + + . +

+
+ +

+ Click Add an Application. + Enter any name and redirect URI (e.g. your site URL). +

+
+ +

+ After creating the app, scroll down to the{" "} + Developer Token{" "} + section. Copy the token value. +

+
+ + + +

The developer token never expires and requires no OAuth flow. No API key or secret exchange needed — the token from the dashboard diff --git a/app/docs/components/request-viewer/page.tsx b/app/docs/components/request-viewer/page.tsx index 499ce7d..1d17a57 100644 --- a/app/docs/components/request-viewer/page.tsx +++ b/app/docs/components/request-viewer/page.tsx @@ -250,7 +250,7 @@ export default async function RequestViewerPage() {

{/* Examples */} -
+

Examples

diff --git a/app/docs/components/stepper/page.tsx b/app/docs/components/stepper/page.tsx index bc27fee..b1f0d36 100644 --- a/app/docs/components/stepper/page.tsx +++ b/app/docs/components/stepper/page.tsx @@ -67,7 +67,7 @@ export default function StepperPage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/components/tip-jar/page.tsx b/app/docs/components/tip-jar/page.tsx index 8f3e164..1f319d6 100644 --- a/app/docs/components/tip-jar/page.tsx +++ b/app/docs/components/tip-jar/page.tsx @@ -87,7 +87,7 @@ export default async function TipJarPage() { } > {/* Examples */} -
+

Examples

diff --git a/app/docs/installation/page.tsx b/app/docs/installation/page.tsx index 52964fd..b19cac7 100644 --- a/app/docs/installation/page.tsx +++ b/app/docs/installation/page.tsx @@ -1,5 +1,7 @@ import type { Metadata } from "next" +import { CodeBlock } from "@/components/docs/code-block" import { CodeLine } from "@/registry/code-line/code-line" +import { Stepper, StepperItem } from "@/registry/stepper/stepper" import { CopyPromptButton } from "@/components/docs/copy-prompt-button" import { generateInstallationPrompt } from "@/lib/prompts" @@ -34,97 +36,180 @@ export default async function InstallationPage() {
-

Prerequisites

+

+ With shadcn CLI +

- Make sure you have a project set up with: + The fastest way to get started. The CLI handles source files, + npm dependencies, and registry dependencies in one command.

- + + + + + + +
+ +

+ This adds the jalco registry to your{" "} + + components.json + + : +

+ +
+
+ + +
+ +

This will:

+
    +
  1. Download the component source into your project
  2. +
  3. Install any required npm dependencies
  4. +
  5. + Add any shadcn registry dependencies (like{" "} + + button + + ,{" "} + + card + + , etc.) +
  6. +
+
+
+ + + + +

- Install a component + Manual setup

- Use the{" "} - shadcn{" "} - CLI to add any component from the registry: + If you prefer to skip the{" "} + + registry add + {" "} + command, add the registry entry to your{" "} + + components.json + {" "} + directly: +

+ +

+ Then install components with the CLI as usual:

-

This will:

-
    -
  1. Download the component source into your project
  2. -
  3. Install any required npm dependencies
  4. -
  5. - Add any shadcn registry dependencies (like{" "} - button - ,{" "} - card - , etc.) -
  6. -

- Manual installation + Without shadcn

- If you prefer not to use the CLI, you can copy the component source - directly from the docs pages. Each component page includes the full - source code. + Don't use shadcn? No problem. Every component page includes the + full source code — copy it directly into your project and install any + listed dependencies manually. The components are plain React + + Tailwind CSS with no framework lock-in.

-

Registry

-

- jalco ui is listed in the official shadcn registry. All components use - the{" "} - @jalco{" "} - namespace: -

- +

+ MCP integration +

- You can also install directly from the registry URL: + Initialize the shadcn MCP server to give your AI editor full context + of the jalco ui library:

diff --git a/app/docs/layout.tsx b/app/docs/layout.tsx index 935a05c..4948c37 100644 --- a/app/docs/layout.tsx +++ b/app/docs/layout.tsx @@ -33,7 +33,7 @@ export default function DocsLayout({ children }: { children: ReactNode }) { jal-co/ui @@ -42,19 +42,19 @@ export default function DocsLayout({ children }: { children: ReactNode }) {
diff --git a/components/changelog-image.tsx b/components/docs/theme-image.tsx similarity index 68% rename from components/changelog-image.tsx rename to components/docs/theme-image.tsx index 3d9f532..3018381 100644 --- a/components/changelog-image.tsx +++ b/components/docs/theme-image.tsx @@ -4,12 +4,17 @@ import * as React from "react" import Image from "next/image" import { useTheme } from "next-themes" -interface ChangelogImageProps { +interface ThemeImageProps { slug: string title: string } -export function ChangelogImage({ slug, title }: ChangelogImageProps) { +/** + * Preview image that swaps between light and dark variants based on the + * active theme. Uses aspect-ratio for the SSR placeholder to avoid layout + * shift once the client hydrates. + */ +export function ThemeImage({ slug, title }: ThemeImageProps) { const { resolvedTheme } = useTheme() const [mounted, setMounted] = React.useState(false) diff --git a/components/docs/theme-switcher.tsx b/components/docs/theme-switcher.tsx index 9b09695..01a41d0 100644 --- a/components/docs/theme-switcher.tsx +++ b/components/docs/theme-switcher.tsx @@ -137,7 +137,7 @@ export function ThemeSwitcher() { setTheme(next) track("theme_changed", { theme: next }) }} - className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground" + className="inline-flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" aria-label={ theme === "system" ? "System theme (click for light)" @@ -160,7 +160,7 @@ export function ThemeSwitcher() { @@ -227,7 +227,7 @@ export function ThemeSwitcher() { + ) +} diff --git a/registry/diff-viewer/diff-viewer.tsx b/registry/diff-viewer/diff-viewer.tsx new file mode 100644 index 0000000..163d68d --- /dev/null +++ b/registry/diff-viewer/diff-viewer.tsx @@ -0,0 +1,362 @@ +/** + * jalco-ui + * DiffViewer + * by Justin Levine + * ui.justinlevine.me + * + * Code diff viewer with line numbers and add/remove coloring. + * Supports unified (single column) and split (side-by-side) layouts. + * + * Props: + * - oldCode / newCode: two strings to diff (component computes the diff) + * - patch: pre-computed unified diff string (alternative to oldCode/newCode) + * - layout: "unified" | "split" (default "unified") + * - language: shiki language key for optional syntax highlighting + * - oldTitle / newTitle: labels for the file headers + * - className: additional CSS classes + * + * Dependencies: diff + */ + +import * as React from "react" +import { structuredPatch, parsePatch } from "diff" +import { cn } from "@/lib/utils" +import { DiffViewerCopyButton } from "@/registry/diff-viewer/diff-viewer-client" + +type DiffLayout = "unified" | "split" + +interface DiffLine { + type: "added" | "removed" | "context" + content: string + oldNumber: number | null + newNumber: number | null +} + +interface WithStrings { + oldCode: string + newCode: string + patch?: never +} + +interface WithPatch { + patch: string + oldCode?: never + newCode?: never +} + +type DiffInput = WithStrings | WithPatch + +type DiffViewerProps = DiffInput & { + layout?: DiffLayout + /** Shiki language key for syntax highlighting. Plain text when omitted. */ + language?: string + oldTitle?: string + newTitle?: string + className?: string +} + +function computeLines(input: DiffInput): DiffLine[] { + let hunks: ReturnType["hunks"] + + if ("patch" in input && input.patch) { + const parsed = parsePatch(input.patch) + hunks = parsed[0]?.hunks ?? [] + } else { + const result = structuredPatch( + "", + "", + input.oldCode ?? "", + input.newCode ?? "", + undefined, + undefined, + { context: 3 } + ) + hunks = result.hunks + } + + const lines: DiffLine[] = [] + + for (const hunk of hunks) { + let oldNum = hunk.oldStart + let newNum = hunk.newStart + + for (const line of hunk.lines) { + if (line.startsWith("+")) { + lines.push({ + type: "added", + content: line.slice(1), + oldNumber: null, + newNumber: newNum++, + }) + } else if (line.startsWith("-")) { + lines.push({ + type: "removed", + content: line.slice(1), + oldNumber: oldNum++, + newNumber: null, + }) + } else { + lines.push({ + type: "context", + content: line.startsWith(" ") ? line.slice(1) : line, + oldNumber: oldNum++, + newNumber: newNum++, + }) + } + } + } + + return lines +} + +function lineNumberWidth(lines: DiffLine[]): number { + let max = 0 + for (const line of lines) { + if (line.oldNumber && line.oldNumber > max) max = line.oldNumber + if (line.newNumber && line.newNumber > max) max = line.newNumber + } + return Math.max(String(max).length, 2) +} + +function lineColor(type: DiffLine["type"], element: "bg" | "text" | "num") { + if (type === "added") { + return element === "bg" + ? "bg-emerald-500/10 dark:bg-emerald-500/10" + : element === "num" + ? "text-emerald-700/70 dark:text-emerald-400/50" + : "text-emerald-900 dark:text-emerald-200" + } + if (type === "removed") { + return element === "bg" + ? "bg-red-500/10 dark:bg-red-500/10" + : element === "num" + ? "text-red-700/70 dark:text-red-400/50" + : "text-red-900 dark:text-red-200" + } + return element === "num" + ? "text-muted-foreground/50" + : "text-foreground/80" +} + +function linePrefix(type: DiffLine["type"]) { + if (type === "added") return "+" + if (type === "removed") return "-" + return " " +} + +function UnifiedView({ lines, numWidth }: { lines: DiffLine[]; numWidth: number }) { + return ( + + + {lines.map((line, i) => ( + + + + + + + ))} + +
+ {line.oldNumber ?? ""} + + {line.newNumber ?? ""} + + {linePrefix(line.type)} + + {line.content || "\u00A0"} +
+ ) +} + +function SplitView({ lines, numWidth }: { lines: DiffLine[]; numWidth: number }) { + const leftLines: (DiffLine | null)[] = [] + const rightLines: (DiffLine | null)[] = [] + + let i = 0 + while (i < lines.length) { + const line = lines[i] + + if (line.type === "context") { + leftLines.push(line) + rightLines.push(line) + i++ + } else if (line.type === "removed") { + const removed: DiffLine[] = [] + while (i < lines.length && lines[i].type === "removed") { + removed.push(lines[i]) + i++ + } + const added: DiffLine[] = [] + while (i < lines.length && lines[i].type === "added") { + added.push(lines[i]) + i++ + } + + const maxLen = Math.max(removed.length, added.length) + for (let j = 0; j < maxLen; j++) { + leftLines.push(j < removed.length ? removed[j] : null) + rightLines.push(j < added.length ? added[j] : null) + } + } else if (line.type === "added") { + leftLines.push(null) + rightLines.push(line) + i++ + } else { + i++ + } + } + + return ( +
+
+ + + {leftLines.map((line, idx) => ( + + + + + ))} + +
+ {line?.oldNumber ?? ""} + + {line?.content || "\u00A0"} +
+
+
+ + + {rightLines.map((line, idx) => ( + + + + + ))} + +
+ {line?.newNumber ?? ""} + + {line?.content || "\u00A0"} +
+
+
+ ) +} + +export function DiffViewer({ + layout = "unified", + oldTitle, + newTitle, + className, + ...input +}: DiffViewerProps) { + const lines = computeLines(input as DiffInput) + const numWidth = lineNumberWidth(lines) + + const stats = lines.reduce( + (acc, l) => { + if (l.type === "added") acc.added++ + if (l.type === "removed") acc.removed++ + return acc + }, + { added: 0, removed: 0 } + ) + + const fullCode = lines.map((l) => l.content).join("\n") + const showHeader = oldTitle || newTitle + + return ( +
+ {showHeader && ( +
+
+ {oldTitle && ( + {oldTitle} + )} + {oldTitle && newTitle && ( + + )} + {newTitle && ( + {newTitle} + )} +
+
+
+ {stats.added > 0 && ( + + +{stats.added} + + )} + {stats.removed > 0 && ( + + -{stats.removed} + + )} +
+ +
+
+ )} + + {!showHeader && ( +
+ +
+ )} + +
+ {layout === "split" ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/registry/kbd/kbd.tsx b/registry/kbd/kbd.tsx new file mode 100644 index 0000000..f2bc09a --- /dev/null +++ b/registry/kbd/kbd.tsx @@ -0,0 +1,191 @@ +/** + * jalco-ui + * Kbd + * by Justin Levine + * ui.justinlevine.me + * + * Keyboard shortcut key rendered as a styled keycap. Three visual profiles + * inspired by real keycap shapes: flat, raised, and sculpted. Optional color + * schemes inspired by popular keycap sets. + * + * Props: + * - children: key label (e.g. "⌘", "K", "Shift") + * - variant: "flat" | "raised" | "sculpted" (default "raised") + * - size: "sm" | "md" | "lg" (default "md") + * - colorScheme: named palette or custom { bg, text, border } object + * - className: additional CSS classes + * + * Compose multiple elements inline for key combos. + */ + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "@/lib/utils" + +interface KbdColorScheme { + bg: string + text: string + border: string +} + +const builtInSchemes: Record = { + dolch: { bg: "#2D2D2D", text: "#868686", border: "#1A1A1A" }, + olivia: { bg: "#1C1C1C", text: "#E8B4B8", border: "#111111" }, + botanical: { bg: "#D5C4A1", text: "#2D4A3E", border: "#B8A88A" }, + oblivion: { bg: "#36393E", text: "#868B8E", border: "#25272B" }, + "8008": { bg: "#333333", text: "#F472B6", border: "#222222" }, + laser: { bg: "#2B1B54", text: "#FF71CE", border: "#1A1040" }, + mizu: { bg: "#E8E8E8", text: "#2E5D8A", border: "#CFCFCF" }, + dracula: { bg: "#282A36", text: "#F8F8F2", border: "#1A1B24" }, + hammerhead: { bg: "#1A1A2E", text: "#16C79A", border: "#0F0F1E" }, + wob: { bg: "#1A1A1A", text: "#FFFFFF", border: "#0D0D0D" }, + bow: { bg: "#F5F5F5", text: "#1A1A1A", border: "#DCDCDC" }, + cream: { bg: "#F5E6C8", text: "#5C4A32", border: "#E0D0B0" }, +} + +type BuiltInColorScheme = keyof typeof builtInSchemes + +function resolveScheme( + scheme: BuiltInColorScheme | KbdColorScheme | undefined +): KbdColorScheme | null { + if (!scheme) return null + if (typeof scheme === "string") return builtInSchemes[scheme] ?? null + return scheme +} + +const kbdVariants = cva( + "inline-flex items-center justify-center font-mono font-medium leading-none select-none", + { + variants: { + variant: { + flat: ["rounded-md border border-border/80 bg-transparent text-foreground/80"], + raised: [ + "rounded-md border border-border bg-muted/60 text-foreground", + "border-b-[2px] border-b-border", + "shadow-[0_1px_0_0_var(--color-border),inset_0_1px_0_0_rgba(255,255,255,0.06)]", + ], + sculpted: [ + "rounded-lg text-foreground", + "border border-border/80 border-b-[3px] border-b-border", + "bg-gradient-to-b from-muted/80 to-muted/40", + "shadow-[0_2px_0_0_var(--color-border),0_3px_6px_-2px_rgba(0,0,0,0.15),inset_0_1px_0_0_rgba(255,255,255,0.1)]", + ], + }, + size: { + sm: "min-h-5 min-w-5 px-1 text-[10px]", + md: "min-h-6 min-w-6 px-1.5 text-[11px]", + lg: "min-h-8 min-w-8 px-2 text-xs", + }, + }, + defaultVariants: { + variant: "raised", + size: "md", + }, + } +) + +type KbdVariantProps = VariantProps + +interface KbdProps + extends React.HTMLAttributes, + KbdVariantProps { + children: React.ReactNode + /** Named color scheme or custom { bg, text, border } palette. */ + colorScheme?: BuiltInColorScheme | KbdColorScheme +} + +function colorStyles( + scheme: KbdColorScheme | null, + variant: KbdVariantProps["variant"] +): React.CSSProperties | undefined { + if (!scheme) return undefined + + const base: React.CSSProperties = { + color: scheme.text, + borderColor: scheme.border, + } + + if (variant === "flat") { + return { ...base, background: "transparent" } + } + + if (variant === "sculpted") { + return { + ...base, + background: `linear-gradient(to bottom, ${scheme.bg}, ${scheme.border})`, + borderBottomColor: scheme.border, + boxShadow: `0 2px 0 0 ${scheme.border}, 0 3px 6px -2px rgba(0,0,0,0.25), inset 0 1px 0 0 rgba(255,255,255,0.1)`, + } + } + + // raised + return { + ...base, + background: scheme.bg, + borderBottomColor: scheme.border, + boxShadow: `0 1px 0 0 ${scheme.border}, inset 0 1px 0 0 rgba(255,255,255,0.06)`, + } +} + +function Kbd({ + children, + variant, + size, + colorScheme, + className, + style, + ...props +}: KbdProps) { + const resolved = resolveScheme(colorScheme) + + return ( + + {children} + + ) +} + +interface KbdComboProps { + /** Array of key labels to render as a combo (e.g. ["⌘", "Shift", "K"]). */ + keys: string[] + /** Separator between keys. Defaults to no separator (keys are adjacent). */ + separator?: React.ReactNode + variant?: KbdVariantProps["variant"] + size?: KbdVariantProps["size"] + /** Named color scheme or custom { bg, text, border } palette. */ + colorScheme?: BuiltInColorScheme | KbdColorScheme + className?: string +} + +function KbdCombo({ + keys, + separator, + variant, + size, + colorScheme, + className, +}: KbdComboProps) { + return ( + + {keys.map((key, i) => ( + + {i > 0 && separator !== undefined && ( + + {separator} + + )} + + {key} + + + ))} + + ) +} + +export { Kbd, KbdCombo, kbdVariants, builtInSchemes } +export type { KbdProps, KbdComboProps, KbdColorScheme, BuiltInColorScheme }