@techstream/quark-create-app 1.8.0 → 1.10.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.
Files changed (80) hide show
  1. package/README.md +2 -2
  2. package/package.json +3 -3
  3. package/src/index.js +415 -150
  4. package/src/utils.js +36 -0
  5. package/src/utils.test.js +63 -0
  6. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  7. package/templates/base-project/.github/copilot-instructions.md +55 -0
  8. package/templates/base-project/.github/workflows/release.yml +37 -8
  9. package/templates/base-project/CLAUDE.md +273 -0
  10. package/templates/base-project/README.md +72 -30
  11. package/templates/base-project/apps/web/next.config.js +5 -1
  12. package/templates/base-project/apps/web/package.json +7 -5
  13. package/templates/base-project/apps/web/public/quark.svg +46 -0
  14. package/templates/base-project/apps/web/railway.json +2 -2
  15. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  16. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  17. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  18. package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
  19. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  20. package/templates/base-project/apps/web/src/app/global-error.js +53 -0
  21. package/templates/base-project/apps/web/src/app/globals.css +121 -15
  22. package/templates/base-project/apps/web/src/app/icon.svg +46 -0
  23. package/templates/base-project/apps/web/src/app/layout.js +1 -0
  24. package/templates/base-project/apps/web/src/app/not-found.js +35 -0
  25. package/templates/base-project/apps/web/src/app/page.js +38 -5
  26. package/templates/base-project/apps/web/src/lib/theme.js +23 -0
  27. package/templates/base-project/apps/web/src/proxy.js +10 -2
  28. package/templates/base-project/package.json +16 -1
  29. package/templates/base-project/packages/db/package.json +4 -4
  30. package/templates/base-project/packages/db/src/client.js +6 -1
  31. package/templates/base-project/packages/db/src/index.js +1 -0
  32. package/templates/base-project/packages/db/src/ping.js +66 -0
  33. package/templates/base-project/scripts/doctor.js +261 -0
  34. package/templates/base-project/turbo.json +2 -1
  35. package/templates/config/package.json +1 -0
  36. package/templates/config/src/index.js +1 -3
  37. package/templates/config/src/validate-env.js +79 -3
  38. package/templates/jobs/package.json +2 -1
  39. package/templates/ui/README.md +67 -0
  40. package/templates/ui/package.json +1 -0
  41. package/templates/ui/src/badge.js +32 -0
  42. package/templates/ui/src/badge.test.js +42 -0
  43. package/templates/ui/src/button.js +64 -15
  44. package/templates/ui/src/button.test.js +34 -5
  45. package/templates/ui/src/card.js +58 -0
  46. package/templates/ui/src/card.test.js +59 -0
  47. package/templates/ui/src/checkbox.js +35 -0
  48. package/templates/ui/src/checkbox.test.js +35 -0
  49. package/templates/ui/src/dialog.js +139 -0
  50. package/templates/ui/src/dialog.test.js +15 -0
  51. package/templates/ui/src/index.js +16 -0
  52. package/templates/ui/src/input.js +15 -0
  53. package/templates/ui/src/input.test.js +27 -0
  54. package/templates/ui/src/label.js +14 -0
  55. package/templates/ui/src/label.test.js +22 -0
  56. package/templates/ui/src/select.js +42 -0
  57. package/templates/ui/src/select.test.js +27 -0
  58. package/templates/ui/src/skeleton.js +14 -0
  59. package/templates/ui/src/skeleton.test.js +22 -0
  60. package/templates/ui/src/table.js +75 -0
  61. package/templates/ui/src/table.test.js +69 -0
  62. package/templates/ui/src/textarea.js +15 -0
  63. package/templates/ui/src/textarea.test.js +27 -0
  64. package/templates/ui/src/theme-constants.js +24 -0
  65. package/templates/ui/src/theme.js +132 -0
  66. package/templates/ui/src/toast.js +229 -0
  67. package/templates/ui/src/toast.test.js +23 -0
  68. package/templates/{base-project/apps/worker → worker}/package.json +2 -2
  69. package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
  70. package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
  71. package/templates/base-project/apps/web/public/file.svg +0 -1
  72. package/templates/base-project/apps/web/public/globe.svg +0 -1
  73. package/templates/base-project/apps/web/public/next.svg +0 -1
  74. package/templates/base-project/apps/web/public/vercel.svg +0 -1
  75. package/templates/base-project/apps/web/public/window.svg +0 -1
  76. /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
  77. /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
  78. /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
  79. /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
  80. /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/doctor.js
