@techstream/quark-create-app 1.9.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 (74) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/src/index.js +376 -143
  4. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  5. package/templates/base-project/.github/copilot-instructions.md +55 -0
  6. package/templates/base-project/CLAUDE.md +273 -0
  7. package/templates/base-project/README.md +72 -30
  8. package/templates/base-project/apps/web/next.config.js +5 -1
  9. package/templates/base-project/apps/web/package.json +3 -3
  10. package/templates/base-project/apps/web/public/quark.svg +46 -0
  11. package/templates/base-project/apps/web/railway.json +2 -2
  12. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  13. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  14. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  15. package/templates/base-project/apps/web/src/app/api/health/route.js +28 -17
  16. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  17. package/templates/base-project/apps/web/src/app/global-error.js +53 -0
  18. package/templates/base-project/apps/web/src/app/globals.css +121 -15
  19. package/templates/base-project/apps/web/src/app/icon.svg +46 -0
  20. package/templates/base-project/apps/web/src/app/layout.js +1 -0
  21. package/templates/base-project/apps/web/src/app/not-found.js +35 -0
  22. package/templates/base-project/apps/web/src/app/page.js +38 -5
  23. package/templates/base-project/apps/web/src/lib/theme.js +23 -0
  24. package/templates/base-project/apps/web/src/proxy.js +10 -2
  25. package/templates/base-project/package.json +2 -0
  26. package/templates/base-project/packages/db/src/client.js +6 -1
  27. package/templates/base-project/packages/db/src/index.js +1 -0
  28. package/templates/base-project/packages/db/src/ping.js +66 -0
  29. package/templates/base-project/scripts/doctor.js +261 -0
  30. package/templates/base-project/turbo.json +2 -1
  31. package/templates/config/package.json +1 -0
  32. package/templates/jobs/package.json +2 -1
  33. package/templates/ui/README.md +67 -0
  34. package/templates/ui/package.json +1 -0
  35. package/templates/ui/src/badge.js +32 -0
  36. package/templates/ui/src/badge.test.js +42 -0
  37. package/templates/ui/src/button.js +64 -15
  38. package/templates/ui/src/button.test.js +34 -5
  39. package/templates/ui/src/card.js +58 -0
  40. package/templates/ui/src/card.test.js +59 -0
  41. package/templates/ui/src/checkbox.js +35 -0
  42. package/templates/ui/src/checkbox.test.js +35 -0
  43. package/templates/ui/src/dialog.js +139 -0
  44. package/templates/ui/src/dialog.test.js +15 -0
  45. package/templates/ui/src/index.js +16 -0
  46. package/templates/ui/src/input.js +15 -0
  47. package/templates/ui/src/input.test.js +27 -0
  48. package/templates/ui/src/label.js +14 -0
  49. package/templates/ui/src/label.test.js +22 -0
  50. package/templates/ui/src/select.js +42 -0
  51. package/templates/ui/src/select.test.js +27 -0
  52. package/templates/ui/src/skeleton.js +14 -0
  53. package/templates/ui/src/skeleton.test.js +22 -0
  54. package/templates/ui/src/table.js +75 -0
  55. package/templates/ui/src/table.test.js +69 -0
  56. package/templates/ui/src/textarea.js +15 -0
  57. package/templates/ui/src/textarea.test.js +27 -0
  58. package/templates/ui/src/theme-constants.js +24 -0
  59. package/templates/ui/src/theme.js +132 -0
  60. package/templates/ui/src/toast.js +229 -0
  61. package/templates/ui/src/toast.test.js +23 -0
  62. package/templates/{base-project/apps/worker → worker}/package.json +2 -2
  63. package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
  64. package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
  65. package/templates/base-project/apps/web/public/file.svg +0 -1
  66. package/templates/base-project/apps/web/public/globe.svg +0 -1
  67. package/templates/base-project/apps/web/public/next.svg +0 -1
  68. package/templates/base-project/apps/web/public/vercel.svg +0 -1
  69. package/templates/base-project/apps/web/public/window.svg +0 -1
  70. /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
  71. /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
  72. /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
  73. /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
  74. /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
