artifacty 0.15.0 → 0.16.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/src/server.js CHANGED
@@ -70,6 +70,8 @@ import {
70
70
  setArtifactVisibility,
71
71
  setReviewStatus,
72
72
  setUserActive,
73
+ setUserRole,
74
+ resetUserPassword,
73
75
  updateArtifact,
74
76
  VersionConflictError,
75
77
  verifyUserPassword
@@ -78,13 +80,16 @@ import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNe
78
80
  import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
79
81
  import { installSamples, removeSamples } from "./lib/samples.js";
80
82
  import { createWebUpdater } from "./lib/web-update.js";
83
+ import { getLanguageSettings, saveLanguageSettings, importLanguagePack } from "./lib/language-settings.js";
84
+ import { getSmtpSettings, getSmtpDeliveryConfig, saveSmtpSettings } from "./lib/smtp-settings.js";
85
+ import { sendSmtpMail } from "./lib/smtp-client.js";
81
86
  import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
82
87
  import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
83
88
  import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
84
89
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
85
90
  import { createEmbeddingProvider } from "./lib/embeddings.js";
86
91
  import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
87
- import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
92
+ import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
88
93
  import { paginationJson } from "./lib/listing.js";
89
94
  import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
90
95
  import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
@@ -112,7 +117,10 @@ import {
112
117
  renderAccountPage,
113
118
  renderAdminBackupPage,
114
119
  renderAdminAccessPage,
120
+ renderAccessModePreviewPage,
115
121
  renderAdminUpdatePage,
122
+ renderAdminLanguagesPage,
123
+ renderAdminEmailPage,
116
124
  renderArtifactSharingPage,
117
125
  renderSharedArtifactPage,
118
126
  renderInboxPage,
@@ -205,7 +213,7 @@ export async function startServer(options = {}) {
205
213
  allowPortFallback,
206
214
  createServer(port) {
207
215
  const candidateServer = http.createServer((request, response) => {
208
- handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater }).catch((error) => {
216
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
209
217
  sendError(response, error);
210
218
  });
211
219
  });
@@ -310,20 +318,66 @@ function closeServer(server) {
310
318
  });
311
319
  }
312
320
 