4
+ *
5
+ * Audits this project for unfinished post-scaffold customisation.
6
+ * Run it at any time — it is safe, read-only by default.
7
+ *
8
+ * Usage:
9
+ * pnpm doctor # Audit only
10
+ * pnpm doctor:fix # Audit + auto-remove Quark aesthetic scaffolding
11
+ *
12
+ * Extend the CHECKS array below to add your own project-specific rules.
13
+ * This script has zero external dependencies.
14
+ */
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const ROOT = path.resolve(__dirname, "..");
22
+ const FIX = process.argv.includes("--fix");
23
+
24
+ // ─── ANSI helpers ─────────────────────────────────────────────────────────────
25
+
26
+ const c = {
27
+ reset: "\x1b[0m",
28
+ bold: "\x1b[1m",
29
+ dim: "\x1b[2m",
30
+ red: "\x1b[31m",
31
+ green: "\x1b[32m",
32
+ yellow: "\x1b[33m",
33
+ blue: "\x1b[34m",
34
+ };
35
+
36
+ const fmt = {
37
+ bold: (s) => `${c.bold}${s}${c.reset}`,
38
+ dim: (s) => `${c.dim}${s}${c.reset}`,
39
+ red: (s) => `${c.red}${s}${c.reset}`,
40
+ green: (s) => `${c.green}${s}${c.reset}`,
41
+ yellow: (s) => `${c.yellow}${s}${c.reset}`,
42
+ blue: (s) => `${c.blue}${s}${c.reset}`,
43
+ };
44
+
45
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
46
+
47
+ function exists(rel) {
48
+ return fs.existsSync(path.join(ROOT, rel));
49
+ }
50
+
51
+ function read(rel) {
52
+ const abs = path.join(ROOT, rel);
53
+ return fs.existsSync(abs) ? fs.readFileSync(abs, "utf-8") : null;
54
+ }
55
+
56
+ /** Remove a file or directory tree (relative to ROOT). */
57
+ function remove(rel) {
58
+ const abs = path.join(ROOT, rel);
59
+ fs.rmSync(abs, { recursive: true, force: true });
60
+ }
61
+
62
+ // ─── Finding model ────────────────────────────────────────────────────────────
63
+
64
+ /**
65
+ * @typedef {{ category: string, status: 'error'|'warn'|'info', key: string,
66
+ * message: string, detail: string, fix: string, fixable: boolean }} Finding
67
+ */
68
+
69
+ /** @type {Finding[]} */
70
+ const findings = [];
71
+
72
+ function warn(key, category, message, detail, fix, fixable = false) {
73
+ findings.push({ category, status: "warn", key, message, detail, fix, fixable });
74
+ }
75
+
76
+ function error(key, category, message, detail, fix, fixable = false) {
77
+ findings.push({ category, status: "error", key, message, detail, fix, fixable });
78
+ }
79
+
80
+ function info(key, category, message, detail, fix) {
81
+ findings.push({ category, status: "info", key, message, detail, fix, fixable: false });
82
+ }
83
+
84
+ // ─── Checks ───────────────────────────────────────────────────────────────────
85
+
86
+ // Read .quark-link.json to understand what packages were included at scaffold time.
87
+ const quarkLink = (() => {
88
+ try {
89
+ return JSON.parse(read(".quark-link.json") ?? "{}");
90
+ } catch {
91
+ return {};
92
+ }
93
+ })();
94
+ const hasUI = Array.isArray(quarkLink.packages) && quarkLink.packages.includes("ui");
95
+
96
+ // ── Check 1: Quark Animation still present ────────────────────────────────────
97
+ if (exists("apps/web/src/app/_components/QuarkAnimation.js")) {
98
+ warn(
99
+ "quark-animation",
100
+ "branding",
101
+ "QuarkAnimation is still in the project",
102
+ "apps/web/src/app/_components/QuarkAnimation.js",
103
+ "Replace or remove the animation and update the home page with your own hero content",
104
+ true,
105
+ );
106
+ }
107
+
108
+ // ── Check 2: Playground page present (only relevant with UI package) ──────────
109
+ if (hasUI && exists("apps/web/src/app/playground")) {
110
+ warn(
111
+ "playground",
112
+ "branding",
113
+ "Playground page is still present",
114
+ "apps/web/src/app/playground/",
115
+ "Consider removing the playground before going to production",
116
+ true,
117
+ );
118
+ }
119
+
120
+ // ── Check 3: APP_NAME / APP_DESCRIPTION still reference "Quark" ───────────────
121
+ const envContent = read(".env");
122
+ if (envContent) {
123
+ const nameMatch = envContent.match(/^APP_NAME=(.+)$/m);
124
+ const descMatch = envContent.match(/^APP_DESCRIPTION=(.+)$/m);
125
+ const nameVal = nameMatch?.[1]?.trim() ?? "";
126
+ const descVal = descMatch?.[1]?.trim() ?? "";
127
+
128
+ if (/\bquark\b/i.test(nameVal) || /\bquark\b/i.test(descVal)) {
129
+ warn(
130
+ "app-identity",
131
+ "metadata",
132
+ 'APP_NAME or APP_DESCRIPTION still references "Quark"',
133
+ `.env → APP_NAME="${nameVal}", APP_DESCRIPTION="${descVal}"`,
134
+ "Update APP_NAME and APP_DESCRIPTION in your .env to match your project",
135
+ );
136
+ }
137
+ }
138
+
139
+ // ── Check 4: Placeholder secrets (CHANGE_ME) left in .env ────────────────────
140
+ if (envContent && envContent.includes("CHANGE_ME")) {
141
+ const lines = envContent
142
+ .split("\n")
143
+ .map((l, i) => ({ line: i + 1, text: l }))
144
+ .filter(({ text }) => text.includes("CHANGE_ME"))
145
+ .map(({ line, text }) => ` line ${line}: ${text.split("=")[0]}`);
146
+
147
+ error(
148
+ "secrets",
149
+ "security",
150
+ "CHANGE_ME placeholders found in .env — rotate these before deploying",
151
+ lines.join("\n"),
152
+ "Replace every CHANGE_ME value with a real secret",
153
+ );
154
+ }
155
+
156
+ // ── Check 5: .env missing vars that exist in .env.example ────────────────────
157
+ const exampleContent = read(".env.example");
158
+ if (envContent && exampleContent) {
159
+ const defined = new Set(
160
+ envContent
161
+ .split("\n")
162
+ .filter((l) => l.includes("=") && !l.startsWith("#"))
163
+ .map((l) => l.split("=")[0].trim()),
164
+ );
165
+ const missing = exampleContent
166
+ .split("\n")
167
+ .filter((l) => l.includes("=") && !l.startsWith("#"))
168
+ .map((l) => l.split("=")[0].trim())
169
+ .filter((k) => k && !defined.has(k));
170
+
171
+ if (missing.length > 0) {
172
+ warn(
173
+ "env-missing",
174
+ "configuration",
175
+ `.env.example defines ${missing.length} key(s) not present in .env`,
176
+ missing.map((k) => ` ${k}`).join("\n"),
177
+ "Add the missing keys to your .env (copy from .env.example and fill in values)",
178
+ );
179
+ }
180
+ }
181
+
182
+ // ── Check 6: README still contains Quark template content ────────────────────
183
+ const readmeContent = read("README.md");
184
+ if (readmeContent && /quark/i.test(readmeContent)) {
185
+ info(
186
+ "readme",
187
+ "documentation",
188
+ "README.md still contains references to Quark",
189
+ "README.md",
190
+ "Update the README to describe your own project",
191
+ );
192
+ }
193
+
194
+ // ─── --fix: auto-remove fixable items ────────────────────────────────────────
195
+
196
+ const STATUS_ICON = { error: "✗", warn: "⚠", info: "·" };
197
+ const STATUS_COLOR = { error: fmt.red, warn: fmt.yellow, info: fmt.dim };
198
+
199
+ // Print the report first so users see what was found before anything is changed.
200
+ console.log(fmt.bold(fmt.blue("\n🩺 Quark Doctor\n")));
201
+
202
+ if (findings.length === 0) {
203
+ console.log(fmt.green(" ✓ Nothing to do — project looks clean!\n"));
204
+ process.exit(0);
205
+ }
206
+
207
+ // Group by category
208
+ const categories = [...new Set(findings.map((f) => f.category))];
209
+ for (const cat of categories) {
210
+ console.log(fmt.bold(` ${cat}`));
211
+ for (const f of findings.filter((f) => f.category === cat)) {
212
+ const icon = STATUS_ICON[f.status];
213
+ const colorFn = STATUS_COLOR[f.status];
214
+ console.log(` ${colorFn(`${icon} ${f.message}`)}`);
215
+ if (f.detail) {
216
+ for (const line of f.detail.split("\n")) {
217
+ console.log(fmt.dim(` ${line}`));
218
+ }
219
+ }
220
+ if (!FIX || !f.fixable) {
221
+ console.log(fmt.dim(` → ${f.fix}`));
222
+ }
223
+ }
224
+ console.log();
225
+ }
226
+
227
+ const errorCount = findings.filter((f) => f.status === "error").length;
228
+ const warnCount = findings.filter((f) => f.status === "warn").length;
229
+ const fixableCount = findings.filter((f) => f.fixable).length;
230
+
231
+ const parts = [];
232
+ if (errorCount) parts.push(fmt.red(`${errorCount} error${errorCount > 1 ? "s" : ""}`));
233
+ if (warnCount) parts.push(fmt.yellow(`${warnCount} warning${warnCount > 1 ? "s" : ""}`));
234
+ console.log(fmt.bold(` Summary: ${parts.join(", ")}`));
235
+
236
+ if (FIX) {
237
+ const fixable = findings.filter((f) => f.fixable);
238
+ if (fixable.length === 0) {
239
+ console.log(fmt.dim(" No auto-fixable items found.\n"));
240
+ } else {
241
+ console.log(fmt.bold(fmt.blue("\n🔧 Applying fixes…\n")));
242
+ for (const f of fixable) {
243
+ if (f.key === "quark-animation") {
244
+ remove("apps/web/src/app/_components/QuarkAnimation.js");
245
+ console.log(fmt.green(` ✓ Removed QuarkAnimation.js`));
246
+ } else if (f.key === "playground") {
247
+ remove("apps/web/src/app/playground");
248
+ console.log(fmt.green(` ✓ Removed apps/web/src/app/playground/`));
249
+ }
250
+ }
251
+ console.log();
252
+ }
253
+ } else if (fixableCount > 0) {
254
+ console.log(
255
+ fmt.dim(` ${fixableCount} item(s) can be auto-removed with: pnpm doctor:fix\n`),
256
+ );
257
+ } else {
258
+ console.log();
259
+ }
260
+
261
+ if (errorCount > 0) process.exit(1);
@@ -18,7 +18,8 @@
18
18
  "outputs": ["node_modules/.prisma/client"]
