@usequark/quark-create-app 1.24.2 → 1.25.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/package.json +1 -1
- package/src/index.js +17 -2
- package/templates/base-project/apps/web/src/app/api/health/route.js +200 -97
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +20 -0
- package/templates/base-project/apps/web/src/proxy.js +6 -4
- package/templates/base-project/scripts/check-standards.mjs +43 -0
- package/templates/base-project/scripts/run-tests.mjs +5 -2
- package/templates/ui/src/badge.js +2 -2
- package/templates/ui/src/button.js +1 -1
- package/templates/ui/src/card.js +7 -7
- package/templates/ui/src/checkbox.js +2 -2
- package/templates/ui/src/container.js +1 -1
- package/templates/ui/src/dialog.js +5 -5
- package/templates/ui/src/error-banner.js +1 -1
- package/templates/ui/src/footer.js +9 -9
- package/templates/ui/src/form-field.js +1 -1
- package/templates/ui/src/input.js +2 -2
- package/templates/ui/src/label.js +1 -1
- package/templates/ui/src/lightbox.js +6 -6
- package/templates/ui/src/navbar.js +19 -19
- package/templates/ui/src/password-input.js +1 -1
- package/templates/ui/src/rich-text.js +8 -8
- package/templates/ui/src/select.js +10 -10
- package/templates/ui/src/skeleton.js +1 -1
- package/templates/ui/src/table.js +7 -7
- package/templates/ui/src/textarea.js +1 -1
- package/templates/ui/src/toast.js +3 -3
- package/templates/worker/src/index.js +3 -3
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -214,8 +214,23 @@ async function initializeGit(projectDir) {
|
|
|
214
214
|
}
|
|
215
215
|
|
|
216
216
|
try {
|
|
217
|
-
// Initialize git repo
|
|
218
|
-
|
|
217
|
+
// Initialize git repo.
|
|
218
|
+
//
|
|
219
|
+
// `-b main` pins the branch name rather than inheriting whatever the
|
|
220
|
+
// machine has in `init.defaultBranch`. Without it the scaffold is not
|
|
221
|
+
// reproducible: `git init` falls back to `master` wherever that setting
|
|
222
|
+
// is unset (the common case, and GitHub's own default until the hint
|
|
223
|
+
// is followed), so the same CLI produced `master` on one machine and
|
|
224
|
+
// `main` on another. `main` is the name GitHub defaults a new repository
|
|
225
|
+
// to, so it is also what the README's push instructions assume.
|
|
226
|
+
//
|
|
227
|
+
// `-b` needs git 2.28 (2020). If it is unavailable the bare `init` still
|
|
228
|
+
// produces a working repository, just with git's own default branch.
|
|
229
|
+
try {
|
|
230
|
+
await execa("git", ["init", "-b", "main"], { cwd: projectDir });
|
|
231
|
+
} catch {
|
|
232
|
+
await execa("git", ["init"], { cwd: projectDir });
|
|
233
|
+
}
|
|
219
234
|
|
|
220
235
|
// Add all files
|
|
221
236
|
await execa("git", ["add", "."], { cwd: projectDir });
|
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Health Check Endpoint
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Reports the status of the application and its dependencies. Every dependency
|
|
5
|
+
* is probed concurrently, each under its own deadline, and the route always
|
|
6
|
+
* answers `200` — the verdict lives in the body's `status` field.
|
|
7
|
+
*
|
|
8
|
+
* Why always 200: this endpoint is the platform healthcheck. A non-200 here
|
|
9
|
+
* makes the orchestrator restart the container, which drops every warm
|
|
10
|
+
* connection and produces a fresh connection storm on the dependency that was
|
|
11
|
+
* already struggling. That turns a transient blip into a restart loop.
|
|
12
|
+
* Callers that need a hard verdict should read `status`, not the status code.
|
|
5
13
|
*/
|
|
6
14
|
|
|
7
15
|
import {
|
|
@@ -15,146 +23,241 @@ import { NextResponse } from "next/server";
|
|
|
15
23
|
|
|
16
24
|
const logger = createLogger("health");
|
|
17
25
|
|
|
18
|
-
/**
|
|
26
|
+
/** Ceiling for the whole check (ms). */
|
|
19
27
|
const HEALTH_CHECK_TIMEOUT = 5000;
|
|
20
28
|
|
|
29
|
+
/** Ceiling for one dependency probe (ms). Clamped to the overall budget, so a
|
|
30
|
+
* single probe can never outlive the request. */
|
|
31
|
+
const PROBE_TIMEOUT = 3000;
|
|
32
|
+
|
|
33
|
+
/** Message returned in place of a real error when NODE_ENV is production. */
|
|
34
|
+
const GENERIC_MESSAGES = {
|
|
35
|
+
database: "Database unavailable",
|
|
36
|
+
redis: "Redis unavailable",
|
|
37
|
+
storage: "Storage unavailable",
|
|
38
|
+
queue: "Queue unavailable",
|
|
39
|
+
};
|
|
40
|
+
|
|
21
41
|
export async function GET() {
|
|
22
42
|
try {
|
|
23
|
-
const result = await
|
|
24
|
-
const timer = setTimeout(
|
|
25
|
-
() => reject(new Error("Health check timed out")),
|
|
26
|
-
HEALTH_CHECK_TIMEOUT,
|
|
27
|
-
);
|
|
28
|
-
runHealthChecks().then(
|
|
29
|
-
(val) => {
|
|
30
|
-
clearTimeout(timer);
|
|
31
|
-
resolve(val);
|
|
32
|
-
},
|
|
33
|
-
(err) => {
|
|
34
|
-
clearTimeout(timer);
|
|
35
|
-
reject(err);
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
});
|
|
43
|
+
const result = await runHealthChecks();
|
|
39
44
|
|
|
45
|
+
// Always 200 — see the module comment. An orchestrator restarting this
|
|
46
|
+
// service is what turns a dependency blip into an outage.
|
|
40
47
|
return NextResponse.json(result, {
|
|
41
|
-
status:
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { "Cache-Control": "no-store" },
|
|
42
50
|
});
|
|
43
51
|
} catch (error) {
|
|
52
|
+
// Unreachable in practice: every probe below is individually settled and
|
|
53
|
+
// deadline-bounded, so runHealthChecks cannot reject. Kept so a future
|
|
54
|
+
// edit that reintroduces a throw still returns a parseable body.
|
|
44
55
|
logger.error("Health check failed", {
|
|
45
56
|
error: error.message,
|
|
46
57
|
stack: error.stack,
|
|
47
58
|
});
|
|
59
|
+
|
|
48
60
|
return NextResponse.json(
|
|
49
61
|
{
|
|
50
62
|
status: "error",
|
|
51
63
|
timestamp: new Date().toISOString(),
|
|
52
64
|
message: "Service health check failed",
|
|
53
65
|
},
|
|
54
|
-
{ status:
|
|
66
|
+
{ status: 200, headers: { "Cache-Control": "no-store" } },
|
|
55
67
|
);
|
|
56
68
|
}
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
/**
|
|
60
|
-
* Runs
|
|
61
|
-
*
|
|
72
|
+
* Runs every dependency probe concurrently and aggregates the results.
|
|
73
|
+
*
|
|
74
|
+
* Concurrency matters as much as the deadlines: run sequentially, a 3s database
|
|
75
|
+
* timeout plus a 3s Redis timeout exhausts the 5s budget before storage is even
|
|
76
|
+
* attempted, and the whole request falls through to the give-up path.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Promise<{ status: string, timestamp: string, durationMs: number, checks: Record<string, object> }>}
|
|
62
79
|
*/
|
|
63
80
|
async function runHealthChecks() {
|
|
64
|
-
const
|
|
65
|
-
|
|
81
|
+
const startedAt = performance.now();
|
|
82
|
+
|
|
83
|
+
// Every probe is wrapped in settleProbe(), so this Promise.all cannot reject
|
|
84
|
+
// and cannot outlast the deadline.
|
|
85
|
+
const [database, redis, storage, queues] = await Promise.all([
|
|
86
|
+
settleProbe("database", () => pingDatabase()),
|
|
87
|
+
settleProbe("redis", () => pingRedis()),
|
|
88
|
+
// `provider` rides along on the error result so a failure still names the
|
|
89
|
+
// provider that failed — settleProbe owns the message redaction.
|
|
90
|
+
settleProbe("storage", checkStorage, {
|
|
91
|
+
provider: process.env.STORAGE_PROVIDER || "local",
|
|
92
|
+
}),
|
|
93
|
+
settleProbe("queues", checkQueues),
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
const checks = { database, redis, storage };
|
|
97
|
+
if (queues) checks.queues = queues;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
// "degraded" means at least one dependency failed. Both this and "ok"
|
|
101
|
+
// answer 200 — see the module comment.
|
|
102
|
+
status: Object.values(checks).some(isFailing) ? "degraded" : "ok",
|
|
66
103
|
timestamp: new Date().toISOString(),
|
|
67
|
-
|
|
104
|
+
durationMs: Math.round(performance.now() - startedAt),
|
|
105
|
+
checks,
|
|
68
106
|
};
|
|
107
|
+
}
|
|
69
108
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Reports whether a check entry counts as a failure. Handles both a flat probe
|
|
111
|
+
* result (`{ status: "error" }`) and the nested queue map, where the verdict
|
|
112
|
+
* lives on each queue rather than on the map itself.
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} check
|
|
115
|
+
* @returns {boolean}
|
|
116
|
+
*/
|
|
117
|
+
function isFailing(check) {
|
|
118
|
+
if (!check || typeof check !== "object") return false;
|
|
119
|
+
|
|
120
|
+
// A flat probe result carries its own `status`. The nested queue map does
|
|
121
|
+
// not, so fall through to its entries.
|
|
122
|
+
if (typeof (/** @type {any} */ (check).status) === "string") {
|
|
123
|
+
return /** @type {any} */ (check).status === "error";
|
|
80
124
|
}
|
|
81
125
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
126
|
+
return Object.values(/** @type {Record<string, any>} */ (check)).some(
|
|
127
|
+
(entry) => entry?.status === "error",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Runs one dependency probe with its own deadline and normalises every failure
|
|
133
|
+
* mode — rejection, timeout, or a probe that never settles — into a result
|
|
134
|
+
* object. Never throws, so callers can rely on `Promise.all` resolving.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - Probe name, also the key under `checks`.
|
|
137
|
+
* @param {() => Promise<object>} probe
|
|
138
|
+
* @param {object} [context] - Fields to merge into the error result.
|
|
139
|
+
* @returns {Promise<{ status: string } & Record<string, unknown>>}
|
|
140
|
+
*/
|
|
141
|
+
async function settleProbe(name, probe, context = {}) {
|
|
142
|
+
// A probe that hangs must not hold up the aggregate, and must never outlive
|
|
143
|
+
// the overall budget. The timer is always cleared, so it never leaks.
|
|
144
|
+
let timer;
|
|
145
|
+
const deadline = new Promise((resolve) => {
|
|
146
|
+
timer = setTimeout(
|
|
147
|
+
() => resolve({ status: "error", message: `${name} check timed out` }),
|
|
148
|
+
Math.min(PROBE_TIMEOUT, HEALTH_CHECK_TIMEOUT),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
return withSafeMessage(name, await Promise.race([probe(), deadline]));
|
|
154
|
+
} catch (/** @type {any} */ error) {
|
|
155
|
+
return withSafeMessage(name, {
|
|
156
|
+
...context,
|
|
88
157
|
status: "error",
|
|
89
|
-
message:
|
|
90
|
-
};
|
|
91
|
-
|
|
158
|
+
message: error?.message,
|
|
159
|
+
});
|
|
160
|
+
} finally {
|
|
161
|
+
clearTimeout(timer);
|
|
92
162
|
}
|
|
163
|
+
}
|
|
93
164
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Returns a check result whose message is safe to serve publicly.
|
|
167
|
+
*
|
|
168
|
+
* A probe can signal failure two ways — by rejecting, or by *resolving* with
|
|
169
|
+
* `{ status: "error", message }`. Both are routed through here so neither can
|
|
170
|
+
* bypass the redaction: `pingRedis` returned `{ status: "error" }` carrying the
|
|
171
|
+
* raw `REDIS_URL`, so a normaliser that only handled rejections would have let
|
|
172
|
+
* the password straight through.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} name - Probe name.
|
|
175
|
+
* @param {object} [result]
|
|
176
|
+
* @returns {object}
|
|
177
|
+
*/
|
|
178
|
+
function withSafeMessage(name, result) {
|
|
179
|
+
if (result?.status !== "error") return result;
|
|
100
180
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const entries = [...registeredQueues.entries()];
|
|
105
|
-
const results = await Promise.allSettled(
|
|
106
|
-
entries.map(([, queue]) =>
|
|
107
|
-
Promise.all([
|
|
108
|
-
queue.getWaitingCount(),
|
|
109
|
-
queue.getActiveCount(),
|
|
110
|
-
queue.getFailedCount(),
|
|
111
|
-
]),
|
|
112
|
-
),
|
|
113
|
-
);
|
|
181
|
+
const { message, ...rest } = result;
|
|
182
|
+
return { ...rest, status: "error", message: safeMessage(name, message) };
|
|
183
|
+
}
|
|
114
184
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
health.checks.queues = queueChecks;
|
|
185
|
+
/**
|
|
186
|
+
* Reduces a failure to a message safe to return from an unauthenticated route.
|
|
187
|
+
*
|
|
188
|
+
* Driver errors carry the connection string they failed on, so the raw text is
|
|
189
|
+
* only echoed outside production. `pingRedis`/`pingDatabase` redact at the
|
|
190
|
+
* source regardless; this is the second layer.
|
|
191
|
+
*
|
|
192
|
+
* @param {string} name - Probe name.
|
|
193
|
+
* @param {string|Error} [source] - The failure message or the error itself.
|
|
194
|
+
* @returns {string}
|
|
195
|
+
*/
|
|
196
|
+
function safeMessage(name, source) {
|
|
197
|
+
if (process.env.NODE_ENV === "production") {
|
|
198
|
+
return GENERIC_MESSAGES[name] ?? "Check unavailable";
|
|
132
199
|
}
|
|
133
200
|
|
|
134
|
-
return
|
|
201
|
+
return source?.message ?? source ?? "Unknown error";
|
|
135
202
|
}
|
|
136
203
|
|
|
137
204
|
/**
|
|
138
205
|
* Verifies storage is reachable and writable by writing then deleting a
|
|
139
206
|
* small sentinel object. Uses whichever provider is configured via
|
|
140
207
|
* STORAGE_PROVIDER (defaults to "local" when unset).
|
|
141
|
-
* @returns {Promise<{ status: string, provider: string
|
|
208
|
+
* @returns {Promise<{ status: string, provider: string }>}
|
|
142
209
|
*/
|
|
143
210
|
async function checkStorage() {
|
|
144
211
|
const provider = process.env.STORAGE_PROVIDER || "local";
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
212
|
+
const storage = createStorage();
|
|
213
|
+
const sentinelKey = ".health-check-sentinel";
|
|
214
|
+
|
|
215
|
+
await storage.put(sentinelKey, Buffer.from("ok"), {
|
|
216
|
+
contentType: "text/plain",
|
|
217
|
+
});
|
|
218
|
+
await storage.delete(sentinelKey);
|
|
219
|
+
|
|
220
|
+
return { status: "ok", provider };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Reports queue depths. Only meaningful in a process that registered the
|
|
225
|
+
* queues — the web app registers none, so this resolves to null and the key is
|
|
226
|
+
* omitted from the response rather than reported as a false pass.
|
|
227
|
+
*
|
|
228
|
+
* @returns {Promise<Record<string, { status: string, waiting?: number, active?: number, failed?: number, message?: string }> | null>}
|
|
229
|
+
*/
|
|
230
|
+
async function checkQueues() {
|
|
231
|
+
const registeredQueues = getRegisteredQueues();
|
|
232
|
+
if (registeredQueues.size === 0) return null;
|
|
233
|
+
|
|
234
|
+
const entries = [...registeredQueues.entries()];
|
|
235
|
+
const results = await Promise.allSettled(
|
|
236
|
+
entries.map(([, queue]) =>
|
|
237
|
+
Promise.all([
|
|
238
|
+
queue.getWaitingCount(),
|
|
239
|
+
queue.getActiveCount(),
|
|
240
|
+
queue.getFailedCount(),
|
|
241
|
+
]),
|
|
242
|
+
),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const queueChecks = {};
|
|
246
|
+
|
|
247
|
+
for (let i = 0; i < entries.length; i++) {
|
|
248
|
+
const [name] = entries[i];
|
|
249
|
+
const result = results[i];
|
|
250
|
+
|
|
251
|
+
if (result.status === "fulfilled") {
|
|
252
|
+
const [waiting, active, failed] = result.value;
|
|
253
|
+
queueChecks[name] = { status: "ok", waiting, active, failed };
|
|
254
|
+
} else {
|
|
255
|
+
queueChecks[name] = withSafeMessage("queue", {
|
|
256
|
+
status: "error",
|
|
257
|
+
message: result.reason?.message,
|
|
258
|
+
});
|
|
259
|
+
}
|
|
159
260
|
}
|
|
261
|
+
|
|
262
|
+
return queueChecks;
|
|
160
263
|
}
|
|
@@ -7,6 +7,17 @@ const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
|
|
|
7
7
|
"/api/auth/signin/credentials",
|
|
8
8
|
]);
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Routes that are never rate limited.
|
|
12
|
+
*
|
|
13
|
+
* `/api/health` is the platform healthcheck. It sits behind the same 100-req
|
|
14
|
+
* `api` bucket as everything else, so a probe that lands in an already-full
|
|
15
|
+
* bucket gets a 429 — the orchestrator reads that as unhealthy and restarts the
|
|
16
|
+
* container, which fills the bucket again on startup. Limiting the probe is how
|
|
17
|
+
* a healthy service gets killed.
|
|
18
|
+
*/
|
|
19
|
+
const RATE_LIMIT_EXEMPT_ROUTES = new Set(["/api/health"]);
|
|
20
|
+
|
|
10
21
|
function getForwardedProtocol(request) {
|
|
11
22
|
const forwardedProto = request.headers.get("x-forwarded-proto");
|
|
12
23
|
if (!forwardedProto) return null;
|
|
@@ -78,3 +89,12 @@ export function getRateLimitBucket(pathname, method = "GET") {
|
|
|
78
89
|
? "auth"
|
|
79
90
|
: "api";
|
|
80
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether a route bypasses rate limiting entirely.
|
|
95
|
+
* @param {string} pathname
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isRateLimitExempt(pathname) {
|
|
99
|
+
return RATE_LIMIT_EXEMPT_ROUTES.has(pathname);
|
|
100
|
+
}
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* Guards (in order):
|
|
6
6
|
* 1. Metrics guard - optionally protects /api/metrics with a bearer
|
|
7
7
|
* token. Set METRICS_TOKEN in env to enable.
|
|
8
|
-
* 2. Rate limiting, CORS, security headers for all API routes
|
|
8
|
+
* 2. Rate limiting, CORS, security headers for all API routes, except the
|
|
9
|
+
* rate-limit-exempt healthcheck (see lib/proxy-auth.js).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { getAllowedOrigins } from "@usequark/quark-config/app-url";
|
|
12
13
|
import { NextResponse } from "next/server";
|
|
13
14
|
|
|
14
15
|
import { buildContentSecurityPolicy } from "./lib/analytics/umami-csp.js";
|
|
15
|
-
import { getRateLimitBucket } from "./lib/proxy-auth";
|
|
16
|
+
import { getRateLimitBucket, isRateLimitExempt } from "./lib/proxy-auth";
|
|
16
17
|
|
|
17
18
|
// ─── 1. Metrics guard ────────────────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -218,8 +219,9 @@ export async function proxy(request) {
|
|
|
218
219
|
}
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
// Apply rate limiting to API routes only
|
|
222
|
-
|
|
222
|
+
// Apply rate limiting to API routes only. Exempt routes (the healthcheck)
|
|
223
|
+
// skip the bucket entirely — no counter, no headers.
|
|
224
|
+
if (pathname.startsWith("/api/") && !isRateLimitExempt(pathname)) {
|
|
223
225
|
const ip = getClientIp(request);
|
|
224
226
|
const rateLimitBucket = getRateLimitBucket(pathname, request.method);
|
|
225
227
|
const maxRequests = RATE_LIMIT_CONFIG.maxRequests[rateLimitBucket];
|
|
@@ -15,6 +15,38 @@ const tsExtensions = new Set([".ts", ".tsx"]);
|
|
|
15
15
|
const throwNewErrorPattern = /\bthrow new Error\s*\(/;
|
|
16
16
|
const consolePattern = /\bconsole\.(?:log|warn|error|info)\s*\(/;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Tailwind v4 removed the bare `[--var]` shorthand that v3 accepted. Under v4 a
|
|
20
|
+
* class such as `text-[--navbar-text-muted]` is still a *valid* utility name, so
|
|
21
|
+
* Tailwind emits a rule for it - but the declaration body is the bare token
|
|
22
|
+
* `color: --navbar-text-muted`, which is not a valid CSS value. The browser
|
|
23
|
+
* discards the declaration and the element silently inherits instead.
|
|
24
|
+
*
|
|
25
|
+
* Nothing warns: the build exits 0 and the rule is present in the stylesheet, so
|
|
26
|
+
* the breakage is only visible by rendering both themes and reading computed
|
|
27
|
+
* styles. In light mode `body` sets no colour and the inherited default happens
|
|
28
|
+
* to be black, which reads as "working".
|
|
29
|
+
*
|
|
30
|
+
* The v4 forms are `text-(--navbar-text-muted)` or, when the utility namespace
|
|
31
|
+
* would be ambiguous (e.g. `text-` shared between colour and font-size),
|
|
32
|
+
* `text-[color:var(--navbar-text-muted)]`.
|
|
33
|
+
*
|
|
34
|
+
* The pattern matches the *shape* of the v3 shorthand rather than an
|
|
35
|
+
* enumerated list of utilities. An earlier version listed the colour, border and
|
|
36
|
+
* radius families explicitly, which was a false economy: the same mistake is
|
|
37
|
+
* equally reachable through `w-[--panel-width]`, `leading-[--line-height]` or
|
|
38
|
+
* `p-[--gutter]`, and any list has to be extended every time someone reaches
|
|
39
|
+
* for a family it omits.
|
|
40
|
+
*
|
|
41
|
+
* The `-` immediately before `[` is what separates this from ordinary
|
|
42
|
+
* JavaScript. `rows[--i]` (decrement-then-index) and `obj[key--1]` have no
|
|
43
|
+
* hyphen there and do not match. The one shape that is indistinguishable from a
|
|
44
|
+
* real class is `<ident>-[--ident]`, which would require JavaScript that
|
|
45
|
+
* subtracts from a decrement expression - it does not occur in this codebase.
|
|
46
|
+
*/
|
|
47
|
+
const tailwindV3VarPattern =
|
|
48
|
+
/(?:^|[\s"'`(])(?:[a-z][a-z0-9-]*:)*[a-z][a-z0-9-]*-\[--[a-zA-Z][a-zA-Z0-9-]*\]/;
|
|
49
|
+
|
|
18
50
|
const consoleAllowlist = [
|
|
19
51
|
/^packages\/config\/src\/validate-env\.js$/,
|
|
20
52
|
/^packages\/core\/src\/testing\//,
|
|
@@ -159,6 +191,17 @@ async function main() {
|
|
|
159
191
|
}
|
|
160
192
|
}
|
|
161
193
|
|
|
194
|
+
if (isSourceFile(relativePath)) {
|
|
195
|
+
for (const lineNumber of findMatchingLines(
|
|
196
|
+
content,
|
|
197
|
+
tailwindV3VarPattern,
|
|
198
|
+
)) {
|
|
199
|
+
violations.push(
|
|
200
|
+
`${relativePath}:${lineNumber}: Tailwind v4 ignores the v3 \`[--var]\` shorthand - the utility is emitted with an invalid value and silently does nothing. Use \`utility-(--var)\`, or \`utility-[color:var(--var)]\` when the namespace is ambiguous.`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
162
205
|
if (
|
|
163
206
|
isSourceFile(relativePath) &&
|
|
164
207
|
!isTestFile(relativePath) &&
|
|
@@ -50,8 +50,11 @@ function collectTests(targetPath) {
|
|
|
50
50
|
const files = roots.flatMap((rootPath) => collectTests(path.resolve(rootPath)));
|
|
51
51
|
|
|
52
52
|
if (files.length === 0) {
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
// An empty suite is not a failure. `node --test` exits 0 when it matches no
|
|
54
|
+
// files, so exiting 1 here made a freshly scaffolded project fail its own
|
|
55
|
+
// `pnpm test` and therefore its pre-push hook.
|
|
56
|
+
console.log(`No test files found in: ${roots.join(", ")} - nothing to run.`);
|
|
57
|
+
process.exit(0);
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
const result = spawnSync(
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
const base =
|
|
4
|
-
"inline-flex items-center justify-center border font-medium bg-
|
|
4
|
+
"inline-flex items-center justify-center border font-medium bg-(--badge-bg) border-(--badge-border) text-(--badge-text)";
|
|
5
5
|
|
|
6
6
|
export function Badge({ variant = "default", className = "", ...props }) {
|
|
7
|
-
const cls = `${base} rounded-
|
|
7
|
+
const cls = `${base} rounded-(--radius-default) ${className}`.trim();
|
|
8
8
|
return React.createElement("span", {
|
|
9
9
|
"data-badge-variant": variant,
|
|
10
10
|
className: cls,
|
|
@@ -6,7 +6,7 @@ const base =
|
|
|
6
6
|
"inline-flex items-center justify-center font-medium tracking-wide transition-all duration-200 linear cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 disabled:opacity-40 disabled:cursor-not-allowed active:opacity-80";
|
|
7
7
|
|
|
8
8
|
const variantCls =
|
|
9
|
-
"bg-
|
|
9
|
+
"bg-(--btn-bg) border border-(--btn-border) text-(--btn-text) rounded-(--btn-radius) hover:brightness-[var(--btn-hover-brightness)] hover:shadow-[var(--btn-hover-shadow)] hover:bg-[var(--btn-hover-bg)] hover:text-[var(--btn-hover-text)] hover:opacity-[var(--btn-hover-opacity)] focus-visible:ring-[var(--btn-ring)]";
|
|
10
10
|
|
|
11
11
|
const sizes = {
|
|
12
12
|
sm: "h-8 px-3 text-sm",
|
package/templates/ui/src/card.js
CHANGED
|
@@ -4,19 +4,19 @@ import { ChevronDown } from "lucide-react";
|
|
|
4
4
|
import React, { useState } from "react";
|
|
5
5
|
|
|
6
6
|
const cardCls =
|
|
7
|
-
"border border-
|
|
7
|
+
"border border-(--card-border) bg-(--card-bg) transition-colors duration-200 linear";
|
|
8
8
|
|
|
9
9
|
const headerCls = "flex flex-col space-y-1.5 p-6";
|
|
10
10
|
|
|
11
11
|
const titleCls =
|
|
12
|
-
"text-lg font-bold leading-none tracking-tight text-
|
|
12
|
+
"text-lg font-bold leading-none tracking-tight text-(--card-title-text)";
|
|
13
13
|
|
|
14
14
|
const contentCls = "p-6 pt-0";
|
|
15
15
|
|
|
16
16
|
const footerCls = "flex items-center p-6 pt-0";
|
|
17
17
|
|
|
18
18
|
const collapsibleTriggerCls =
|
|
19
|
-
"flex w-full cursor-pointer items-center justify-between p-6 text-
|
|
19
|
+
"flex w-full cursor-pointer items-center justify-between p-6 text-(--card-title-text) transition-colors duration-200 hover:bg-(--card-trigger-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--card-trigger-ring)]";
|
|
20
20
|
|
|
21
21
|
function CollapsibleCard({
|
|
22
22
|
className = "",
|
|
@@ -31,7 +31,7 @@ function CollapsibleCard({
|
|
|
31
31
|
"div",
|
|
32
32
|
{
|
|
33
33
|
className:
|
|
34
|
-
`${cardCls} h-fit self-start rounded-
|
|
34
|
+
`${cardCls} h-fit self-start rounded-(--radius-default) ${className}`.trim(),
|
|
35
35
|
...props,
|
|
36
36
|
},
|
|
37
37
|
React.createElement(
|
|
@@ -51,7 +51,7 @@ function CollapsibleCard({
|
|
|
51
51
|
"span",
|
|
52
52
|
{
|
|
53
53
|
"aria-hidden": "true",
|
|
54
|
-
className: `pointer-events-none ml-3 shrink-0 text-
|
|
54
|
+
className: `pointer-events-none ml-3 shrink-0 text-(--card-text-muted) transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] ${isOpen ? "rotate-180" : "rotate-0"}`,
|
|
55
55
|
},
|
|
56
56
|
React.createElement(ChevronDown, { size: 16 }),
|
|
57
57
|
),
|
|
@@ -59,7 +59,7 @@ function CollapsibleCard({
|
|
|
59
59
|
React.createElement(
|
|
60
60
|
"div",
|
|
61
61
|
{
|
|
62
|
-
className: `border-t border-
|
|
62
|
+
className: `border-t border-(--card-border) text-(--card-text-muted) grid transition-[grid-template-rows,opacity,transform] duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] motion-reduce:transition-none ${isOpen ? "translate-y-0 opacity-100" : "-translate-y-2 opacity-0"}`,
|
|
63
63
|
style: { gridTemplateRows: isOpen ? "1fr" : "0fr" },
|
|
64
64
|
},
|
|
65
65
|
React.createElement(
|
|
@@ -92,7 +92,7 @@ export function Card({
|
|
|
92
92
|
return React.createElement(
|
|
93
93
|
"div",
|
|
94
94
|
{
|
|
95
|
-
className: `${cardCls} rounded-
|
|
95
|
+
className: `${cardCls} rounded-(--radius-default) ${className}`.trim(),
|
|
96
96
|
...props,
|
|
97
97
|
},
|
|
98
98
|
children,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
const inputCls =
|
|
4
|
-
"h-4 w-4 rounded border-
|
|
4
|
+
"h-4 w-4 rounded border-(--checkbox-border) bg-(--checkbox-bg) accent-(--checkbox-accent) transition-colors cursor-pointer focus-visible:ring-2 focus-visible:ring-[var(--checkbox-ring)] disabled:cursor-not-allowed disabled:opacity-[var(--checkbox-disabled-opacity)]";
|
|
5
5
|
|
|
6
|
-
const labelCls = "text-sm text-
|
|
6
|
+
const labelCls = "text-sm text-(--checkbox-label-text) select-none";
|
|
7
7
|
|
|
8
8
|
export function Checkbox({ id, label, className = "", ...props }) {
|
|
9
9
|
return React.createElement(
|
|
@@ -5,7 +5,7 @@ export function Container({ className = "", children, ...props }) {
|
|
|
5
5
|
"section",
|
|
6
6
|
{
|
|
7
7
|
className:
|
|
8
|
-
`relative overflow-hidden rounded-
|
|
8
|
+
`relative overflow-hidden rounded-(--radius-default) border border-(--container-border) bg-(--container-bg) ${className}`.trim(),
|
|
9
9
|
...props,
|
|
10
10
|
},
|
|
11
11
|
children,
|
|
@@ -28,18 +28,18 @@ import React, {
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
const dialogCls =
|
|
31
|
-
"backdrop:bg-[var(--dialog-backdrop)] border border-
|
|
31
|
+
"backdrop:bg-[var(--dialog-backdrop)] border border-(--dialog-border) bg-(--dialog-bg) p-0 w-full max-w-lg rounded-(--radius-default)";
|
|
32
32
|
|
|
33
33
|
const headerCls =
|
|
34
|
-
"flex items-center justify-between border-b border-
|
|
34
|
+
"flex items-center justify-between border-b border-(--dialog-border) px-5 py-4";
|
|
35
35
|
|
|
36
36
|
const titleCls =
|
|
37
|
-
"text-base font-bold tracking-tight text-
|
|
37
|
+
"text-base font-bold tracking-tight text-(--dialog-title-text)";
|
|
38
38
|
|
|
39
39
|
const closeCls =
|
|
40
|
-
"flex h-8 w-8 cursor-pointer items-center justify-center text-lg text-
|
|
40
|
+
"flex h-8 w-8 cursor-pointer items-center justify-center text-lg text-(--dialog-text-muted) transition-colors duration-200 linear hover:text-(--dialog-title-text) hover:bg-(--dialog-close-hover-bg) active:bg-(--dialog-close-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--dialog-close-ring)] rounded-(--radius-default)";
|
|
41
41
|
|
|
42
|
-
const bodyCls = "px-5 py-4 text-
|
|
42
|
+
const bodyCls = "px-5 py-4 text-(--dialog-text-muted)";
|
|
43
43
|
|
|
44
44
|
export function Dialog({ open, onClose, title, children, className = "" }) {
|
|
45
45
|
const ref = useRef(null);
|
|
@@ -8,7 +8,7 @@ export function ErrorBanner({ message, className = "" }) {
|
|
|
8
8
|
role: "alert",
|
|
9
9
|
"aria-live": "polite",
|
|
10
10
|
className:
|
|
11
|
-
`mb-4 border border-
|
|
11
|
+
`mb-4 border border-(--error-border) bg-(--error-bg) px-3 py-2 text-sm text-(--error-text) rounded-(--radius-default) ${className}`.trim(),
|
|
12
12
|
},
|
|
13
13
|
message,
|
|
14
14
|
);
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
const footerCls = "border-t border-
|
|
3
|
+
const footerCls = "border-t border-(--footer-border) bg-(--footer-bg)";
|
|
4
4
|
const containerCls = "mx-auto";
|
|
5
5
|
const topGridCls =
|
|
6
6
|
"grid gap-8 sm:gap-10 md:grid-cols-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)]";
|
|
7
7
|
const brandRowCls = "flex items-center gap-3";
|
|
8
8
|
const markCls =
|
|
9
|
-
"flex shrink-0 items-center justify-center rounded-2xl bg-
|
|
9
|
+
"flex shrink-0 items-center justify-center rounded-2xl bg-(--footer-mark-bg) font-bold text-(--footer-mark-text)";
|
|
10
10
|
const brandNameCls =
|
|
11
|
-
"text-2xl font-bold tracking-tight text-
|
|
12
|
-
const brandTextCls = "max-w-md text-sm leading-7 text-
|
|
11
|
+
"text-2xl font-bold tracking-tight text-(--footer-text) sm:text-3xl";
|
|
12
|
+
const brandTextCls = "max-w-md text-sm leading-7 text-(--footer-text-muted)";
|
|
13
13
|
const ctaCls =
|
|
14
|
-
"inline-flex w-full items-center justify-center rounded-full border border-
|
|
15
|
-
const columnTitleCls = "mb-4 font-semibold text-
|
|
14
|
+
"inline-flex w-full items-center justify-center rounded-full border border-(--footer-cta-border) font-semibold text-(--footer-mark-text) transition-colors hover:bg-(--footer-cta-hover-bg) sm:w-auto";
|
|
15
|
+
const columnTitleCls = "mb-4 font-semibold text-(--footer-text-faint)";
|
|
16
16
|
const columnListCls = "space-y-2.5";
|
|
17
17
|
const linkCls =
|
|
18
|
-
"break-words text-
|
|
19
|
-
const textItemCls = "break-words text-
|
|
18
|
+
"break-words text-(--footer-text-muted) transition-colors hover:text-(--footer-link-hover)";
|
|
19
|
+
const textItemCls = "break-words text-(--footer-text-muted)";
|
|
20
20
|
const bottomBarCls =
|
|
21
|
-
"mt-8 border-t border-
|
|
21
|
+
"mt-8 border-t border-(--footer-border) pt-5 flex flex-col gap-3 text-sm text-(--footer-text-faint) sm:mt-10 sm:pt-6 lg:flex-row lg:items-center lg:justify-between";
|
|
22
22
|
const legalCls = "flex flex-wrap items-center gap-2 sm:gap-3";
|
|
23
23
|
const sepCls = "text-[var(--footer-sep)]";
|
|
24
24
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
const base =
|
|
4
|
-
"block h-10 w-full border border-
|
|
4
|
+
"block h-10 w-full border border-(--input-border) bg-(--input-bg) px-3 text-sm text-(--input-text) placeholder:text-(--input-placeholder) transition-colors duration-200 linear hover:border-(--input-border-hover) focus-visible:border-(--input-border-focus) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--input-ring-focus)] disabled:opacity-(--input-disabled-opacity) disabled:cursor-not-allowed";
|
|
5
5
|
|
|
6
6
|
export function Input({ className = "", ...props }) {
|
|
7
7
|
return React.createElement("input", {
|
|
8
|
-
className: `${base} rounded-
|
|
8
|
+
className: `${base} rounded-(--radius-default) ${className}`.trim(),
|
|
9
9
|
...props,
|
|
10
10
|
});
|
|
11
11
|
}
|
|
@@ -3,16 +3,16 @@ import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
|
3
3
|
import React, { useEffect, useRef, useState } from "react";
|
|
4
4
|
|
|
5
5
|
const BACKDROP_CLS =
|
|
6
|
-
"absolute inset-0 z-0 bg-
|
|
6
|
+
"absolute inset-0 z-0 bg-(--lightbox-backdrop) px-3 py-4 transition-opacity duration-200 ease-out sm:px-6 sm:py-8 cursor-pointer";
|
|
7
7
|
const DIALOG_CLS =
|
|
8
8
|
"relative z-10 mx-auto flex h-full w-full max-w-6xl flex-col p-3 transition-all duration-200 ease-out sm:p-5";
|
|
9
9
|
const NAV_BTN_CLS =
|
|
10
|
-
"inline-flex items-center gap-1.5 rounded-
|
|
10
|
+
"inline-flex items-center gap-1.5 rounded-(--radius-default) border border-(--lightbox-btn-border) px-3 py-1.5 text-sm font-medium text-(--lightbox-btn-text) transition-colors duration-200 hover:bg-(--lightbox-btn-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--lightbox-btn-ring)] cursor-pointer";
|
|
11
11
|
const CLOSE_BTN_CLS =
|
|
12
|
-
"rounded-
|
|
12
|
+
"rounded-(--radius-default) border border-(--lightbox-btn-border) font-medium text-(--lightbox-btn-text) transition-colors duration-200 hover:bg-(--lightbox-btn-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--lightbox-btn-ring)] flex items-center cursor-pointer";
|
|
13
13
|
|
|
14
14
|
const IMG_CLS =
|
|
15
|
-
"max-h-[72vh] w-auto max-w-full rounded-
|
|
15
|
+
"max-h-[72vh] w-auto max-w-full rounded-(--radius-default) object-contain transition-all duration-200 ease-out";
|
|
16
16
|
|
|
17
17
|
export function Lightbox({
|
|
18
18
|
src,
|
|
@@ -129,7 +129,7 @@ export function Lightbox({
|
|
|
129
129
|
"p",
|
|
130
130
|
{
|
|
131
131
|
key: "caption",
|
|
132
|
-
className: "mt-3 text-sm leading-6 text-
|
|
132
|
+
className: "mt-3 text-sm leading-6 text-(--lightbox-caption-text)",
|
|
133
133
|
},
|
|
134
134
|
caption,
|
|
135
135
|
),
|
|
@@ -169,7 +169,7 @@ export function Lightbox({
|
|
|
169
169
|
"p",
|
|
170
170
|
{
|
|
171
171
|
key: "counter",
|
|
172
|
-
className: "text-center text-
|
|
172
|
+
className: "text-center text-(--lightbox-counter-text)",
|
|
173
173
|
style: {
|
|
174
174
|
minWidth: "var(--lightbox-counter-min-width)",
|
|
175
175
|
fontSize: "var(--lightbox-counter-font-size)",
|
|
@@ -2,47 +2,47 @@
|
|
|
2
2
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
const shellCls =
|
|
5
|
-
"relative w-full border-b border-
|
|
5
|
+
"relative w-full border-b border-(--navbar-border) bg-(--navbar-bg) shadow-[var(--navbar-inset-shadow)] backdrop-blur";
|
|
6
6
|
const mobileShellCls =
|
|
7
|
-
"relative w-full border-b border-
|
|
7
|
+
"relative w-full border-b border-(--navbar-border) bg-(--navbar-mobile-bg) shadow-[var(--navbar-inset-shadow)]";
|
|
8
8
|
const mobileShellOpenCls =
|
|
9
|
-
"relative w-full border-b border-transparent bg-
|
|
9
|
+
"relative w-full border-b border-transparent bg-(--navbar-mobile-bg) shadow-[var(--navbar-inset-shadow)]";
|
|
10
10
|
const containerCls = "mx-auto w-full px-4 sm:px-6 lg:px-8";
|
|
11
11
|
const desktopInnerCls = "flex h-[4.5rem] items-center justify-center gap-6";
|
|
12
12
|
const leftZoneCls = "flex items-center mr-auto";
|
|
13
13
|
const rightZoneCls = "flex items-center justify-end ml-auto";
|
|
14
14
|
const logoCls =
|
|
15
|
-
"inline-flex items-center gap-2.5 rounded-
|
|
15
|
+
"inline-flex items-center gap-2.5 rounded-(--radius-default) px-1.5 py-1 font-semibold tracking-wide text-(--navbar-text) transition-colors hover:text-(--navbar-logo-hover)";
|
|
16
16
|
const markCls =
|
|
17
|
-
"inline-flex h-8 w-8 items-center justify-center rounded-
|
|
17
|
+
"inline-flex h-8 w-8 items-center justify-center rounded-(--radius-default) border border-(--navbar-mark-border) bg-(--navbar-mark-bg) font-bold text-(--navbar-mark-text)";
|
|
18
18
|
const centerNavCls = "flex min-w-0 justify-center";
|
|
19
19
|
const desktopListCls = "flex items-center gap-3";
|
|
20
20
|
const desktopLinkCls =
|
|
21
|
-
"inline-flex h-10 items-center rounded-
|
|
21
|
+
"inline-flex h-10 items-center rounded-(--radius-default) px-3.5 font-medium text-(--navbar-text-muted) transition-colors hover:bg-(--navbar-hover-bg) hover:text-(--navbar-hover-text)";
|
|
22
22
|
|
|
23
23
|
const desktopDropdownWrapCls =
|
|
24
|
-
"group inline-flex cursor-pointer items-center rounded-
|
|
24
|
+
"group inline-flex cursor-pointer items-center rounded-(--radius-default) transition-colors hover:bg-(--navbar-hover-bg)";
|
|
25
25
|
const desktopDropdownLinkCls =
|
|
26
|
-
"flex items-center font-medium text-
|
|
26
|
+
"flex items-center font-medium text-(--navbar-text-muted) transition-colors group-hover:text-(--navbar-hover-text)";
|
|
27
27
|
const desktopDropdownChevronCls =
|
|
28
|
-
"flex items-center px-2 pt-1 text-
|
|
28
|
+
"flex items-center px-2 pt-1 text-(--navbar-text-muted) transition-colors group-hover:text-(--navbar-hover-text) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--navbar-ring)] rounded-r-(--radius-default)";
|
|
29
29
|
const actionCls =
|
|
30
|
-
"inline-flex h-11 items-center justify-center whitespace-nowrap rounded-
|
|
30
|
+
"inline-flex h-11 items-center justify-center whitespace-nowrap rounded-(--radius-default) border border-(--navbar-action-border) bg-(--navbar-action-bg) px-5 font-semibold text-(--navbar-action-text) transition-colors hover:border-(--navbar-action-hover-border) hover:bg-(--navbar-action-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--navbar-ring)]";
|
|
31
31
|
|
|
32
32
|
const mobileInnerCls = "flex h-14 items-center justify-between gap-4";
|
|
33
33
|
const mobileToggleCls =
|
|
34
|
-
"inline-flex h-10 w-10 items-center justify-center rounded-
|
|
34
|
+
"inline-flex h-10 w-10 items-center justify-center rounded-(--radius-default) text-(--navbar-text-muted) transition-colors hover:text-(--navbar-hover-text) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--navbar-ring)]";
|
|
35
35
|
const mobilePanelWrapCls =
|
|
36
36
|
"absolute inset-x-0 top-full z-[80] overflow-y-auto overscroll-contain [touch-action:pan-y] [-webkit-overflow-scrolling:touch] origin-top";
|
|
37
37
|
const mobilePanelCls =
|
|
38
|
-
"border-y border-
|
|
38
|
+
"border-y border-(--navbar-border) bg-(--navbar-mobile-panel-bg) will-change-transform transition-all duration-300 ease-out";
|
|
39
39
|
const mobileLinkCls =
|
|
40
|
-
"flex min-h-12 w-full items-center justify-between px-4 text-left font-medium text-
|
|
40
|
+
"flex min-h-12 w-full items-center justify-between px-4 text-left font-medium text-(--navbar-text-muted) transition-colors hover:bg-(--navbar-hover-bg) hover:text-(--navbar-hover-text)";
|
|
41
41
|
const mobileSubLinkCls =
|
|
42
|
-
"block px-6 py-2 text-
|
|
42
|
+
"block px-6 py-2 text-(--navbar-text-muted) transition-colors hover:bg-(--navbar-hover-bg) hover:text-(--navbar-hover-text)";
|
|
43
43
|
const mobileActionWrapCls = "px-3 pb-3 pt-2";
|
|
44
44
|
const mobileActionCls =
|
|
45
|
-
"inline-flex h-10 w-full items-center justify-center rounded-
|
|
45
|
+
"inline-flex h-10 w-full items-center justify-center rounded-(--radius-default) border border-(--navbar-action-border) bg-(--navbar-action-bg) px-4 font-semibold text-(--navbar-action-text) transition-colors hover:border-(--navbar-action-hover-border) hover:bg-(--navbar-action-hover-bg) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--navbar-ring)]";
|
|
46
46
|
|
|
47
47
|
const DEFAULT_LINKS = [
|
|
48
48
|
{ label: "Home", href: "#" },
|
|
@@ -239,7 +239,7 @@ export function Navbar({
|
|
|
239
239
|
"a",
|
|
240
240
|
{
|
|
241
241
|
href: link.href ?? "#",
|
|
242
|
-
className: `${desktopDropdownLinkCls} rounded-l-
|
|
242
|
+
className: `${desktopDropdownLinkCls} rounded-l-(--radius-default) px-3.5 py-2`,
|
|
243
243
|
},
|
|
244
244
|
link.label,
|
|
245
245
|
),
|
|
@@ -262,7 +262,7 @@ export function Navbar({
|
|
|
262
262
|
{
|
|
263
263
|
role: "menu",
|
|
264
264
|
className:
|
|
265
|
-
"absolute left-0 top-[calc(100%+0.6rem)] z-40 w-48 rounded-
|
|
265
|
+
"absolute left-0 top-[calc(100%+0.6rem)] z-40 w-48 rounded-(--radius-default) border border-(--navbar-border) bg-(--navbar-dropdown-bg) shadow-[var(--navbar-dropdown-shadow)]",
|
|
266
266
|
},
|
|
267
267
|
...(link.items ?? []).map((item, itemIndex) =>
|
|
268
268
|
React.createElement(
|
|
@@ -272,7 +272,7 @@ export function Navbar({
|
|
|
272
272
|
href: item.href ?? "#",
|
|
273
273
|
role: "menuitem",
|
|
274
274
|
className:
|
|
275
|
-
"block rounded-
|
|
275
|
+
"block rounded-(--radius-default) px-3.5 py-2.5 text-base text-(--navbar-text-muted) transition-colors hover:bg-(--navbar-hover-bg) hover:text-(--navbar-hover-text)",
|
|
276
276
|
onClick: () => setOpenIndex(null),
|
|
277
277
|
},
|
|
278
278
|
item.label,
|
|
@@ -476,7 +476,7 @@ export function MobileNavbar({
|
|
|
476
476
|
},
|
|
477
477
|
React.createElement(
|
|
478
478
|
"ul",
|
|
479
|
-
{ className: "divide-y divide-
|
|
479
|
+
{ className: "divide-y divide-(--navbar-divide-border)" },
|
|
480
480
|
...navLinks.map((link, index) => {
|
|
481
481
|
if (!hasSubItems(link)) {
|
|
482
482
|
return React.createElement(
|
|
@@ -23,7 +23,7 @@ export function PasswordInput({ className = "", ...props }) {
|
|
|
23
23
|
onClick: () => setVisible((v) => !v),
|
|
24
24
|
"aria-label": visible ? "Hide password" : "Show password",
|
|
25
25
|
className:
|
|
26
|
-
"absolute right-2.5 top-1/2 -translate-y-1/2 text-
|
|
26
|
+
"absolute right-2.5 top-1/2 -translate-y-1/2 text-(--input-icon) hover:text-(--input-icon-hover) transition-colors",
|
|
27
27
|
},
|
|
28
28
|
React.createElement(Icon, { size: 16, "aria-hidden": true }),
|
|
29
29
|
),
|
|
@@ -27,24 +27,24 @@ import { Input } from "./input.js";
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
const wrapperCls =
|
|
30
|
-
"rounded-
|
|
30
|
+
"rounded-(--radius-default) border border-(--richtext-border) bg-(--richtext-bg) text-sm text-(--richtext-text) transition-colors duration-200 focus-within:border-(--richtext-border-focus) focus-within:ring-2 focus-within:ring-[var(--richtext-ring)] overflow-hidden";
|
|
31
31
|
|
|
32
32
|
const toolbarCls =
|
|
33
|
-
"flex flex-wrap items-center border-b border-
|
|
33
|
+
"flex flex-wrap items-center border-b border-(--richtext-border) bg-(--richtext-toolbar-bg)";
|
|
34
34
|
|
|
35
35
|
const btnBase =
|
|
36
|
-
"inline-flex items-center justify-center rounded-
|
|
36
|
+
"inline-flex items-center justify-center rounded-(--radius-default) text-(--richtext-btn-text) transition-colors cursor-pointer hover:bg-(--richtext-btn-hover-bg) hover:text-(--richtext-btn-hover-text) disabled:opacity-30 disabled:cursor-not-allowed";
|
|
37
37
|
|
|
38
38
|
const btnActive =
|
|
39
|
-
"bg-
|
|
39
|
+
"bg-(--richtext-btn-active-bg) text-(--richtext-btn-active-text)";
|
|
40
40
|
|
|
41
|
-
const separatorCls = "bg-
|
|
41
|
+
const separatorCls = "bg-(--richtext-separator)";
|
|
42
42
|
|
|
43
43
|
const editorCls =
|
|
44
|
-
"outline-none px-3 py-2 overflow-y-auto prose-sm [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:my-0.5 [&_a]:text-
|
|
44
|
+
"outline-none px-3 py-2 overflow-y-auto prose-sm [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mt-4 [&_h1]:mb-2 [&_h2]:text-lg [&_h2]:font-semibold [&_h2]:mt-3 [&_h2]:mb-1.5 [&_h3]:text-base [&_h3]:font-semibold [&_h3]:mt-2 [&_h3]:mb-1 [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:my-0.5 [&_a]:text-(--richtext-link) [&_a]:underline [&_blockquote]:border-l-2 [&_blockquote]:border-(--richtext-blockquote-border) [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-(--richtext-blockquote-text) [&_blockquote]:my-2 [&_code]:bg-(--richtext-code-bg) [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-xs [&_code]:font-mono";
|
|
45
45
|
|
|
46
46
|
const placeholderCls =
|
|
47
|
-
"before:content-[attr(data-placeholder)] before:text-
|
|
47
|
+
"before:content-[attr(data-placeholder)] before:text-(--richtext-placeholder) before:pointer-events-none before:absolute before:left-3 before:top-2";
|
|
48
48
|
|
|
49
49
|
const TOOLBAR_ITEMS = [
|
|
50
50
|
{ command: "bold", icon: "B", title: "Bold", style: "font-bold" },
|
|
@@ -433,7 +433,7 @@ export function RichText({
|
|
|
433
433
|
linkError
|
|
434
434
|
? React.createElement(
|
|
435
435
|
"p",
|
|
436
|
-
{ className: "mt-1.5 text-xs text-
|
|
436
|
+
{ className: "mt-1.5 text-xs text-(--color-danger)" },
|
|
437
437
|
linkError,
|
|
438
438
|
)
|
|
439
439
|
: null,
|
|
@@ -10,19 +10,19 @@ import React, {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
|
|
12
12
|
const triggerCls =
|
|
13
|
-
"flex h-10 w-full items-center justify-between rounded-
|
|
13
|
+
"flex h-10 w-full items-center justify-between rounded-(--radius-default) border border-(--select-border) bg-(--select-bg) px-3 text-sm text-(--select-text) shadow-sm transition-all duration-200 cursor-pointer hover:border-(--select-border-hover) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--select-ring)] disabled:cursor-not-allowed disabled:opacity-50";
|
|
14
14
|
|
|
15
15
|
const panelCls =
|
|
16
|
-
"absolute z-30 max-h-60 w-full overflow-auto rounded-
|
|
16
|
+
"absolute z-30 max-h-60 w-full overflow-auto rounded-(--radius-default) border border-(--select-border) bg-(--select-panel-bg) shadow-xl origin-top transition-all duration-200 ease-out";
|
|
17
17
|
|
|
18
18
|
const optionBaseCls =
|
|
19
|
-
"flex w-full items-center justify-between rounded-
|
|
19
|
+
"flex w-full items-center justify-between rounded-(--radius-default) px-2.5 py-2 text-left text-sm transition-colors duration-150";
|
|
20
20
|
|
|
21
21
|
const optionIdleCls =
|
|
22
|
-
"cursor-pointer text-
|
|
22
|
+
"cursor-pointer text-(--select-text) hover:bg-(--select-option-hover-bg) hover:text-(--select-option-hover-text)";
|
|
23
23
|
|
|
24
24
|
const optionActiveCls =
|
|
25
|
-
"bg-
|
|
25
|
+
"bg-(--select-option-active-bg) text-(--select-option-active-text)";
|
|
26
26
|
|
|
27
27
|
function normalizeValue(value) {
|
|
28
28
|
if (value === undefined || value === null) return "";
|
|
@@ -249,8 +249,8 @@ export function Select({
|
|
|
249
249
|
"span",
|
|
250
250
|
{
|
|
251
251
|
className: selectedOption
|
|
252
|
-
? "text-
|
|
253
|
-
: "text-
|
|
252
|
+
? "text-(--select-text)"
|
|
253
|
+
: "text-(--select-text-faint)",
|
|
254
254
|
},
|
|
255
255
|
selectedOption?.label ??
|
|
256
256
|
(options.length === 0 ? "No options" : placeholder),
|
|
@@ -260,7 +260,7 @@ export function Select({
|
|
|
260
260
|
{
|
|
261
261
|
"aria-hidden": "true",
|
|
262
262
|
className:
|
|
263
|
-
`ml-2 text-
|
|
263
|
+
`ml-2 text-(--select-text-faint) transition-transform duration-200 ease-in-out ${open ? "rotate-180" : "rotate-0"}`.trim(),
|
|
264
264
|
},
|
|
265
265
|
React.createElement(ChevronDown, { size: 16 }),
|
|
266
266
|
),
|
|
@@ -276,7 +276,7 @@ export function Select({
|
|
|
276
276
|
options.length === 0
|
|
277
277
|
? React.createElement(
|
|
278
278
|
"p",
|
|
279
|
-
{ className: "px-2.5 py-2 text-sm text-
|
|
279
|
+
{ className: "px-2.5 py-2 text-sm text-(--select-text-faint)" },
|
|
280
280
|
"No options",
|
|
281
281
|
)
|
|
282
282
|
: options.map((option) => {
|
|
@@ -305,7 +305,7 @@ export function Select({
|
|
|
305
305
|
"span",
|
|
306
306
|
{
|
|
307
307
|
"aria-hidden": "true",
|
|
308
|
-
className: "text-
|
|
308
|
+
className: "text-(--select-option-active-text)",
|
|
309
309
|
},
|
|
310
310
|
React.createElement(Check, { size: 16 }),
|
|
311
311
|
)
|
|
@@ -2,11 +2,11 @@ import { ChevronDown, ChevronsUpDown, ChevronUp } from "lucide-react";
|
|
|
2
2
|
import React from "react";
|
|
3
3
|
|
|
4
4
|
const wrapperCls =
|
|
5
|
-
"w-full overflow-auto rounded-
|
|
5
|
+
"w-full overflow-auto rounded-(--radius-default) border border-(--table-border) bg-(--table-bg)";
|
|
6
6
|
|
|
7
7
|
const tableCls = "w-full caption-bottom text-sm";
|
|
8
8
|
|
|
9
|
-
const headerCls = "border-b border-
|
|
9
|
+
const headerCls = "border-b border-(--table-border) bg-(--table-header-bg)";
|
|
10
10
|
|
|
11
11
|
const bodyCls = "[&_tr:last-child]:border-0";
|
|
12
12
|
|
|
@@ -14,15 +14,15 @@ const rowCls =
|
|
|
14
14
|
"border-b border-[var(--table-border)] transition-colors hover:bg-[var(--table-row-hover-bg)] active:bg-[var(--table-row-active-bg)]";
|
|
15
15
|
|
|
16
16
|
const headCls =
|
|
17
|
-
"h-11 text-left align-middle font-medium text-
|
|
17
|
+
"h-11 text-left align-middle font-medium text-(--table-head-text)";
|
|
18
18
|
|
|
19
|
-
const cellCls = "align-middle text-
|
|
19
|
+
const cellCls = "align-middle text-(--table-cell-text)";
|
|
20
20
|
|
|
21
21
|
const sortButtonCls =
|
|
22
|
-
"group inline-flex w-full items-center justify-between gap-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--table-ring)] rounded-
|
|
22
|
+
"group inline-flex w-full items-center justify-between gap-2 text-left outline-none focus-visible:ring-2 focus-visible:ring-[var(--table-ring)] rounded-(--radius-default)";
|
|
23
23
|
|
|
24
24
|
const sortIconCls =
|
|
25
|
-
"text-
|
|
25
|
+
"text-(--table-sort-icon) transition-colors group-hover:text-(--table-sort-icon-hover)";
|
|
26
26
|
|
|
27
27
|
function toAriaSort(direction) {
|
|
28
28
|
if (direction === "asc") return "ascending";
|
|
@@ -148,7 +148,7 @@ export function TableHead({
|
|
|
148
148
|
{
|
|
149
149
|
"aria-hidden": "true",
|
|
150
150
|
className:
|
|
151
|
-
`${sortIconCls} ${direction !== "none" ? "text-
|
|
151
|
+
`${sortIconCls} ${direction !== "none" ? "text-(--table-sort-active)" : ""}`.trim(),
|
|
152
152
|
},
|
|
153
153
|
sortIndicator(direction),
|
|
154
154
|
),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
3
|
const base =
|
|
4
|
-
"block w-full rounded-
|
|
4
|
+
"block w-full rounded-(--radius-default) border border-(--input-border) bg-(--input-bg) px-3 py-2 text-sm text-(--input-text) placeholder:text-(--input-placeholder) transition-colors duration-200 hover:border-(--input-border-hover) focus-visible:border-(--input-border-focus) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--input-ring-focus)] disabled:opacity-(--input-disabled-opacity) disabled:cursor-not-allowed resize-y";
|
|
5
5
|
|
|
6
6
|
export function Textarea({ className = "", ...props }) {
|
|
7
7
|
return React.createElement("textarea", {
|
|
@@ -21,7 +21,7 @@ export function Toast({
|
|
|
21
21
|
const [shouldRender, setShouldRender] = useState(visible);
|
|
22
22
|
|
|
23
23
|
const variantCls =
|
|
24
|
-
"bg-
|
|
24
|
+
"bg-(--toast-bg) border-(--toast-border) text-(--toast-text) shadow-[var(--toast-shadow)]";
|
|
25
25
|
|
|
26
26
|
const remainingRef = useRef(duration);
|
|
27
27
|
const startRef = useRef(null);
|
|
@@ -98,7 +98,7 @@ export function Toast({
|
|
|
98
98
|
"data-toast-variant": variant,
|
|
99
99
|
onMouseEnter: () => setHovered(true),
|
|
100
100
|
onMouseLeave: () => setHovered(false),
|
|
101
|
-
className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-
|
|
101
|
+
className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-(--radius-default) px-4 py-3 text-sm transition-colors duration-200 ${variantCls}`,
|
|
102
102
|
style: animStyle,
|
|
103
103
|
},
|
|
104
104
|
React.createElement("span", null, message),
|
|
@@ -116,7 +116,7 @@ export function Toast({
|
|
|
116
116
|
"aria-label": "Dismiss notification",
|
|
117
117
|
onClick: onClose,
|
|
118
118
|
className:
|
|
119
|
-
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-
|
|
119
|
+
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-(--radius-default) text-base leading-none opacity-80 transition-all hover:bg-black/10 dark:hover:bg-white/10 hover:opacity-100 active:bg-black/20 dark:active:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
|
120
120
|
},
|
|
121
121
|
React.createElement(
|
|
122
122
|
"svg",
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
createLogger,
|
|
10
10
|
createQueue,
|
|
11
11
|
createWorker,
|
|
12
|
-
|
|
12
|
+
getRedisEndpoint,
|
|
13
13
|
updateQueueDepths,
|
|
14
14
|
} from "@usequark/quark-core";
|
|
15
15
|
import { AppError } from "@usequark/quark-core/errors";
|
|
@@ -170,7 +170,7 @@ export async function waitForRedis(
|
|
|
170
170
|
|
|
171
171
|
// All retries exhausted
|
|
172
172
|
throw new AppError(
|
|
173
|
-
`Redis unavailable at ${
|
|
173
|
+
`Redis unavailable at ${getRedisEndpoint()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
|
|
174
174
|
503,
|
|
175
175
|
"REDIS_UNAVAILABLE",
|
|
176
176
|
);
|
|
@@ -397,7 +397,7 @@ async function startWorker() {
|
|
|
397
397
|
isDevMode ? { maxRetries: 3, intervalMs: 500 } : {},
|
|
398
398
|
);
|
|
399
399
|
|
|
400
|
-
logger.info("Redis connected", { address:
|
|
400
|
+
logger.info("Redis connected", { address: getRedisEndpoint() });
|
|
401
401
|
|
|
402
402
|
// Wait for database schema to be ready (migrations may still be running)
|
|
403
403
|
logger.info("Checking database schema readiness...");
|