@@ -0,0 +1,23 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { Toast, useToast } from "./toast.js";
4
+
5
+ // Toast uses React hooks (useState, useEffect, useRef) and must be rendered
6
+ // via a React renderer. Direct invocation in node --test is not supported.
7
+ // Tests here verify the module exports correct types only.
8
+
9
+ test("Toast - exports correctly", () => {
10
+ assert.strictEqual(typeof Toast, "function");
11
+ });
12
+
13
+ test("Toast - has expected function name", () => {
14
+ assert.strictEqual(Toast.name, "Toast");
15
+ });
16
+
17
+ test("useToast - exports correctly", () => {
18
+ assert.strictEqual(typeof useToast, "function");
19
+ });
20
+
21
+ test("useToast - has expected function name", () => {
22
+ assert.strictEqual(useToast.name, "useToast");
23
+ });
@@ -20,10 +20,10 @@
20
20
  "@techstream/quark-core": "^1.0.0",
21
21
  "@techstream/quark-db": "workspace:*",
22
22
  "@techstream/quark-jobs": "workspace:*",
23
- "bullmq": "^5.70.2"
23
+ "bullmq": "^5.70.4"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/node": "^25.3.3",
26
+ "@types/node": "^25.3.5",
27
27
  "tsx": "^4.21.0"
28
28
  }
29
29
  }
@@ -9,15 +9,20 @@ import {
9
9
  createLogger,
10
10
  createQueue,
11
11
  createWorker,
12
+ getRedisUrl,
12
13
  } from "@techstream/quark-core";
13
14
  import { prisma } from "@techstream/quark-db";
14
15
  import { JOB_NAMES, JOB_QUEUES } from "@techstream/quark-jobs";
15
16
  import { jobHandlers } from "./handlers/index.js";
16
17
 
17
18
  const logger = createLogger("worker");
19
+ const isDevMode =
20
+ process.env.NODE_ENV !== "production" &&
21
+ process.env.npm_lifecycle_event === "dev";
18
22
 
19
23
  // Store workers for graceful shutdown
20
24
  const workers = [];
25
+ let devDisabledKeepAlive = null;
21
26
  let isShuttingDown = false;
22
27
 
23
28
  // ============================================================================
@@ -40,6 +45,7 @@ export function isConnectionError(error) {
40
45
  "EHOSTUNREACH", // Host unreachable
41
46
  "ENETUNREACH", // Network unreachable
42
47
  "Error: Redis connection failed", // Generic redis failure
48
+ "Redis unavailable at", // Final wrapped startup error
43
49
  "Ready status is false", // BullMQ readiness
44
50
  ];
45
51
  return connectionErrors.some((err) => message.includes(err));
