@arkstack/common 0.5.1 → 0.5.3

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.
@@ -0,0 +1,452 @@
1
+ import { a as env, f as resolveRuntimeModule, o as importFile, t as appKey } from "./system-DUaI4u99.js";
2
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
3
+ import { Arkstack } from "@arkstack/contract";
4
+ import path from "node:path";
5
+ import { Secret, TOTP } from "otpauth";
6
+ import { compare, genSalt, hash } from "bcryptjs";
7
+ import { getUserConfig } from "arkormx";
8
+ //#region src/utils/encryption.ts
9
+ var Encryption = class {
10
+ static algorithm = "aes-256-gcm";
11
+ static getKey() {
12
+ const secret = appKey("TWO_FACTOR_ENCRYPTION_KEY");
13
+ if (!secret) throw new Error("APP_KEY is required to use two-factor authentication. Run `ark key:generate`.");
14
+ return createHash("sha256").update(secret).digest();
15
+ }
16
+ static encrypt(value) {
17
+ const iv = randomBytes(12);
18
+ const cipher = createCipheriv(this.algorithm, this.getKey(), iv);
19
+ const ciphertext = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
20
+ return [
21
+ iv,
22
+ cipher.getAuthTag(),
23
+ ciphertext
24
+ ].map((part) => part.toString("base64url")).join(":");
25
+ }
26
+ static decrypt(payload) {
27
+ const [iv, authTag, ciphertext] = payload.split(":");
28
+ if (!iv || !authTag || !ciphertext) throw new Error("Invalid encrypted payload format");
29
+ const decipher = createDecipheriv(this.algorithm, this.getKey(), Buffer.from(iv, "base64url"));
30
+ decipher.setAuthTag(Buffer.from(authTag, "base64url"));
31
+ return Buffer.concat([decipher.update(Buffer.from(ciphertext, "base64url")), decipher.final()]).toString("utf8");
32
+ }
33
+ };
34
+ //#endregion
35
+ //#region src/utils/hash.ts
36
+ var Hash = class {
37
+ /**
38
+ * Hash a value using bcrypt
39
+ *
40
+ * @param value
41
+ * @returns
42
+ */
43
+ static async make(value) {
44
+ return await hash(value, await genSalt(10));
45
+ }
46
+ /**
47
+ * Verify a value against a hashed value
48
+ *
49
+ * @param value
50
+ * @param hashedValue
51
+ * @returns
52
+ */
53
+ static async verify(value, hashedValue) {
54
+ return await compare(value, hashedValue);
55
+ }
56
+ /**
57
+ * Generate a one-time password (OTP) using TOTP algorithm
58
+ *
59
+ * @param digits The number of digits for the OTP, default is 6.
60
+ * @param label A label to identify the OTP, can be an email or phone number.
61
+ * @param period Interval of time for which a token is valid, in seconds.
62
+ * @returns
63
+ */
64
+ static otp(digits = 6, label = "Alice", period = 30) {
65
+ return new TOTP({
66
+ label,
67
+ digits,
68
+ issuer: env("APP_NAME", "Roseed"),
69
+ algorithm: "SHA1",
70
+ period,
71
+ secret: "US3WHSG7X5KAPV27VANWKQHF3SH3HULL"
72
+ });
73
+ }
74
+ static totp(secret, label, issuer = env("APP_NAME", "Roseed"), period = 30) {
75
+ return new TOTP({
76
+ issuer,
77
+ label,
78
+ algorithm: "SHA1",
79
+ digits: 6,
80
+ period,
81
+ secret: Secret.fromBase32(secret)
82
+ });
83
+ }
84
+ };
85
+ //#endregion
86
+ //#region src/Exceptions/Exception.ts
87
+ var Exception = class extends Error {
88
+ name;
89
+ constructor(message, options) {
90
+ super(message, options);
91
+ this.name = "Exception";
92
+ }
93
+ };
94
+ //#endregion
95
+ //#region src/Exceptions/AppException.ts
96
+ var AppException = class extends Exception {
97
+ errors = void 0;
98
+ statusCode;
99
+ /**
100
+ * Custom properties merged into the error response payload.
101
+ *
102
+ * When set, these are merged over the standard error payload (`status`,
103
+ * `code`, `message`, …), letting a subclass add fields to — or reshape — the
104
+ * returned error body.
105
+ *
106
+ * @example
107
+ * ```ts
108
+ * class PaymentException extends AppException {
109
+ * body = { error_code: 'PAYMENT_FAILED', retryable: true }
110
+ * }
111
+ * ```
112
+ */
113
+ body;
114
+ constructor(message, statusCode = 400, options) {
115
+ super(message, options);
116
+ this.statusCode = statusCode;
117
+ }
118
+ };
119
+ //#endregion
120
+ //#region src/Exceptions/RequestException.ts
121
+ var RequestException = class RequestException extends AppException {
122
+ statusCode;
123
+ constructor(message, statusCode = 400, options) {
124
+ super(message, statusCode, options);
125
+ this.statusCode = statusCode;
126
+ }
127
+ /**
128
+ * Asserts that a value is not null or undefined.
129
+ *
130
+ * @param value
131
+ * @param message
132
+ * @param code
133
+ * @throws {RequestException} Throws if the value is null or undefined.
134
+ */
135
+ static assertFound(value, message, code = 404) {
136
+ if (!value) throw new RequestException(message, code);
137
+ }
138
+ /**
139
+ * Asserts that a value is not null or undefined.
140
+ *
141
+ * @param value
142
+ * @param message
143
+ * @param code
144
+ * @throws {RequestException} Throws if the value is null or undefined.
145
+ * @deprecated Use assertFound instead
146
+ */
147
+ static assertNotEmpty(value, message, code = 404) {
148
+ return this.assertFound(value, message, code);
149
+ }
150
+ /**
151
+ * Asserts that a boolean condition is true.
152
+ *
153
+ * @param boolean
154
+ * @param message
155
+ * @param code
156
+ * @throws {RequestException} Throws if the boolean condition is true.
157
+ */
158
+ static abortIf(boolean, message, code) {
159
+ if (boolean) throw new RequestException(message, code);
160
+ }
161
+ };
162
+ //#endregion
163
+ //#region src/utils/helpers.ts
164
+ /**
165
+ * Checks and asserts if target is a class
166
+ *
167
+ * @param target
168
+ * @returns
169
+ */
170
+ const isClass = (target) => {
171
+ return typeof target === "function" && /^class\s/.test(Function.prototype.toString.call(target));
172
+ };
173
+ /**
174
+ * Determine the number of items to return per page based on the provided query parameters.
175
+ *
176
+ * @param query
177
+ * @returns
178
+ */
179
+ const perPage = (query) => {
180
+ const requestedPerPage = Number(query.limit ?? query.perPage ?? 15);
181
+ return Number.isFinite(requestedPerPage) && requestedPerPage > 0 ? Math.min(requestedPerPage, 50) : 15;
182
+ };
183
+ async function getModel(modelName) {
184
+ const resolveModelExport = (module, modelName) => {
185
+ if (!isModelModule(module)) return module;
186
+ return module.default ?? module[modelName] ?? module;
187
+ };
188
+ const isModelModule = (value) => typeof value === "object" && value !== null;
189
+ const modelPath = getUserConfig().paths?.models || "./src/models";
190
+ const model = resolveModelExport(await importFile(resolveRuntimeModule(path.join(path.isAbsolute(modelPath) ? modelPath : path.join(Arkstack.rootDir(), modelPath), modelName))), path.basename(modelName, path.extname(modelName)));
191
+ if (typeof model !== "function") throw new Error(`Model "${modelName}" not found`);
192
+ return model;
193
+ }
194
+ const initializeGlobalContext = async ({ Request, Response, Session } = {}) => {
195
+ try {
196
+ const { Request: Req, Response: Res, Session: Ses } = await import("@arkstack/http");
197
+ Session ??= new Ses();
198
+ Request ??= new Req();
199
+ Response ??= new Res();
200
+ } catch {
201
+ Session ??= new class {}();
202
+ Request ??= new class {}();
203
+ Response ??= new class {}();
204
+ }
205
+ globalThis.session ??= () => Session;
206
+ globalThis.request ??= () => Request;
207
+ globalThis.response ??= () => Response;
208
+ };
209
+ /**
210
+ * Thows to abort the current request
211
+ *
212
+ * @param message
213
+ * @param code
214
+ * @throws {RequestException}
215
+ */
216
+ const abort = (message = "Request Aborted", code = 404) => {
217
+ RequestException.abortIf(true, message, code);
218
+ };
219
+ /**
220
+ * Asserts that a boolean condition is true.
221
+ *
222
+ * @param boolean
223
+ * @param message
224
+ * @param code
225
+ * @throws {RequestException} Throws if the boolean condition is true.
226
+ */
227
+ const abortIf = (boolean, message = "Request Aborted", code = 404) => {
228
+ RequestException.abortIf(boolean, message, code);
229
+ };
230
+ /**
231
+ * Asserts that a value is not null or undefined.
232
+ *
233
+ * @param value
234
+ * @param message
235
+ * @param code
236
+ * @throws {RequestException} Throws if the value is null or undefined.
237
+ */
238
+ const assertFound = (value, message, code = 404) => {
239
+ if (!value) throw new RequestException(message, code);
240
+ };
241
+ //#endregion
242
+ //#region src/utils/traits.ts
243
+ /**
244
+ * CRC32 implementation in TypeScript, adapted from https://stackoverflow.com/a/18639999
245
+ * Note: This implementation is not cryptographically secure and is only used for generating
246
+ * unique identifiers for traits based on their factory function's string representation.
247
+ */
248
+ const crcTable = [];
249
+ for (let n = 0; n < 256; n++) {
250
+ let c = n;
251
+ for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
252
+ crcTable[n] = c;
253
+ }
254
+ const crc32 = (str) => {
255
+ let crc = -1;
256
+ for (let i = 0; i < str.length; i++) crc = crc >>> 8 ^ crcTable[(crc ^ str.charCodeAt(i)) & 255];
257
+ return (crc ^ -1) >>> 0;
258
+ };
259
+ const isCons = (fn) => typeof fn === "function" && !!fn.prototype && !!fn.prototype.constructor;
260
+ const isArkormModelInstance = (value) => typeof value === "object" && value !== null && typeof value.constructor === "function" && typeof value.getAttribute === "function" && typeof value.setAttribute === "function";
261
+ const isTypeFactory = (fn) => typeof fn === "function" && !fn.prototype && fn.length === 0;
262
+ /**
263
+ * API: generate trait (technical implementation)
264
+ *
265
+ * @param args
266
+ */
267
+ function trait(...args) {
268
+ const factory = args.length === 2 ? args[1] : args[0];
269
+ const superTraits = args.length === 2 ? args[0] : void 0;
270
+ return {
271
+ id: crc32(factory.toString()),
272
+ symbol: Symbol("trait"),
273
+ factory,
274
+ superTraits
275
+ };
276
+ }
277
+ /**
278
+ * utility function: add an additional invisible property to an object
279
+ *
280
+ * @param cons
281
+ * @param field
282
+ * @param value
283
+ * @returns
284
+ */
285
+ const extendProperties = (cons, field, value) => Object.defineProperty(cons, field, {
286
+ value,
287
+ enumerable: false,
288
+ writable: false
289
+ });
290
+ const traitMethodRegistry = Symbol("trait-method-registry");
291
+ const cloneMethodRegistry = (target) => {
292
+ const registry = target[traitMethodRegistry];
293
+ return new Map([...registry?.entries() ?? []].map(([name, methods]) => [name, [...methods]]));
294
+ };
295
+ const registerMethodScope = (target, base, ignored) => {
296
+ const registry = cloneMethodRegistry(base);
297
+ for (const name of Reflect.ownKeys(target)) {
298
+ if (ignored.has(name)) continue;
299
+ const method = Object.getOwnPropertyDescriptor(target, name)?.value;
300
+ if (typeof method !== "function") continue;
301
+ const methods = registry.get(name);
302
+ const previous = base?.[name];
303
+ if (methods) {
304
+ if (methods.at(-1) !== method) methods.push(method);
305
+ registry.set(name, methods);
306
+ } else if (typeof previous === "function" && previous !== method) registry.set(name, [previous, method]);
307
+ }
308
+ Object.defineProperty(target, traitMethodRegistry, {
309
+ configurable: false,
310
+ enumerable: false,
311
+ value: registry,
312
+ writable: false
313
+ });
314
+ };
315
+ /**
316
+ * Registers conflicting trait methods
317
+ *
318
+ * @param classInstance
319
+ * @param baseClass
320
+ */
321
+ const registerTraitMethods = (classInstance, baseClass) => {
322
+ registerMethodScope(classInstance.prototype, baseClass.prototype, new Set(["constructor"]));
323
+ registerMethodScope(classInstance, baseClass, new Set([
324
+ "length",
325
+ "name",
326
+ "prototype",
327
+ "arguments",
328
+ "caller"
329
+ ]));
330
+ };
331
+ /**
332
+ * Return every trait implementation for a method, bound to the supplied
333
+ * instance or class. Methods are ordered from the base implementation to the
334
+ * currently active trait implementation.
335
+ *
336
+ * @param target
337
+ * @param name
338
+ * @returns
339
+ */
340
+ const getTraitMethods = (target, name) => {
341
+ return (((typeof target === "function" ? target : Object.getPrototypeOf(target))?.[traitMethodRegistry])?.get(name) ?? []).map((method) => method.bind(target));
342
+ };
343
+ /**
344
+ * Invoke every trait implementation for a method in registration order.
345
+ *
346
+ * @param target
347
+ * @param name
348
+ * @param args
349
+ * @returns
350
+ */
351
+ const callTraitMethods = (target, name, ...args) => {
352
+ const methods = getTraitMethods(target, name);
353
+ if (methods.length === 0) {
354
+ console.warn(`No conflicting trait methods found for "${String(name)}".`);
355
+ return [];
356
+ }
357
+ return methods.map((method) => method(...args));
358
+ };
359
+ /**
360
+ * utility function: get raw trait
361
+ *
362
+ * @param x
363
+ * @returns
364
+ */
365
+ const rawTrait = (x) => isTypeFactory(x) ? x() : x;
366
+ /**
367
+ * utility function: derive a trait
368
+ *
369
+ * @param trait$
370
+ * @param baseClass
371
+ * @param derived
372
+ * @returns
373
+ */
374
+ const deriveTrait = (trait$, baseClass, derived) => {
375
+ const trait = rawTrait(trait$);
376
+ if (trait === void 0 || trait === null || typeof trait.id !== "number") throw new Error("use(): received an undefined or invalid trait. This usually means a circular import — the trait module had not finished initializing when use() ran. Avoid importing models at the top level of trait modules, or break the import cycle.");
377
+ let classInstance = baseClass;
378
+ if (!derived.has(trait.id)) {
379
+ derived.set(trait.id, true);
380
+ if (trait.superTraits !== void 0) for (const superTrait of reverseTraitList(trait.superTraits)) classInstance = deriveTrait(superTrait, classInstance, derived);
381
+ const base = classInstance;
382
+ classInstance = trait.factory(classInstance);
383
+ registerTraitMethods(classInstance, base);
384
+ extendProperties(classInstance, "id", crc32(trait.factory.toString()));
385
+ extendProperties(classInstance, trait.symbol, true);
386
+ }
387
+ return classInstance;
388
+ };
389
+ /**
390
+ * utility function: get reversed trait list
391
+ *
392
+ * @param traits
393
+ * @returns
394
+ */
395
+ const reverseTraitList = (traits) => traits.slice().reverse();
396
+ function use(...args) {
397
+ const withMethodHelpers = args[0] === true;
398
+ const traits = withMethodHelpers ? args.slice(1) : args;
399
+ if (traits.length === 0) throw new Error("invalid number of parameters (expected one or more traits)");
400
+ let classInstance;
401
+ let lot;
402
+ const last = traits[traits.length - 1];
403
+ if (isCons(last) && !isTypeFactory(last)) {
404
+ classInstance = last;
405
+ lot = traits.slice(0, -1);
406
+ } else if (isArkormModelInstance(last)) {
407
+ classInstance = last.constructor;
408
+ lot = traits.slice(0, -1);
409
+ } else {
410
+ classInstance = class ROOT {};
411
+ lot = traits;
412
+ }
413
+ const derived = /* @__PURE__ */ new Map();
414
+ for (const trait of reverseTraitList(lot)) classInstance = deriveTrait(trait, classInstance, derived);
415
+ if (withMethodHelpers) classInstance = class TraitMethodEnabled extends classInstance {
416
+ getTraitMethods(name) {
417
+ return getTraitMethods(this, name);
418
+ }
419
+ callTraitMethods(name, ...args) {
420
+ return callTraitMethods(this, name, ...args);
421
+ }
422
+ static getTraitMethods(name) {
423
+ return getTraitMethods(this, name);
424
+ }
425
+ static callTraitMethods(name, ...args) {
426
+ return callTraitMethods(this, name, ...args);
427
+ }
428
+ };
429
+ return classInstance;
430
+ }
431
+ /**
432
+ * API: type guard for checking whether class instance is derived from a trait
433
+ *
434
+ * @param instance
435
+ * @param trait
436
+ * @returns
437
+ */
438
+ function uses(instance, trait) {
439
+ if (typeof instance !== "object" || instance === null) return false;
440
+ let obj = instance;
441
+ if (isCons(trait) && !isTypeFactory(trait)) return instance instanceof trait;
442
+ const idTrait = (isTypeFactory(trait) ? trait() : trait)["id"];
443
+ while (obj) {
444
+ if (Object.hasOwn(obj, "constructor")) {
445
+ if ((obj.constructor["id"] ?? 0) === idTrait) return true;
446
+ }
447
+ obj = Object.getPrototypeOf(obj);
448
+ }
449
+ return false;
450
+ }
451
+ //#endregion
452
+ export { Hash as _, use as a, abortIf as c, initializeGlobalContext as d, isClass as f, Exception as g, AppException as h, trait as i, assertFound as l, RequestException as m, crc32 as n, uses as o, perPage as p, getTraitMethods as r, abort as s, callTraitMethods as t, getModel as u, Encryption as v };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@arkstack/common",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
4
4
  "type": "module",