313
- export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater }) {
321
+ function isBrowserPath(pathname) {
322
+ return !/^\/(?:api(?:\/|$)|mcp(?:\/|$)|assets\/|vendor\/|health$|events$|openapi\.json$)/.test(pathname)
323
+ && !/\/(?:raw|react-frame|export)$/.test(pathname);
324
+ }
325
+
326
+ export async function handleRequest(args) {
327
+ const { request, store } = args;
314
328
  await assertNetworkAccess(store, request.socket.remoteAddress);
329
+ const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
330
+ if (!isBrowserPath(pathname)) return handleScopedRequest(args);
331
+ const settings = await getLanguageSettings(store);
332
+ return runWithLanguageSettings(settings, () => handleScopedRequest(args));
333
+ }
334
+
335
+ async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail }) {
315
336
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
316
337
  const pathname = decodeURIComponent(url.pathname);
317
338
  const baseUrl = `http://${host}:${port}`;
318
339
  const authToken = url.searchParams.get("token") || "";
319
- const locale = localeFromUrl(url);
320
340
  const currentPath = `${url.pathname}${url.search}`;
341
+ const browserPage = isBrowserPath(pathname);
342
+ let locale = localeFromUrl(url);
343
+ if (browserPage) {
344
+ const explicit = url.searchParams.get("lang");
345
+ const cookie = parseCookies(request.headers.cookie).artifacty_locale;
346
+ const enabled = enabledLocales();
347
+ locale = enabled.includes(explicit) ? explicit
348
+ : enabled.includes(cookie) ? cookie : defaultLocale();
349
+ // Only this request-local URL changes. Keep the original path for links,
350
+ // and leave API and artifact bytes independent of browser preferences.
351
+ url.searchParams.set("lang", locale);
352
+ response.setHeader("cache-control", "no-store");
353
+ response.setHeader("vary", "Cookie");
354
+ if (enabled.includes(explicit)) {
355
+ response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
356
+ }
357
+ }
321
358
  const headOnly = request.method === "HEAD";
322
359
  const method = headOnly ? "GET" : request.method;
323
360
 
324
361
  if (method === "GET" && pathname === "/assets/update.js") {
325
362
  return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
326
363
  }
364
+ if (method === "GET" && pathname === "/assets/theme.js") {
365
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
366
+ }
367
+ if (method === "GET" && pathname === "/assets/auto-apply.js") {
368
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
369
+ }
370
+ if (method === "GET" && pathname === "/assets/sidebar.js") {
371
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
372
+ }
373
+ if (method === "GET" && pathname === "/assets/ui-controls.css") {
374
+ const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
375
+ response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
376
+ return response.end(headOnly ? undefined : content);
377
+ }
378
+ if (method === "GET" && pathname === "/assets/api-docs.js") {
379
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
380
+ }
327
381
 
328
382
  const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
329
383
  if (publicShareMatch && method === "GET") {
@@ -382,7 +436,7 @@ export async function handleRequest({ request, response, store, host, port, secu
382
436
  }
383
437
 
384
438
  if (method === "GET" && pathname === "/docs/api") {
385
- return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
439
+ return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
386
440
  }
387
441
 
388
442
  const userCount = await countUsers(store);
@@ -403,6 +457,7 @@ export async function handleRequest({ request, response, store, host, port, secu
403
457
  if (method === "POST" && pathname === "/login") {
404
458
  await enforceRateLimit({ store, host, request, bucket: "auth" });
405
459
  const body = await readFormBody(request);
460
+ const bodyLocale = localeFromBodyOrUrl(body, url);
406
461
  const email = body.email;
407
462
  const password = body.password;
408
463
  const user = userCount === 0
@@ -417,13 +472,13 @@ export async function handleRequest({ request, response, store, host, port, secu
417
472
  return sendHtml(response, renderLoginPage({
418
473
  baseUrl,
419
474
  setup: false,
420
- error: "Invalid email or password.",
421
- locale,
475
+ error: createI18n(bodyLocale).t("auth.invalidCredentials"),
476
+ locale: bodyLocale,
422
477
  currentPath
423
478
  }), 401);
424
479
  }
425
480
  const session = await createSession(store, user.id);
426
- return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
481
+ return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
427
482
  "set-cookie": sessionCookie(session.token)
428
483
  });
429
484
  }
@@ -453,13 +508,14 @@ export async function handleRequest({ request, response, store, host, port, secu
453
508
  return sendRedirect(response, "/login");
454
509
  }
455
510
  const body = await readFormBody(request);
511
+ const bodyLocale = localeFromBodyOrUrl(body, url);
456
512
  if (body.newPassword !== body.confirmPassword) {
457
513
  return sendHtml(response, renderPasswordPage({
458
514
  baseUrl,
459
515
  user: currentUser,
460
516
  required: currentUser.passwordResetRequired,
461
- error: "New password and confirmation do not match.",
462
- locale,
517
+ error: createI18n(bodyLocale).t("password.mismatch"),
518
+ locale: bodyLocale,
463
519
  currentPath
464
520
  }), 400);
465
521
  }
@@ -471,8 +527,8 @@ export async function handleRequest({ request, response, store, host, port, secu
471
527
  return sendHtml(response, renderPasswordPage({
472
528
  baseUrl,
473
529
  user: updatedUser,
474
- success: "Password changed.",
475
- locale,
530
+ success: createI18n(bodyLocale).t("password.changed"),
531
+ locale: bodyLocale,
476
532
  currentPath
477
533
  }));
478
534
  } catch (error) {
@@ -553,6 +609,68 @@ export async function handleRequest({ request, response, store, host, port, secu
553
609
  }
554
610
 
555
611
  const browserAccess = accessContext(request, currentUser);
612
+ if (pathname === "/admin/email") {
613
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
614
+ requireAdmin(currentUser);
615
+ const emailPage = async (extra = {}) => renderAdminEmailPage({
616
+ baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
617
+ });
618
+ if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
619
+ if (method === "POST") {
620
+ assertSameOrigin(request);
621
+ await enforceRateLimit({ store, host, request, bucket: "write" });
622
+ const body = await readFormBody(request, 16 * 1024);
623
+ try {
624
+ await saveSmtpSettings(store, {
625
+ enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
626
+ tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
627
+ }, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
628
+ return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
629
+ } catch (error) {
630
+ if (error.statusCode !== 400) throw error;
631
+ return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
632
+ }
633
+ }
634
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
635
+ }
636
+ if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
637
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
638
+ requireAdmin(currentUser);
639
+ if (method === "GET" && pathname === "/admin/languages/template") {
640
+ response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
641
+ return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
642
+ }
643
+ const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
644
+ baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
645
+ settings: await getLanguageSettings(store), ...extra
646
+ });
647
+ if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
648
+ if (method === "POST" && pathname !== "/admin/languages/template") {
649
+ assertSameOrigin(request);
650
+ await enforceRateLimit({ store, host, request, bucket: "write" });
651
+ try {
652
+ // URL encoding can use three bytes for each byte of the JSON text.
653
+ const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
654
+ const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
655
+ if (pathname.endsWith("/import")) {
656
+ if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
657
+ throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
658
+ }
659
+ let pack;
660
+ try { pack = JSON.parse(body.pack); }
661
+ catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
662
+ await importLanguagePack(store, pack, options);
663
+ } else {
664
+ await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
665
+ }
666
+ return sendRedirect(response, "/admin/languages");
667
+ } catch (error) {
668
+ if (![400, 413].includes(error.statusCode)) throw error;
669
+ return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
670
+ }
671
+ }
672
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
673
+ }
556
674
  if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
557
675
  if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
558
676
  requireAdmin(currentUser);
@@ -591,16 +709,23 @@ export async function handleRequest({ request, response, store, host, port, secu
591
709
  if (!currentUser) return sendRedirect(response, "/login");
592
710
  requireAdmin(currentUser);
593
711
  if (method === "GET" && pathname === "/admin/access") {
594
- return sendHtml(response, await adminAccessPage(), 200, headOnly);
712
+ const selectedMode = url.searchParams.get("mode");
713
+ const preview = ["public", "private"].includes(selectedMode)
714
+ ? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
715
+ return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
595
716
  }
596
717
  if (method === "POST") {
597
718
  assertLocalOrigin(request);
719
+ if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
598
720
  request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
599
721
  await enforceRateLimit({ store, host, request, bucket: "write" });
600
722
  const body = await readFormBody(request);
601
723
  const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
602
724
  if (pathname === "/admin/access/preview") {
603
- return sendHtml(response, await adminAccessPage({ preview: await previewAccessMode(store, body.mode, options) }), 200, headOnly);
725
+ return sendHtml(response, renderAccessModePreviewPage({
726
+ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
727
+ ...await previewAccessMode(store, body.mode, options)
728
+ }), 200, headOnly);
604
729
  } else if (pathname === "/admin/access") {
605
730
  await setAccessMode(store, body.mode, options);
606
731
  } else if (pathname === "/admin/access/network") {
@@ -811,6 +936,67 @@ export async function handleRequest({ request, response, store, host, port, secu
811
936
  }
812
937
  }
813
938
 
939
+ const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
940
+ if (userPasswordResetMatch && method === "POST") {
941
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
942
+ requireAdmin(currentUser);
943
+ assertSameOrigin(request);
944
+ await enforceRateLimit({ store, host, request, bucket: "write" });
945
+ const body = await readFormBody(request);
946
+ const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
947
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
948
+ });
949
+ resetResult.mailStatus = "not-configured";
950
+ try {
951
+ const smtp = await getSmtpDeliveryConfig(store);
952
+ if (smtp.enabled) {
953
+ const messages = createI18n(localeFromBodyOrUrl(body, url));
954
+ await smtpSender(smtp, {
955
+ to: resetResult.user.email,
956
+ subject: messages.t("smtp.resetSubject"),
957
+ text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
958
+ });
959
+ resetResult.mailStatus = "sent";
960
+ }
961
+ } catch {
962
+ // Reset already succeeded. Keep the one-time password available for
963
+ // manual delivery, including when SMTP acceptance is uncertain.
964
+ resetResult.mailStatus = "failed";
965
+ }
966
+ response.setHeader("cache-control", "no-store");
967
+ return sendHtml(response, renderAdminUsersPage({
968
+ baseUrl, user: currentUser, users: await listUsers(store), resetResult,
969
+ locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
970
+ }));
971
+ }
972
+
973
+ const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
974
+ if (userRoleMatch && method === "POST") {
975
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
976
+ requireAdmin(currentUser);
977
+ assertLocalOrigin(request);
978
+ if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
979
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
980
+ }
981
+ await enforceRateLimit({ store, host, request, bucket: "write" });
982
+ const body = await readFormBody(request);
983
+ const bodyLocale = localeFromBodyOrUrl(body, url);
984
+ try {
985
+ await setUserRole(store, userRoleMatch[1], body.role, {
986
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
987
+ });
988
+ const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
989
+ return sendRedirect(response, localizedHref(destination, bodyLocale));
990
+ } catch (error) {
991
+ if (![400, 409].includes(error.statusCode)) throw error;
992
+ return sendHtml(response, renderAdminUsersPage({
993
+ baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
994
+ roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
995
+ : error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
996
+ }), error.statusCode);
997
+ }
998
+ }
999
+
814
1000
  const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
815
1001
  if (userActiveMatch && method === "POST") {
816
1002
  if (!currentUser) {
@@ -1932,7 +2118,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
1932
2118
  const params = new URLSearchParams(raw);
1933
2119
  const body = Object.fromEntries(params.entries());
1934
2120
  // Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
1935
- for (const key of ["userIds", "teamIds"]) {
2121
+ for (const key of ["userIds", "teamIds", "enabledLocales"]) {
1936
2122
  if (params.has(key)) body[key] = params.getAll(key);
1937
2123
  }
1938
2124
  return body;
@@ -1954,6 +2140,13 @@ export async function readBody(request, limitBytes) {
1954
2140
  return Buffer.concat(chunks).toString("utf8");
1955
2141
  }
1956
2142
 
2143
+ function assertSameOrigin(request) {
2144
+ const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
2145
+ if (request.headers.origin !== expected) {
2146
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
2147
+ }
2148
+ }
2149
+
1957
2150
  export function assertLocalOrigin(request) {
1958
2151
  const origin = request.headers.origin;
1959
2152
  if (!origin) {
@@ -2200,6 +2393,10 @@ function javascriptCorsHeaders(request) {
2200
2393
  }
2201
2394
 
2202
2395
  export function sendRedirect(response, location, headers = {}) {
2396
+ const existingCookies = response.getHeader("set-cookie");
2397
+ if (existingCookies && headers["set-cookie"]) {
2398
+ headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
2399
+ }
2203
2400
  response.writeHead(303, {
2204
2401
  location,
2205
2402
  "cache-control": "no-store",
@@ -2776,7 +2973,7 @@ function parseCookies(header) {
2776
2973
  const key = part.slice(0, index).trim();
2777
2974
  const value = part.slice(index + 1).trim();
2778
2975
  if (key) {
2779
- cookies[key] = decodeURIComponent(value);
2976
+ try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
2780
2977
  }
2781
2978
  }
2782
2979
  return cookies;
@@ -0,0 +1,63 @@
1
+ // Artifacty control layer: official Bootstrap components only; no Reboot or layout reset.
2
+ @import "bootstrap/scss/functions";
3
+ $primary: #315ca8;
4
+ $secondary: #58616e;
5
+ $danger: #b42332;
6
+ $font-family-sans-serif: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
7
+ $font-size-base: .875rem;
8
+ $border-radius: .375rem;
9
+ $input-btn-font-size: .8125rem;
10
+ $input-btn-padding-y: .4375rem;
11
+ $input-btn-padding-x: .75rem;
12
+ $btn-font-weight: 500;
13
+ $btn-white-space: nowrap;
14
+ $input-btn-focus-width: .1875rem;
15
+ $input-btn-focus-color-opacity: .2;
16
+ $enable-shadows: false;
17
+ @import "bootstrap/scss/variables";
18
+ @import "bootstrap/scss/variables-dark";
19
+ @import "bootstrap/scss/maps";
20
+ @import "bootstrap/scss/mixins";
21
+ @import "bootstrap/scss/root";
22
+ @import "bootstrap/scss/forms";
23
+ @import "bootstrap/scss/buttons";
24
+ @import "bootstrap/scss/button-group";
25
+
26
+ :root, [data-bs-theme="dark"] {
27
+ --bs-body-font-family: #{$font-family-sans-serif};
28
+ --bs-body-color: var(--text, #20242b);
29
+ --bs-body-bg: var(--panel, #fff);
30
+ --bs-secondary-color: var(--muted, #58616e);
31
+ --bs-border-color: var(--line-2, #b9c1cc);
32
+ --bs-secondary-bg: var(--panel-2, #eef0f3);
33
+ }
34
+ .btn { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; gap: .375rem; }
35
+ .btn[hidden] { display: none !important; }
36
+ .form-control, .form-select { min-height: 38px; font-family: var(--bs-body-font-family); }
37
+ .form-control::placeholder { color: var(--muted, #58616e); opacity: .85; }
38
+ .form-control:focus, .form-select:focus { border-color: var(--accent, #315ca8); }
39
+ .btn-outline-secondary, .btn-ghost {
40
+ --bs-btn-color: var(--text, #20242b);
41
+ --bs-btn-border-color: var(--line, #dfe3e8);
42
+ --bs-btn-hover-color: var(--text, #20242b);
43
+ --bs-btn-hover-bg: var(--panel-2, #eef0f3);
44
+ --bs-btn-hover-border-color: var(--line-2, #b9c1cc);
45
+ --bs-btn-active-color: var(--text, #20242b);
46
+ --bs-btn-active-bg: var(--panel-2, #eef0f3);
47
+ --bs-btn-active-border-color: var(--line-2, #b9c1cc);
48
+ --bs-btn-disabled-color: var(--muted, #58616e);
49
+ --bs-btn-disabled-border-color: var(--line, #dfe3e8);
50
+ --bs-btn-focus-shadow-rgb: 49, 92, 168;
51
+ }
52
+ .btn-ghost { --bs-btn-border-color: transparent; }
53
+ [data-bs-theme="dark"] .btn-outline-danger {
54
+ --bs-btn-color: #ff9ba5;
55
+ --bs-btn-border-color: #73404a;
56
+ --bs-btn-hover-bg: #93313e;
57
+ --bs-btn-hover-border-color: #93313e;
58
+ }
59
+ .btn:focus-visible { outline: 2px solid var(--ring, #315ca8); outline-offset: 2px; }
60
+ @media (pointer: coarse) {
61
+ .btn, .form-control, .form-select { min-height: 44px; }
62
+ }
63
+ form[data-auto-apply] [hidden] { display: none !important; }