diff --git a/src/components/SkillCommentsPanel.tsx b/src/components/SkillCommentsPanel.tsx
new file mode 100644
index 0000000..3dbc7e1
--- /dev/null
+++ b/src/components/SkillCommentsPanel.tsx
@@ -0,0 +1,77 @@
+import { useMutation, useQuery } from 'convex/react'
+import { useState } from 'react'
+import { api } from '../../convex/_generated/api'
+import type { Doc, Id } from '../../convex/_generated/dataModel'
+
+type SkillCommentsPanelProps = {
+ skillId: Id<'skills'>
+ isAuthenticated: boolean
+ me: Doc<'users'> | null
+}
+
+export function SkillCommentsPanel({ skillId, isAuthenticated, me }: SkillCommentsPanelProps) {
+ const addComment = useMutation(api.comments.add)
+ const removeComment = useMutation(api.comments.remove)
+ const [comment, setComment] = useState('')
+ const comments = useQuery(api.comments.listBySkill, { skillId, limit: 50 }) as
+ | Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }>
+ | undefined
+
+ return (
+
+
+ Comments
+
+ {isAuthenticated ? (
+
+ ) : (
+
Sign in to comment.
+ )}
+
+ {(comments ?? []).length === 0 ? (
+
No comments yet.
+ ) : (
+ (comments ?? []).map((entry) => (
+
+
+
@{entry.user?.handle ?? entry.user?.name ?? 'user'}
+
{entry.comment.body}
+
+ {isAuthenticated &&
+ me &&
+ (me._id === entry.comment.userId ||
+ me.role === 'admin' ||
+ me.role === 'moderator') ? (
+
+ ) : null}
+
+ ))
+ )}
+
+
+ )
+}
diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx
index 0fc6022..e6773ad 100644
--- a/src/components/SkillDetailPage.tsx
+++ b/src/components/SkillDetailPage.tsx
@@ -1,13 +1,21 @@
import { useNavigate } from '@tanstack/react-router'
-import type { ClawdisSkillMetadata, SkillInstallSpec } from 'clawdhub-schema'
+import type { ClawdisSkillMetadata } from 'clawdhub-schema'
import { useAction, useMutation, useQuery } from 'convex/react'
import { useEffect, useMemo, useState } from 'react'
-import ReactMarkdown from 'react-markdown'
-import remarkGfm from 'remark-gfm'
import { api } from '../../convex/_generated/api'
import type { Doc, Id } from '../../convex/_generated/dataModel'
import { useAuthStatus } from '../lib/useAuthStatus'
-import { SkillDiffCard } from './SkillDiffCard'
+import { SkillCommentsPanel } from './SkillCommentsPanel'
+import { SkillDetailTabs } from './SkillDetailTabs'
+import {
+ buildSkillHref,
+ formatConfigSnippet,
+ formatInstallCommand,
+ formatInstallLabel,
+ formatNixInstallSnippet,
+ formatOsList,
+ stripFrontmatter,
+} from './skillDetailUtils'
type SkillDetailPageProps = {
slug: string
@@ -26,14 +34,11 @@ export function SkillDetailPage({
const { isAuthenticated, me } = useAuthStatus()
const result = useQuery(api.skills.getBySlug, { slug })
const toggleStar = useMutation(api.stars.toggle)
- const addComment = useMutation(api.comments.add)
- const removeComment = useMutation(api.comments.remove)
const updateTags = useMutation(api.skills.updateTags)
const setBatch = useMutation(api.skills.setBatch)
const getReadme = useAction(api.skills.getReadme)
const [readme, setReadme] = useState(null)
const [readmeError, setReadmeError] = useState(null)
- const [comment, setComment] = useState('')
const [tagName, setTagName] = useState('latest')
const [tagVersionId, setTagVersionId] = useState | ''>('')
const [activeTab, setActiveTab] = useState<'files' | 'compare' | 'versions'>('files')
@@ -55,11 +60,6 @@ export function SkillDetailPage({
api.stars.isStarred,
isAuthenticated && skill ? { skillId: skill._id } : 'skip',
)
- const comments = useQuery(
- api.comments.listBySkill,
- skill ? { skillId: skill._id, limit: 50 } : 'skip',
- ) as Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }> | undefined
-
const canManage = Boolean(
me && skill && (me._id === skill.ownerUserId || ['admin', 'moderator'].includes(me.role ?? '')),
)
@@ -460,324 +460,20 @@ export function SkillDetailPage({
) : null}
-
-
-
-
-
-
- {activeTab === 'files' ? (
-
-
-
- SKILL.md
-
-
- {readmeContent ? (
-
{readmeContent}
- ) : readmeError ? (
-
Failed to load SKILL.md: {readmeError}
- ) : (
-
Loading…
- )}
-
-
-
-
-
- Files
-
-
- {latestFiles.length} total
-
-
-
- {latestFiles.length === 0 ? (
-
No files available.
- ) : (
- latestFiles.map((file) => (
-
- {file.path}
- {formatBytes(file.size)}
-
- ))
- )}
-
-
-
- ) : null}
- {activeTab === 'compare' && skill ? (
-
-
-
- ) : null}
- {activeTab === 'versions' ? (
-
-
-
- Versions
-
-
- {nixPlugin
- ? 'Review release history and changelog.'
- : 'Download older releases or scan the changelog.'}
-
-
-
-
- {(versions ?? []).map((version) => (
-
-
-
- v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
- {version.changelogSource === 'auto' ? (
- · auto
- ) : null}
-
-
- {version.changelog}
-
-
- {!nixPlugin ? (
-
- ) : null}
-
- ))}
-
-
-
- ) : null}
-
-
-
- Comments
-
- {isAuthenticated ? (
-
- ) : (
-
Sign in to comment.
- )}
-
- {(comments ?? []).length === 0 ? (
-
No comments yet.
- ) : (
- (comments ?? []).map((entry) => (
-
-
-
@{entry.user?.handle ?? entry.user?.name ?? 'user'}
-
{entry.comment.body}
-
- {isAuthenticated &&
- me &&
- (me._id === entry.comment.userId ||
- me.role === 'admin' ||
- me.role === 'moderator') ? (
-
- ) : null}
-
- ))
- )}
-
-
+
+
)
}
-
-function buildSkillHref(ownerHandle: string | null, slug: string) {
- if (ownerHandle) return `/${ownerHandle}/${slug}`
- return `/skills/${slug}`
-}
-
-function formatConfigSnippet(raw: string) {
- const trimmed = raw.trim()
- if (!trimmed || raw.includes('\n')) return raw
- try {
- const parsed = JSON.parse(raw)
- return JSON.stringify(parsed, null, 2)
- } catch {
- // fall through
- }
-
- let out = ''
- let indent = 0
- let inString = false
- let isEscaped = false
-
- const newline = () => {
- out = out.replace(/[ \t]+$/u, '')
- out += `\n${' '.repeat(indent * 2)}`
- }
-
- for (let i = 0; i < raw.length; i += 1) {
- const ch = raw[i]
- if (inString) {
- out += ch
- if (isEscaped) {
- isEscaped = false
- } else if (ch === '\\') {
- isEscaped = true
- } else if (ch === '"') {
- inString = false
- }
- continue
- }
-
- if (ch === '"') {
- inString = true
- out += ch
- continue
- }
-
- if (ch === '{' || ch === '[') {
- out += ch
- indent += 1
- newline()
- continue
- }
-
- if (ch === '}' || ch === ']') {
- indent = Math.max(0, indent - 1)
- newline()
- out += ch
- continue
- }
-
- if (ch === ';' || ch === ',') {
- out += ch
- newline()
- continue
- }
-
- if (ch === '\n' || ch === '\r' || ch === '\t') {
- continue
- }
-
- if (ch === ' ') {
- if (out.endsWith(' ') || out.endsWith('\n')) {
- continue
- }
- out += ' '
- continue
- }
-
- out += ch
- }
-
- return out.trim()
-}
-
-function stripFrontmatter(content: string) {
- const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
- if (!normalized.startsWith('---')) return content
- const endIndex = normalized.indexOf('\n---', 3)
- if (endIndex === -1) return content
- return normalized.slice(endIndex + 4).replace(/^\n+/, '')
-}
-
-function formatOsList(os?: string[]) {
- if (!os?.length) return []
- return os.map((entry) => {
- const key = entry.trim().toLowerCase()
- if (key === 'darwin' || key === 'macos' || key === 'mac') return 'macOS'
- if (key === 'linux') return 'Linux'
- if (key === 'windows' || key === 'win32') return 'Windows'
- return entry
- })
-}
-
-function formatInstallLabel(spec: SkillInstallSpec) {
- if (spec.kind === 'brew') return 'Homebrew'
- if (spec.kind === 'node') return 'Node'
- if (spec.kind === 'go') return 'Go'
- if (spec.kind === 'uv') return 'uv'
- return 'Install'
-}
-
-function formatInstallCommand(spec: SkillInstallSpec) {
- if (spec.kind === 'brew' && spec.formula) {
- if (spec.tap && !spec.formula.includes('/')) {
- return `brew install ${spec.tap}/${spec.formula}`
- }
- return `brew install ${spec.formula}`
- }
- if (spec.kind === 'node' && spec.package) {
- return `npm i -g ${spec.package}`
- }
- if (spec.kind === 'go' && spec.module) {
- return `go install ${spec.module}`
- }
- if (spec.kind === 'uv' && spec.package) {
- return `uv tool install ${spec.package}`
- }
- return null
-}
-
-function formatBytes(bytes: number) {
- if (!Number.isFinite(bytes)) return '—'
- if (bytes < 1024) return `${bytes} B`
- const units = ['KB', 'MB', 'GB']
- let value = bytes / 1024
- let unitIndex = 0
- while (value >= 1024 && unitIndex < units.length - 1) {
- value /= 1024
- unitIndex += 1
- }
- return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
-}
-
-function formatNixInstallSnippet(plugin: string) {
- const snippet = `programs.clawdbot.plugins = [ { source = "${plugin}"; } ];`
- return formatConfigSnippet(snippet)
-}
diff --git a/src/components/SkillDetailTabs.tsx b/src/components/SkillDetailTabs.tsx
new file mode 100644
index 0000000..4bc10a4
--- /dev/null
+++ b/src/components/SkillDetailTabs.tsx
@@ -0,0 +1,115 @@
+import type { Doc, Id } from '../../convex/_generated/dataModel'
+import { SkillDiffCard } from './SkillDiffCard'
+import { SkillFilesPanel } from './SkillFilesPanel'
+
+type SkillFile = Doc<'skillVersions'>['files'][number]
+
+type SkillDetailTabsProps = {
+ activeTab: 'files' | 'compare' | 'versions'
+ setActiveTab: (tab: 'files' | 'compare' | 'versions') => void
+ readmeContent: string | null
+ readmeError: string | null
+ latestFiles: SkillFile[]
+ latestVersionId: Id<'skillVersions'> | null
+ skill: Doc<'skills'>
+ diffVersions: Doc<'skillVersions'>[] | undefined
+ versions: Doc<'skillVersions'>[] | undefined
+ nixPlugin: boolean
+}
+
+export function SkillDetailTabs({
+ activeTab,
+ setActiveTab,
+ readmeContent,
+ readmeError,
+ latestFiles,
+ latestVersionId,
+ skill,
+ diffVersions,
+ versions,
+ nixPlugin,
+}: SkillDetailTabsProps) {
+ return (
+
+
+
+
+
+
+ {activeTab === 'files' ? (
+
+ ) : null}
+ {activeTab === 'compare' ? (
+
+
+
+ ) : null}
+ {activeTab === 'versions' ? (
+
+
+
+ Versions
+
+
+ {nixPlugin
+ ? 'Review release history and changelog.'
+ : 'Download older releases or scan the changelog.'}
+
+
+
+
+ {(versions ?? []).map((version) => (
+
+
+
+ v{version.version} · {new Date(version.createdAt).toLocaleDateString()}
+ {version.changelogSource === 'auto' ? (
+ · auto
+ ) : null}
+
+
+ {version.changelog}
+
+
+ {!nixPlugin ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ ) : null}
+
+ )
+}
diff --git a/src/components/SkillFilesPanel.tsx b/src/components/SkillFilesPanel.tsx
new file mode 100644
index 0000000..4b54e8e
--- /dev/null
+++ b/src/components/SkillFilesPanel.tsx
@@ -0,0 +1,216 @@
+import { useAction } from 'convex/react'
+import type { ReactNode } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import { api } from '../../convex/_generated/api'
+import type { Doc, Id } from '../../convex/_generated/dataModel'
+import { formatBytes } from './skillDetailUtils'
+
+type SkillFile = Doc<'skillVersions'>['files'][number]
+
+type SkillFilesPanelProps = {
+ versionId: Id<'skillVersions'> | null
+ readmeContent: string | null
+ readmeError: string | null
+ latestFiles: SkillFile[]
+}
+
+export function SkillFilesPanel({
+ versionId,
+ readmeContent,
+ readmeError,
+ latestFiles,
+}: SkillFilesPanelProps) {
+ const getFileText = useAction(api.skills.getFileText)
+ const [selectedPath, setSelectedPath] = useState(null)
+ const [fileContent, setFileContent] = useState(null)
+ const [fileMeta, setFileMeta] = useState<{ size: number; sha256: string } | null>(null)
+ const [fileError, setFileError] = useState(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const isMounted = useRef(true)
+ const activeRequest = useRef(null)
+ const requestId = useRef(0)
+ const warnings = useMemo(() => (fileContent ? collectWarnings(fileContent) : []), [fileContent])
+ const highlightedContent = useMemo(
+ () => (fileContent ? highlightDangerousCommands(fileContent) : null),
+ [fileContent],
+ )
+
+ useEffect(() => {
+ isMounted.current = true
+ return () => {
+ isMounted.current = false
+ activeRequest.current?.abort()
+ activeRequest.current = null
+ }
+ }, [])
+
+ useEffect(() => {
+ activeRequest.current?.abort()
+ activeRequest.current = null
+ requestId.current += 1
+
+ setSelectedPath(null)
+ setFileContent(null)
+ setFileMeta(null)
+ setFileError(null)
+ setIsLoading(false)
+
+ if (versionId === null) return
+ }, [versionId])
+
+ const handleSelect = useCallback(
+ (path: string) => {
+ if (!versionId) return
+ activeRequest.current?.abort()
+ const controller = new AbortController()
+ activeRequest.current = controller
+
+ const current = requestId.current + 1
+ requestId.current = current
+ setSelectedPath(path)
+ setFileContent(null)
+ setFileMeta(null)
+ setFileError(null)
+ setIsLoading(true)
+ void getFileText({ versionId, path })
+ .then((data) => {
+ if (!isMounted.current) return
+ if (controller.signal.aborted) return
+ if (requestId.current !== current) return
+ setFileContent(data.text)
+ setFileMeta({ size: data.size, sha256: data.sha256 })
+ setIsLoading(false)
+ })
+ .catch((error) => {
+ if (!isMounted.current) return
+ if (controller.signal.aborted) return
+ if (requestId.current !== current) return
+ setFileError(error instanceof Error ? error.message : 'Failed to load file')
+ setIsLoading(false)
+ })
+ },
+ [getFileText, versionId],
+ )
+
+ return (
+
+
+
+ SKILL.md
+
+
+ {readmeContent ? (
+
{readmeContent}
+ ) : readmeError ? (
+
Failed to load SKILL.md: {readmeError}
+ ) : (
+
Loading…
+ )}
+
+
+
+
+
+
+ Files
+
+
+ {latestFiles.length} total
+
+
+
+ {latestFiles.length === 0 ? (
+
No files available.
+ ) : (
+ latestFiles.map((file) => (
+
+ ))
+ )}
+
+
+
+
+
{selectedPath ?? 'Select a file'}
+ {fileMeta ? (
+
+ {formatBytes(fileMeta.size)} · {fileMeta.sha256.slice(0, 12)}…
+
+ ) : null}
+
+ {fileContent && warnings.length > 0 ? (
+
+ Potentially dangerous commands:{' '}
+ {warnings.map((warning) => (
+
+ {warning.label} × {warning.count}
+
+ ))}
+
+ ) : null}
+
+ {isLoading ? (
+
Loading…
+ ) : fileError ? (
+
Failed to load file: {fileError}
+ ) : fileContent ? (
+
{highlightedContent}
+ ) : (
+
Select a file to preview.
+ )}
+
+
+
+
+ )
+}
+
+const DANGEROUS_PATTERNS: Array<{ label: string; regex: RegExp }> = [
+ { label: 'curl', regex: /\bcurl\b/gi },
+ { label: 'wget', regex: /\bwget\b/gi },
+ { label: 'bash', regex: /\bbash\b/gi },
+ { label: 'sh', regex: /\bsh\b/gi },
+ { label: 'eval', regex: /\beval\b/gi },
+]
+
+const HIGHLIGHT_PATTERN = /\b(?:curl|wget|bash|sh|eval)\b/gi
+
+function collectWarnings(content: string) {
+ return DANGEROUS_PATTERNS.flatMap((entry) => {
+ const matches = content.match(entry.regex)
+ if (!matches?.length) return []
+ return [{ label: entry.label, count: matches.length }]
+ })
+}
+
+function highlightDangerousCommands(content: string) {
+ const parts: ReactNode[] = []
+ let lastIndex = 0
+ for (const match of content.matchAll(HIGHLIGHT_PATTERN)) {
+ if (match.index === undefined) continue
+ if (match.index > lastIndex) {
+ parts.push(content.slice(lastIndex, match.index))
+ }
+ parts.push(
+
+ {match[0]}
+ ,
+ )
+ lastIndex = match.index + match[0].length
+ }
+ if (lastIndex < content.length) {
+ parts.push(content.slice(lastIndex))
+ }
+ return parts
+}
diff --git a/src/components/skillDetailUtils.ts b/src/components/skillDetailUtils.ts
new file mode 100644
index 0000000..a192c99
--- /dev/null
+++ b/src/components/skillDetailUtils.ts
@@ -0,0 +1,148 @@
+import type { SkillInstallSpec } from 'clawdhub-schema'
+
+export function buildSkillHref(ownerHandle: string | null, slug: string) {
+ if (ownerHandle) return `/${ownerHandle}/${slug}`
+ return `/skills/${slug}`
+}
+
+export function formatConfigSnippet(raw: string) {
+ const trimmed = raw.trim()
+ if (!trimmed || raw.includes('\n')) return raw
+ try {
+ const parsed = JSON.parse(raw)
+ return JSON.stringify(parsed, null, 2)
+ } catch {
+ // fall through
+ }
+
+ let out = ''
+ let indent = 0
+ let inString = false
+ let isEscaped = false
+
+ const newline = () => {
+ out = out.replace(/[ \t]+$/u, '')
+ out += `\n${' '.repeat(indent * 2)}`
+ }
+
+ for (let i = 0; i < raw.length; i += 1) {
+ const ch = raw[i]
+ if (inString) {
+ out += ch
+ if (isEscaped) {
+ isEscaped = false
+ } else if (ch === '\\') {
+ isEscaped = true
+ } else if (ch === '"') {
+ inString = false
+ }
+ continue
+ }
+
+ if (ch === '"') {
+ inString = true
+ out += ch
+ continue
+ }
+
+ if (ch === '{' || ch === '[') {
+ out += ch
+ indent += 1
+ newline()
+ continue
+ }
+
+ if (ch === '}' || ch === ']') {
+ indent = Math.max(0, indent - 1)
+ newline()
+ out += ch
+ continue
+ }
+
+ if (ch === ';' || ch === ',') {
+ out += ch
+ newline()
+ continue
+ }
+
+ if (ch === '\n' || ch === '\r' || ch === '\t') {
+ continue
+ }
+
+ if (ch === ' ') {
+ if (out.endsWith(' ') || out.endsWith('\n')) {
+ continue
+ }
+ out += ' '
+ continue
+ }
+
+ out += ch
+ }
+
+ return out.trim()
+}
+
+export function stripFrontmatter(content: string) {
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
+ if (!normalized.startsWith('---')) return content
+ const endIndex = normalized.indexOf('\n---', 3)
+ if (endIndex === -1) return content
+ return normalized.slice(endIndex + 4).replace(/^\n+/, '')
+}
+
+export function formatOsList(os?: string[]) {
+ if (!os?.length) return []
+ return os.map((entry) => {
+ const key = entry.trim().toLowerCase()
+ if (key === 'darwin' || key === 'macos' || key === 'mac') return 'macOS'
+ if (key === 'linux') return 'Linux'
+ if (key === 'windows' || key === 'win32') return 'Windows'
+ return entry
+ })
+}
+
+export function formatInstallLabel(spec: SkillInstallSpec) {
+ if (spec.kind === 'brew') return 'Homebrew'
+ if (spec.kind === 'node') return 'Node'
+ if (spec.kind === 'go') return 'Go'
+ if (spec.kind === 'uv') return 'uv'
+ return 'Install'
+}
+
+export function formatInstallCommand(spec: SkillInstallSpec) {
+ if (spec.kind === 'brew' && spec.formula) {
+ if (spec.tap && !spec.formula.includes('/')) {
+ return `brew install ${spec.tap}/${spec.formula}`
+ }
+ return `brew install ${spec.formula}`
+ }
+ if (spec.kind === 'node' && spec.package) {
+ return `npm i -g ${spec.package}`
+ }
+ if (spec.kind === 'go' && spec.module) {
+ return `go install ${spec.module}`
+ }
+ if (spec.kind === 'uv' && spec.package) {
+ return `uv tool install ${spec.package}`
+ }
+ return null
+}
+
+export function formatBytes(bytes: number) {
+ if (!Number.isFinite(bytes)) return '—'
+ if (bytes < 1024) return `${bytes} B`
+ const units = ['KB', 'MB', 'GB']
+ let value = bytes / 1024
+ let unitIndex = 0
+ while (value >= 1024 && unitIndex < units.length - 1) {
+ value /= 1024
+ unitIndex += 1
+ }
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`
+}
+
+export function formatNixInstallSnippet(plugin: string) {
+ const snippet = `programs.clawdbot.plugins = [ { source = "${plugin}"; } ];`
+ return formatConfigSnippet(snippet)
+}
diff --git a/src/styles.css b/src/styles.css
index 76205bb..e32cd1a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1751,6 +1751,76 @@ code {
padding-right: 4px;
}
+.file-browser {
+ display: grid;
+ gap: 16px;
+}
+
+.file-row-button {
+ border: 1px solid var(--line);
+ background: var(--surface-muted);
+ text-align: left;
+ cursor: pointer;
+}
+
+.file-row-button.is-active {
+ border-color: var(--ink);
+ box-shadow: 0 8px 18px rgba(29, 26, 23, 0.12);
+}
+
+.file-viewer {
+ border-radius: 16px;
+ border: 1px solid var(--line);
+ background: var(--surface);
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+}
+
+.file-warning {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 10px 12px;
+ border-radius: 12px;
+ border: 1px solid rgba(208, 91, 18, 0.35);
+ background: rgba(244, 187, 143, 0.25);
+ color: #8a3c06;
+ font-size: 0.85rem;
+}
+
+.file-warning-item {
+ font-weight: 600;
+}
+
+.danger-mark {
+ background: rgba(244, 187, 143, 0.6);
+ color: inherit;
+ border-radius: 4px;
+ padding: 0 2px;
+}
+
+.file-viewer-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.file-viewer-body {
+ max-height: 320px;
+ overflow: auto;
+ padding-right: 4px;
+}
+
+.file-viewer-code {
+ margin: 0;
+ white-space: pre-wrap;
+ font-family: var(--font-mono);
+ font-size: 0.9rem;
+ color: var(--ink);
+}
+
.version-scroll {
max-height: 320px;
overflow: auto;