Skip to content

Commit 167d6f0

Browse files
jesseouecursoragent
andcommitted
feat: add reusable module card system
Unify registry module, preset, provider, and command palette surfaces around a reusable card primitive while refreshing generated registry metadata and adding a web ownership gate. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 74db816 commit 167d6f0

387 files changed

Lines changed: 4421 additions & 473 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
* @jesseoue
2+
3+
# The hosted web app is maintainer-owned. External contributors should avoid
4+
# changing it unless coordinated with @jesseoue.
5+
apps/web/** @jesseoue
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: Web Owner Gate
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
8+
permissions:
9+
contents: read
10+
pull-requests: read
11+
12+
jobs:
13+
web-owner-gate:
14+
runs-on: ubuntu-latest
15+
timeout-minutes: 5
16+
steps:
17+
- name: Block unauthorized apps/web changes
18+
uses: actions/github-script@v8
19+
with:
20+
script: |
21+
const allowedActor = "jesseoue";
22+
const files = await github.paginate(github.rest.pulls.listFiles, {
23+
owner: context.repo.owner,
24+
repo: context.repo.repo,
25+
pull_number: context.payload.pull_request.number,
26+
per_page: 100,
27+
});
28+
29+
const webFiles = files
30+
.map((file) => file.filename)
31+
.filter((filename) => filename.startsWith("apps/web/"));
32+
33+
if (webFiles.length === 0) {
34+
core.info("No apps/web changes detected.");
35+
return;
36+
}
37+
38+
if (context.actor === allowedActor) {
39+
core.info(`apps/web changes allowed for ${allowedActor}.`);
40+
return;
41+
}
42+
43+
core.setFailed(
44+
[
45+
`Only @${allowedActor} may change apps/web.`,
46+
"Changed files:",
47+
...webFiles.map((filename) => `- ${filename}`),
48+
].join("\n"),
49+
);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import type { ReactNode } from "react";
2+
3+
type ModuleTagTone =
4+
| "default"
5+
| "dependency"
6+
| "skill"
7+
| "provider"
8+
| "warning"
9+
| "success"
10+
| "accent";
11+
12+
export type ModuleTag = {
13+
label: string;
14+
tone?: ModuleTagTone;
15+
};
16+
17+
export type ModuleCardVariant =
18+
| "default"
19+
| "featured"
20+
| "beta"
21+
| "coming-soon"
22+
| "preset"
23+
| "provider";
24+
25+
type ModuleCardFooterAction = {
26+
label: string;
27+
href: string;
28+
};
29+
30+
type ModuleCardProps = {
31+
name: string;
32+
title?: string;
33+
description: string;
34+
version?: string;
35+
badge?: string;
36+
variant?: ModuleCardVariant;
37+
icon?: ReactNode;
38+
tags?: ModuleTag[];
39+
footer?: {
40+
summary: string;
41+
actions?: ModuleCardFooterAction[];
42+
};
43+
};
44+
45+
type ModuleCardRowProps = {
46+
name: string;
47+
category: string;
48+
description: string;
49+
action?: string;
50+
selected?: boolean;
51+
tags?: ModuleTag[];
52+
};
53+
54+
const variantBadge: Partial<Record<ModuleCardVariant, string>> = {
55+
featured: "Featured",
56+
beta: "Beta",
57+
"coming-soon": "Coming soon",
58+
};
59+
60+
function tagClassName(tone: ModuleTagTone = "default") {
61+
return tone === "default" ? "module-tag" : `module-tag ${tone}`;
62+
}
63+
64+
function DefaultModuleIcon() {
65+
return (
66+
<span className="module-icon" aria-hidden="true">
67+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.7">
68+
<title>Module</title>
69+
<path d="M4 7h16M4 12h12M4 17h8" strokeLinecap="round" />
70+
</svg>
71+
</span>
72+
);
73+
}
74+
75+
export function ModuleTags({ tags }: { tags?: ModuleTag[] }) {
76+
if (!tags?.length) {
77+
return null;
78+
}
79+
80+
return (
81+
<div className="module-meta">
82+
{tags.map((tag) => (
83+
<span className={tagClassName(tag.tone)} key={`${tag.label}-${tag.tone ?? "default"}`}>
84+
{tag.label}
85+
</span>
86+
))}
87+
</div>
88+
);
89+
}
90+
91+
export function ModuleCard({
92+
name,
93+
title,
94+
description,
95+
version,
96+
badge,
97+
variant = "default",
98+
icon,
99+
tags,
100+
footer,
101+
}: ModuleCardProps) {
102+
const resolvedBadge = badge ?? variantBadge[variant];
103+
const className = variant === "default" ? "module-card" : `module-card ${variant}`;
104+
105+
return (
106+
<article className={className}>
107+
{resolvedBadge ? <span className="module-badge">{resolvedBadge}</span> : null}
108+
<div className="module-head">
109+
{icon ?? <DefaultModuleIcon />}
110+
<div className="module-title-block">
111+
<span className="module-name">{name}</span>
112+
{title ? <h3>{title}</h3> : null}
113+
</div>
114+
{version ? <span className="module-version">{version}</span> : null}
115+
</div>
116+
<p className="module-desc">{description}</p>
117+
<ModuleTags tags={tags} />
118+
{footer ? (
119+
<div className="module-footer">
120+
<span>{footer.summary}</span>
121+
{footer.actions?.length ? (
122+
<span className="module-footer-actions">
123+
{footer.actions.map((action) => (
124+
<a href={action.href} key={action.label}>
125+
{action.label}
126+
</a>
127+
))}
128+
</span>
129+
) : null}
130+
</div>
131+
) : null}
132+
</article>
133+
);
134+
}
135+
136+
export function ModuleCardRow({
137+
name,
138+
category,
139+
description,
140+
action = "install",
141+
selected = false,
142+
tags,
143+
}: ModuleCardRowProps) {
144+
return (
145+
<div className={selected ? "module-card-row selected" : "module-card-row"}>
146+
<DefaultModuleIcon />
147+
<span className="module-name">{name}</span>
148+
<span className="module-tag">{category}</span>
149+
<span className="module-row-desc">{description}</span>
150+
<ModuleTags tags={tags} />
151+
<span className="module-row-action">{action}</span>
152+
</div>
153+
);
154+
}

0 commit comments

Comments
 (0)