5
- "description": "Shared foundations and core utilities for Arkstack applications and packages.",
5
+ "description": "Core utilities, primitives, and shared infrastructure for the Arkstack ecosystem.",
6
6
  "homepage": "https://arkstack.toneflix.net",
7
7
  "repository": {
8
8
  "type": "git",
@@ -20,29 +20,41 @@
20
20
  "arkstack"
21
21
  ],
22
22
  "files": [
23
- "dist"
23
+ "dist",
24
+ "resources"
24
25
  ],
25
26
  "publishConfig": {
26
27
  "access": "public"
27
28
  },
28
29
  "exports": {
29
30
  ".": "./dist/index.js",
31
+ "./faker": "./dist/faker.js",
32
+ "./utils": "./dist/utils/index.js",
30
33
  "./package.json": "./package.json"
31
34
  },
32
35
  "dependencies": {
33
- "jiti": "^2.7.0",
36
+ "@pictwo/faker": "^1.1.0",
34
37
  "bcryptjs": "^3.0.3",
35
38
  "chalk": "^5.6.2",
36
39
  "detect-port": "^2.1.0",
40
+ "dotenv": "^17.4.2",
41
+ "jiti": "^2.7.0",
37
42
  "otpauth": "^9.5.1",
38
- "pino": "^10.3.1"
43
+ "pino": "^10.3.1",
44
+ "selfsigned": "^2.4.1"
39
45
  },
