@useworkapp/construct-skill 1.0.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/README.md +181 -0
- package/bin/install.js +226 -0
- package/bin/validate.js +207 -0
- package/claude-code/construct-builder/SKILL.md +209 -0
- package/claude-code/construct-builder/example.tsx +157 -0
- package/opencode/construct.md +319 -0
- package/package.json +39 -0
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# @useworkapp/construct-skill
|
|
2
|
+
|
|
3
|
+
AI coding assistant skill for building [Work](https://usework.app) platform Constructs. Compatible with **Claude Code** and **OpenCode**.
|
|
4
|
+
|
|
5
|
+
## What is a Construct?
|
|
6
|
+
|
|
7
|
+
A Construct is a self-contained React micro-app that runs on the Work streaming platform. Think of it like a song on Spotify - users stream Constructs to solve business problems, and Runners (developers) earn from every stream.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @useworkapp/construct-skill
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The skill will auto-detect your AI coding tools and install automatically.
|
|
16
|
+
|
|
17
|
+
### Manual Installation
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# Install for Claude Code
|
|
21
|
+
construct-skill install claude-code
|
|
22
|
+
|
|
23
|
+
# Install for OpenCode
|
|
24
|
+
construct-skill install opencode
|
|
25
|
+
|
|
26
|
+
# Install for both
|
|
27
|
+
construct-skill install all
|
|
28
|
+
|
|
29
|
+
# Install to current project instead of global
|
|
30
|
+
construct-skill install claude-code --project
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
### With Claude Code
|
|
36
|
+
|
|
37
|
+
The skill is automatically invoked when you ask Claude to:
|
|
38
|
+
- "Build me an expense tracker Construct"
|
|
39
|
+
- "Review this component for Work standards"
|
|
40
|
+
- "Help me create a Construct for inventory management"
|
|
41
|
+
|
|
42
|
+
### With OpenCode
|
|
43
|
+
|
|
44
|
+
Use the `/construct` command:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
/construct Build a todo list app
|
|
48
|
+
/construct Review this code for Work standards
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Validate Your Code
|
|
52
|
+
|
|
53
|
+
Check if your Construct follows Work standards:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx @useworkapp/construct-skill validate ./App.tsx
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Output:
|
|
60
|
+
```
|
|
61
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
62
|
+
║ WORK CONSTRUCT VALIDATOR ║
|
|
63
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
64
|
+
|
|
65
|
+
File: ./App.tsx
|
|
66
|
+
|
|
67
|
+
WARNINGS:
|
|
68
|
+
⚠ Consider adding responsive breakpoints
|
|
69
|
+
|
|
70
|
+
✓ Construct passes required validations (with warnings)
|
|
71
|
+
|
|
72
|
+
────────────────────────────────────────────────────────────
|
|
73
|
+
Status: PASS
|
|
74
|
+
Errors: 0 | Warnings: 1
|
|
75
|
+
────────────────────────────────────────────────────────────
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Construct Requirements
|
|
79
|
+
|
|
80
|
+
All Constructs must follow these standards:
|
|
81
|
+
|
|
82
|
+
| Requirement | Description |
|
|
83
|
+
|------------|-------------|
|
|
84
|
+
| **Single File** | One `App.tsx` with default export |
|
|
85
|
+
| **shadcn/ui** | Only use approved components |
|
|
86
|
+
| **localStorage** | Persist data with `work-construct-` prefix |
|
|
87
|
+
| **Tailwind CSS** | No inline styles or CSS modules |
|
|
88
|
+
| **Responsive** | Use `sm:`, `md:`, `lg:` breakpoints |
|
|
89
|
+
| **Dark Mode** | Support via CSS variables |
|
|
90
|
+
| **Accessible** | aria-labels, semantic HTML, labels |
|
|
91
|
+
| **No API Calls** | No fetch, axios, or external requests |
|
|
92
|
+
|
|
93
|
+
## Available Components
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
// Layout
|
|
97
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
98
|
+
|
|
99
|
+
// Forms
|
|
100
|
+
import { Button, Input, Textarea, Label } from "@/components/ui/...";
|
|
101
|
+
import { Select, Checkbox, Switch } from "@/components/ui/...";
|
|
102
|
+
|
|
103
|
+
// Feedback
|
|
104
|
+
import { Badge, Alert, Progress } from "@/components/ui/...";
|
|
105
|
+
|
|
106
|
+
// Data Display
|
|
107
|
+
import { Table, Tabs } from "@/components/ui/...";
|
|
108
|
+
|
|
109
|
+
// Overlay
|
|
110
|
+
import { Dialog, DropdownMenu } from "@/components/ui/...";
|
|
111
|
+
|
|
112
|
+
// Icons (Lucide only)
|
|
113
|
+
import { Plus, Trash2, Edit } from "lucide-react";
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Example Construct
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
"use client";
|
|
120
|
+
|
|
121
|
+
import { useState, useEffect } from "react";
|
|
122
|
+
import { Button } from "@/components/ui/button";
|
|
123
|
+
import { Input } from "@/components/ui/input";
|
|
124
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
125
|
+
import { Plus } from "lucide-react";
|
|
126
|
+
|
|
127
|
+
const STORAGE_KEY = "work-construct-my-app";
|
|
128
|
+
|
|
129
|
+
export default function App() {
|
|
130
|
+
const [items, setItems] = useState<string[]>(() => {
|
|
131
|
+
if (typeof window === "undefined") return [];
|
|
132
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
133
|
+
return saved ? JSON.parse(saved) : [];
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
|
138
|
+
}, [items]);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className="min-h-screen bg-background p-4 sm:p-6">
|
|
142
|
+
<Card className="mx-auto max-w-md">
|
|
143
|
+
<CardHeader>
|
|
144
|
+
<CardTitle>My Construct</CardTitle>
|
|
145
|
+
</CardHeader>
|
|
146
|
+
<CardContent>
|
|
147
|
+
{/* Your app here */}
|
|
148
|
+
</CardContent>
|
|
149
|
+
</Card>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Commands
|
|
156
|
+
|
|
157
|
+
| Command | Description |
|
|
158
|
+
|---------|-------------|
|
|
159
|
+
| `construct-skill install <platform>` | Install skill for Claude Code or OpenCode |
|
|
160
|
+
| `construct-skill uninstall <platform>` | Remove skill |
|
|
161
|
+
| `construct-skill validate <file>` | Validate a Construct file |
|
|
162
|
+
| `construct-skill detect` | Show detected AI coding tools |
|
|
163
|
+
| `construct-skill help` | Show help |
|
|
164
|
+
|
|
165
|
+
## Become a Runner
|
|
166
|
+
|
|
167
|
+
Runners are developers who build Constructs and earn from every stream.
|
|
168
|
+
|
|
169
|
+
- **60% revenue share** (70% for Founders Protocol members)
|
|
170
|
+
- Build once, earn forever
|
|
171
|
+
- Join at [usework.app/runner](https://usework.app/runner)
|
|
172
|
+
|
|
173
|
+
## Links
|
|
174
|
+
|
|
175
|
+
- [Work Platform](https://usework.app)
|
|
176
|
+
- [Runner Documentation](https://usework.app/docs/runners)
|
|
177
|
+
- [Construct Guidelines](https://usework.app/docs/runners/constructs)
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT
|
package/bin/install.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const os = require("os");
|
|
6
|
+
|
|
7
|
+
const PACKAGE_ROOT = path.join(__dirname, "..");
|
|
8
|
+
|
|
9
|
+
const TARGETS = {
|
|
10
|
+
"claude-code": {
|
|
11
|
+
name: "Claude Code",
|
|
12
|
+
globalDir: path.join(os.homedir(), ".claude", "skills"),
|
|
13
|
+
projectDir: ".claude/skills",
|
|
14
|
+
sourceDir: path.join(PACKAGE_ROOT, "claude-code"),
|
|
15
|
+
},
|
|
16
|
+
opencode: {
|
|
17
|
+
name: "OpenCode",
|
|
18
|
+
globalDir: path.join(os.homedir(), ".config", "opencode", "commands"),
|
|
19
|
+
projectDir: ".opencode/commands",
|
|
20
|
+
sourceDir: path.join(PACKAGE_ROOT, "opencode"),
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function copyRecursive(src, dest) {
|
|
25
|
+
if (!fs.existsSync(src)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const stats = fs.statSync(src);
|
|
30
|
+
|
|
31
|
+
if (stats.isDirectory()) {
|
|
32
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
33
|
+
const files = fs.readdirSync(src);
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
copyRecursive(path.join(src, file), path.join(dest, file));
|
|
36
|
+
}
|
|
37
|
+
} else {
|
|
38
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
39
|
+
fs.copyFileSync(src, dest);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function detectPlatforms() {
|
|
46
|
+
const detected = [];
|
|
47
|
+
|
|
48
|
+
// Check for Claude Code
|
|
49
|
+
const claudeConfig = path.join(os.homedir(), ".claude");
|
|
50
|
+
if (fs.existsSync(claudeConfig)) {
|
|
51
|
+
detected.push("claude-code");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Check for OpenCode
|
|
55
|
+
const opencodeConfig = path.join(os.homedir(), ".config", "opencode");
|
|
56
|
+
if (fs.existsSync(opencodeConfig)) {
|
|
57
|
+
detected.push("opencode");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return detected;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function install(platform, scope = "global") {
|
|
64
|
+
const target = TARGETS[platform];
|
|
65
|
+
if (!target) {
|
|
66
|
+
console.error(`Unknown platform: ${platform}`);
|
|
67
|
+
console.error(`Available platforms: ${Object.keys(TARGETS).join(", ")}`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const destDir = scope === "project" ? target.projectDir : target.globalDir;
|
|
72
|
+
|
|
73
|
+
console.log(`Installing ${target.name} skill...`);
|
|
74
|
+
console.log(` Source: ${target.sourceDir}`);
|
|
75
|
+
console.log(` Destination: ${destDir}`);
|
|
76
|
+
|
|
77
|
+
if (copyRecursive(target.sourceDir, destDir)) {
|
|
78
|
+
console.log(` ✓ Installed successfully!\n`);
|
|
79
|
+
return true;
|
|
80
|
+
} else {
|
|
81
|
+
console.error(` ✗ Source directory not found\n`);
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function uninstall(platform, scope = "global") {
|
|
87
|
+
const target = TARGETS[platform];
|
|
88
|
+
if (!target) {
|
|
89
|
+
console.error(`Unknown platform: ${platform}`);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const destDir = scope === "project" ? target.projectDir : target.globalDir;
|
|
94
|
+
|
|
95
|
+
// For Claude Code, remove the specific skill directory
|
|
96
|
+
const skillDir =
|
|
97
|
+
platform === "claude-code"
|
|
98
|
+
? path.join(destDir, "construct-builder")
|
|
99
|
+
: path.join(destDir, "construct.md");
|
|
100
|
+
|
|
101
|
+
if (fs.existsSync(skillDir)) {
|
|
102
|
+
if (fs.statSync(skillDir).isDirectory()) {
|
|
103
|
+
fs.rmSync(skillDir, { recursive: true });
|
|
104
|
+
} else {
|
|
105
|
+
fs.unlinkSync(skillDir);
|
|
106
|
+
}
|
|
107
|
+
console.log(`✓ Uninstalled ${target.name} skill from ${destDir}`);
|
|
108
|
+
return true;
|
|
109
|
+
} else {
|
|
110
|
+
console.log(`Skill not found at ${skillDir}`);
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function printUsage() {
|
|
116
|
+
console.log(`
|
|
117
|
+
@usework/construct-skill - AI skill for building Work Constructs
|
|
118
|
+
|
|
119
|
+
USAGE:
|
|
120
|
+
construct-skill [command] [options]
|
|
121
|
+
|
|
122
|
+
COMMANDS:
|
|
123
|
+
install [platform] Install skill for specified platform
|
|
124
|
+
uninstall [platform] Remove skill for specified platform
|
|
125
|
+
validate <file> Validate a Construct file
|
|
126
|
+
detect Show detected AI coding tools
|
|
127
|
+
help Show this help message
|
|
128
|
+
|
|
129
|
+
PLATFORMS:
|
|
130
|
+
claude-code Install for Claude Code (~/.claude/skills/)
|
|
131
|
+
opencode Install for OpenCode (~/.config/opencode/commands/)
|
|
132
|
+
all Install for all detected platforms
|
|
133
|
+
|
|
134
|
+
OPTIONS:
|
|
135
|
+
--global Install to user home directory (default)
|
|
136
|
+
--project Install to current project directory
|
|
137
|
+
--auto Auto-detect and install (used by postinstall)
|
|
138
|
+
|
|
139
|
+
EXAMPLES:
|
|
140
|
+
construct-skill install claude-code
|
|
141
|
+
construct-skill install opencode --project
|
|
142
|
+
construct-skill install all
|
|
143
|
+
construct-skill uninstall claude-code
|
|
144
|
+
`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function main() {
|
|
148
|
+
const args = process.argv.slice(2);
|
|
149
|
+
|
|
150
|
+
if (args.length === 0 || args.includes("help") || args.includes("--help")) {
|
|
151
|
+
printUsage();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const command = args[0];
|
|
156
|
+
|
|
157
|
+
// Route to validate command
|
|
158
|
+
if (command === "validate") {
|
|
159
|
+
require("./validate.js");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const platform = args[1];
|
|
163
|
+
const scope = args.includes("--project") ? "project" : "global";
|
|
164
|
+
const auto = args.includes("--auto");
|
|
165
|
+
|
|
166
|
+
switch (command) {
|
|
167
|
+
case "detect":
|
|
168
|
+
const detected = detectPlatforms();
|
|
169
|
+
console.log("\nDetected AI coding tools:");
|
|
170
|
+
if (detected.length === 0) {
|
|
171
|
+
console.log(" None found. Install Claude Code or OpenCode first.\n");
|
|
172
|
+
} else {
|
|
173
|
+
detected.forEach((p) => console.log(` ✓ ${TARGETS[p].name}`));
|
|
174
|
+
console.log();
|
|
175
|
+
}
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case "install":
|
|
179
|
+
if (auto) {
|
|
180
|
+
// Auto-install mode (postinstall script)
|
|
181
|
+
const platforms = detectPlatforms();
|
|
182
|
+
if (platforms.length === 0) {
|
|
183
|
+
console.log(
|
|
184
|
+
"\n@usework/construct-skill installed but no AI coding tools detected."
|
|
185
|
+
);
|
|
186
|
+
console.log("Run 'construct-skill install <platform>' after installing Claude Code or OpenCode.\n");
|
|
187
|
+
} else {
|
|
188
|
+
console.log("\n@usework/construct-skill - Auto-installing...\n");
|
|
189
|
+
platforms.forEach((p) => install(p, scope));
|
|
190
|
+
}
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!platform) {
|
|
195
|
+
console.error("Please specify a platform: claude-code, opencode, or all");
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (platform === "all") {
|
|
200
|
+
Object.keys(TARGETS).forEach((p) => install(p, scope));
|
|
201
|
+
} else {
|
|
202
|
+
install(platform, scope);
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
|
|
206
|
+
case "uninstall":
|
|
207
|
+
if (!platform) {
|
|
208
|
+
console.error("Please specify a platform: claude-code, opencode, or all");
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (platform === "all") {
|
|
213
|
+
Object.keys(TARGETS).forEach((p) => uninstall(p, scope));
|
|
214
|
+
} else {
|
|
215
|
+
uninstall(platform, scope);
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
|
|
219
|
+
default:
|
|
220
|
+
console.error(`Unknown command: ${command}`);
|
|
221
|
+
printUsage();
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
main();
|
package/bin/validate.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Construct Validator
|
|
5
|
+
* Validates that a React component follows Work platform Construct standards
|
|
6
|
+
*
|
|
7
|
+
* Usage: npx @useworkapp/construct-skill validate <path-to-file>
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const ALLOWED_SHADCN_COMPONENTS = [
|
|
14
|
+
// Layout
|
|
15
|
+
"Card", "CardContent", "CardHeader", "CardTitle", "CardDescription", "CardFooter",
|
|
16
|
+
// Forms
|
|
17
|
+
"Button", "Input", "Textarea", "Label",
|
|
18
|
+
"Select", "SelectContent", "SelectItem", "SelectTrigger", "SelectValue",
|
|
19
|
+
"Checkbox", "RadioGroup", "RadioGroupItem", "Switch",
|
|
20
|
+
// Feedback
|
|
21
|
+
"Badge", "Alert", "AlertDescription", "AlertTitle", "Progress",
|
|
22
|
+
// Data Display
|
|
23
|
+
"Table", "TableBody", "TableCell", "TableHead", "TableHeader", "TableRow",
|
|
24
|
+
"Tabs", "TabsContent", "TabsList", "TabsTrigger",
|
|
25
|
+
// Overlay
|
|
26
|
+
"Dialog", "DialogContent", "DialogDescription", "DialogFooter",
|
|
27
|
+
"DialogHeader", "DialogTitle", "DialogTrigger",
|
|
28
|
+
"DropdownMenu", "DropdownMenuContent", "DropdownMenuItem", "DropdownMenuTrigger",
|
|
29
|
+
// Other
|
|
30
|
+
"Separator", "ScrollArea", "Tooltip", "TooltipContent", "TooltipProvider", "TooltipTrigger",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const FORBIDDEN_PATTERNS = [
|
|
34
|
+
{ pattern: /fetch\s*\(/, message: "External API calls (fetch) are not allowed", error: true },
|
|
35
|
+
{ pattern: /axios/, message: "External API calls (axios) are not allowed", error: true },
|
|
36
|
+
{ pattern: /XMLHttpRequest/, message: "External API calls (XMLHttpRequest) are not allowed", error: true },
|
|
37
|
+
{ pattern: /style\s*=\s*\{/, message: "Inline styles detected - use Tailwind classes instead", error: false },
|
|
38
|
+
{ pattern: /\.module\.css/, message: "CSS modules are not supported", error: true },
|
|
39
|
+
{ pattern: /import.*['"].*\.css['"]/, message: "External CSS imports are not supported", error: true },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const REQUIRED_PATTERNS = [
|
|
43
|
+
{ pattern: /export\s+default/, message: "Must have a default export", error: true },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const RECOMMENDED_PATTERNS = [
|
|
47
|
+
{ pattern: /localStorage/, message: "Consider using localStorage for data persistence" },
|
|
48
|
+
{ pattern: /(sm:|md:|lg:)/, message: "Consider adding responsive breakpoints" },
|
|
49
|
+
{ pattern: /(dark:|bg-background|text-foreground)/, message: "Consider adding dark mode support" },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
function validateConstruct(filePath) {
|
|
53
|
+
const errors = [];
|
|
54
|
+
const warnings = [];
|
|
55
|
+
|
|
56
|
+
// Check file exists
|
|
57
|
+
if (!fs.existsSync(filePath)) {
|
|
58
|
+
return { valid: false, errors: [`File not found: ${filePath}`], warnings: [] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Check file extension
|
|
62
|
+
const ext = path.extname(filePath);
|
|
63
|
+
if (ext !== ".tsx" && ext !== ".ts" && ext !== ".jsx" && ext !== ".js") {
|
|
64
|
+
errors.push(`File must be .tsx, .ts, .jsx, or .js - got ${ext}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
68
|
+
|
|
69
|
+
// Check for forbidden patterns
|
|
70
|
+
for (const { pattern, message, error } of FORBIDDEN_PATTERNS) {
|
|
71
|
+
if (pattern.test(content)) {
|
|
72
|
+
if (error) {
|
|
73
|
+
errors.push(message);
|
|
74
|
+
} else {
|
|
75
|
+
warnings.push(message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Check for required patterns
|
|
81
|
+
for (const { pattern, message } of REQUIRED_PATTERNS) {
|
|
82
|
+
if (!pattern.test(content)) {
|
|
83
|
+
errors.push(message);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Check for recommended patterns
|
|
88
|
+
for (const { pattern, message } of RECOMMENDED_PATTERNS) {
|
|
89
|
+
if (!pattern.test(content)) {
|
|
90
|
+
warnings.push(message);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Check for non-standard component usage
|
|
95
|
+
const jsxComponentRegex = /<([A-Z][a-zA-Z]*)/g;
|
|
96
|
+
const usedComponents = new Set();
|
|
97
|
+
let match;
|
|
98
|
+
|
|
99
|
+
while ((match = jsxComponentRegex.exec(content)) !== null) {
|
|
100
|
+
usedComponents.add(match[1]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Find locally defined components
|
|
104
|
+
const localComponents = new Set();
|
|
105
|
+
const functionComponentRegex = /(?:function|const)\s+([A-Z][a-zA-Z]*)/g;
|
|
106
|
+
while ((match = functionComponentRegex.exec(content)) !== null) {
|
|
107
|
+
localComponents.add(match[1]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Validate components are from allowed list or defined locally
|
|
111
|
+
for (const comp of usedComponents) {
|
|
112
|
+
if (
|
|
113
|
+
!ALLOWED_SHADCN_COMPONENTS.includes(comp) &&
|
|
114
|
+
!localComponents.has(comp) &&
|
|
115
|
+
comp !== "App"
|
|
116
|
+
) {
|
|
117
|
+
warnings.push(`Unknown component: <${comp}> - ensure it's defined locally or is a valid shadcn/ui component`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check for accessibility - icon buttons without aria-label
|
|
122
|
+
const iconButtonMatches = content.match(/<Button[^>]*size\s*=\s*["']icon["'][^>]*>/g) || [];
|
|
123
|
+
for (const buttonMatch of iconButtonMatches) {
|
|
124
|
+
if (!buttonMatch.includes("aria-label")) {
|
|
125
|
+
warnings.push("Icon button found without aria-label - add aria-label for accessibility");
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Check for form inputs without labels
|
|
131
|
+
const inputMatches = content.match(/<Input[^>]*>/g) || [];
|
|
132
|
+
const labelMatches = content.match(/<Label[^>]*>/g) || [];
|
|
133
|
+
if (inputMatches.length > labelMatches.length) {
|
|
134
|
+
warnings.push("Some Input components may be missing associated Label components");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check localStorage key format
|
|
138
|
+
const storageKeyRegex = /const\s+STORAGE_KEY\s*=\s*["']([^"']+)["']/;
|
|
139
|
+
const keyMatch = content.match(storageKeyRegex);
|
|
140
|
+
if (keyMatch && !keyMatch[1].startsWith("work-construct-")) {
|
|
141
|
+
warnings.push(`localStorage key should start with "work-construct-" (found "${keyMatch[1]}")`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
valid: errors.length === 0,
|
|
146
|
+
errors,
|
|
147
|
+
warnings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function printResult(result, filePath) {
|
|
152
|
+
console.log("\n╔══════════════════════════════════════════════════════════════╗");
|
|
153
|
+
console.log("║ WORK CONSTRUCT VALIDATOR ║");
|
|
154
|
+
console.log("╚══════════════════════════════════════════════════════════════╝\n");
|
|
155
|
+
console.log(`File: ${filePath}\n`);
|
|
156
|
+
|
|
157
|
+
if (result.errors.length > 0) {
|
|
158
|
+
console.log("ERRORS:");
|
|
159
|
+
result.errors.forEach((e) => console.log(` ✗ ${e}`));
|
|
160
|
+
console.log();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (result.warnings.length > 0) {
|
|
164
|
+
console.log("WARNINGS:");
|
|
165
|
+
result.warnings.forEach((w) => console.log(` ⚠ ${w}`));
|
|
166
|
+
console.log();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (result.valid && result.warnings.length === 0) {
|
|
170
|
+
console.log("✓ Construct passes all validations!\n");
|
|
171
|
+
} else if (result.valid) {
|
|
172
|
+
console.log("✓ Construct passes required validations (with warnings)\n");
|
|
173
|
+
} else {
|
|
174
|
+
console.log("✗ Construct has validation errors that must be fixed.\n");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log("─".repeat(60));
|
|
178
|
+
console.log(`Status: ${result.valid ? "PASS" : "FAIL"}`);
|
|
179
|
+
console.log(`Errors: ${result.errors.length} | Warnings: ${result.warnings.length}`);
|
|
180
|
+
console.log("─".repeat(60) + "\n");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// CLI execution
|
|
184
|
+
if (require.main === module) {
|
|
185
|
+
const args = process.argv.slice(2);
|
|
186
|
+
|
|
187
|
+
// Handle validate subcommand from install.js routing
|
|
188
|
+
if (args[0] === "validate") {
|
|
189
|
+
args.shift();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (args.length === 0) {
|
|
193
|
+
console.log("Usage: npx @useworkapp/construct-skill validate <path-to-file>");
|
|
194
|
+
console.log("\nExample:");
|
|
195
|
+
console.log(" npx @useworkapp/construct-skill validate ./App.tsx");
|
|
196
|
+
process.exit(1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const filePath = path.resolve(args[0]);
|
|
200
|
+
const result = validateConstruct(filePath);
|
|
201
|
+
|
|
202
|
+
printResult(result, filePath);
|
|
203
|
+
|
|
204
|
+
process.exit(result.valid ? 0 : 1);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
module.exports = { validateConstruct };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: construct-builder
|
|
3
|
+
description: Build and validate Constructs for the Work streaming platform. Use when creating React micro-apps, reviewing component code for Work standards, or helping Runners build Constructs. Enforces shadcn/ui patterns, Tailwind CSS, localStorage persistence, and accessibility requirements.
|
|
4
|
+
allowed-tools:
|
|
5
|
+
- Read
|
|
6
|
+
- Grep
|
|
7
|
+
- Glob
|
|
8
|
+
- Write
|
|
9
|
+
- Edit
|
|
10
|
+
- Bash(npm:*)
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# Work Construct Builder
|
|
14
|
+
|
|
15
|
+
Build production-ready Constructs for the Work app streaming platform.
|
|
16
|
+
|
|
17
|
+
## What is a Construct?
|
|
18
|
+
|
|
19
|
+
A Construct is a self-contained React micro-app that:
|
|
20
|
+
- Solves a specific business need (tracker, calculator, dashboard, form, etc.)
|
|
21
|
+
- Runs in Work's sandboxed iframe environment
|
|
22
|
+
- Uses localStorage for data persistence
|
|
23
|
+
- Follows strict UI/UX standards
|
|
24
|
+
|
|
25
|
+
## Construct Requirements
|
|
26
|
+
|
|
27
|
+
### 1. Single-File Architecture
|
|
28
|
+
|
|
29
|
+
Constructs must be a single `App.tsx` file with:
|
|
30
|
+
- Default export of main component
|
|
31
|
+
- All sub-components defined in the same file
|
|
32
|
+
- No external API calls (localStorage only)
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
// Correct structure
|
|
36
|
+
import { useState } from "react";
|
|
37
|
+
import { Button } from "@/components/ui/button";
|
|
38
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
39
|
+
|
|
40
|
+
export default function App() {
|
|
41
|
+
// Component logic
|
|
42
|
+
return <div>...</div>;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### 2. Required UI Components
|
|
47
|
+
|
|
48
|
+
Use ONLY these shadcn/ui components (available in Work runtime):
|
|
49
|
+
|
|
50
|
+
**Layout:**
|
|
51
|
+
- `Card`, `CardContent`, `CardHeader`, `CardTitle`, `CardDescription`, `CardFooter`
|
|
52
|
+
|
|
53
|
+
**Forms:**
|
|
54
|
+
- `Button`, `Input`, `Textarea`, `Label`
|
|
55
|
+
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
|
56
|
+
- `Checkbox`, `RadioGroup`, `RadioGroupItem`
|
|
57
|
+
- `Switch`
|
|
58
|
+
|
|
59
|
+
**Feedback:**
|
|
60
|
+
- `Badge`, `Alert`, `AlertDescription`, `AlertTitle`
|
|
61
|
+
- `Progress`
|
|
62
|
+
|
|
63
|
+
**Data Display:**
|
|
64
|
+
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
|
65
|
+
- `Tabs`, `TabsContent`, `TabsList`, `TabsTrigger`
|
|
66
|
+
|
|
67
|
+
**Overlay:**
|
|
68
|
+
- `Dialog`, `DialogContent`, `DialogDescription`, `DialogFooter`, `DialogHeader`, `DialogTitle`, `DialogTrigger`
|
|
69
|
+
- `DropdownMenu`, `DropdownMenuContent`, `DropdownMenuItem`, `DropdownMenuTrigger`
|
|
70
|
+
|
|
71
|
+
### 3. Data Persistence Pattern
|
|
72
|
+
|
|
73
|
+
Always use localStorage with a unique key prefix:
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
const STORAGE_KEY = "work-construct-{construct-name}";
|
|
77
|
+
|
|
78
|
+
// Load data
|
|
79
|
+
const [data, setData] = useState(() => {
|
|
80
|
+
if (typeof window === "undefined") return initialState;
|
|
81
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
82
|
+
return saved ? JSON.parse(saved) : initialState;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Save data (in useEffect)
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
88
|
+
}, [data]);
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
### 4. Styling Requirements
|
|
92
|
+
|
|
93
|
+
**DO:**
|
|
94
|
+
- Use Tailwind CSS utility classes
|
|
95
|
+
- Use responsive classes (`sm:`, `md:`, `lg:`)
|
|
96
|
+
- Use CSS variables for theming (`text-foreground`, `bg-background`, `border-border`)
|
|
97
|
+
- Add `dark:` variants for dark mode support
|
|
98
|
+
|
|
99
|
+
**DON'T:**
|
|
100
|
+
- Use inline styles
|
|
101
|
+
- Use CSS modules or external stylesheets
|
|
102
|
+
- Use fixed pixel widths (use responsive units)
|
|
103
|
+
- Override shadcn component styles
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
// Correct
|
|
107
|
+
<div className="flex flex-col gap-4 p-4 sm:p-6">
|
|
108
|
+
<Card className="w-full">
|
|
109
|
+
<CardHeader>
|
|
110
|
+
<CardTitle className="text-lg sm:text-xl">Title</CardTitle>
|
|
111
|
+
</CardHeader>
|
|
112
|
+
</Card>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
// Incorrect
|
|
116
|
+
<div style={{ padding: "20px" }}>
|
|
117
|
+
<Card style={{ width: "500px" }}>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### 5. Accessibility Requirements
|
|
121
|
+
|
|
122
|
+
**Mandatory:**
|
|
123
|
+
- All interactive elements must be keyboard accessible
|
|
124
|
+
- Use semantic HTML (`button`, `nav`, `main`, `section`, `article`)
|
|
125
|
+
- Add `aria-label` to icon-only buttons
|
|
126
|
+
- Ensure color contrast meets WCAG AA (4.5:1 for text)
|
|
127
|
+
- Form inputs must have associated labels
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// Correct
|
|
131
|
+
<Button variant="ghost" size="icon" aria-label="Delete item">
|
|
132
|
+
<Trash2 className="h-4 w-4" />
|
|
133
|
+
</Button>
|
|
134
|
+
|
|
135
|
+
<div className="space-y-2">
|
|
136
|
+
<Label htmlFor="email">Email</Label>
|
|
137
|
+
<Input id="email" type="email" />
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
// Incorrect
|
|
141
|
+
<Button variant="ghost" size="icon">
|
|
142
|
+
<Trash2 className="h-4 w-4" />
|
|
143
|
+
</Button>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 6. Icon Usage
|
|
147
|
+
|
|
148
|
+
Use Lucide React icons only:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
import { Plus, Trash2, Edit, Check, X, Search, Filter } from "lucide-react";
|
|
152
|
+
|
|
153
|
+
<Plus className="h-4 w-4" />
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 7. State Management
|
|
157
|
+
|
|
158
|
+
For complex state, use `useReducer`:
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
type State = { items: Item[]; filter: string };
|
|
162
|
+
type Action =
|
|
163
|
+
| { type: "ADD_ITEM"; payload: Item }
|
|
164
|
+
| { type: "DELETE_ITEM"; payload: string }
|
|
165
|
+
| { type: "SET_FILTER"; payload: string };
|
|
166
|
+
|
|
167
|
+
function reducer(state: State, action: Action): State {
|
|
168
|
+
switch (action.type) {
|
|
169
|
+
case "ADD_ITEM":
|
|
170
|
+
return { ...state, items: [...state.items, action.payload] };
|
|
171
|
+
// ...
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Validation Checklist
|
|
177
|
+
|
|
178
|
+
Before submitting a Construct, verify:
|
|
179
|
+
|
|
180
|
+
- [ ] Single `App.tsx` file with default export
|
|
181
|
+
- [ ] Uses only approved shadcn/ui components
|
|
182
|
+
- [ ] localStorage persistence with unique key
|
|
183
|
+
- [ ] Responsive design (works on mobile)
|
|
184
|
+
- [ ] Dark mode support
|
|
185
|
+
- [ ] All buttons/inputs have proper labels
|
|
186
|
+
- [ ] No external API calls
|
|
187
|
+
- [ ] No TypeScript errors
|
|
188
|
+
- [ ] Meaningful title and description
|
|
189
|
+
|
|
190
|
+
## Example Construct
|
|
191
|
+
|
|
192
|
+
See [example.tsx](example.tsx) for a complete working example.
|
|
193
|
+
|
|
194
|
+
## Validation
|
|
195
|
+
|
|
196
|
+
Run the validator to check your Construct:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npx @useworkapp/construct-skill validate path/to/App.tsx
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Commands
|
|
203
|
+
|
|
204
|
+
When asked to build or review a Construct:
|
|
205
|
+
|
|
206
|
+
1. **Build**: Generate complete `App.tsx` following all requirements
|
|
207
|
+
2. **Review**: Check existing code against the validation checklist
|
|
208
|
+
3. **Fix**: Identify and correct any violations
|
|
209
|
+
4. **Optimize**: Suggest performance and UX improvements
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Example Construct: Todo List
|
|
5
|
+
* A complete, production-ready example following all Work platform standards.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect } from "react";
|
|
9
|
+
import { Button } from "@/components/ui/button";
|
|
10
|
+
import { Input } from "@/components/ui/input";
|
|
11
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
12
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
13
|
+
import { Label } from "@/components/ui/label";
|
|
14
|
+
import { Badge } from "@/components/ui/badge";
|
|
15
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
16
|
+
|
|
17
|
+
// Types
|
|
18
|
+
interface Todo {
|
|
19
|
+
id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
completed: boolean;
|
|
22
|
+
createdAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Storage key with work-construct- prefix
|
|
26
|
+
const STORAGE_KEY = "work-construct-todo-list";
|
|
27
|
+
|
|
28
|
+
export default function App() {
|
|
29
|
+
// Initialize state from localStorage
|
|
30
|
+
const [todos, setTodos] = useState<Todo[]>(() => {
|
|
31
|
+
if (typeof window === "undefined") return [];
|
|
32
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
33
|
+
return saved ? JSON.parse(saved) : [];
|
|
34
|
+
});
|
|
35
|
+
const [newTodo, setNewTodo] = useState("");
|
|
36
|
+
|
|
37
|
+
// Persist to localStorage on change
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
|
40
|
+
}, [todos]);
|
|
41
|
+
|
|
42
|
+
// Handlers
|
|
43
|
+
const addTodo = () => {
|
|
44
|
+
if (!newTodo.trim()) return;
|
|
45
|
+
setTodos([
|
|
46
|
+
...todos,
|
|
47
|
+
{
|
|
48
|
+
id: crypto.randomUUID(),
|
|
49
|
+
text: newTodo.trim(),
|
|
50
|
+
completed: false,
|
|
51
|
+
createdAt: Date.now(),
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
setNewTodo("");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const toggleTodo = (id: string) => {
|
|
58
|
+
setTodos(
|
|
59
|
+
todos.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t))
|
|
60
|
+
);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const deleteTodo = (id: string) => {
|
|
64
|
+
setTodos(todos.filter((t) => t.id !== id));
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Computed values
|
|
68
|
+
const completedCount = todos.filter((t) => t.completed).length;
|
|
69
|
+
const totalCount = todos.length;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="min-h-screen bg-background p-4 sm:p-6">
|
|
73
|
+
<Card className="mx-auto max-w-md">
|
|
74
|
+
<CardHeader>
|
|
75
|
+
<div className="flex items-center justify-between">
|
|
76
|
+
<CardTitle>Todo List</CardTitle>
|
|
77
|
+
{totalCount > 0 && (
|
|
78
|
+
<Badge variant="secondary">
|
|
79
|
+
{completedCount}/{totalCount}
|
|
80
|
+
</Badge>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardContent className="space-y-4">
|
|
85
|
+
{/* Add Todo Form */}
|
|
86
|
+
<div className="flex gap-2">
|
|
87
|
+
<Input
|
|
88
|
+
placeholder="Add a task..."
|
|
89
|
+
value={newTodo}
|
|
90
|
+
onChange={(e) => setNewTodo(e.target.value)}
|
|
91
|
+
onKeyDown={(e) => e.key === "Enter" && addTodo()}
|
|
92
|
+
aria-label="New task input"
|
|
93
|
+
/>
|
|
94
|
+
<Button onClick={addTodo} aria-label="Add task">
|
|
95
|
+
<Plus className="h-4 w-4" />
|
|
96
|
+
</Button>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
{/* Todo List */}
|
|
100
|
+
<div className="space-y-2">
|
|
101
|
+
{todos.map((todo) => (
|
|
102
|
+
<div
|
|
103
|
+
key={todo.id}
|
|
104
|
+
className="flex items-center gap-3 rounded-lg border bg-card p-3 transition-colors hover:bg-accent/50"
|
|
105
|
+
>
|
|
106
|
+
<Checkbox
|
|
107
|
+
id={todo.id}
|
|
108
|
+
checked={todo.completed}
|
|
109
|
+
onCheckedChange={() => toggleTodo(todo.id)}
|
|
110
|
+
aria-label={`Mark "${todo.text}" as ${todo.completed ? "incomplete" : "complete"}`}
|
|
111
|
+
/>
|
|
112
|
+
<Label
|
|
113
|
+
htmlFor={todo.id}
|
|
114
|
+
className={`flex-1 cursor-pointer ${
|
|
115
|
+
todo.completed
|
|
116
|
+
? "text-muted-foreground line-through"
|
|
117
|
+
: "text-foreground"
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
{todo.text}
|
|
121
|
+
</Label>
|
|
122
|
+
<Button
|
|
123
|
+
variant="ghost"
|
|
124
|
+
size="icon"
|
|
125
|
+
onClick={() => deleteTodo(todo.id)}
|
|
126
|
+
aria-label={`Delete "${todo.text}"`}
|
|
127
|
+
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
|
128
|
+
>
|
|
129
|
+
<Trash2 className="h-4 w-4" />
|
|
130
|
+
</Button>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{/* Empty State */}
|
|
136
|
+
{todos.length === 0 && (
|
|
137
|
+
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
138
|
+
No tasks yet. Add one above!
|
|
139
|
+
</p>
|
|
140
|
+
)}
|
|
141
|
+
|
|
142
|
+
{/* Clear Completed */}
|
|
143
|
+
{completedCount > 0 && (
|
|
144
|
+
<Button
|
|
145
|
+
variant="outline"
|
|
146
|
+
size="sm"
|
|
147
|
+
className="w-full"
|
|
148
|
+
onClick={() => setTodos(todos.filter((t) => !t.completed))}
|
|
149
|
+
>
|
|
150
|
+
Clear {completedCount} completed
|
|
151
|
+
</Button>
|
|
152
|
+
)}
|
|
153
|
+
</CardContent>
|
|
154
|
+
</Card>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Build and validate Constructs for the Work streaming platform. Creates React micro-apps following Work standards with shadcn/ui, Tailwind CSS, and localStorage persistence.
|
|
3
|
+
agent: build
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Work Construct Builder
|
|
7
|
+
|
|
8
|
+
Build production-ready Constructs for the Work app streaming platform.
|
|
9
|
+
|
|
10
|
+
$ARGUMENTS
|
|
11
|
+
|
|
12
|
+
## What is a Construct?
|
|
13
|
+
|
|
14
|
+
A Construct is a self-contained React micro-app that:
|
|
15
|
+
- Solves a specific business need (tracker, calculator, dashboard, form, etc.)
|
|
16
|
+
- Runs in Work's sandboxed iframe environment
|
|
17
|
+
- Uses localStorage for data persistence
|
|
18
|
+
- Follows strict UI/UX standards
|
|
19
|
+
|
|
20
|
+
## Construct Requirements
|
|
21
|
+
|
|
22
|
+
### 1. Single-File Architecture
|
|
23
|
+
|
|
24
|
+
Constructs must be a single `App.tsx` file with:
|
|
25
|
+
- Default export of main component
|
|
26
|
+
- All sub-components defined in the same file
|
|
27
|
+
- No external API calls (localStorage only)
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// Correct structure
|
|
31
|
+
import { useState } from "react";
|
|
32
|
+
import { Button } from "@/components/ui/button";
|
|
33
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
34
|
+
|
|
35
|
+
export default function App() {
|
|
36
|
+
// Component logic
|
|
37
|
+
return <div>...</div>;
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### 2. Required UI Components
|
|
42
|
+
|
|
43
|
+
Use ONLY these shadcn/ui components (available in Work runtime):
|
|
44
|
+
|
|
45
|
+
**Layout:**
|
|
46
|
+
- `Card`, `CardContent`, `CardHeader`, `CardTitle`, `CardDescription`, `CardFooter`
|
|
47
|
+
|
|
48
|
+
**Forms:**
|
|
49
|
+
- `Button`, `Input`, `Textarea`, `Label`
|
|
50
|
+
- `Select`, `SelectContent`, `SelectItem`, `SelectTrigger`, `SelectValue`
|
|
51
|
+
- `Checkbox`, `RadioGroup`, `RadioGroupItem`
|
|
52
|
+
- `Switch`
|
|
53
|
+
|
|
54
|
+
**Feedback:**
|
|
55
|
+
- `Badge`, `Alert`, `AlertDescription`, `AlertTitle`
|
|
56
|
+
- `Progress`
|
|
57
|
+
|
|
58
|
+
**Data Display:**
|
|
59
|
+
- `Table`, `TableBody`, `TableCell`, `TableHead`, `TableHeader`, `TableRow`
|
|
60
|
+
- `Tabs`, `TabsContent`, `TabsList`, `TabsTrigger`
|
|
61
|
+
|
|
62
|
+
**Overlay:**
|
|
63
|
+
- `Dialog`, `DialogContent`, `DialogDescription`, `DialogFooter`, `DialogHeader`, `DialogTitle`, `DialogTrigger`
|
|
64
|
+
- `DropdownMenu`, `DropdownMenuContent`, `DropdownMenuItem`, `DropdownMenuTrigger`
|
|
65
|
+
|
|
66
|
+
### 3. Data Persistence Pattern
|
|
67
|
+
|
|
68
|
+
Always use localStorage with a unique key prefix:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
const STORAGE_KEY = "work-construct-{construct-name}";
|
|
72
|
+
|
|
73
|
+
// Load data
|
|
74
|
+
const [data, setData] = useState(() => {
|
|
75
|
+
if (typeof window === "undefined") return initialState;
|
|
76
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
77
|
+
return saved ? JSON.parse(saved) : initialState;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Save data (in useEffect)
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
|
83
|
+
}, [data]);
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### 4. Styling Requirements
|
|
87
|
+
|
|
88
|
+
**DO:**
|
|
89
|
+
- Use Tailwind CSS utility classes
|
|
90
|
+
- Use responsive classes (`sm:`, `md:`, `lg:`)
|
|
91
|
+
- Use CSS variables for theming (`text-foreground`, `bg-background`, `border-border`)
|
|
92
|
+
- Add `dark:` variants for dark mode support
|
|
93
|
+
|
|
94
|
+
**DON'T:**
|
|
95
|
+
- Use inline styles
|
|
96
|
+
- Use CSS modules or external stylesheets
|
|
97
|
+
- Use fixed pixel widths (use responsive units)
|
|
98
|
+
- Override shadcn component styles
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
// Correct
|
|
102
|
+
<div className="flex flex-col gap-4 p-4 sm:p-6">
|
|
103
|
+
<Card className="w-full">
|
|
104
|
+
<CardHeader>
|
|
105
|
+
<CardTitle className="text-lg sm:text-xl">Title</CardTitle>
|
|
106
|
+
</CardHeader>
|
|
107
|
+
</Card>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
// Incorrect
|
|
111
|
+
<div style={{ padding: "20px" }}>
|
|
112
|
+
<Card style={{ width: "500px" }}>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 5. Accessibility Requirements
|
|
116
|
+
|
|
117
|
+
**Mandatory:**
|
|
118
|
+
- All interactive elements must be keyboard accessible
|
|
119
|
+
- Use semantic HTML (`button`, `nav`, `main`, `section`, `article`)
|
|
120
|
+
- Add `aria-label` to icon-only buttons
|
|
121
|
+
- Ensure color contrast meets WCAG AA (4.5:1 for text)
|
|
122
|
+
- Form inputs must have associated labels
|
|
123
|
+
|
|
124
|
+
```tsx
|
|
125
|
+
// Correct
|
|
126
|
+
<Button variant="ghost" size="icon" aria-label="Delete item">
|
|
127
|
+
<Trash2 className="h-4 w-4" />
|
|
128
|
+
</Button>
|
|
129
|
+
|
|
130
|
+
<div className="space-y-2">
|
|
131
|
+
<Label htmlFor="email">Email</Label>
|
|
132
|
+
<Input id="email" type="email" />
|
|
133
|
+
</div>
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### 6. Icon Usage
|
|
137
|
+
|
|
138
|
+
Use Lucide React icons only:
|
|
139
|
+
|
|
140
|
+
```tsx
|
|
141
|
+
import { Plus, Trash2, Edit, Check, X, Search, Filter } from "lucide-react";
|
|
142
|
+
|
|
143
|
+
<Plus className="h-4 w-4" />
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 7. State Management
|
|
147
|
+
|
|
148
|
+
For complex state, use `useReducer`:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
type State = { items: Item[]; filter: string };
|
|
152
|
+
type Action =
|
|
153
|
+
| { type: "ADD_ITEM"; payload: Item }
|
|
154
|
+
| { type: "DELETE_ITEM"; payload: string }
|
|
155
|
+
| { type: "SET_FILTER"; payload: string };
|
|
156
|
+
|
|
157
|
+
function reducer(state: State, action: Action): State {
|
|
158
|
+
switch (action.type) {
|
|
159
|
+
case "ADD_ITEM":
|
|
160
|
+
return { ...state, items: [...state.items, action.payload] };
|
|
161
|
+
// ...
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Validation Checklist
|
|
167
|
+
|
|
168
|
+
Before submitting a Construct, verify:
|
|
169
|
+
|
|
170
|
+
- [ ] Single `App.tsx` file with default export
|
|
171
|
+
- [ ] Uses only approved shadcn/ui components
|
|
172
|
+
- [ ] localStorage persistence with unique key
|
|
173
|
+
- [ ] Responsive design (works on mobile)
|
|
174
|
+
- [ ] Dark mode support
|
|
175
|
+
- [ ] All buttons/inputs have proper labels
|
|
176
|
+
- [ ] No external API calls
|
|
177
|
+
- [ ] No TypeScript errors
|
|
178
|
+
- [ ] Meaningful title and description
|
|
179
|
+
|
|
180
|
+
## Example Construct
|
|
181
|
+
|
|
182
|
+
```tsx
|
|
183
|
+
"use client";
|
|
184
|
+
|
|
185
|
+
import { useState, useEffect } from "react";
|
|
186
|
+
import { Button } from "@/components/ui/button";
|
|
187
|
+
import { Input } from "@/components/ui/input";
|
|
188
|
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
189
|
+
import { Checkbox } from "@/components/ui/checkbox";
|
|
190
|
+
import { Label } from "@/components/ui/label";
|
|
191
|
+
import { Badge } from "@/components/ui/badge";
|
|
192
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
193
|
+
|
|
194
|
+
interface Todo {
|
|
195
|
+
id: string;
|
|
196
|
+
text: string;
|
|
197
|
+
completed: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const STORAGE_KEY = "work-construct-todo-list";
|
|
201
|
+
|
|
202
|
+
export default function App() {
|
|
203
|
+
const [todos, setTodos] = useState<Todo[]>(() => {
|
|
204
|
+
if (typeof window === "undefined") return [];
|
|
205
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
206
|
+
return saved ? JSON.parse(saved) : [];
|
|
207
|
+
});
|
|
208
|
+
const [newTodo, setNewTodo] = useState("");
|
|
209
|
+
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
|
|
212
|
+
}, [todos]);
|
|
213
|
+
|
|
214
|
+
const addTodo = () => {
|
|
215
|
+
if (!newTodo.trim()) return;
|
|
216
|
+
setTodos([
|
|
217
|
+
...todos,
|
|
218
|
+
{ id: crypto.randomUUID(), text: newTodo.trim(), completed: false },
|
|
219
|
+
]);
|
|
220
|
+
setNewTodo("");
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
const toggleTodo = (id: string) => {
|
|
224
|
+
setTodos(todos.map((t) =>
|
|
225
|
+
t.id === id ? { ...t, completed: !t.completed } : t
|
|
226
|
+
));
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const deleteTodo = (id: string) => {
|
|
230
|
+
setTodos(todos.filter((t) => t.id !== id));
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const completedCount = todos.filter((t) => t.completed).length;
|
|
234
|
+
|
|
235
|
+
return (
|
|
236
|
+
<div className="min-h-screen bg-background p-4 sm:p-6">
|
|
237
|
+
<Card className="mx-auto max-w-md">
|
|
238
|
+
<CardHeader>
|
|
239
|
+
<div className="flex items-center justify-between">
|
|
240
|
+
<CardTitle>Todo List</CardTitle>
|
|
241
|
+
{todos.length > 0 && (
|
|
242
|
+
<Badge variant="secondary">
|
|
243
|
+
{completedCount}/{todos.length}
|
|
244
|
+
</Badge>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</CardHeader>
|
|
248
|
+
<CardContent className="space-y-4">
|
|
249
|
+
<div className="flex gap-2">
|
|
250
|
+
<Input
|
|
251
|
+
placeholder="Add a task..."
|
|
252
|
+
value={newTodo}
|
|
253
|
+
onChange={(e) => setNewTodo(e.target.value)}
|
|
254
|
+
onKeyDown={(e) => e.key === "Enter" && addTodo()}
|
|
255
|
+
/>
|
|
256
|
+
<Button onClick={addTodo} aria-label="Add task">
|
|
257
|
+
<Plus className="h-4 w-4" />
|
|
258
|
+
</Button>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
<div className="space-y-2">
|
|
262
|
+
{todos.map((todo) => (
|
|
263
|
+
<div
|
|
264
|
+
key={todo.id}
|
|
265
|
+
className="flex items-center gap-3 rounded-lg border p-3"
|
|
266
|
+
>
|
|
267
|
+
<Checkbox
|
|
268
|
+
id={todo.id}
|
|
269
|
+
checked={todo.completed}
|
|
270
|
+
onCheckedChange={() => toggleTodo(todo.id)}
|
|
271
|
+
/>
|
|
272
|
+
<Label
|
|
273
|
+
htmlFor={todo.id}
|
|
274
|
+
className={`flex-1 ${
|
|
275
|
+
todo.completed ? "line-through text-muted-foreground" : ""
|
|
276
|
+
}`}
|
|
277
|
+
>
|
|
278
|
+
{todo.text}
|
|
279
|
+
</Label>
|
|
280
|
+
<Button
|
|
281
|
+
variant="ghost"
|
|
282
|
+
size="icon"
|
|
283
|
+
onClick={() => deleteTodo(todo.id)}
|
|
284
|
+
aria-label="Delete task"
|
|
285
|
+
>
|
|
286
|
+
<Trash2 className="h-4 w-4" />
|
|
287
|
+
</Button>
|
|
288
|
+
</div>
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
|
|
292
|
+
{todos.length === 0 && (
|
|
293
|
+
<p className="text-center text-sm text-muted-foreground">
|
|
294
|
+
No tasks yet. Add one above!
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
</CardContent>
|
|
298
|
+
</Card>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
## Validation
|
|
305
|
+
|
|
306
|
+
Run the validator to check your Construct:
|
|
307
|
+
|
|
308
|
+
```bash
|
|
309
|
+
npx @useworkapp/construct-skill validate path/to/App.tsx
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
## Commands
|
|
313
|
+
|
|
314
|
+
When asked to build or review a Construct:
|
|
315
|
+
|
|
316
|
+
1. **Build**: Generate complete `App.tsx` following all requirements
|
|
317
|
+
2. **Review**: Check existing code against the validation checklist
|
|
318
|
+
3. **Fix**: Identify and correct any violations
|
|
319
|
+
4. **Optimize**: Suggest performance and UX improvements
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@useworkapp/construct-skill",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "AI coding assistant skill for building Work platform Constructs. Compatible with Claude Code and OpenCode.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"opencode",
|
|
8
|
+
"ai-coding",
|
|
9
|
+
"skill",
|
|
10
|
+
"react",
|
|
11
|
+
"constructs",
|
|
12
|
+
"work-platform",
|
|
13
|
+
"shadcn-ui",
|
|
14
|
+
"tailwind"
|
|
15
|
+
],
|
|
16
|
+
"author": "Work <hello@usework.app>",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "https://github.com/usework/construct-skill"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://usework.app/docs/runners/constructs",
|
|
23
|
+
"bin": {
|
|
24
|
+
"construct-skill": "./bin/install.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin",
|
|
28
|
+
"claude-code",
|
|
29
|
+
"opencode",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"postinstall": "node bin/install.js --auto",
|
|
34
|
+
"validate": "node bin/validate.js"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|