@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,69 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import {
4
+ Table,
5
+ TableBody,
6
+ TableCell,
7
+ TableHead,
8
+ TableHeader,
9
+ TableRow,
10
+ } from "./table.js";
11
+
12
+ test("Table - exports correctly", () => {
13
+ assert(typeof Table === "function");
14
+ });
15
+
16
+ test("TableHeader - exports correctly", () => {
17
+ assert(typeof TableHeader === "function");
18
+ });
19
+
20
+ test("TableBody - exports correctly", () => {
21
+ assert(typeof TableBody === "function");
22
+ });
23
+
24
+ test("TableRow - exports correctly", () => {
25
+ assert(typeof TableRow === "function");
26
+ });
27
+
28
+ test("TableHead - exports correctly", () => {
29
+ assert(typeof TableHead === "function");
30
+ });
31
+
32
+ test("TableCell - exports correctly", () => {
33
+ assert(typeof TableCell === "function");
34
+ });
35
+
36
+ test("Table - renders with default props", () => {
37
+ const result = Table({});
38
+ assert.ok(result);
39
+ });
40
+
41
+ test("TableHeader - renders with default props", () => {
42
+ const result = TableHeader({});
43
+ assert.ok(result);
44
+ });
45
+
46
+ test("TableBody - renders with default props", () => {
47
+ const result = TableBody({});
48
+ assert.ok(result);
49
+ });
50
+
51
+ test("TableRow - renders with default props", () => {
52
+ const result = TableRow({});
53
+ assert.ok(result);
54
+ });
55
+
56
+ test("TableHead - renders with default props", () => {
57
+ const result = TableHead({});
58
+ assert.ok(result);
59
+ });
60
+
61
+ test("TableCell - renders with default props", () => {
62
+ const result = TableCell({});
63
+ assert.ok(result);
64
+ });
65
+
66
+ test("Table - accepts className override", () => {
67
+ const result = Table({ className: "custom" });
68
+ assert.ok(result);
69
+ });
@@ -0,0 +1,15 @@
1
+ import React from "react";
2
+
3
+ const THEMES = {
4
+ light:
5
+ "block w-full rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-all duration-200 hover:border-gray-400 focus-visible:border-blue-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/30 disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed resize-y",
6
+ dark: "block w-full rounded-sm border border-[#1e2535] bg-[#090d14] px-3 py-2 text-sm text-[#e0e0e0] placeholder-[#2d3a52] font-mono transition-all duration-200 hover:border-[#377dff]/30 focus-visible:border-[#377dff]/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#377dff]/15 disabled:opacity-30 disabled:cursor-not-allowed resize-y",
7
+ };
8
+
9
+ export function Textarea({ theme = "light", className = "", ...props }) {
10
+ const base = THEMES[theme] ?? THEMES.light;
11
+ return React.createElement("textarea", {
12
+ className: `${base} ${className}`.trim(),
13
+ ...props,
14
+ });
15
+ }
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert";
2
+ import { test } from "node:test";
3
+ import { Textarea } from "./textarea.js";
4
+
5
+ test("Textarea - exports correctly", () => {
6
+ assert(typeof Textarea === "function");
7
+ });
8
+
9
+ test("Textarea - renders with default props", () => {
10
+ const result = Textarea({});
11
+ assert.ok(result);
12
+ });
13
+
14
+ test("Textarea - accepts className override", () => {
15
+ const result = Textarea({ className: "h-32" });
16
+ assert.ok(result);
17
+ });
18
+
19
+ test("Textarea - accepts rows prop", () => {
20
+ const result = Textarea({ rows: 4, placeholder: "Enter text..." });
21
+ assert.ok(result);
22
+ });
23
+
24
+ test("Textarea - accepts disabled prop", () => {
25
+ const result = Textarea({ disabled: true });
26
+ assert.ok(result);
27
+ });
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Theme system constants for @techstream/quark-ui.
3
+ *
4
+ * These three values form the contract between ThemeProvider and any
5
+ * out-of-tree component (e.g. HomeThemeToggle in apps/web) that interoperates
6
+ * with it via the shared localStorage key, HTML attribute, and DOM event.
7
+ *
8
+ * An identical copy lives in apps/web/src/lib/theme.js — each package owns
9
+ * its own copy so there is no cross-package import. If you rename any of
10
+ * these, update both files and the CSS selectors in globals.css.
11
+ */
12
+
13
+ /** Key used to persist the user's explicit theme choice in localStorage. */
14
+ export const THEME_STORAGE_KEY = "quark-theme";
15
+
16
+ /**
17
+ * Attribute set on <html> so CSS custom properties can react to JS state.
18
+ * Use via element.setAttribute(THEME_ATTR, value) / element.getAttribute(THEME_ATTR).
19
+ * Referenced in globals.css as [data-theme="light"] / [data-theme="dark"].
20
+ */
21
+ export const THEME_ATTR = "data-theme";
22
+
23
+ /** CustomEvent name dispatched when the theme changes outside a ThemeProvider. */
24
+ export const THEME_CHANGE_EVENT = "quark-theme-change";
@@ -0,0 +1,132 @@
1
+ "use client";
2
+ import React, { createContext, useContext, useEffect, useState } from "react";
3
+ import {
4
+ THEME_ATTR,
5
+ THEME_CHANGE_EVENT,
6
+ THEME_STORAGE_KEY,
7
+ } from "./theme-constants.js";
8
+
9
+ const ThemeCtx = createContext({ theme: "dark", setTheme: () => {} });
10
+
11
+ /**
12
+ * Wraps a subtree with a shared theme value.
13
+ *
14
+ * Behaviour:
15
+ * - If `defaultTheme` is provided it is used as the initial value (good for
16
+ * pages that have a deliberate starting theme, e.g. the playground).
17
+ * - If `defaultTheme` is omitted, the initial value is derived from the OS
18
+ * `prefers-color-scheme` media query on first mount.
19
+ * - In both cases the user's explicit toggle choice is persisted to
20
+ * `localStorage` under the key `quark-theme` and restored on subsequent
21
+ * visits so their preference is remembered across sessions.
22
+ *
23
+ * @param {{ defaultTheme?: 'light' | 'dark', children: React.ReactNode }} props
24
+ */
25
+ export function ThemeProvider({ defaultTheme, children }) {
26
+ // Lazy initialiser — runs synchronously on the client before first paint,
27
+ // so the correct theme is in place from frame 0 (no flash).
28
+ // On the server `window` is undefined, so we fall back to defaultTheme ?? 'dark'.
29
+ const [theme, setTheme] = useState(() => {
30
+ if (typeof window === "undefined") return defaultTheme ?? "dark";
31
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
32
+ if (stored === "light" || stored === "dark") return stored;
33
+ if (defaultTheme != null) return defaultTheme;
34
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
35
+ ? "dark"
36
+ : "light";
37
+ });
38
+
39
+ // Keep state in sync when the OS colour scheme changes (only relevant when
40
+ // no defaultTheme was provided and the user has no stored preference).
41
+ useEffect(() => {
42
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
43
+ if (stored || defaultTheme != null) return;
44
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
45
+ const onChange = (e) => setTheme(e.matches ? "dark" : "light");
46
+ mq.addEventListener("change", onChange);
47
+ return () => mq.removeEventListener("change", onChange);
48
+ }, [defaultTheme]);
49
+
50
+ // Listen for toggles fired by HomeThemeToggle (or any other out-of-tree
51
+ // component) so React context stays in sync without any import coupling.
52
+ useEffect(() => {
53
+ function onExternalChange(e) {
54
+ const t = e.detail?.theme;
55
+ if (t === "light" || t === "dark") setTheme(t);
56
+ }
57
+ document.addEventListener(THEME_CHANGE_EVENT, onExternalChange);
58
+ return () =>
59
+ document.removeEventListener(THEME_CHANGE_EVENT, onExternalChange);
60
+ }, []);
61
+
62
+ function persistSetTheme(t) {
63
+ localStorage.setItem(THEME_STORAGE_KEY, t);
64
+ document.documentElement.setAttribute(THEME_ATTR, t);
65
+ setTheme(t);
66
+ }
67
+
68
+ return React.createElement(
69
+ ThemeCtx.Provider,
70
+ { value: { theme, setTheme: persistSetTheme } },
71
+ children,
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Returns the current theme and a setter from the nearest ThemeProvider.
77
+ * Falls back to `{ theme: 'light' }` when used outside a provider.
78
+ *
79
+ * @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
80
+ */
81
+ export function useTheme() {
82
+ return useContext(ThemeCtx);
83
+ }
84
+
85
+ /**
86
+ * Slide-pill toggle that switches between light and dark themes.
87
+ * 32×18 px track with a 12 px sliding knob. Pure inline styles — no
88
+ * Tailwind dependency. Must be rendered inside a ThemeProvider.
89
+ */
90
+ export function ThemeToggle({ className = "", style = {} }) {
91
+ const { theme, setTheme } = useTheme();
92
+ const isDark = theme === "dark";
93
+
94
+ return React.createElement(
95
+ "button",
96
+ {
97
+ type: "button",
98
+ onClick: () => setTheme(isDark ? "light" : "dark"),
99
+ "aria-label": `Switch to ${isDark ? "light" : "dark"} theme`,
100
+ "aria-pressed": isDark,
101
+ className: className || undefined,
102
+ style: {
103
+ display: "inline-flex",
104
+ alignItems: "center",
105
+ width: "32px",
106
+ height: "18px",
107
+ borderRadius: "9px",
108
+ border: `1px solid ${isDark ? "#1e2d45" : "#d1d5db"}`,
109
+ background: isDark ? "#0d1420" : "#e5e7eb",
110
+ cursor: "pointer",
111
+ padding: "2px",
112
+ transition: "background 0.2s ease, border-color 0.2s ease",
113
+ outline: "none",
114
+ flexShrink: 0,
115
+ ...style,
116
+ },
117
+ },
118
+ React.createElement("span", {
119
+ "aria-hidden": "true",
120
+ style: {
121
+ display: "block",
122
+ width: "12px",
123
+ height: "12px",
124
+ borderRadius: "50%",
125
+ background: isDark ? "#377dff" : "#9ca3af",
126
+ transform: isDark ? "translateX(14px)" : "translateX(0)",
127
+ transition: "transform 0.2s ease, background 0.2s ease",
128
+ flexShrink: 0,
129
+ },
130
+ }),
131
+ );
132
+ }
@@ -0,0 +1,229 @@
1
+ "use client";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+
4
+ /**
5
+ * Toast — fixed bottom-right notification with auto-dismiss countdown.
6
+ *
7
+ * Props:
8
+ * message (string) — notification text
9
+ * variant ('default'|'success'|'error') — colour scheme
10
+ * onClose (fn) — called when dismissed / expired
11
+ * visible (bool) — controlled visibility
12
+ * duration (number) — ms before auto-dismiss (default 4000)
13
+ * theme (string) — 'light' (default) | 'dark'
14
+ * toastId (number) — incremented by useToast on each show()
15
+ * so the timer resets correctly
16
+ *
17
+ * Features:
18
+ * • SVG circle progress shows remaining time
19
+ * • Hovering pauses the timer and replaces the circle with a × close button
20
+ */
21
+
22
+ const RADIUS = 9;
23
+ const CIRC = 2 * Math.PI * RADIUS; // ≈ 56.55
24
+
25
+ const THEMES = {
26
+ light: {
27
+ default: {
28
+ cls: "border border-gray-700 bg-gray-900 text-white",
29
+ track: "rgba(255,255,255,0.2)",
30
+ progress: "rgba(255,255,255,0.85)",
31
+ },
32
+ success: {
33
+ cls: "border border-green-500 bg-green-600 text-white",
34
+ track: "rgba(255,255,255,0.2)",
35
+ progress: "rgba(255,255,255,0.85)",
36
+ },
37
+ error: {
38
+ cls: "border border-red-500 bg-red-600 text-white",
39
+ track: "rgba(255,255,255,0.2)",
40
+ progress: "rgba(255,255,255,0.85)",
41
+ },
42
+ },
43
+ dark: {
44
+ default: {
45
+ cls: "border border-[#1e2535] bg-[#090d14] text-[#e0e0e0]",
46
+ track: "rgba(55,125,255,0.12)",
47
+ progress: "rgba(55,125,255,0.7)",
48
+ },
49
+ success: {
50
+ cls: "border border-emerald-800/50 bg-[#090d14] text-emerald-400",
51
+ track: "rgba(52,211,153,0.12)",
52
+ progress: "rgba(52,211,153,0.7)",
53
+ },
54
+ error: {
55
+ cls: "border border-[#ff4757]/40 bg-[#090d14] text-[#ff4757]",
56
+ track: "rgba(255,71,87,0.12)",
57
+ progress: "rgba(255,71,87,0.7)",
58
+ },
59
+ },
60
+ };
61
+
62
+ export function Toast({
63
+ message,
64
+ variant = "default",
65
+ onClose,
66
+ visible = true,
67
+ duration = 4000,
68
+ theme = "light",
69
+ _toastId,
70
+ }) {
71
+ const [progress, setProgress] = useState(100); // 100 → 0 as time elapses
72
+ const [hovered, setHovered] = useState(false);
73
+
74
+ const themeMap = THEMES[theme] ?? THEMES.light;
75
+ const v = themeMap[variant] ?? themeMap.default;
76
+
77
+ // Mutable refs so the interval callback always reads fresh values
78
+ const remainingRef = useRef(duration);
79
+ const startRef = useRef(null);
80
+ const intervalRef = useRef(null);
81
+
82
+ const stop = useCallback(() => clearInterval(intervalRef.current), []);
83
+
84
+ const start = useCallback(() => {
85
+ stop();
86
+ startRef.current = Date.now();
87
+ intervalRef.current = setInterval(() => {
88
+ const elapsed = Date.now() - startRef.current;
89
+ const pct = Math.max(0, 100 - (elapsed / remainingRef.current) * 100);
90
+ setProgress(pct);
91
+ if (pct <= 0) {
92
+ stop();
93
+ onClose?.();
94
+ }
95
+ }, 16);
96
+ }, [stop, onClose]);
97
+
98
+ // Reset when toast becomes visible or a new toast is shown (toastId changes)
99
+ useEffect(() => {
100
+ if (!visible) {
101
+ stop();
102
+ setProgress(100);
103
+ return;
104
+ }
105
+ remainingRef.current = duration;
106
+ setProgress(100);
107
+ setHovered(false);
108
+ start();
109
+ return stop;
110
+ }, [visible, duration, start, stop]); // eslint-disable-line react-hooks/exhaustive-deps
111
+
112
+ // Pause on hover, RESET on leave (restart from full duration)
113
+ useEffect(() => {
114
+ if (!visible) return;
115
+ if (hovered) {
116
+ stop();
117
+ } else {
118
+ // Reset to full duration on every mouse-leave
119
+ remainingRef.current = duration;
120
+ start();
121
+ }
122
+ }, [hovered, duration, start, stop, visible]); // eslint-disable-line react-hooks/exhaustive-deps
123
+
124
+ if (!visible) return null;
125
+
126
+ const dashOffset = CIRC * (1 - progress / 100);
127
+
128
+ return React.createElement(
129
+ "div",
130
+ {
131
+ role: "alert",
132
+ "aria-live": "assertive",
133
+ onMouseEnter: () => setHovered(true),
134
+ onMouseLeave: () => setHovered(false),
135
+ className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded px-4 py-3 text-sm shadow-xl transition-all duration-200 ${v.cls}`,
136
+ },
137
+ React.createElement("span", null, message),
138
+ // Trailing indicator: × when hovered, progress circle otherwise
139
+ React.createElement(
140
+ "div",
141
+ {
142
+ className:
143
+ "relative ml-2 flex h-6 w-6 shrink-0 items-center justify-center",
144
+ },
145
+ hovered
146
+ ? React.createElement(
147
+ "button",
148
+ {
149
+ type: "button",
150
+ "aria-label": "Dismiss notification",
151
+ onClick: onClose,
152
+ className:
153
+ "flex h-6 w-6 cursor-pointer items-center justify-center rounded-sm text-base leading-none opacity-80 transition-all hover:bg-white/15 hover:opacity-100 active:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60",
154
+ },
155
+ "\u00d7",
156
+ )
157
+ : React.createElement(
158
+ "svg",
159
+ {
160
+ width: 24,
161
+ height: 24,
162
+ viewBox: "0 0 24 24",
163
+ "aria-hidden": "true",
164
+ },
165
+ // Track ring
166
+ React.createElement("circle", {
167
+ cx: 12,
168
+ cy: 12,
169
+ r: RADIUS,
170
+ fill: "none",
171
+ stroke: v.track,
172
+ strokeWidth: 2,
173
+ }),
174
+ // Progress arc
175
+ React.createElement("circle", {
176
+ cx: 12,
177
+ cy: 12,
178
+ r: RADIUS,
179
+ fill: "none",
180
+ stroke: v.progress,
181
+ strokeWidth: 2,
182
+ strokeLinecap: "round",
183
+ strokeDasharray: CIRC,
184
+ strokeDashoffset: dashOffset,
185
+ style: { transform: "rotate(-90deg)", transformOrigin: "center" },
186
+ }),
187
+ ),
188
+ ),
189
+ );
190
+ }
191
+
192
+ /**
193
+ * useToast — manages toast state and exposes a stable show() / hide() API.
194
+ *
195
+ * Usage:
196
+ * const { show, hide, toastProps } = useToast();
197
+ * <Toast {...toastProps} />
198
+ *
199
+ * show("Saved!", "success");
200
+ * show("Oops", "error", 6000); // custom duration
201
+ */
202
+ export function useToast() {
203
+ const [state, setState] = useState({
204
+ visible: false,
205
+ message: "",
206
+ variant: "default",
207
+ duration: 4000,
208
+ toastId: 0,
209
+ });
210
+
211
+ const show = useCallback(
212
+ (message, variant = "default", duration = 4000) =>
213
+ setState((s) => ({
214
+ visible: true,
215
+ message,
216
+ variant,
217
+ duration,
218
+ toastId: s.toastId + 1,
219
+ })),
220
+ [],
221
+ );
222
+
223
+ const hide = useCallback(
224
+ () => setState((s) => ({ ...s, visible: false })),
225
+ [],
226
+ );
227
+
228
+ return { show, hide, toastProps: { ...state, onClose: hide } };
229
+ }
@@ -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.69.3"
23
+ "bullmq": "^5.70.4"
24
24
  },
25
25
  "devDependencies": {
26
- "@types/node": "^25.2.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
  }