40
46
  "peerDependencies": {
41
- "@h3ravel/support": "^0.15.11",
42
- "arkormx": "^2.0.11"
47
+ "@h3ravel/support": "^2.2.0",
48
+ "arkormx": "^2.10.1",
49
+ "@arkstack/contract": "^0.5.3",
50
+ "@arkstack/foundry": "^0.5.3"
51
+ },
52
+ "optionalDependencies": {
53
+ "@faker-js/faker": "^10.4.0"
43
54
  },
44
55
  "scripts": {
45
56
  "build": "tsdown --config-loader unrun",
57
+ "test": "vitest",
46
58
  "version:patch": "pnpm version patch"
47
59
  }
48
60
  }
@@ -0,0 +1,199 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>{{ code }} | {{ title }}</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Syne:wght@700;800&family=DM+Mono:wght@400;500&display=swap"
11
+ rel="stylesheet" />
12
+ <style>
13
+ *,
14
+ *::before,
15
+ *::after {
16
+ box-sizing: border-box;
17
+ margin: 0;
18
+ padding: 0;
19
+ }
20
+
21
+ /* ── Dark theme (default) ── */
22
+ :root {
23
+ --gold-light: #f0c040;
24
+ --gold-mid: #c9960c;
25
+ --bg: #0b0c0e;
26
+ --bg-card: #111317;
27
+ --bg-stack: #0d0f12;
28
+ --border: rgba(201, 150, 12, 0.18);
29
+ --border-err: rgba(220, 60, 60, 0.22);
30
+ --text: #f5f0e8;
31
+ --muted: #7a7567;
32
+ --red: #e05252;
33
+ --radius: 10px;
34
+ --pre-color: #c0a888;
35
+ }
36
+
37
+ /* ── Light theme ── */
38
+ [data-theme="light"] {
39
+ --gold-light: #a06800;
40
+ --gold-mid: #8a5a00;
41
+ --bg: #f5f3ef;
42
+ --bg-card: #ffffff;
43
+ --bg-stack: #faf8f5;
44
+ --border: rgba(160, 104, 0, 0.16);
45
+ --border-err: rgba(180, 40, 40, 0.18);
46
+ --text: #1a1714;
47
+ --muted: #9a9080;
48
+ --red: #c0392b;
49
+ --pre-color: #6a5040;
50
+ }
51
+
52
+ /* ── OS preference fallback (no JS) ── */
53
+ @media (prefers-color-scheme: light) {
54
+ :root:not([data-theme="dark"]) {
55
+ --gold-light: #a06800;
56
+ --gold-mid: #8a5a00;
57
+ --bg: #f5f3ef;
58
+ --bg-card: #ffffff;
59
+ --bg-stack: #faf8f5;
60
+ --border: rgba(160, 104, 0, 0.16);
61
+ --border-err: rgba(180, 40, 40, 0.18);
62
+ --text: #1a1714;
63
+ --muted: #9a9080;
64
+ --red: #c0392b;
65
+ --pre-color: #6a5040;
66
+ }
67
+ }
68
+
69
+ html,
70
+ body {
71
+ min-height: 100vh;
72
+ background: var(--bg);
73
+ color: var(--text);
74
+ font-family: 'DM Mono', monospace;
75
+ display: flex;
76
+ align-items: center;
77
+ justify-content: center;
78
+ padding: 2rem 1rem;
79
+ transition: background 0.25s, color 0.25s;
80
+ }
81
+
82
+ .page {
83
+ max-width: 620px;
84
+ width: 100%;
85
+ display: flex;
86
+ flex-direction: column;
87
+ align-items: flex-start;
88
+ }
89
+
90
+ /* ── Top bar ── */
91
+ .topbar {
92
+ width: 100%;
93
+ display: flex;
94
+ align-items: center;
95
+ justify-content: space-between;
96
+ margin-bottom: 3rem;
97
+ }
98
+
99
+ /* ── Error heading ── */
100
+ .error-code {
101
+ font-family: 'Syne', sans-serif;
102
+ font-size: 3.5rem;
103
+ font-weight: 800;
104
+ line-height: 1;
105
+ letter-spacing: -0.04em;
106
+ color: var(--red);
107
+ margin-bottom: 0.5rem;
108
+ opacity: 0.9;
109
+ }
110
+
111
+ .error-title {
112
+ font-family: 'Syne', sans-serif;
113
+ font-size: 1.2rem;
114
+ font-weight: 700;
115
+ color: var(--text);
116
+ margin-bottom: 0.6rem;
117
+ }
118
+
119
+ .error-message {
120
+ font-size: 0.78rem;
121
+ color: var(--muted);
122
+ line-height: 1.7;
123
+ margin-bottom: 2rem;
124
+ }
125
+
126
+ /* ── Divider ── */
127
+ .divider {
128
+ width: 100%;
129
+ height: 1px;
130
+ background: linear-gradient(90deg, var(--border-err), transparent);
131
+ margin-bottom: 2rem;
132
+ }
133
+
134
+ /* ── Stack trace ── */
135
+ .stack-label {
136
+ font-size: 0.65rem;
137
+ letter-spacing: 0.14em;
138
+ text-transform: uppercase;
139
+ color: var(--muted);
140
+ margin-bottom: 0.65rem;
141
+ }
142
+
143
+ .stack-block {
144
+ width: 100%;
145
+ background: var(--bg-stack);
146
+ border: 1px solid var(--border-err);
147
+ border-radius: var(--radius);
148
+ padding: 1.1rem 1.25rem;
149
+ position: relative;
150
+ overflow: hidden;
151
+ transition: background 0.25s, border-color 0.25s;
152
+ }
153
+
154
+ .stack-block::before {
155
+ content: '';
156
+ position: absolute;
157
+ top: 0;
158
+ left: 0;
159
+ right: 0;
160
+ height: 2px;
161
+ background: linear-gradient(90deg, var(--red), transparent);
162
+ }
163
+
164
+ .stack-block pre {
165
+ font-family: 'DM Mono', monospace;
166
+ font-size: 0.72rem;
167
+ color: var(--pre-color);
168
+ line-height: 1.85;
169
+ overflow-x: auto;
170
+ white-space: pre;
171
+ }
172
+ </style>
173
+ </head>
174
+
175
+ <body>
176
+ <div class="page">
177
+ <div class="error-code">{{ code }}</div>
178
+ <div class="error-title">{{ title }}</div>
179
+ <p class="error-message">{{ message }}</p>
180
+
181
+ <div class="divider"></div>
182
+
183
+ @if(stack)
184
+ <div class="stack-label">Stack Trace</div>
185
+ <div class="stack-block">
186
+ <pre>{{ stack }}</pre>
187
+ </div>
188
+ @end
189
+
190
+ </div>
191
+
192
+ <script>
193
+ const root = document.documentElement;
194
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
195
+ root.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
196
+ </script>
197
+ </body>
198
+
199
+ </html>