19
19
  },
20
20
  "db:migrate": {
21
- "cache": false
21
+ "cache": false,
22
+ "interactive": true
22
23
  },
23
24
  "db:push": {
24
25
  "cache": false
@@ -2,6 +2,7 @@
2
2
  "name": "@myquark/config",
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
+ "main": "src/index.js",
5
6
  "exports": {
6
7
  ".": "./src/index.js",
7
8
  "./app-url": "./src/app-url.js",
@@ -1,8 +1,6 @@
1
1
  export const config = {
2
2
  appName: process.env.APP_NAME || "Quark",
3
- appDescription:
4
- process.env.APP_DESCRIPTION ||
5
- "A modern monorepo with Next.js, React, and Prisma",
3
+ appDescription: process.env.APP_DESCRIPTION || "A Quark-powered application",
6
4
  };
7
5
 
8
6
  export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
@@ -37,10 +37,16 @@ const envSchema = {
37
37
  // Email provider
38
38
  EMAIL_PROVIDER: {
39
39
  required: false,
40
- description: 'Email provider — "smtp" (default) or "resend"',
40
+ description: 'Email provider — "smtp" (default), "resend", or "zeptomail"',
41
41
  },
42
42
  EMAIL_FROM: { required: false, description: "Sender email address" },
43
43
  RESEND_API_KEY: { required: false, description: "Resend API key" },
44
+ ZEPTOMAIL_TOKEN: { required: false, description: "Zeptomail API token" },
45
+ ZEPTOMAIL_URL: { required: false, description: "Zeptomail API base URL" },
46
+ ZEPTOMAIL_BOUNCE_EMAIL: {
47
+ required: false,
48
+ description: "Zeptomail bounce email address",
49
+ },
44
50
 
45
51
  // NextAuth
46
52
  NEXTAUTH_SECRET: {
@@ -58,6 +64,11 @@ const envSchema = {
58
64
  required: false,
59
65
  description: "Application name — used in metadata, emails, and page titles",
60
66
  },
67
+ APP_DESCRIPTION: {
68
+ required: false,
69
+ description:
70
+ "Application description — used for SEO metadata and social previews",
71
+ },
61
72
  APP_URL: {
62
73
  required: false,
63
74
  description:
@@ -69,6 +80,12 @@ const envSchema = {
69
80
  },
70
81
  PORT: { required: false, description: "Web server port" },
71
82
 
83
+ // Worker
84
+ WORKER_CONCURRENCY: {
85
+ required: false,
86
+ description: "Number of concurrent jobs per queue (default: 5)",
87
+ },
88
+
72
89
  // Storage
73
90
  STORAGE_PROVIDER: {
74
91
  required: false,
@@ -80,10 +97,19 @@ const envSchema = {
80
97
  },
81
98
  S3_BUCKET: { required: false, description: "S3 bucket name" },
82
99
  S3_REGION: { required: false, description: "S3 region" },
83
- S3_ENDPOINT: { required: false, description: "S3-compatible endpoint URL" },
100
+ S3_ENDPOINT: {
101
+ required: false,
102
+ description:
103
+ "S3-compatible endpoint URL (required for non-AWS providers: R2, MinIO, etc.)",
104
+ },
84
105
  S3_ACCESS_KEY_ID: { required: false, description: "S3 access key" },
85
106
  S3_SECRET_ACCESS_KEY: { required: false, description: "S3 secret key" },
86
107
  S3_PUBLIC_URL: { required: false, description: "S3 public URL prefix" },
108
+ ASSET_CDN_URL: {
109
+ required: false,
110
+ description:
111
+ "Public CDN base URL for asset delivery — provider-agnostic (CloudFront, Cloudflare, Bunny, etc.). Falls back to /api/files when unset.",
112
+ },
87
113
  };
88
114
 
89
115
  /**
@@ -129,6 +155,25 @@ export function validateEnv(service = "web") {
129
155
 
130
156
  // --- Cross-field validation ---
131
157
 
158
+ // Placeholder value security check
159
+ const placeholderPattern = /^CHANGE_ME_/i;
160
+ const criticalKeys = [
161
+ "NEXTAUTH_SECRET",
162
+ "POSTGRES_PASSWORD",
163
+ "RESEND_API_KEY",
164
+ "ZEPTOMAIL_TOKEN",
165
+ "S3_SECRET_ACCESS_KEY",
166
+ "SMTP_PASSWORD",
167
+ ];
168
+ for (const key of criticalKeys) {
169
+ const value = process.env[key];
170
+ if (value && placeholderPattern.test(value)) {
171
+ errors.push(
172
+ `${key} contains a placeholder value — replace with a real secret (${envSchema[key]?.description || ""})`,
173
+ );
174
+ }
175
+ }
176
+
132
177
  // Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
133
178
  if (!isTest) {
134
179
  const hasDbUrl = !!process.env.DATABASE_URL;
@@ -141,16 +186,33 @@ export function validateEnv(service = "web") {
141
186
  }
142
187
 
143
188
  // Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
189
+ const currentEnv = (process.env.NODE_ENV || "").toLowerCase();
144
190
  if (
145
191
  !process.env.REDIS_URL &&
146
192
  !process.env.REDIS_HOST &&
147
- process.env.NODE_ENV === "production"
193
+ (currentEnv === "production" || currentEnv === "staging")
148
194
  ) {
149
195
  warnings.push(
150
196
  "Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
151
197
  );
152
198
  }
153
199
 
200
+ // SEO metadata: APP_DESCRIPTION should be explicitly set before production
201
+ const isProductionLike =
202
+ currentEnv === "production" || currentEnv === "staging";
203
+ if (!isTest && isProductionLike) {
204
+ const appDescription = process.env.APP_DESCRIPTION;
205
+ if (!appDescription) {
206
+ warnings.push(
207
+ "APP_DESCRIPTION not set: metadata description will fall back to a generic value. Set APP_DESCRIPTION before production.",
208
+ );
209
+ } else if (/^CHANGE_ME_|^TODO_/i.test(appDescription)) {
210
+ warnings.push(
211
+ "APP_DESCRIPTION appears to be a placeholder value. Update it before production.",
212
+ );
213
+ }
214
+ }
215
+
154
216
  // Conditional: S3 storage requires bucket + credentials
155
217
  if (process.env.STORAGE_PROVIDER === "s3") {
156
218
  for (const key of [
@@ -171,6 +233,20 @@ export function validateEnv(service = "web") {
171
233
  errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
172
234
  }
173
235
 
236
+ // Conditional: Zeptomail provider requires token and URL
237
+ if (process.env.EMAIL_PROVIDER === "zeptomail") {
238
+ if (!process.env.ZEPTOMAIL_TOKEN) {
239
+ errors.push(
240
+ "Missing ZEPTOMAIL_TOKEN — required when EMAIL_PROVIDER=zeptomail",
241
+ );
242
+ }
243
+ if (!process.env.ZEPTOMAIL_URL) {
244
+ errors.push(
245
+ "Missing ZEPTOMAIL_URL — required when EMAIL_PROVIDER=zeptomail",
246
+ );
247
+ }
248
+ }
249
+
174
250
  // Log warnings (non-fatal)
175
251
  for (const warning of warnings) {
176
252
  console.warn(`[env] ⚠️ ${warning}`);
@@ -2,7 +2,8 @@
2
2
  "name": "@myquark/jobs",
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
+ "main": "src/index.js",
5
6
  "dependencies": {
6
- "bullmq": "^5.69.3"
7
+ "bullmq": "^5.70.4"
7
8
  }
8
9
  }
@@ -0,0 +1,67 @@
1
+ # @yourscope/ui
2
+
3
+ Scaffolded UI primitives for your Quark project. These components are **yours** — modify, extend, or replace them freely.
4
+
5
+ > This package is scaffolded via `quark-create-app`. There is no version sync back to Quark after scaffolding.
6
+
7
+ ## Import
8
+
9
+ ```javascript
10
+ import { Button, Card, Badge } from '@yourscope/ui';
11
+ ```
12
+
13
+ ## Components
14
+
15
+ ### Button
16
+ Props: `variant` ('primary' | 'secondary' | 'danger' | 'ghost', default: 'primary'), `size` ('sm' | 'md' | 'lg', default: 'md'), `className`, all native button attributes.
17
+
18
+ ### Input
19
+ Props: `className`, all native input attributes.
20
+
21
+ ### Label
22
+ Props: `className`, all native label attributes.
23
+
24
+ ### Textarea
25
+ Props: `className`, all native textarea attributes.
26
+
27
+ ### Select
28
+ Props: `className`, `children` (option elements), all native select attributes.
29
+
30
+ ### Checkbox
31
+ Props: `id`, `label` (string), `className`, all native checkbox input attributes.
32
+
33
+ ### Badge
34
+ Props: `variant` ('default' | 'success' | 'warning' | 'danger' | 'info', default: 'default'), `className`.
35
+
36
+ ### Card / CardHeader / CardTitle / CardContent / CardFooter
37
+ Composable card container. All parts accept `className`.
38
+
39
+ ### Table / TableHeader / TableBody / TableRow / TableHead / TableCell
40
+ Composable table. `Table` wraps in a scrollable container. All parts accept `className`.
41
+
42
+ ### Skeleton
43
+ Props: `className` (use to set width/height for the placeholder shape).
44
+
45
+ ### Dialog
46
+ `"use client"` — Props: `open` (bool), `onClose` (fn), `title` (string), `children`, `className`.
47
+
48
+ ### Toast / useToast
49
+ `"use client"` — `Toast` props: `message`, `variant` ('default' | 'success' | 'error'), `onClose` (fn), `visible` (bool).
50
+ `useToast()` returns `{ show(message, variant?), hide, toastProps }`. Spread `toastProps` onto `<Toast />`.
51
+
52
+ ```javascript
53
+ // Example
54
+ const { show, toastProps } = useToast();
55
+ return (
56
+ <>
57
+ <Button onClick={() => show('Saved!', 'success')}>Save</Button>
58
+ <Toast {...toastProps} />
59
+ </>
60
+ );
61
+ ```
62
+
63
+ ## Design notes
64
+ - Tailwind CSS only. No CSS-in-JS, no external dependencies.
65
+ - All components accept `className` for overrides.
66
+ - Server Component compatible except Dialog and Toast (marked `"use client"`).
67
+ - Accessible: ARIA attributes, focus management on interactive elements.
@@ -2,6 +2,7 @@
2
2
  "name": "@myquark/ui",
3
3
  "version": "1.0.0",
4
4
  "type": "module",
5
+ "main": "src/index.js",
5
6
  "devDependencies": {
6
7
  "@types/react": "^19.2.14",
7
8
  "@types/react-dom": "^19.2.3",
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+
3
+ const base =
4
+ "inline-flex items-center rounded-sm border px-2.5 py-0.5 text-xs font-medium tracking-wide";
5
+
6
+ const THEMES = {
7
+ light: {
8
+ default: "border-gray-200 bg-gray-100 text-gray-800",
9
+ success: "border-green-200 bg-green-100 text-green-800",
10
+ warning: "border-yellow-200 bg-yellow-100 text-yellow-800",
11
+ danger: "border-red-200 bg-red-100 text-red-800",
12
+ info: "border-blue-200 bg-blue-100 text-blue-800",
13
+ },
14
+ dark: {
15
+ default: "border-[#1e2535] bg-[#1e2535]/50 text-[#6b7a99]",
16
+ success: "border-emerald-800/50 bg-emerald-900/20 text-emerald-400",
17
+ warning: "border-yellow-800/50 bg-yellow-900/20 text-yellow-400",
18
+ danger: "border-[#ff4757]/30 bg-[#ff4757]/10 text-[#ff4757]",
19
+ info: "border-[#377dff]/30 bg-[#377dff]/10 text-[#377dff]",
20
+ },
21
+ };
22
+
23
+ export function Badge({
24
+ variant = "default",
25
+ theme = "light",
26
+ className = "",
27
+ ...props
28
+ }) {
29
+ const t = THEMES[theme] ?? THEMES.light;
30
+ const cls = `${base} ${t[variant] ?? t.default} ${className}`.trim();
31
+ return React.createElement("span", { className: cls, ...props });
32
+ }
@@ -0,0 +1,42 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { Badge } from "./badge.js";
4
+
5
+ test("Badge - exports correctly", () => {
6
+ assert(typeof Badge === "function");
7
+ });
8
+
9
+ test("Badge - renders with default props", () => {
10
+ const result = Badge({});
11
+ assert.ok(result);
12
+ });
13
+
14
+ test("Badge - supports default variant", () => {
15
+ const result = Badge({ variant: "default" });
16
+ assert.ok(result);
17
+ });
18
+
19
+ test("Badge - supports success variant", () => {
20
+ const result = Badge({ variant: "success" });
21
+ assert.ok(result);
22
+ });
23
+
24
+ test("Badge - supports warning variant", () => {
25
+ const result = Badge({ variant: "warning" });
26
+ assert.ok(result);
27
+ });
28
+
29
+ test("Badge - supports danger variant", () => {
30
+ const result = Badge({ variant: "danger" });
31
+ assert.ok(result);
32
+ });
33
+
34
+ test("Badge - supports info variant", () => {
35
+ const result = Badge({ variant: "info" });
36
+ assert.ok(result);
37
+ });
38
+
39
+ test("Badge - accepts className override", () => {
40
+ const result = Badge({ className: "custom" });
41
+ assert.ok(result);
42
+ });
@@ -1,19 +1,68 @@
1
1
  import React from "react";
2
2
 
3
- export const Button = ({ variant = "primary", className, ...props }) => {
4
- const baseStyles = "px-4 py-2 rounded-md font-medium transition-colors";
5
- const variants = {
6
- primary: "bg-blue-600 text-white hover:bg-blue-700",
7
- secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
8
- };
3
+ const base =
4
+ "inline-flex items-center justify-center rounded-sm font-medium transition-all duration-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none active:translate-y-px";
9
5
 
10
- return React.createElement(
11
- "button",
12
- {
13
- type: "button",
14
- className: `${baseStyles} ${variants[variant]} ${className || ""}`,
15
- ...props,
16
- },
17
- props.children,
18
- );
6
+ const THEMES = {
7
+ light: {
8
+ primary:
9
+ "bg-blue-600 text-white shadow-sm hover:bg-blue-700 hover:shadow focus-visible:ring-blue-500",
10
+ secondary:
11
+ "border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50 hover:border-gray-300 hover:shadow focus-visible:ring-gray-400",
12
+ danger:
13
+ "bg-red-600 text-white shadow-sm hover:bg-red-700 hover:shadow focus-visible:ring-red-500",
14
+ ghost:
15
+ "bg-transparent text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400",
16
+ success:
17
+ "bg-emerald-600 text-white shadow-sm hover:bg-emerald-700 hover:shadow focus-visible:ring-emerald-500",
18
+ warning:
19
+ "bg-amber-500 text-white shadow-sm hover:bg-amber-600 hover:shadow focus-visible:ring-amber-400",
20
+ info: "bg-cyan-600 text-white shadow-sm hover:bg-cyan-700 hover:shadow focus-visible:ring-cyan-500",
21
+ outline:
22
+ "border border-blue-600 text-blue-600 bg-transparent hover:bg-blue-50 focus-visible:ring-blue-500",
23
+ solid:
24
+ "bg-blue-700 text-white shadow-sm hover:bg-blue-800 hover:shadow focus-visible:ring-blue-600",
25
+ },
26
+ dark: {
27
+ primary:
28
+ "bg-[#377dff]/10 border border-[#377dff]/40 text-[#377dff] hover:bg-[#377dff]/20 hover:border-[#377dff]/80 focus-visible:ring-[#377dff]/40 focus-visible:ring-offset-0",
29
+ secondary:
30
+ "border border-[#1e2535] text-[#6b7a99] hover:border-[#377dff]/30 hover:text-[#e0e0e0] focus-visible:ring-[#377dff]/30 focus-visible:ring-offset-0",
31
+ danger:
32
+ "bg-[#ff4757]/10 border border-[#ff4757]/40 text-[#ff4757] hover:bg-[#ff4757]/20 hover:border-[#ff4757]/80 focus-visible:ring-[#ff4757]/40 focus-visible:ring-offset-0",
33
+ ghost:
34
+ "text-[#4a4a6a] hover:bg-[#1e2535] hover:text-[#e0e0e0] focus-visible:ring-[#377dff]/30 focus-visible:ring-offset-0",
35
+ success:
36
+ "bg-emerald-500/10 border border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/80 focus-visible:ring-emerald-400/40 focus-visible:ring-offset-0",
37
+ warning:
38
+ "bg-amber-500/10 border border-amber-400/40 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400/80 focus-visible:ring-amber-400/40 focus-visible:ring-offset-0",
39
+ info: "bg-cyan-500/10 border border-cyan-400/40 text-cyan-400 hover:bg-cyan-500/20 hover:border-cyan-400/80 focus-visible:ring-cyan-400/40 focus-visible:ring-offset-0",
40
+ outline:
41
+ "border border-[#377dff]/60 text-[#377dff] bg-transparent hover:bg-[#377dff]/10 focus-visible:ring-[#377dff]/40 focus-visible:ring-offset-0",
42
+ solid:
43
+ "bg-[#377dff] text-white hover:bg-[#2563eb] focus-visible:ring-[#377dff]/60 focus-visible:ring-offset-0",
44
+ },
19
45
  };
46
+
47
+ const sizes = {
48
+ sm: "h-8 px-3 text-sm",
49
+ md: "h-10 px-4 text-sm",
50
+ lg: "h-11 px-6 text-base",
51
+ };
52
+
53
+ export function Button({
54
+ variant = "primary",
55
+ size = "md",
56
+ theme = "light",
57
+ className = "",
58
+ ...props
59
+ }) {
60
+ const t = THEMES[theme] ?? THEMES.light;
61
+ const cls =
62
+ `${base} ${t[variant] ?? t.primary} ${sizes[size] ?? sizes.md} ${className}`.trim();
63
+ return React.createElement("button", {
64
+ type: "button",
65
+ className: cls,
66
+ ...props,
67
+ });
68
+ }