@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.24.2",
3
+ "version": "1.25.0",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
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
- await execa("git", ["init"], { cwd: projectDir });
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
- * Verifies the application and its dependencies are functioning correctly.
4
- * Times out after 5 seconds to prevent hanging.
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
- /** Overall timeout for the health check (ms). */
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 new Promise((resolve, reject) => {
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: result.status === "error" ? 503 : 200,
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: 500 },
66
+ { status: 200, headers: { "Cache-Control": "no-store" } },
55
67
  );
56
68
  }
57
69
  }
58
70
 
59
71
  /**
60
- * Runs all individual health checks and aggregates the results.
61
- * @returns {Promise<{ status: string, timestamp: string, checks: Record<string, object> }>}
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 health = {
65
- status: "ok",
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
- checks: {},
104
+ durationMs: Math.round(performance.now() - startedAt),
105
+ checks,
68
106
  };
107
+ }
69
108
 
70
- // Check database connectivity
71
- const dbResult = await pingDatabase();
72
- if (dbResult.status === "ok") {
73
- health.checks.database = { status: "ok", latencyMs: dbResult.latencyMs };
74
- } else {
75
- health.checks.database = {
76
- status: "error",
77
- message: dbResult.message,
78
- };
79
- health.status = "degraded";
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
- // Check Redis connectivity (actual PING)
83
- const redisResult = await pingRedis();
84
- if (redisResult.status === "ok") {
85
- health.checks.redis = { status: "ok", latencyMs: redisResult.latencyMs };
86
- } else {
87
- health.checks.redis = {
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: redisResult.message,
90
- };
91
- health.status = "degraded";
158
+ message: error?.message,
159
+ });
160
+ } finally {
161
+ clearTimeout(timer);
92
162
  }
163
+ }
93
164
 
94
- // Check storage connectivity
95
- const storageResult = await checkStorage();
96
- health.checks.storage = storageResult;
97
- if (storageResult.status === "error") {
98
- health.status = "degraded";
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
- // Check queue depths (only when queues are registered in this process)
102
- const registeredQueues = getRegisteredQueues();
103
- if (registeredQueues.size > 0) {
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
- const queueChecks = {};
116
- for (let i = 0; i < entries.length; i++) {
117
- const [name] = entries[i];
118
- const result = results[i];
119
- if (result.status === "fulfilled") {
120
- const [waiting, active, failed] = result.value;
121
- queueChecks[name] = { status: "ok", waiting, active, failed };
122
- } else {
123
- const message =
124
- process.env.NODE_ENV === "production"
125
- ? "Queue unavailable"
126
- : result.reason?.message;
127
- queueChecks[name] = { status: "error", message };
128
- health.status = "degraded";
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 health;
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, message?: 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
- try {
146
- const storage = createStorage();
147
- const sentinelKey = ".health-check-sentinel";
148
- await storage.put(sentinelKey, Buffer.from("ok"), {
149
- contentType: "text/plain",
150
- });
151
- await storage.delete(sentinelKey);
152
- return { status: "ok", provider };
153
- } catch (error) {
154
- const message =
155
- process.env.NODE_ENV === "production"
156
- ? "Storage unavailable"
157
- : error.message;
158
- return { status: "error", provider, message };
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
- if (pathname.startsWith("/api/")) {
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
- console.error(`No test files found in: ${roots.join(", ")}`);
54
- process.exit(1);
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-[--badge-bg] border-[--badge-border] text-[--badge-text]";
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-[--radius-default] ${className}`.trim();
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-[--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)]";
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",
@@ -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-[--card-border] bg-[--card-bg] transition-colors duration-200 linear";
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-[--card-title-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-[--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)]";
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-[--radius-default] ${className}`.trim(),
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-[--card-text-muted] transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] ${isOpen ? "rotate-180" : "rotate-0"}`,
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-[--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"}`,
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-[--radius-default] ${className}`.trim(),
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-[--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)]";
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-[--checkbox-label-text] select-none";
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-[--radius-default] border border-[--container-border] bg-[--container-bg] ${className}`.trim(),
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-[--dialog-border] bg-[--dialog-bg] p-0 w-full max-w-lg rounded-[--radius-default]";
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-[--dialog-border] px-5 py-4";
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-[--dialog-title-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-[--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]";
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-[--dialog-text-muted]";
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-[--error-border] bg-[--error-bg] px-3 py-2 text-sm text-[--error-text] rounded-[--radius-default] ${className}`.trim(),
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-[--footer-border] bg-[--footer-bg]";
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-[--footer-mark-bg] font-bold text-[--footer-mark-text]";
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-[--footer-text] sm:text-3xl";
12
- const brandTextCls = "max-w-md text-sm leading-7 text-[--footer-text-muted]";
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-[--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]";
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-[--footer-text-muted] transition-colors hover:text-[--footer-link-hover]";
19
- const textItemCls = "break-words text-[--footer-text-muted]";
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-[--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";
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
 
@@ -40,7 +40,7 @@ export function FormField({
40
40
  "p",
41
41
  {
42
42
  id: errorId,
43
- className: "text-xs text-[--error-text]",
43
+ className: "text-xs text-(--error-text)",
44
44
  role: "alert",
45
45
  },
46
46
  error,
@@ -1,11 +1,11 @@
1
1
  import React from "react";
2
2
 
3
3
  const base =
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";
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-[--radius-default] ${className}`.trim(),
8
+ className: `${base} rounded-(--radius-default) ${className}`.trim(),
9
9
  ...props,
10
10
  });
11
11
  }
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- const base = "block text-xs font-medium text-[--label-text]";
3
+ const base = "block text-xs font-medium text-(--label-text)";
4
4
 
5
5
  export function Label({ className = "", ...props }) {
6
6
  return React.createElement("label", {
@@ -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-[--lightbox-backdrop] px-3 py-4 transition-opacity duration-200 ease-out sm:px-6 sm:py-8 cursor-pointer";
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-[--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";
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-[--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";
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-[--radius-default] object-contain transition-all duration-200 ease-out";
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-[--lightbox-caption-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-[--lightbox-counter-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-[--navbar-border] bg-[--navbar-bg] shadow-[var(--navbar-inset-shadow)] backdrop-blur";
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-[--navbar-border] bg-[--navbar-mobile-bg] shadow-[var(--navbar-inset-shadow)]";
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-[--navbar-mobile-bg] shadow-[var(--navbar-inset-shadow)]";
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-[--radius-default] px-1.5 py-1 font-semibold tracking-wide text-[--navbar-text] transition-colors hover:text-[--navbar-logo-hover]";
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-[--radius-default] border border-[--navbar-mark-border] bg-[--navbar-mark-bg] font-bold text-[--navbar-mark-text]";
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-[--radius-default] px-3.5 font-medium text-[--navbar-text-muted] transition-colors hover:bg-[--navbar-hover-bg] hover:text-[--navbar-hover-text]";
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-[--radius-default] transition-colors hover:bg-[--navbar-hover-bg]";
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-[--navbar-text-muted] transition-colors group-hover:text-[--navbar-hover-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-[--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]";
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-[--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)]";
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-[--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)]";
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-[--navbar-border] bg-[--navbar-mobile-panel-bg] will-change-transform transition-all duration-300 ease-out";
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-[--navbar-text-muted] transition-colors hover:bg-[--navbar-hover-bg] hover:text-[--navbar-hover-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-[--navbar-text-muted] transition-colors hover:bg-[--navbar-hover-bg] hover:text-[--navbar-hover-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-[--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)]";
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-[--radius-default] px-3.5 py-2`,
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-[--radius-default] border border-[--navbar-border] bg-[--navbar-dropdown-bg] shadow-[var(--navbar-dropdown-shadow)]",
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-[--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]",
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-[--navbar-divide-border]" },
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-[--input-icon] hover:text-[--input-icon-hover] transition-colors",
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-[--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";
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-[--richtext-border] bg-[--richtext-toolbar-bg]";
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-[--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";
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-[--richtext-btn-active-bg] text-[--richtext-btn-active-text]";
39
+ "bg-(--richtext-btn-active-bg) text-(--richtext-btn-active-text)";
40
40
 
41
- const separatorCls = "bg-[--richtext-separator]";
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-[--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";
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-[--richtext-placeholder] before:pointer-events-none before:absolute before:left-3 before:top-2";
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-[--color-danger]" },
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-[--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";
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-[--radius-default] border border-[--select-border] bg-[--select-panel-bg] shadow-xl origin-top transition-all duration-200 ease-out";
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-[--radius-default] px-2.5 py-2 text-left text-sm transition-colors duration-150";
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-[--select-text] hover:bg-[--select-option-hover-bg] hover:text-[--select-option-hover-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-[--select-option-active-bg] text-[--select-option-active-text]";
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-[--select-text]"
253
- : "text-[--select-text-faint]",
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-[--select-text-faint] transition-transform duration-200 ease-in-out ${open ? "rotate-180" : "rotate-0"}`.trim(),
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-[--select-text-faint]" },
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-[--select-option-active-text]",
308
+ className: "text-(--select-option-active-text)",
309
309
  },
310
310
  React.createElement(Check, { size: 16 }),
311
311
  )
@@ -1,6 +1,6 @@
1
1
  import React from "react";
2
2
 
3
- const base = "animate-pulse rounded-lg bg-[--skeleton-bg]";
3
+ const base = "animate-pulse rounded-lg bg-(--skeleton-bg)";
4
4
 
5
5
  export function Skeleton({ className = "", ...props }) {
6
6
  return React.createElement("div", {
@@ -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-[--radius-default] border border-[--table-border] bg-[--table-bg]";
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-[--table-border] bg-[--table-header-bg]";
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-[--table-head-text]";
17
+ "h-11 text-left align-middle font-medium text-(--table-head-text)";
18
18
 
19
- const cellCls = "align-middle text-[--table-cell-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-[--radius-default]";
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-[--table-sort-icon] transition-colors group-hover:text-[--table-sort-icon-hover]";
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-[--table-sort-active]" : ""}`.trim(),
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-[--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";
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-[--toast-bg] border-[--toast-border] text-[--toast-text] shadow-[var(--toast-shadow)]";
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-[--radius-default] px-4 py-3 text-sm transition-colors duration-200 ${variantCls}`,
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-[--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",
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
- getRedisUrl,
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 ${getRedisUrl()} after ${maxRetries} attempts. Start Redis or check REDIS_URL/REDIS_HOST/REDIS_PORT.`,
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: getRedisUrl() });
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...");