@@ -62,10 +68,8 @@ export function throttledError(logger, windowMs = 5000) {
62
68
 
63
69
  // Log if new error type or window expired
64
70
  if (msg !== lastErrorMsg || now - lastErrorTime > windowMs) {
65
- logger.error(`Connection error (will retry)`, {
66
- error: msg,
67
- code: error.code,
68
- timestamp: new Date().toISOString(),
71
+ logger.warn("Waiting for Redis", {
72
+ reason: msg,
69
73
  });
70
74
  lastErrorTime = now;
71
75
  lastErrorMsg = msg;
@@ -73,6 +77,14 @@ export function throttledError(logger, windowMs = 5000) {
73
77
  };
74
78
  }
75
79
 
80
+ function disableWorkerInDev() {
81
+ if (!devDisabledKeepAlive) {
82
+ // Keep the process alive so the dev session stays healthy even when the
83
+ // worker is intentionally disabled due to missing Redis.
84
+ devDisabledKeepAlive = setInterval(() => {}, 60_000);
85
+ }
86
+ }
87
+
76
88
  /**
77
89
  * Waits for Redis to be ready with retries
78
90
  * @param {Function} healthCheck - Async function that returns boolean or throws
@@ -91,7 +103,6 @@ export async function waitForRedis(
91
103
  } = config;
92
104
 
93
105
  const reportThrottledError = throttledError(logger, 3000);
94
- let lastError;
95
106
 
96
107
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
97
108
  try {
@@ -103,30 +114,20 @@ export async function waitForRedis(
103
114
  return true;
104
115
  }
105
116
  } catch (error) {
106
- lastError = error;
107
117
  if (isConnectionError(error)) {
108
118
  reportThrottledError(error);
109
119
  if (attempt < maxRetries) {
110
- // Wait before retrying
111
120
  await new Promise((resolve) => setTimeout(resolve, intervalMs));
112
121
  }
113
122
  } else {
114
- // Non-connection error; don't retry
115
- logger.error("Health check failed with non-network error", {
116
- error: error.message,
117
- });
118
- throw error;
123
+ throw new Error(`Redis health check failed: ${error.message}`);
119
124
  }
120
125
  }
121
126
  }
122
127
 
123
128
  // All retries exhausted
124
- logger.error("Redis health check failed after all retries", {
125
- attempts: maxRetries,
126
- lastError: lastError?.message,
127
- });
128
129
  throw new Error(
129
- `Failed to connect to Redis after ${maxRetries} attempts: ${lastError?.message}`,
130
+ `Redis unavailable at ${getRedisUrl()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
130
131
  );
131
132
  }
132
133
 
@@ -148,10 +149,7 @@ async function preflight() {
148
149
  try {
149
150
  // Check Redis
150
151
  logger.info("Checking Redis connectivity...");
151
- const redisReady = await checkQueueHealth();
152
- if (!redisReady) {
153
- throw new Error("Redis health check returned false");
154
- }
152
+ await checkQueueHealth();
155
153
  logger.info("✓ Redis connected");
156
154
 
157
155
  // Check Database
@@ -256,8 +254,12 @@ async function startWorker() {
256
254
  try {
257
255
  // Pre-flight: Wait for Redis with health checks and retries
258
256
  logger.info("Performing health checks...");
259
- await waitForRedis();
257
+ await waitForRedis(
258
+ checkQueueHealth,
259
+ isDevMode ? { maxRetries: 3, intervalMs: 500 } : {},
260
+ );
260
261
 
262
+ logger.info("Redis connected", { address: getRedisUrl() });
261
263
  // Register a worker for each queue
262
264
  for (const queueName of Object.values(JOB_QUEUES)) {
263
265
  createQueueWorker(queueName);
@@ -276,9 +278,17 @@ async function startWorker() {
276
278
 
277
279
  logger.info("Worker service ready");
278
280
  } catch (error) {
281
+ if (isDevMode && isConnectionError(error)) {
282
+ logger.warn("Redis unavailable — worker disabled in dev", {
283
+ action:
284
+ "Start Redis and restart the worker when background jobs are needed.",
285
+ });
286
+ disableWorkerInDev();
287
+ return;
288
+ }
289
+
279
290
  logger.error("Failed to start worker service", {
280
291
  error: error.message,
281
- stack: error.stack,
282
292
  });
283
293
  process.exit(1);
284
294
  }
@@ -297,6 +307,11 @@ async function shutdown(signal = "unknown") {
297
307
  logger.info("Shutting down worker service", { signal });
298
308
 
299
309
  try {
310
+ if (devDisabledKeepAlive) {
311
+ clearInterval(devDisabledKeepAlive);
312
+ devDisabledKeepAlive = null;
313
+ }
314
+
300
315
  for (const worker of workers) {
301
316
  await worker.close();
302
317
  }
@@ -362,9 +362,9 @@ describe("throttledError", () => {
362
362
 
363
363
  throttle(new Error("Redis unavailable"));
364
364
 
365
- assert.strictEqual(logger.error.mock.callCount(), 1);
366
- const call = logger.error.mock.calls[0];
367
- assert.ok(call.arguments[0].includes("Connection error"));
365
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
366
+ const call = logger.warn.mock.calls[0];
367
+ assert.ok(call.arguments[0].includes("Waiting for Redis"));
368
368
  });
369
369
 
370
370
  test("suppresses duplicate errors within window", () => {
@@ -372,15 +372,15 @@ describe("throttledError", () => {
372
372
  const throttle = throttledError(logger, 100);
373
373
 
374
374
  throttle(new Error("Redis unavailable"));
375
- assert.strictEqual(logger.error.mock.callCount(), 1);
375
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
376
376
 
377
377
  // Same error within window — should be suppressed
378
378
  throttle(new Error("Redis unavailable"));
379
- assert.strictEqual(logger.error.mock.callCount(), 1);
379
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
380
380
 
381
381
  // Different error within window — should log
382
382
  throttle(new Error("Redis timeout"));
383
- assert.strictEqual(logger.error.mock.callCount(), 2);
383
+ assert.strictEqual(logger.warn.mock.callCount(), 2);
384
384
  });
385
385
 
386
386
  test("logs error again after window expires", async () => {
@@ -388,18 +388,18 @@ describe("throttledError", () => {
388
388
  const throttle = throttledError(logger, 50); // 50ms window
389
389
 
390
390
  throttle(new Error("Redis unavailable"));
391
- assert.strictEqual(logger.error.mock.callCount(), 1);
391
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
392
392
 
393
393
  // Same error within window — suppressed
394
394
  throttle(new Error("Redis unavailable"));
395
- assert.strictEqual(logger.error.mock.callCount(), 1);
395
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
396
396
 
397
397
  // Wait for window to expire
398
398
  await new Promise((resolve) => setTimeout(resolve, 60));
399
399
 
400
400
  // Same error after window — logged again
401
401
  throttle(new Error("Redis unavailable"));
402
- assert.strictEqual(logger.error.mock.callCount(), 2);
402
+ assert.strictEqual(logger.warn.mock.callCount(), 2);
403
403
  });
404
404
 
405
405
  test("includes error details in log", () => {
@@ -410,11 +410,10 @@ describe("throttledError", () => {
410
410
  error.code = "ECONNREFUSED";
411
411
  throttle(error);
412
412
 
413
- const call = logger.error.mock.calls[0];
413
+ const call = logger.warn.mock.calls[0];
414
414
  const args = call.arguments;
415
- assert.strictEqual(args[0], "Connection error (will retry)");
416
- assert.ok(args[1].error.includes("ECONNREFUSED"));
417
- assert.ok(args[1].timestamp); // Should have timestamp
415
+ assert.strictEqual(args[0], "Waiting for Redis");
416
+ assert.ok(args[1].reason.includes("ECONNREFUSED"));
418
417
  });
419
418
 
420
419
  test("uses default 5 second window if not specified", async () => {
@@ -422,14 +421,14 @@ describe("throttledError", () => {
422
421
  const throttle = throttledError(logger); // No window specified
423
422
 
424
423
  throttle(new Error("Test"));
425
- assert.strictEqual(logger.error.mock.callCount(), 1);
424
+ assert.strictEqual(logger.warn.mock.callCount(), 1);
426
425
 
427
426
  throttle(new Error("Test"));
428
- assert.strictEqual(logger.error.mock.callCount(), 1); // Suppressed
427
+ assert.strictEqual(logger.warn.mock.callCount(), 1); // Suppressed
429
428
 
430
429
  // 5 second default window hasn't expired
431
430
  throttle(new Error("Test"));
432
- assert.strictEqual(logger.error.mock.callCount(), 1); // Still suppressed
431
+ assert.strictEqual(logger.warn.mock.callCount(), 1); // Still suppressed
433
432
  });
434
433
  });
435
434
 
@@ -479,7 +478,7 @@ describe("waitForRedis", () => {
479
478
  maxRetries: 2,
480
479
  intervalMs: 10,
481
480
  }),
482
- { message: /Failed to connect to Redis after 2 attempts/ },
481
+ { message: /Redis unavailable at .+ after 2 attempts/ },
483
482
  );
484
483
 
485
484
  assert.strictEqual(healthCheck.mock.callCount(), 2);
@@ -496,7 +495,7 @@ describe("waitForRedis", () => {
496
495
  maxRetries: 5,
497
496
  intervalMs: 10,
498
497
  }),
499
- { message: "Invalid configuration" },
498
+ { message: "Redis health check failed: Invalid configuration" },
500
499
  );
501
500
 
502
501
  // Should fail immediately, not retry 5 times
@@ -514,7 +513,7 @@ describe("waitForRedis", () => {
514
513
  maxRetries: 4,
515
514
  intervalMs: 10,
516
515
  }),
517
- /Failed to connect to Redis after 4 attempts/,
516
+ /Redis unavailable at .+ after 4 attempts/,
518
517
  );
519
518
 
520
519
  assert.strictEqual(healthCheck.mock.callCount(), 4);
@@ -565,7 +564,7 @@ describe("waitForRedis", () => {
565
564
  // Call without explicit config — should use env defaults
566
565
  await assert.rejects(
567
566
  () => waitForRedis(healthCheck),
568
- /Failed to connect to Redis after 2 attempts/,
567
+ /Redis unavailable at .+ after 2 attempts/,
569
568
  );
570
569
 
571
570
  assert.strictEqual(healthCheck.mock.callCount(), 2);
@@ -1 +0,0 @@
1
- <svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
@@ -1 +0,0 @@
1
- <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
@@ -1 +0,0 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
@@ -1 +0,0 @@
1
- <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
@@ -1 +0,0 @@
1
- <svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>