@deck-ui/skills 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@deck-ui/skills",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "files": ["src"],
8
+ "peerDependencies": {
9
+ "react": "^19.0.0",
10
+ "react-dom": "^19.0.0",
11
+ "@deck-ui/core": "^0.1.0"
12
+ },
13
+ "dependencies": {
14
+ "lucide-react": "^0.577.0"
15
+ },
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit"
18
+ }
19
+ }
@@ -0,0 +1,67 @@
1
+ import type { CommunitySkill } from "./types"
2
+ import { cn } from "@deck-ui/core"
3
+ import { Plus, Loader2, Check } from "lucide-react"
4
+
5
+ export interface CommunitySkillRowProps {
6
+ skill: CommunitySkill
7
+ installing: boolean
8
+ installed: boolean
9
+ onInstall: () => void
10
+ }
11
+
12
+ function formatInstalls(n: number): string {
13
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
14
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
15
+ return String(n)
16
+ }
17
+
18
+ /** Convert "vercel-react-best-practices" to "Vercel React Best Practices" */
19
+ function kebabToTitle(s: string): string {
20
+ return s
21
+ .split("-")
22
+ .filter(Boolean)
23
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
24
+ .join(" ")
25
+ }
26
+
27
+ export function CommunitySkillRow({
28
+ skill,
29
+ installing,
30
+ installed,
31
+ onInstall,
32
+ }: CommunitySkillRowProps) {
33
+ return (
34
+ <div
35
+ className="flex items-center gap-3 px-4 py-3 rounded-xl border border-black/[0.08]
36
+ bg-white hover:border-black/[0.15] transition-all"
37
+ >
38
+ <div className="flex-1 min-w-0">
39
+ <p className="text-sm font-medium text-[#0d0d0d] truncate">
40
+ {kebabToTitle(skill.name)}
41
+ </p>
42
+ <p className="text-xs text-[#9b9b9b] truncate mt-0.5">
43
+ {skill.source} · {formatInstalls(skill.installs)} installs
44
+ </p>
45
+ </div>
46
+ <button
47
+ onClick={onInstall}
48
+ disabled={installing || installed}
49
+ className={cn(
50
+ "shrink-0 size-7 flex items-center justify-center rounded-lg transition-colors",
51
+ installed
52
+ ? "text-[#9b9b9b] cursor-default"
53
+ : "text-[#9b9b9b] hover:bg-black/[0.06] hover:text-[#0d0d0d]",
54
+ installing && "opacity-50 cursor-wait",
55
+ )}
56
+ >
57
+ {installing ? (
58
+ <Loader2 className="size-4 animate-spin" />
59
+ ) : installed ? (
60
+ <Check className="size-4" />
61
+ ) : (
62
+ <Plus className="size-4" />
63
+ )}
64
+ </button>
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * CommunitySkillsSection — Search and install skills from a community marketplace.
3
+ * Fully props-driven: host app provides search + install callbacks.
4
+ */
5
+ import { useCallback, useEffect, useState } from "react"
6
+ import type { CommunitySkill } from "./types"
7
+ import { CommunitySkillRow } from "./community-skill-row"
8
+ import { Search } from "lucide-react"
9
+
10
+ const PAGE_SIZE = 20
11
+
12
+ export interface CommunitySkillsSectionProps {
13
+ /** Called when the user types a search query (debounced internally at 350ms). */
14
+ onSearch: (query: string) => Promise<CommunitySkill[]>
15
+ /** Called when the user clicks install on a community skill. Should return the installed skill name. */
16
+ onInstall: (skill: CommunitySkill) => Promise<string>
17
+ }
18
+
19
+ export function CommunitySkillsSection({
20
+ onSearch,
21
+ onInstall,
22
+ }: CommunitySkillsSectionProps) {
23
+ const [query, setQuery] = useState("")
24
+ const [results, setResults] = useState<CommunitySkill[]>([])
25
+ const [loading, setLoading] = useState(false)
26
+ const [showAll, setShowAll] = useState(false)
27
+ const [installingIds, setInstallingIds] = useState<Set<string>>(new Set())
28
+ const [installedIds, setInstalledIds] = useState<Set<string>>(new Set())
29
+
30
+ useEffect(() => {
31
+ const q = query.trim()
32
+ if (!q) {
33
+ setResults([])
34
+ return
35
+ }
36
+ const timer = setTimeout(() => {
37
+ doSearch(q)
38
+ setShowAll(false)
39
+ }, 350)
40
+ return () => clearTimeout(timer)
41
+ }, [query])
42
+
43
+ const doSearch = async (q: string) => {
44
+ setLoading(true)
45
+ try {
46
+ const skills = await onSearch(q)
47
+ setResults(skills)
48
+ } catch {
49
+ setResults([])
50
+ } finally {
51
+ setLoading(false)
52
+ }
53
+ }
54
+
55
+ const handleInstall = useCallback(
56
+ async (skill: CommunitySkill) => {
57
+ setInstallingIds((prev) => new Set(prev).add(skill.id))
58
+ try {
59
+ await onInstall(skill)
60
+ setInstalledIds((prev) => new Set(prev).add(skill.id))
61
+ } finally {
62
+ setInstallingIds((prev) => {
63
+ const next = new Set(prev)
64
+ next.delete(skill.id)
65
+ return next
66
+ })
67
+ }
68
+ },
69
+ [onInstall],
70
+ )
71
+
72
+ const visible = showAll ? results : results.slice(0, PAGE_SIZE)
73
+ const hasMore = results.length > PAGE_SIZE && !showAll
74
+
75
+ return (
76
+ <section className="space-y-3">
77
+ <div>
78
+ <h2 className="text-sm font-medium text-[#0d0d0d]">
79
+ Discover skills from the community
80
+ </h2>
81
+ <p className="text-xs text-[#9b9b9b] mt-0.5">
82
+ Browse thousands of ready-made procedures on skills.sh
83
+ </p>
84
+ </div>
85
+
86
+ {/* Search bar */}
87
+ <div className="relative">
88
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-[#9b9b9b]" />
89
+ <input
90
+ type="text"
91
+ value={query}
92
+ onChange={(e) => setQuery(e.target.value)}
93
+ placeholder={'Search by what you want to achieve, like "sdr" or "writing"'}
94
+ className="w-full h-9 pl-9 pr-3 rounded-full border border-black/[0.10] bg-white text-sm
95
+ placeholder:text-black/40 focus:outline-none focus:border-black/[0.25] transition-colors"
96
+ />
97
+ </div>
98
+
99
+ {/* Results grid */}
100
+ {loading && results.length === 0 && (
101
+ <p className="text-sm text-[#9b9b9b] animate-pulse">Loading...</p>
102
+ )}
103
+
104
+ {!loading && results.length === 0 && query.trim() && (
105
+ <p className="text-sm text-[#9b9b9b]">
106
+ No skills found for "{query.trim()}"
107
+ </p>
108
+ )}
109
+
110
+ {visible.length > 0 && (
111
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
112
+ {visible.map((skill) => (
113
+ <CommunitySkillRow
114
+ key={skill.id}
115
+ skill={skill}
116
+ installing={installingIds.has(skill.id)}
117
+ installed={installedIds.has(skill.id)}
118
+ onInstall={() => handleInstall(skill)}
119
+ />
120
+ ))}
121
+ </div>
122
+ )}
123
+
124
+ {hasMore && (
125
+ <button
126
+ onClick={() => setShowAll(true)}
127
+ className="text-sm text-[#5d5d5d] hover:text-[#0d0d0d] transition-colors"
128
+ >
129
+ Show {results.length - PAGE_SIZE} more
130
+ </button>
131
+ )}
132
+ </section>
133
+ )
134
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
1
+ // Types
2
+ export type {
3
+ Skill,
4
+ CommunitySkill,
5
+ LearningCategory,
6
+ SkillLearning,
7
+ } from "./types"
8
+ export { CATEGORY_LABELS } from "./types"
9
+
10
+ // Components
11
+ export { SkillsGrid } from "./skills-grid"
12
+ export type { SkillsGridProps } from "./skills-grid"
13
+ export { SkillRow } from "./skill-row"
14
+ export type { SkillRowProps } from "./skill-row"
15
+ export { SkillDetailPage } from "./skill-detail-page"
16
+ export type { SkillDetailPageProps } from "./skill-detail-page"
17
+ export { CommunitySkillsSection } from "./community-skills-browser"
18
+ export type { CommunitySkillsSectionProps } from "./community-skills-browser"
19
+ export { CommunitySkillRow } from "./community-skill-row"
20
+ export type { CommunitySkillRowProps } from "./community-skill-row"
21
+ export { LearningRow } from "./learning-row"
22
+ export type { LearningRowProps } from "./learning-row"
@@ -0,0 +1,51 @@
1
+ import { CATEGORY_LABELS } from "./types"
2
+ import type { LearningCategory } from "./types"
3
+ import { Trash2 } from "lucide-react"
4
+
5
+ export interface LearningRowProps {
6
+ content: string
7
+ category: LearningCategory
8
+ sourceTitle: string | null
9
+ createdAt: string
10
+ onDelete: () => void
11
+ }
12
+
13
+ export function LearningRow({
14
+ content,
15
+ category,
16
+ sourceTitle,
17
+ createdAt,
18
+ onDelete,
19
+ }: LearningRowProps) {
20
+ const label = CATEGORY_LABELS[category] ?? category
21
+ const date = new Date(createdAt).toLocaleDateString("en-US", {
22
+ month: "short",
23
+ day: "numeric",
24
+ })
25
+
26
+ return (
27
+ <div className="rounded-xl border border-black/[0.06] p-4 group">
28
+ <div className="flex items-start gap-3">
29
+ <p className="text-sm text-[#0d0d0d] flex-1 leading-relaxed">
30
+ {content}
31
+ </p>
32
+ <button
33
+ onClick={onDelete}
34
+ className="shrink-0 size-7 flex items-center justify-center rounded-lg text-[#9b9b9b] hover:text-red-500 hover:bg-red-50 transition-colors"
35
+ aria-label="Delete learning"
36
+ >
37
+ <Trash2 className="size-3.5" />
38
+ </button>
39
+ </div>
40
+ <div className="flex items-center gap-2 mt-2 text-xs text-[#9b9b9b]">
41
+ <span className="px-1.5 py-0.5 rounded-md bg-black/[0.04] text-[#5d5d5d]">
42
+ {label}
43
+ </span>
44
+ {sourceTitle && (
45
+ <span className="truncate max-w-[200px]">{sourceTitle}</span>
46
+ )}
47
+ <span>{date}</span>
48
+ </div>
49
+ </div>
50
+ )
51
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SkillDetailPage — View and edit a skill's instructions + learnings.
3
+ * Fully props-driven: host app provides the skill data and save callback.
4
+ */
5
+ import { useCallback, useEffect, useState } from "react"
6
+ import type { Skill } from "./types"
7
+ import { ArrowLeft, FileText } from "lucide-react"
8
+
9
+ export interface SkillDetailPageProps {
10
+ skill: Skill | undefined
11
+ onBack: () => void
12
+ onSave: (skillName: string, instructions: string) => Promise<void>
13
+ }
14
+
15
+ export function SkillDetailPage({
16
+ skill,
17
+ onBack,
18
+ onSave,
19
+ }: SkillDetailPageProps) {
20
+ const [instructions, setInstructions] = useState("")
21
+ const [saving, setSaving] = useState(false)
22
+
23
+ useEffect(() => {
24
+ if (skill) setInstructions(skill.instructions)
25
+ }, [skill?.id])
26
+
27
+ const handleSave = useCallback(async () => {
28
+ if (!skill) return
29
+ setSaving(true)
30
+ try {
31
+ await onSave(skill.name, instructions)
32
+ } finally {
33
+ setSaving(false)
34
+ }
35
+ }, [skill, instructions, onSave])
36
+
37
+ if (!skill) {
38
+ return (
39
+ <div className="flex-1 flex items-center justify-center">
40
+ <p className="text-sm text-[#9b9b9b]">Skill not found</p>
41
+ </div>
42
+ )
43
+ }
44
+
45
+ const isDirty = instructions !== skill.instructions
46
+
47
+ return (
48
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden">
49
+ {/* Header */}
50
+ <div className="shrink-0 px-6 py-3 border-b border-black/[0.06]">
51
+ <div className="max-w-2xl mx-auto flex items-center gap-3">
52
+ <button
53
+ onClick={onBack}
54
+ className="size-8 flex items-center justify-center rounded-lg text-[#9b9b9b] hover:text-[#0d0d0d] hover:bg-black/[0.05] transition-colors"
55
+ >
56
+ <ArrowLeft className="size-4" />
57
+ </button>
58
+ <div className="flex-1 min-w-0">
59
+ <h1 className="text-sm font-medium text-[#0d0d0d] truncate">
60
+ {skill.name}
61
+ </h1>
62
+ <p className="text-xs text-[#9b9b9b] flex items-center gap-1 truncate">
63
+ <FileText className="size-3" />
64
+ {skill.file_path}
65
+ </p>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ {/* Body */}
71
+ <div className="flex-1 overflow-y-auto px-6 py-6">
72
+ <div className="max-w-2xl mx-auto space-y-8">
73
+ {/* Instructions */}
74
+ <section>
75
+ <label className="block text-xs font-medium text-[#9b9b9b] tracking-wider mb-2">
76
+ Instructions
77
+ </label>
78
+ <textarea
79
+ value={instructions}
80
+ onChange={(e) => setInstructions(e.target.value)}
81
+ rows={12}
82
+ className="w-full rounded-xl border border-black/[0.08] bg-white px-4 py-3 text-sm text-[#0d0d0d] placeholder:text-[#9b9b9b] focus:outline-none focus:border-black/[0.2] resize-y font-mono"
83
+ placeholder="Instructions for this skill..."
84
+ />
85
+ <div className="flex items-center gap-3 mt-3">
86
+ <button
87
+ onClick={handleSave}
88
+ disabled={!isDirty || saving}
89
+ className="px-4 py-2 rounded-full text-sm font-medium bg-[#0d0d0d] text-white hover:bg-[#2d2d2d] disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
90
+ >
91
+ {saving ? "Saving..." : "Save"}
92
+ </button>
93
+ {isDirty && (
94
+ <span className="text-xs text-[#9b9b9b]">Unsaved changes</span>
95
+ )}
96
+ </div>
97
+ </section>
98
+
99
+ {/* Learnings */}
100
+ {skill.learnings.trim() && (
101
+ <section>
102
+ <label className="block text-xs font-medium text-[#9b9b9b] tracking-wider mb-2">
103
+ Learnings
104
+ </label>
105
+ <div className="rounded-xl border border-black/[0.08] bg-[#fafafa] px-4 py-3 text-sm text-[#5d5d5d] whitespace-pre-wrap font-mono">
106
+ {skill.learnings}
107
+ </div>
108
+ <p className="text-xs text-[#9b9b9b] mt-2">
109
+ Learnings are added automatically when you give feedback on completed tasks.
110
+ </p>
111
+ </section>
112
+ )}
113
+ </div>
114
+ </div>
115
+ </div>
116
+ )
117
+ }
@@ -0,0 +1,27 @@
1
+ import type { Skill } from "./types"
2
+
3
+ export interface SkillRowProps {
4
+ skill: Skill
5
+ onClick: () => void
6
+ }
7
+
8
+ export function SkillRow({ skill, onClick }: SkillRowProps) {
9
+ return (
10
+ <button
11
+ onClick={onClick}
12
+ className="w-full flex items-center gap-3 px-4 py-3 rounded-xl border border-black/[0.08]
13
+ bg-white hover:border-black/[0.15] transition-all text-left"
14
+ >
15
+ <div className="flex-1 min-w-0">
16
+ <p className="text-sm font-medium text-[#0d0d0d] truncate">
17
+ {skill.name}
18
+ </p>
19
+ {skill.description && (
20
+ <p className="text-xs text-[#9b9b9b] truncate mt-0.5">
21
+ {skill.description}
22
+ </p>
23
+ )}
24
+ </div>
25
+ </button>
26
+ )
27
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * SkillsGrid — Installed skills list + optional community skills section.
3
+ * Fully props-driven: host app provides data and callbacks.
4
+ */
5
+ import { useMemo } from "react"
6
+ import type { Skill, CommunitySkill } from "./types"
7
+ import { SkillRow } from "./skill-row"
8
+ import { CommunitySkillsSection } from "./community-skills-browser"
9
+
10
+ export interface SkillsGridProps {
11
+ skills: Skill[]
12
+ loading: boolean
13
+ onSkillClick: (skill: Skill) => void
14
+ /** Community marketplace callbacks. Omit to hide the community section. */
15
+ community?: {
16
+ onSearch: (query: string) => Promise<CommunitySkill[]>
17
+ onInstall: (skill: CommunitySkill) => Promise<string>
18
+ }
19
+ }
20
+
21
+ export function SkillsGrid({
22
+ skills,
23
+ loading,
24
+ onSkillClick,
25
+ community,
26
+ }: SkillsGridProps) {
27
+ const sorted = useMemo(() => {
28
+ return [...skills].sort((a, b) => a.name.localeCompare(b.name))
29
+ }, [skills])
30
+
31
+ if (loading && skills.length === 0) {
32
+ return (
33
+ <div className="flex-1 flex items-center justify-center">
34
+ <p className="text-sm text-[#9b9b9b] animate-pulse">
35
+ Loading skills...
36
+ </p>
37
+ </div>
38
+ )
39
+ }
40
+
41
+ return (
42
+ <div className="flex-1 overflow-y-auto">
43
+ <div className="max-w-3xl mx-auto px-6 py-6 space-y-8">
44
+ {/* Installed section */}
45
+ {sorted.length > 0 && (
46
+ <section className="space-y-3">
47
+ <h2 className="text-sm font-medium text-[#0d0d0d] normal-case">Installed</h2>
48
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-2">
49
+ {sorted.map((skill) => (
50
+ <SkillRow
51
+ key={skill.id}
52
+ skill={skill}
53
+ onClick={() => onSkillClick(skill)}
54
+ />
55
+ ))}
56
+ </div>
57
+ </section>
58
+ )}
59
+
60
+ {/* Community marketplace */}
61
+ {community && (
62
+ <CommunitySkillsSection
63
+ onSearch={community.onSearch}
64
+ onInstall={community.onInstall}
65
+ />
66
+ )}
67
+ </div>
68
+ </div>
69
+ )
70
+ }
package/src/styles.css ADDED
@@ -0,0 +1,2 @@
1
+ /* @deck-ui/skills — Tell Tailwind to scan this package's components */
2
+ @source ".";
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export interface Skill {
2
+ id: string
3
+ name: string
4
+ description: string
5
+ instructions: string
6
+ learnings: string
7
+ file_path: string
8
+ }
9
+
10
+ export interface CommunitySkill {
11
+ id: string
12
+ skillId: string
13
+ name: string
14
+ installs: number
15
+ source: string
16
+ }
17
+
18
+ export type LearningCategory =
19
+ | "pattern"
20
+ | "pitfall"
21
+ | "preference"
22
+ | "procedure"
23
+
24
+ export const CATEGORY_LABELS: Record<LearningCategory, string> = {
25
+ pattern: "Pattern",
26
+ pitfall: "Pitfall",
27
+ preference: "Preference",
28
+ procedure: "Procedure",
29
+ }
30
+
31
+ export interface SkillLearning {
32
+ id: string
33
+ skill_id: string
34
+ project_id: string
35
+ content: string
36
+ rationale: string
37
+ category: LearningCategory
38
+ source_issue_id: string | null
39
+ source_issue_title: string | null
40
+ created_at: string
41
+ }