feat: custom themes initial

This commit is contained in:
3252a8
2026-05-14 16:33:50 +03:00
parent 46520fb37c
commit b6ee5e8790
79 changed files with 4151 additions and 152 deletions
+2
View File
@@ -39,6 +39,8 @@ WEBAPP_SERVER_HOST=0.0.0.0 #
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="/minishop" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_THEMES_DIR=data/themes # Folder with theme subfolders: <key>/theme.json and optional CSS/assets
WEBAPP_DEFAULT_THEME= # Optional: override descriptor default theme key (e.g. light)
WEBAPP_LOGO_URL= # Optional logo URL; if empty the emoji below is used
WEBAPP_LOGO_EMOJI="🫥" # Emoji logo fallback shown in the header and login screen
WEBAPP_SESSION_SECRET= # Optional: HMAC secret for webapp sessions; generated if empty
+2
View File
@@ -17,6 +17,7 @@ from bot.app.web.admin_api_impl import (
stats as _stats,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
users as _users,
)
@@ -34,6 +35,7 @@ _MODULES = (
_ads,
_settings,
_tariffs,
_themes,
_panel,
_routes,
)
+4
View File
@@ -270,6 +270,10 @@ def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None:
path.write_text(payload, encoding="utf-8")
def _webapp_themes_catalog_payload(config: Any) -> Dict[str, Any]:
return config.model_dump(mode="json", exclude_none=True)
def _panel_node_uuid_key(node: Dict[str, Any]) -> str:
uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id")
return str(uid).strip().lower() if uid else ""
+2
View File
@@ -54,4 +54,6 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/tariffs", admin_tariffs_get_route)
router.add_put("/api/admin/tariffs", admin_tariffs_save_route)
router.add_get("/api/admin/themes", admin_themes_get_route)
router.add_put("/api/admin/themes", admin_themes_save_route)
router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route)
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import (
WebappThemesConfig,
ensure_webapp_core_themes,
resolved_webapp_themes_catalog,
write_webapp_theme_dir,
)
async def admin_themes_get_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
primary = settings.WEBAPP_PRIMARY_COLOR or "#00fe7a"
catalog = resolved_webapp_themes_catalog(
primary_accent=primary,
env_default_theme=settings.WEBAPP_DEFAULT_THEME,
theme_dir=settings.WEBAPP_THEMES_DIR,
)
return _ok(
{
"exists": Path(settings.WEBAPP_THEMES_DIR).expanduser().exists(),
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(catalog),
}
)
async def admin_themes_save_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
settings: Settings = request.app["settings"]
payload = await _read_json(request)
catalog = payload.get("catalog") if "catalog" in payload else payload
if not isinstance(catalog, dict):
return _error(400, "invalid_payload", "catalog must be an object")
try:
config = WebappThemesConfig.model_validate(catalog)
except (ValidationError, ValueError) as exc:
return _error(400, "invalid_webapp_themes_config", str(exc))
config, _changed = ensure_webapp_core_themes(config, settings.WEBAPP_PRIMARY_COLOR or "#00fe7a")
try:
write_webapp_theme_dir(settings.WEBAPP_THEMES_DIR, config, delete_missing=True)
except OSError as exc:
logger.exception("Failed to write webapp themes to %s", settings.WEBAPP_THEMES_DIR)
return _error(500, "write_failed", str(exc))
cache = request.app.get("webapp_settings_cache")
if isinstance(cache, dict):
cache["ts"] = 0.0
cache["data"] = {}
return _ok(
{
"exists": True,
"themes_dir": str(Path(settings.WEBAPP_THEMES_DIR).expanduser()),
"catalog": _webapp_themes_catalog_payload(config),
}
)
+32 -2
View File
@@ -40,6 +40,13 @@
import { normalizedEmail, telegramName } from "./lib/webapp/formatters.js";
import { activeTariffName, buildTariffCatalog } from "./lib/webapp/tariffs.js";
import { premiumTrafficPercent, trafficPercent } from "./lib/webapp/traffic.js";
import {
findThemeEntry,
resolveEffectiveThemeKey,
themeCssHref,
themeEntryToInlineStyle,
themeRootClass,
} from "./lib/webapp/themeStyle.js";
/** Used-traffic percent from which top-up modals and CTAs unlock in the web app home screen */
const TRAFFIC_TOPUP_UNLOCK_PERCENT = 80;
@@ -243,7 +250,6 @@
emoji: brandEmoji,
emojiFont: brandEmojiFont,
});
$: accent = CFG.primaryColor || "#00fe7a";
$: plans = data?.plans?.length ? data.plans : DEV_MOCK.data.plans;
$: methods = data?.payment_methods?.length ? data.payment_methods : [];
$: appSettings = data?.settings || DEV_MOCK.data.settings;
@@ -300,6 +306,26 @@
premiumTrafficPercent(subscription) >= TRAFFIC_TOPUP_UNLOCK_PERCENT)
);
$: user = data?.user || {};
$: themesCatalog = data?.themes_catalog ||
CFG.themesCatalog || { default_theme: "dark", themes: [] };
$: resolvedThemeKey = resolveEffectiveThemeKey(themesCatalog);
$: activeThemeEntry = findThemeEntry(themesCatalog, resolvedThemeKey);
$: darkThemeEntry = findThemeEntry(themesCatalog, "dark");
$: effectiveThemeEntry =
screen === "admin" && activeThemeEntry?.use_in_admin === false
? darkThemeEntry || activeThemeEntry
: activeThemeEntry;
$: shellStyle = themeEntryToInlineStyle(effectiveThemeEntry, CFG.primaryColor);
$: shellToneClass =
effectiveThemeEntry?.tokens?.color_scheme === "light" ? "theme-light" : "theme-dark";
$: shellThemeClass = themeRootClass(effectiveThemeEntry);
$: shellThemeCssHref = themeCssHref(effectiveThemeEntry);
$: if (typeof document !== "undefined" && effectiveThemeEntry?.tokens) {
const scheme = effectiveThemeEntry.tokens.color_scheme || "dark";
document.documentElement.style.colorScheme = scheme;
const bg = effectiveThemeEntry.tokens.bg;
if (bg) document.body.style.backgroundColor = bg;
}
$: isAdmin = Boolean(user?.is_admin);
$: if (screen === "admin" && !isAdmin) {
screen = "settings";
@@ -873,6 +899,9 @@
<svelte:head>
<title>{brandTitle}</title>
{#if shellThemeCssHref}
<link rel="stylesheet" href={shellThemeCssHref} data-theme-css={resolvedThemeKey} />
{/if}
</svelte:head>
<Tooltip.Provider>
@@ -880,7 +909,7 @@
{#if isPreviewBoard}
<PreviewBoard config={CFG} mockData={DEV_MOCK.data} />
{:else}
<div class="app-shell" style={`--accent: ${accent};`}>
<div class="app-shell {shellToneClass} {shellThemeClass}" style={shellStyle}>
{#if mode === "loading"}
<div class="loader">
<BrandMark {brand} size="md" />
@@ -931,6 +960,7 @@
onSectionChange={handleAdminSectionChange}
onSettingsSaved={handleAdminPersistedSaved}
onTariffsSaved={handleAdminPersistedSaved}
onThemesSaved={handleAdminPersistedSaved}
{brandTitle}
{brand}
appVersion={CFG.appVersion}
@@ -11,6 +11,7 @@
LayoutDashboard,
Megaphone,
Menu,
Paintbrush,
Plus,
RefreshCw,
Save,
@@ -34,6 +35,7 @@
import StatsSection from "./sections/StatsSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte";
import ThemesSection from "./sections/ThemesSection.svelte";
import UserDetailModal from "./sections/UserDetailModal.svelte";
import UsersSection from "./sections/UsersSection.svelte";
import { createAdsStore } from "../lib/admin/stores/adsStore.js";
@@ -44,6 +46,7 @@
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
import {
fmtDate,
@@ -70,6 +73,7 @@
export let onSectionChange = () => {};
export let onSettingsSaved = () => {};
export let onTariffsSaved = () => {};
export let onThemesSaved = () => {};
export let brand = {};
export let brandTitle = "/minishop";
export let appVersion = "dev+local";
@@ -111,6 +115,7 @@
label: at("nav_system", {}, "Система"),
items: [
{ id: "tariffs", label: at("nav_tariffs", {}, "Тарифы"), icon: Coins },
{ id: "themes", label: at("nav_themes", {}, "Темы"), icon: Paintbrush },
{ id: "settings", label: at("nav_settings", {}, "Настройки"), icon: Sliders },
],
},
@@ -153,6 +158,10 @@
title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
},
themes: {
title: at("section_themes_title", {}, "Темы Web App"),
subtitle: at("section_themes_subtitle", {}, "Цвета, шрифты и темы оформления Mini App"),
},
settings: {
title: at("section_settings_title", {}, "Настройки приложения"),
subtitle: at("section_settings_subtitle", {}, "Оверрайды над .env, применяются мгновенно"),
@@ -196,6 +205,7 @@
const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at });
setContext("promosStore", promosStore);
@@ -207,6 +217,7 @@
setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore);
setContext("themesStore", themesStore);
$: usersStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
@@ -635,6 +646,10 @@
<TariffsSection {at} {fmtMoney} />
{/if}
{#if active === "themes"}
<ThemesSection {at} {currentLang} />
{/if}
{#if active === "settings"}
<SettingsSection {at} {isCompact} {onSettingsSaved} {currentLang} />
{/if}
@@ -0,0 +1,267 @@
<script>
import { Check, FileText, RefreshCw, Save } from "$components/ui/icons.js";
import { getContext, onMount } from "svelte";
import { AdminBadge, AdminButton, AdminEmptyState } from "$components/patterns/admin/index.js";
import { localizedThemeName } from "$lib/webapp/themeStyle.js";
export let at;
export let currentLang = "ru";
const themesStore = getContext("themesStore");
$: ({ themesCatalog, themesLoading, themesDir, themesSaving } = $themesStore);
$: activeKey = themesCatalog.default_theme;
function themeTitle(theme) {
return localizedThemeName(theme, currentLang) || "—";
}
function themeDescription(theme) {
const folder = `${themesDir || "data/themes"}/${theme.key}`;
return theme.css_file ? `${folder}/${theme.css_file}` : `${folder}/theme.json`;
}
function toggleAccent(event, theme) {
event.stopPropagation();
themesStore.togglePrimaryAccent(theme.key, event.currentTarget.checked);
}
function toggleAdminTheme(event, theme) {
event.stopPropagation();
themesStore.toggleAdminUse(theme.key, event.currentTarget.checked);
}
function isThemeOptionEvent(event) {
return Boolean(event?.target?.closest?.(".admin-theme-card-option"));
}
function selectTheme(theme, event = null) {
if (isThemeOptionEvent(event)) return;
if (!themesSaving) themesStore.setCurrentTheme(theme.key);
}
function handleCardKeydown(event, theme) {
if (isThemeOptionEvent(event)) return;
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
selectTheme(theme);
}
onMount(() => {
themesStore.loadThemes();
});
</script>
{#if themesLoading}
<AdminEmptyState>{at("loading", {}, "Загрузка…")}</AdminEmptyState>
{:else}
<article class="admin-card">
<header class="admin-card-head">
<div>
<h3>{at("themes_catalog_title", {}, "Темы Web App")}</h3>
<small
>{at(
"themes_catalog_sub",
{},
"Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы"
)}</small
>
</div>
<div class="admin-editor-section-actions">
<AdminButton
size="sm"
onclick={themesStore.loadThemes}
disabled={themesLoading || themesSaving}
>
<RefreshCw size={13} />
{at("btn_refresh", {}, "Обновить")}
</AdminButton>
<AdminButton
size="sm"
variant="primary"
onclick={themesStore.saveThemes}
disabled={themesLoading || themesSaving}
>
<Save size={13} />
{at("btn_save", {}, "Сохранить")}
</AdminButton>
</div>
</header>
<div class="admin-card-body">
{#if !themesCatalog.themes.length}
<AdminEmptyState>
{at(
"themes_catalog_empty",
{},
"Каталог пуст. Добавьте папку темы в data/themes и обновите список."
)}
</AdminEmptyState>
{:else}
<div class="admin-theme-grid">
{#each themesCatalog.themes as theme (theme.key)}
{@const isCurrent = theme.key === activeKey}
<div
role="button"
tabindex={themesSaving ? -1 : 0}
class="admin-theme-card"
class:is-current={isCurrent}
class:is-disabled={theme.enabled === false}
aria-pressed={isCurrent}
aria-disabled={themesSaving}
onclick={(event) => selectTheme(theme, event)}
onkeydown={(event) => handleCardKeydown(event, theme)}
>
<span class="admin-theme-card-main">
<span class="admin-theme-card-title">
<strong>{themeTitle(theme)}</strong>
{#if isCurrent}
<AdminBadge variant="success">{at("status_current", {}, "Текущая")}</AdminBadge>
{/if}
</span>
<small>{theme.key}</small>
</span>
<span class="admin-theme-card-meta">
<FileText size={15} />
<span>{themeDescription(theme)}</span>
</span>
<label class="admin-theme-card-option">
<input
type="checkbox"
checked={theme.use_primary_accent !== false}
disabled={themesSaving}
onchange={(event) => toggleAccent(event, theme)}
/>
<span>{at("themes_use_primary_accent", {}, "Протягивать акцент")}</span>
</label>
<label class="admin-theme-card-option">
<input
type="checkbox"
checked={theme.use_in_admin !== false}
disabled={themesSaving}
onchange={(event) => toggleAdminTheme(event, theme)}
/>
<span>{at("themes_use_in_admin", {}, "Использовать в админке")}</span>
</label>
<span class="admin-theme-card-check" aria-hidden="true">
{#if isCurrent}<Check size={18} />{/if}
</span>
</div>
{/each}
</div>
{/if}
</div>
</article>
{/if}
<style>
.admin-theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
}
.admin-theme-card {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
min-height: 118px;
padding: 14px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-surface);
color: var(--admin-text);
text-align: left;
cursor: pointer;
}
.admin-theme-card:hover {
border-color: var(--admin-border-strong);
background: color-mix(in srgb, var(--admin-surface-2) 72%, var(--admin-surface));
}
.admin-theme-card.is-current {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 44%, transparent);
}
.admin-theme-card.is-disabled {
opacity: 0.58;
}
.admin-theme-card-main {
display: grid;
align-content: start;
gap: 5px;
min-width: 0;
}
.admin-theme-card-title {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.admin-theme-card-title strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-theme-card-main small,
.admin-theme-card-meta {
color: var(--admin-muted);
font-size: 12px;
}
.admin-theme-card-meta {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
}
.admin-theme-card-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-theme-card-option {
grid-column: 1 / -1;
display: inline-flex;
align-items: center;
gap: 8px;
width: fit-content;
max-width: 100%;
color: var(--admin-muted);
font-size: 12px;
cursor: default;
}
.admin-theme-card-option input {
flex: 0 0 auto;
width: 15px;
height: 15px;
margin: 0;
accent-color: var(--accent);
}
.admin-theme-card-option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-theme-card-check {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 999px;
color: var(--accent);
}
</style>
@@ -0,0 +1,113 @@
function cloneCatalog(catalog) {
return JSON.parse(JSON.stringify(catalog || { default_theme: "dark", themes: [] }));
}
import { writable } from "svelte/store";
export function createThemesStore({ api, onThemesSaved, flash, at }) {
const state = writable({
themesCatalog: { default_theme: "dark", themes: [] },
themesDir: "",
themesLoading: false,
themesSaving: false,
});
async function loadThemes() {
state.update((s) => ({ ...s, themesLoading: true }));
try {
const data = await api("/admin/themes");
if (data?.ok) {
state.update((s) => ({
...s,
themesCatalog: cloneCatalog(data.catalog),
themesDir: data.themes_dir || "",
}));
} else {
flash(data?.message || data?.error || at("load_failed", {}, "Не удалось загрузить темы"));
}
} finally {
state.update((s) => ({ ...s, themesLoading: false }));
}
}
async function saveThemes(options = {}) {
const silent = Boolean(options.silent);
let catalog = null;
state.update((s) => {
catalog = cloneCatalog(s.themesCatalog);
return { ...s, themesSaving: true };
});
try {
const data = await api("/admin/themes", {
method: "PUT",
body: JSON.stringify({ catalog }),
});
if (data?.ok) {
state.update((s) => ({
...s,
themesCatalog: cloneCatalog(data.catalog),
themesDir: data.themes_dir || s.themesDir,
}));
if (!silent) flash(at("themes_saved", {}, "Темы сохранены"));
if (typeof onThemesSaved === "function") onThemesSaved();
} else {
flash(data?.message || data?.error || at("themes_save_failed", {}, "Не удалось сохранить"));
}
} finally {
state.update((s) => ({ ...s, themesSaving: false }));
}
}
async function setCurrentTheme(key) {
let changed = false;
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
default_theme: key,
themes: (s.themesCatalog.themes || []).map((theme) => ({
...theme,
default: theme.key === key,
})),
},
}));
state.update((s) => {
changed = s.themesCatalog.default_theme === key;
return s;
});
if (changed) await saveThemes({ silent: true });
}
function togglePrimaryAccent(key, enabled) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key ? { ...theme, use_primary_accent: Boolean(enabled) } : theme
),
},
}));
}
function toggleAdminUse(key, enabled) {
state.update((s) => ({
...s,
themesCatalog: {
...s.themesCatalog,
themes: (s.themesCatalog.themes || []).map((theme) =>
theme.key === key ? { ...theme, use_in_admin: Boolean(enabled) } : theme
),
},
}));
}
return {
subscribe: state.subscribe,
loadThemes,
saveThemes,
setCurrentTheme,
togglePrimaryAccent,
toggleAdminUse,
};
}
@@ -35,6 +35,7 @@ export {
Menu,
MessageSquare,
MousePointerClick,
Paintbrush,
Plus,
QrCode,
Radio,
@@ -34,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"broadcast",
"logs",
"tariffs",
"themes",
"settings",
]);
export const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js";
@@ -195,6 +195,30 @@ export async function mockApi(path, options = {}, context = {}) {
},
};
}
if (path === "/admin/themes") {
if (String(options.method || "GET").toUpperCase() === "PUT") {
try {
const body = options?.body ? JSON.parse(String(options.body)) : {};
const catalog = body.catalog || body;
if (catalog?.themes) {
DEV_MOCK.config.themesCatalog = clone(catalog);
DEV_MOCK.data.themes_catalog = clone(catalog);
}
} catch (_e) {
void _e;
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
return {
ok: true,
themes_dir: "data/themes",
catalog: clone(DEV_MOCK.config.themesCatalog),
};
}
if (path === "/admin/settings") return { ok: true, sections: [] };
if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
@@ -1,3 +1,27 @@
const WINDOWS_95_THEME = {
key: "windows95",
names: { ru: "Windows 95", en: "Windows 95" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
style_preset: "win95",
},
};
const ASCII_THEME = {
key: "ascii",
names: { ru: "ASCII", en: "ASCII" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "dark",
style_preset: "ascii",
},
};
export const DEV_MOCK = {
config: {
title: "/minishop",
@@ -18,6 +42,37 @@ export const DEV_MOCK = {
telegramOAuthRequestAccess: ["write"],
appVersion: "dev+local",
appRepositoryUrl: "https://github.com/3252a8/remnawave-minishop",
themesCatalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
default: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
default: false,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
},
data: {
ok: true,
@@ -124,6 +179,35 @@ export const DEV_MOCK = {
{ months: 12, title: "12 месяцев", inviter_days: 62, friend_days: 31 },
],
},
themes_catalog: {
default_theme: "dark",
themes: [
{
key: "dark",
names: { ru: "Тёмная", en: "Dark" },
enabled: true,
tokens: {
color_scheme: "dark",
accent: "#00fe7a",
bg: "#03070b",
panel: "#111820",
text: "#f2f7f4",
muted: "#a9b4b0",
},
},
{
key: "light",
names: { ru: "Светлая", en: "Light" },
enabled: true,
css_file: "style.css",
tokens: {
color_scheme: "light",
},
},
WINDOWS_95_THEME,
ASCII_THEME,
],
},
settings: {
support_url: "https://t.me/support",
traffic_mode: false,
@@ -143,6 +227,20 @@ export function applyPreviewMock(kind) {
const mode = String(kind || "")
.trim()
.toLowerCase();
const themeKeys = new Set((DEV_MOCK.config.themesCatalog.themes || []).map((theme) => theme.key));
if (themeKeys.has(mode)) {
DEV_MOCK.config.themesCatalog.default_theme = mode;
DEV_MOCK.data.themes_catalog.default_theme = mode;
for (const theme of DEV_MOCK.config.themesCatalog.themes || []) {
theme.default = theme.key === mode;
}
for (const theme of DEV_MOCK.data.themes_catalog.themes || []) {
theme.default = theme.key === mode;
}
return;
}
if (mode === "traffic") {
DEV_MOCK.data.settings.traffic_mode = true;
DEV_MOCK.data.settings.trial_available = false;
@@ -0,0 +1,141 @@
/** Maps JSON theme token keys to CSS custom properties used by the Mini App shell. */
const TOKEN_TO_CSS_VAR = {
accent: "--accent",
bg: "--bg",
panel: "--panel",
panel_2: "--panel-2",
panel_3: "--panel-3",
border: "--border",
border_strong: "--border-strong",
text: "--text",
muted: "--muted",
dim: "--dim",
danger: "--danger",
blue: "--blue",
radius: "--radius",
font_sans: "--font-sans",
font_logo: "--font-logo",
font_mono: "--font-mono",
admin_bg: "--admin-bg",
admin_surface: "--admin-surface",
admin_surface_2: "--admin-surface-2",
admin_elev: "--admin-elev",
admin_border: "--admin-border",
admin_border_strong: "--admin-border-strong",
admin_text: "--admin-text",
admin_muted: "--admin-muted",
admin_dim: "--admin-dim",
};
export function themeTokensToInlineStyle(tokens, primaryFallback = "#00fe7a", options = {}) {
const t = tokens && typeof tokens === "object" ? tokens : {};
const parts = [];
const useFallbackAccent = options.fallbackAccent !== false;
const accent = t.accent || (useFallbackAccent ? primaryFallback || "#00fe7a" : "");
if (accent) parts.push(`--accent:${accent}`);
for (const [key, cssVar] of Object.entries(TOKEN_TO_CSS_VAR)) {
if (key === "accent") continue;
const value = t[key];
if (value === undefined || value === null || value === "") continue;
parts.push(`${cssVar}:${String(value)}`);
}
return parts.join(";");
}
export function findThemeEntry(themesCatalog, key) {
const themes = themesCatalog?.themes || [];
return themes.find((entry) => entry && entry.key === key) || null;
}
export function resolveEffectiveThemeKey(themesCatalog) {
const themes = themesCatalog?.themes || [];
const byKey = (k) => themes.find((entry) => entry.key === k);
const def = themesCatalog?.default_theme || themes[0]?.key || "dark";
return byKey(def) ? def : themes[0]?.key || "dark";
}
export function themePresetClass(tokens) {
const preset = String(tokens?.style_preset || "")
.trim()
.toLowerCase();
if (!preset || preset === "none") return "";
if (preset === "win95" || preset === "windows95") return "theme-preset-win95";
return "";
}
export function themeKeyClass(key) {
const safe = String(key || "")
.trim()
.toLowerCase()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return safe ? `theme-key-${safe}` : "";
}
export function themeCssClass(cssFile) {
const filename = String(cssFile || "")
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.pop();
const slug = String(filename || "")
.replace(/\.css$/i, "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug ? `theme-css-${slug}` : "";
}
export function themeRootClass(theme) {
return [
themeKeyClass(theme?.key),
themeCssClass(theme?.css_file),
themePresetClass(theme?.tokens),
]
.filter(Boolean)
.join(" ");
}
export function themeEntryToInlineStyle(theme, primaryFallback = "#00fe7a") {
return themeTokensToInlineStyle(theme?.tokens, primaryFallback, {
fallbackAccent: !theme?.css_file,
});
}
function encodeThemeCssPath(path) {
return String(path || "")
.replace(/\\/g, "/")
.split("/")
.filter(Boolean)
.map(encodeURIComponent)
.join("/");
}
export function themeCssHref(theme) {
const cssFile = String(theme?.css_file || "").trim();
if (!cssFile) return "";
if (/^(?:https?:)?\/\//i.test(cssFile) || cssFile.startsWith("data:")) return "";
if (cssFile.startsWith("/")) return cssFile;
const normalizedCssFile = cssFile.replace(/\\/g, "/").split("/").filter(Boolean).join("/");
const key = String(theme?.key || "")
.trim()
.replace(/[^A-Za-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
const themedPath =
key && normalizedCssFile.split("/")[0] !== key
? `${key}/${normalizedCssFile}`
: normalizedCssFile;
const encoded = encodeThemeCssPath(themedPath);
return encoded ? `/webapp-theme-css/${encoded}` : "";
}
export function localizedThemeName(theme, lang = "en") {
const names = theme?.names || {};
const key = String(lang || "")
.trim()
.toLowerCase();
const base = key.split("-")[0];
return names[key] || names[base] || names.en || theme?.key || "";
}
+43 -32
View File
@@ -7,17 +7,14 @@
--admin-sidebar-w: var(--desktop-rail-width);
--admin-header-h: 60px;
--admin-card-bg:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
--admin-card-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface);
--admin-card-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
background: #02070b;
background: var(--admin-bg);
color: var(--admin-text);
overflow: hidden;
overscroll-behavior: contain;
@@ -38,9 +35,9 @@
gap: 2px;
padding: 28px 14px;
border-right: 1px solid var(--admin-border);
background: rgba(7, 12, 17, 0.55);
background: var(--rail-bg);
backdrop-filter: blur(14px);
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02);
box-shadow: inset -1px 0 0 var(--admin-border);
overflow-y: auto;
}
@@ -74,7 +71,8 @@
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
font-family: var(--font-logo);
letter-spacing: 0;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -115,7 +113,10 @@
text-align: left;
width: 100%;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.admin-nav-item > svg {
@@ -124,7 +125,7 @@
}
.admin-nav-item:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--surface-hover);
color: var(--admin-text);
}
@@ -169,7 +170,7 @@
gap: 8px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
color: var(--admin-text);
padding: 8px 10px;
text-align: left;
@@ -251,7 +252,7 @@
gap: 16px;
padding: 0 28px;
border-bottom: 1px solid var(--admin-border);
background: color-mix(in srgb, #02070b 82%, transparent);
background: color-mix(in srgb, var(--admin-bg) 82%, transparent);
backdrop-filter: blur(14px);
}
@@ -265,7 +266,7 @@
margin: 0;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-header-title small {
@@ -427,7 +428,7 @@
.admin-stat-card .admin-stat-value {
font-size: 26px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
}
@@ -457,7 +458,7 @@
font-size: 14px;
font-weight: 600;
color: var(--admin-text);
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-dashboard-section-head small {
@@ -528,7 +529,7 @@
.admin-revenue-kpi-value {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
line-height: 1.15;
}
@@ -606,7 +607,7 @@
border: 1px solid var(--admin-border);
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-popover);
}
.admin-revenue-range-popover__title {
@@ -946,7 +947,7 @@
.admin-panel-dash-tile-value {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
letter-spacing: 0;
line-height: 1.1;
color: color-mix(in srgb, var(--accent) 22%, var(--admin-text));
font-variant-numeric: tabular-nums;
@@ -1248,7 +1249,7 @@
}
.admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.035);
background: var(--surface-hover);
}
.admin-table td.admin-cell-mono {
@@ -1531,7 +1532,9 @@
font-size: 13px;
resize: vertical;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.admin-textarea:focus,
@@ -2357,7 +2360,7 @@
z-index: 80;
transform: translateX(-100%);
transition: transform 0.22s ease;
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6);
box-shadow: var(--shadow-strong);
}
.admin-screen-wrap.is-sidebar-open .admin-sidebar {
@@ -2367,7 +2370,7 @@
.admin-sidebar-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: var(--overlay-scrim);
z-index: 70;
border: 0;
cursor: pointer;
@@ -2501,7 +2504,9 @@
padding: 10px 12px;
width: 100%;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
border-color 0.12s ease;
}
.settings-row.settings-row-admin:hover {
@@ -2560,7 +2565,10 @@
border-radius: 7px;
cursor: pointer;
white-space: nowrap;
transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
box-shadow 0.12s ease;
outline: none;
}
@@ -2571,7 +2579,7 @@
.admin-tabs-trigger[data-state="active"] {
background: var(--admin-surface);
color: var(--admin-text);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
box-shadow: var(--shadow-soft);
}
.admin-tabs-trigger:focus-visible {
@@ -2641,7 +2649,9 @@
border: 1px solid var(--admin-border);
background: var(--admin-elev);
cursor: pointer;
transition: background 0.18s ease, border-color 0.18s ease;
transition:
background 0.18s ease,
border-color 0.18s ease;
outline: none;
}
@@ -2693,7 +2703,9 @@
cursor: pointer;
text-align: left;
outline: none;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
transition:
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.admin-select-trigger:hover {
@@ -2724,10 +2736,9 @@
border: 1px solid var(--admin-border);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--admin-surface);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--admin-surface);
padding: 4px;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
box-shadow: var(--shadow-popover);
outline: none;
display: flex;
flex-direction: column;
@@ -3202,4 +3213,4 @@
grid-area: meta;
white-space: normal;
}
}
}
+23 -13
View File
@@ -4,21 +4,14 @@
format("woff2");
font-display: swap;
unicode-range:
U+1F1E6-1F1FF,
U+1F3F4,
U+E0062-E0063,
U+E0065,
U+E0067,
U+E006C,
U+E006E,
U+E0073-E0074,
U+E0077,
U+E007F;
U+1F1E6-1F1FF, U+1F3F4, U+E0062-E0063, U+E0065, U+E0067, U+E006C, U+E006E, U+E0073-E0074,
U+E0077, U+E007F;
}
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--font-logo: var(--font-mono);
color-scheme: dark;
--accent: #00fe7a;
--bg: #03070b;
@@ -33,6 +26,19 @@
--danger: #ff6b6b;
--blue: #2d9cff;
--radius: 8px;
--accent-contrast: #03100a;
--surface-sheen: rgba(255, 255, 255, 0.055);
--surface-sheen-soft: rgba(255, 255, 255, 0.018);
--surface-hover: rgba(255, 255, 255, 0.04);
--surface-muted: rgba(255, 255, 255, 0.035);
--surface-subtle-border: rgba(255, 255, 255, 0.1);
--overlay-scrim: rgba(0, 0, 0, 0.56);
--nav-bg: rgba(7, 12, 17, 0.88);
--rail-bg: rgba(7, 12, 17, 0.55);
--shadow-soft: 0 18px 48px rgba(0, 0, 0, 0.22);
--shadow-strong: 0 26px 70px rgba(0, 0, 0, 0.46);
--shadow-popover: 0 20px 34px rgba(0, 0, 0, 0.42);
--inset-highlight: rgba(255, 255, 255, 0.05);
/* Admin design tokens kept on :root so portal-rendered admin
surfaces (dialogs, bits-ui Select.Portal content) inherit them. */
@@ -59,6 +65,10 @@
font-family: var(--font-sans);
}
.theme-light {
color-scheme: light;
}
* {
box-sizing: border-box;
}
@@ -72,7 +82,7 @@ body,
}
body {
background: #02070b;
background: var(--bg, #02070b);
color: var(--text);
-webkit-font-smoothing: antialiased;
letter-spacing: 0;
@@ -86,10 +96,10 @@ input {
.app-shell {
min-height: 100dvh;
background: #02070b !important;
background: var(--bg, #02070b) !important;
}
:root {
--desktop-rail-width: 252px;
--desktop-page-gutter: clamp(28px, 4vw, 72px);
}
}
@@ -26,11 +26,9 @@
border-radius: 12px;
border: 1px solid var(--admin-border);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)),
var(--admin-surface);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
box-shadow: var(--shadow-soft), inset 0 1px 0 var(--inset-highlight);
position: relative;
overflow: hidden;
}
@@ -80,7 +78,7 @@
margin: 0;
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.02em;
letter-spacing: 0;
color: var(--admin-text);
font-variant-numeric: tabular-nums;
line-height: 1.15;
@@ -89,7 +87,7 @@
.admin-cn-card-title--section {
font-size: 1.05rem;
font-weight: 600;
letter-spacing: -0.01em;
letter-spacing: 0;
}
.admin-cn-card-skeleton {
@@ -227,9 +225,9 @@
display: inline-block;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.08),
rgba(255, 255, 255, 0.14),
rgba(255, 255, 255, 0.08)
color-mix(in srgb, var(--muted) 10%, transparent),
color-mix(in srgb, var(--muted) 18%, transparent),
color-mix(in srgb, var(--muted) 10%, transparent)
);
background-size: 220% 100%;
animation: ui-skeleton-pulse 1.2s ease-in-out infinite;
@@ -293,3 +291,4 @@
background-position: -120% 0;
}
}
+5 -5
View File
@@ -23,7 +23,7 @@ button:disabled {
text-align: center;
text-decoration: none;
cursor: pointer;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.18);
box-shadow: var(--shadow-soft);
transition:
transform 0.16s ease,
border-color 0.16s ease,
@@ -34,7 +34,7 @@ button:disabled {
.btn:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 32px rgba(0, 0, 0, 0.22);
box-shadow: var(--shadow-soft);
}
.btn:active:not(:disabled) {
@@ -45,7 +45,7 @@ button:disabled {
outline: none;
box-shadow:
0 0 0 3px color-mix(in srgb, var(--accent) 28%, transparent),
0 14px 32px rgba(0, 0, 0, 0.22);
var(--shadow-soft);
}
.btn.wide,
@@ -56,7 +56,7 @@ button:disabled {
.btn-primary {
border-color: color-mix(in srgb, var(--accent) 72%, white);
background: linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, white));
color: #03100a;
color: var(--accent-contrast);
}
.btn-secondary {
@@ -111,7 +111,7 @@ button:disabled {
margin-top: 12px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
background: color-mix(in srgb, var(--muted) 18%, transparent);
}
.progress span {
+4 -4
View File
@@ -26,7 +26,7 @@
inset: 0;
z-index: 0;
border: 0;
background: rgba(0, 0, 0, 0.56);
background: var(--overlay-scrim);
backdrop-filter: blur(10px);
cursor: pointer;
}
@@ -43,9 +43,9 @@
padding: 18px;
border: 1px solid var(--border);
border-radius: 24px;
background: color-mix(in srgb, var(--panel) 94%, #07111a);
background: color-mix(in srgb, var(--panel) 96%, var(--bg));
color: var(--text);
box-shadow: 0 26px 70px rgba(0, 0, 0, 0.46);
box-shadow: var(--shadow-strong);
}
.dialog-head {
@@ -92,7 +92,7 @@
.payment-submit-button {
border-color: color-mix(in srgb, var(--accent) 72%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--accent) 94%, white), var(--accent));
color: #031009;
color: var(--accent-contrast);
box-shadow:
0 16px 38px color-mix(in srgb, var(--accent) 28%, transparent),
0 0 0 1px color-mix(in srgb, var(--accent) 45%, transparent),
+88 -87
View File
@@ -12,8 +12,8 @@ a {
min-height: 100dvh;
margin: 0 auto;
overflow-x: hidden;
padding:
max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline)) max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
padding: max(16px, env(safe-area-inset-top)) max(var(--screen-gutter), var(--safe-inline))
max(18px, env(safe-area-inset-bottom)) max(var(--screen-gutter), var(--safe-inline));
}
.content {
@@ -94,7 +94,7 @@ a {
color: var(--text);
font-size: 15px;
font-weight: 850;
font-family: var(--font-mono);
font-family: var(--font-logo);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -162,20 +162,19 @@ a {
border: 1px solid var(--border);
border-radius: var(--radius);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
var(--panel);
linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft)), var(--panel);
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
var(--shadow-soft),
inset 0 1px 0 var(--inset-highlight);
padding: 14px;
}
.card-active {
border-color: color-mix(in srgb, var(--accent) 76%, var(--border));
box-shadow:
0 18px 48px rgba(0, 0, 0, 0.22),
var(--shadow-soft),
0 0 0 1px color-mix(in srgb, var(--accent) 38%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
inset 0 1px 0 var(--inset-highlight);
}
.card-compact {
@@ -189,7 +188,11 @@ a {
.status-card-inactive {
border-color: color-mix(in srgb, var(--danger) 58%, var(--border));
background:
linear-gradient(135deg, color-mix(in srgb, var(--danger) 14%, rgba(255, 255, 255, 0.03)), rgba(255, 255, 255, 0.012)),
linear-gradient(
135deg,
color-mix(in srgb, var(--danger) 14%, var(--surface-sheen-soft)),
var(--surface-sheen-soft)
),
var(--panel);
}
@@ -392,7 +395,7 @@ a {
gap: 6px;
}
.premium-server-list>div {
.premium-server-list > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
@@ -401,7 +404,7 @@ a {
.premium-server-list span {
max-width: 100%;
padding: 4px 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid var(--surface-subtle-border);
border-radius: 999px;
color: var(--text);
font-size: 11px;
@@ -416,7 +419,7 @@ a {
padding: 12px;
}
.topup-summary-card>div:not(.premium-server-list) {
.topup-summary-card > div:not(.premium-server-list) {
display: grid;
gap: 3px;
}
@@ -440,7 +443,7 @@ a {
gap: 11px;
}
.trial-card-head>svg {
.trial-card-head > svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -475,7 +478,7 @@ a {
gap: 11px;
}
.devices-summary-head>svg {
.devices-summary-head > svg {
color: var(--accent);
}
@@ -529,7 +532,7 @@ a {
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 38%, var(--border));
border-radius: var(--radius);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.03));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
color: var(--accent);
}
@@ -604,9 +607,9 @@ a {
.settings-row {
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.period-card {
@@ -652,10 +655,10 @@ a {
.period-card.active,
.method-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
inset 0 1px 0 var(--inset-highlight);
}
.total-card {
@@ -688,11 +691,11 @@ a {
gap: 12px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
padding: 12px;
text-align: left;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 var(--inset-highlight);
}
.option-row-main,
@@ -745,10 +748,10 @@ a {
.option-row.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
box-shadow:
0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
inset 0 1px 0 var(--inset-highlight);
}
.tariff-row {
@@ -764,7 +767,7 @@ a {
align-items: flex-start;
}
.change-action-row>svg {
.change-action-row > svg {
flex: 0 0 auto;
margin-top: 1px;
color: var(--accent);
@@ -774,9 +777,9 @@ a {
display: grid;
gap: 6px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
}
.topup-carryover-note p {
@@ -798,11 +801,12 @@ a {
display: block;
overflow: hidden;
border-radius: 999px;
background:
linear-gradient(90deg,
rgba(255, 255, 255, 0.07) 0%,
rgba(255, 255, 255, 0.14) 42%,
rgba(255, 255, 255, 0.07) 84%);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--muted) 10%, transparent) 0%,
color-mix(in srgb, var(--muted) 18%, transparent) 42%,
color-mix(in srgb, var(--muted) 10%, transparent) 84%
);
background-size: 220% 100%;
animation: skeleton-shimmer 1.15s ease-in-out infinite;
}
@@ -893,7 +897,7 @@ a {
.tariff-selected-card {
min-height: 58px;
background: color-mix(in srgb, var(--accent) 7%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 7%, var(--surface-muted));
}
.tariff-action-list {
@@ -909,7 +913,7 @@ a {
gap: 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.018));
background: linear-gradient(135deg, var(--surface-sheen), var(--surface-sheen-soft));
color: var(--text);
padding: 12px;
text-align: left;
@@ -942,7 +946,7 @@ a {
.tariff-action-card.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.035));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
}
.tariff-warning-card {
@@ -969,7 +973,7 @@ a {
min-height: 56px;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
padding: 10px;
text-align: center;
}
@@ -1105,7 +1109,7 @@ a {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(0, 0, 0, 0.2);
background: color-mix(in srgb, var(--panel-2) 86%, transparent);
color: var(--text);
padding: 12px;
font-size: 12px;
@@ -1132,7 +1136,7 @@ a {
gap: 14px;
}
.bonus-card-head>svg {
.bonus-card-head > svg {
flex: 0 0 auto;
color: var(--accent);
}
@@ -1165,7 +1169,7 @@ a {
gap: 3px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.02);
background: var(--surface-sheen-soft);
padding: 10px 11px;
}
@@ -1193,7 +1197,7 @@ a {
overflow: hidden;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
background: var(--surface-muted);
}
.settings-avatar img {
@@ -1245,15 +1249,15 @@ a {
min-height: 50px;
padding: 9px 10px;
text-align: left;
background: rgba(255, 255, 255, 0.03);
background: var(--surface-muted);
}
.settings-row>svg:first-child {
.settings-row > svg:first-child {
color: var(--text);
opacity: 0.9;
}
.settings-row>svg:last-child {
.settings-row > svg:last-child {
color: var(--muted);
}
@@ -1277,10 +1281,10 @@ a {
.settings-row-linked {
grid-template-columns: 28px minmax(0, 1fr);
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--accent) 11%, rgba(255, 255, 255, 0.03));
background: color-mix(in srgb, var(--accent) 11%, var(--surface-muted));
}
.settings-row-linked>svg:first-child {
.settings-row-linked > svg:first-child {
color: var(--accent);
}
@@ -1290,12 +1294,8 @@ a {
.emoji-flag {
font-family:
"Twemoji Country Flags",
"Apple Color Emoji",
"Segoe UI Emoji",
"Segoe UI Symbol",
"Noto Color Emoji",
sans-serif;
"Twemoji Country Flags", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji", sans-serif;
font-variant-emoji: emoji;
line-height: 1;
}
@@ -1325,7 +1325,7 @@ a {
border-radius: 6px;
}
.language-select-trigger>svg {
.language-select-trigger > svg {
color: var(--muted);
}
@@ -1361,7 +1361,7 @@ a {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: var(--panel-3);
box-shadow: 0 20px 34px rgba(0, 0, 0, 0.42);
box-shadow: var(--shadow-popover);
overflow: hidden;
box-sizing: border-box;
transform-origin: top right;
@@ -1387,7 +1387,7 @@ a {
}
.language-select-item[data-highlighted] {
background: rgba(255, 255, 255, 0.06);
background: var(--surface-hover);
}
.language-select-item[data-selected] {
@@ -1409,11 +1409,11 @@ a {
text-overflow: ellipsis;
}
.language-select-item-main>span {
.language-select-item-main > span {
display: inline-block !important;
}
.language-select-item-main>span:last-child {
.language-select-item-main > span:last-child {
margin-left: 6px;
white-space: nowrap !important;
overflow: hidden;
@@ -1494,8 +1494,8 @@ a {
min-height: 64px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(7, 12, 17, 0.88);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.36);
background: var(--nav-bg);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
@@ -1580,7 +1580,7 @@ a {
color: var(--accent);
font-size: 32px;
font-weight: 900;
font-family: var(--font-mono);
font-family: var(--font-logo);
line-height: 1.04;
}
@@ -1739,13 +1739,13 @@ a {
place-items: center;
border: 1px solid color-mix(in srgb, var(--accent) 74%, var(--border));
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.035);
background: var(--surface-muted);
font-size: 25px;
font-weight: 900;
}
.otp-slots span.filled {
background: color-mix(in srgb, var(--accent) 9%, rgba(255, 255, 255, 0.04));
background: color-mix(in srgb, var(--accent) 9%, var(--surface-muted));
}
.link-button {
@@ -1790,8 +1790,8 @@ a {
max-width: min(420px, calc(100vw - 32px));
border: 1px solid color-mix(in srgb, var(--accent) 46%, var(--border));
border-radius: var(--radius);
background: rgba(15, 21, 27, 0.92);
box-shadow: 0 16px 42px rgba(0, 0, 0, 0.42);
background: color-mix(in srgb, var(--panel) 92%, transparent);
box-shadow: var(--shadow-popover);
color: var(--text);
padding: 12px 14px;
font-size: 13px;
@@ -1873,8 +1873,7 @@ a {
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 18px;
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)),
#071017;
linear-gradient(135deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.015)), #071017;
box-shadow:
0 28px 80px rgba(0, 0, 0, 0.54),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
@@ -2011,7 +2010,6 @@ a {
}
@media (prefers-reduced-motion: reduce) {
.content,
.home-layout,
.language-select-content,
@@ -2039,7 +2037,7 @@ a {
@media (min-width: 1024px) {
body {
background: #02070b;
background: var(--bg);
}
.app-shell {
@@ -2060,16 +2058,16 @@ a {
border-radius: 0;
background: transparent;
box-shadow: none;
padding:
max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
padding: max(28px, env(safe-area-inset-top)) var(--desktop-page-gutter) 40px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
overflow-x: visible;
}
/* Centre and cap the width of the actual content blocks. */
.phone-screen>.app-header,
.phone-screen>main,
.phone-screen>.home-layout,
.phone-screen>nav.bottom-nav~* {
.phone-screen > .app-header,
.phone-screen > main,
.phone-screen > .home-layout,
.phone-screen > nav.bottom-nav ~ * {
max-width: 1080px;
margin-left: auto;
margin-right: auto;
@@ -2079,12 +2077,11 @@ a {
.phone-screen.auth-screen {
width: min(100%, 460px);
min-height: 100dvh;
padding:
max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
padding: max(16px, env(safe-area-inset-top)) clamp(24px, 4vw, 16px) 16px;
margin: 0 auto;
}
.phone-screen.auth-screen~.bottom-nav,
.phone-screen.auth-screen ~ .bottom-nav,
.auth-screen .bottom-nav {
display: none !important;
}
@@ -2094,8 +2091,8 @@ a {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
min-height: 100dvh;
padding:
max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
padding: max(32px, env(safe-area-inset-top)) var(--desktop-page-gutter) 42px
calc(var(--desktop-rail-width) + var(--desktop-page-gutter));
}
/* Home: keep the phone-style vertical flow, but center it in the
@@ -2113,7 +2110,7 @@ a {
align-items: stretch;
}
.home-layout>.home-brand {
.home-layout > .home-brand {
grid-column: auto;
align-self: center;
justify-items: center;
@@ -2174,9 +2171,9 @@ a {
border: 0 !important;
border-right: 1px solid var(--border) !important;
border-radius: 0 !important;
background: rgba(7, 12, 17, 0.55) !important;
background: var(--rail-bg) !important;
backdrop-filter: blur(14px) !important;
box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important;
box-shadow: inset -1px 0 0 var(--border) !important;
z-index: 40;
}
@@ -2197,22 +2194,25 @@ a {
font-size: 13px !important;
color: var(--muted);
border: 1px solid transparent;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
transition:
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
}
.bottom-nav button>svg {
.bottom-nav button > svg {
width: 20px;
height: 20px;
}
.bottom-nav button>span {
.bottom-nav button > span {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
}
.bottom-nav button:hover {
background: rgba(255, 255, 255, 0.04);
background: var(--surface-hover);
color: var(--text);
}
@@ -2235,7 +2235,7 @@ a {
display: none !important;
}
.phone-screen>main.content.with-nav {
.phone-screen > main.content.with-nav {
padding-top: 0;
}
@@ -2310,7 +2310,8 @@ a {
color: var(--accent);
font-size: 14px;
font-weight: 800;
letter-spacing: -0.01em;
font-family: var(--font-logo);
letter-spacing: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+953
View File
@@ -0,0 +1,953 @@
/*
* ASCII / console theme.
* Pure black background, white foreground, monospace everything,
* 1px white borders, animated ASCII spinners and block-progress bars.
*/
.theme-key-ascii {
color-scheme: dark;
--accent: #ffffff;
--accent-contrast: #000000;
--bg: #000000;
--panel: #000000;
--panel-2: #050505;
--panel-3: #0c0c0c;
--border: #ffffff;
--border-strong: #ffffff;
--text: #ffffff;
--muted: #b0b0b0;
--dim: #6a6a6a;
--danger: #ff5555;
--blue: #ffffff;
--radius: 0px;
--font-sans: "JetBrains Mono", "Cascadia Code", "Fira Code", "Consolas",
"Source Code Pro", "Courier New", ui-monospace, monospace;
--font-logo: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
ui-monospace, monospace;
--font-mono: "JetBrains Mono", "Cascadia Code", "Consolas", "Courier New",
ui-monospace, monospace;
--surface-sheen: transparent;
--surface-sheen-soft: transparent;
--surface-hover: rgba(255, 255, 255, 0.08);
--surface-muted: #0a0a0a;
--surface-subtle-border: #ffffff;
--overlay-scrim: rgba(0, 0, 0, 0.85);
--nav-bg: #000000;
--rail-bg: #000000;
--shadow-soft: none;
--shadow-strong: none;
--shadow-popover: 0 0 0 1px #ffffff;
--inset-highlight: transparent;
--admin-bg: #000000;
--admin-surface: #000000;
--admin-surface-2: #050505;
--admin-elev: #0c0c0c;
--admin-border: #ffffff;
--admin-border-strong: #ffffff;
--admin-text: #ffffff;
--admin-muted: #b0b0b0;
--admin-dim: #6a6a6a;
}
/* ---------- Base typography ---------- */
.theme-key-ascii,
.theme-key-ascii body,
.theme-key-ascii button,
.theme-key-ascii input,
.theme-key-ascii textarea,
.theme-key-ascii select {
font-family: var(--font-sans);
letter-spacing: 0;
font-synthesis: none;
-webkit-font-smoothing: none;
font-smooth: never;
font-variant-ligatures: none;
}
.theme-key-ascii.app-shell {
background: var(--bg) !important;
background-image:
repeating-linear-gradient(
0deg,
rgba(255, 255, 255, 0.025) 0,
rgba(255, 255, 255, 0.025) 1px,
transparent 1px,
transparent 3px
) !important;
}
/* Slight CRT-like flicker on the shell. */
@keyframes ascii-flicker {
0%, 96%, 100% { opacity: 1; }
97% { opacity: 0.96; }
98% { opacity: 1; }
99% { opacity: 0.94; }
}
.theme-key-ascii.app-shell::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9998;
background: repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.02) 0,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 2px
);
animation: ascii-flicker 5s infinite;
}
/* ---------- Panels / cards ---------- */
.theme-key-ascii .card,
.theme-key-ascii .period-card,
.theme-key-ascii .method-card,
.theme-key-ascii .settings-row,
.theme-key-ascii .option-row,
.theme-key-ascii .tariff-selected-card,
.theme-key-ascii .tariff-action-card,
.theme-key-ascii .tariff-warning-card,
.theme-key-ascii .topup-carryover-note,
.theme-key-ascii .input,
.theme-key-ascii .dialog-card,
.theme-key-ascii .language-select-content,
.theme-key-ascii .bottom-nav,
.theme-key-ascii .toast,
.theme-key-ascii .admin-sidebar,
.theme-key-ascii .admin-header,
.theme-key-ascii .admin-card,
.theme-key-ascii .admin-stat-card,
.theme-key-ascii .admin-revenue-panel,
.theme-key-ascii .admin-empty,
.theme-key-ascii .admin-tariff-card,
.theme-key-ascii .admin-toolbar-card,
.theme-key-ascii .admin-table-card,
.theme-key-ascii .admin-panel-dash-card,
.theme-key-ascii .admin-select-trigger,
.theme-key-ascii .admin-select-content,
.theme-key-ascii .admin-cn-card[data-slot="card"],
.theme-key-ascii .admin-dialog .dialog-card,
.theme-key-ascii .admin-theme-editor-section {
border: 1px solid #ffffff;
border-radius: 0;
background: var(--panel);
box-shadow: none;
}
/* No ribbon/corner overlays: those caused dialog overflow scrollbars.
* The console feel comes from the crisp 1px borders, monospace text,
* and the animated marquees / glitches applied to interactive elements. */
/* ---------- Buttons ---------- */
.theme-key-ascii .btn,
.theme-key-ascii .language-select-trigger,
.theme-key-ascii .bottom-nav button,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-chip,
.theme-key-ascii .admin-tabs-trigger,
.theme-key-ascii .admin-revenue-period-btn,
.theme-key-ascii .admin-mobile-toggle,
.theme-key-ascii .admin-nav-item {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
box-shadow: none;
text-transform: none;
font-family: var(--font-sans);
transform: none;
position: relative;
}
.theme-key-ascii .btn:hover:not(:disabled),
.theme-key-ascii .admin-btn:hover:not(:disabled),
.theme-key-ascii .admin-nav-item:hover,
.theme-key-ascii .admin-tabs-trigger:hover,
.theme-key-ascii .admin-revenue-period-btn:hover,
.theme-key-ascii .bottom-nav button:hover {
background: #ffffff;
color: #000000;
}
.theme-key-ascii .btn:active:not(:disabled),
.theme-key-ascii .bottom-nav button:active,
.theme-key-ascii .admin-btn:active:not(:disabled) {
background: #ffffff;
color: #000000;
transform: translate(1px, 1px);
}
.theme-key-ascii .btn-primary,
.theme-key-ascii .admin-btn-primary,
.theme-key-ascii .bottom-nav button.active,
.theme-key-ascii .period-card.active,
.theme-key-ascii .method-card.active,
.theme-key-ascii .option-row.active,
.theme-key-ascii .admin-nav-item.active,
.theme-key-ascii .admin-tabs-trigger[data-state="active"],
.theme-key-ascii .admin-revenue-period-btn.is-active {
background: #ffffff;
color: #000000;
border-color: #ffffff;
}
.theme-key-ascii .btn-primary:hover:not(:disabled),
.theme-key-ascii .admin-btn-primary:hover:not(:disabled) {
background: #000000;
color: #ffffff;
outline: 1px solid #ffffff;
outline-offset: -2px;
}
/* Blinking caret-style focus ring. */
@keyframes ascii-caret {
0%, 49% { outline-color: #ffffff; }
50%, 100% { outline-color: transparent; }
}
.theme-key-ascii .btn:focus-visible,
.theme-key-ascii .admin-btn:focus-visible,
.theme-key-ascii .admin-nav-item:focus-visible,
.theme-key-ascii .admin-tabs-trigger:focus-visible,
.theme-key-ascii .admin-revenue-period-btn:focus-visible,
.theme-key-ascii .admin-mobile-toggle:focus-visible,
.theme-key-ascii .language-select-trigger:focus-visible,
.theme-key-ascii .bottom-nav button:focus-visible {
outline: 2px solid #ffffff;
outline-offset: 1px;
animation: ascii-caret 1s steps(1) infinite;
}
/* ---------- Inputs ---------- */
.theme-key-ascii .input,
.theme-key-ascii .admin-input,
.theme-key-ascii .admin-textarea,
.theme-key-ascii .admin-screen-wrap textarea,
.theme-key-ascii .admin-dialog textarea {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
box-shadow: none;
font-family: var(--font-mono);
caret-color: #ffffff;
}
.theme-key-ascii .input::placeholder,
.theme-key-ascii .admin-input::placeholder,
.theme-key-ascii .admin-textarea::placeholder,
.theme-key-ascii .admin-screen-wrap textarea::placeholder,
.theme-key-ascii .admin-dialog textarea::placeholder {
color: var(--dim);
font-style: normal;
}
.theme-key-ascii .input:focus,
.theme-key-ascii .admin-input:focus,
.theme-key-ascii .admin-textarea:focus,
.theme-key-ascii .admin-screen-wrap textarea:focus,
.theme-key-ascii .admin-dialog textarea:focus {
outline: none;
border-color: #ffffff;
box-shadow: inset 0 0 0 1px #ffffff;
}
/* ---------- Bottom nav (desktop rail) ---------- */
@media (min-width: 1024px) {
.theme-key-ascii .bottom-nav {
border-right: 1px solid #ffffff !important;
background: var(--rail-bg) !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
.theme-key-ascii .bottom-nav button {
border: 1px solid #ffffff !important;
border-radius: 0 !important;
background: #000000 !important;
color: #ffffff !important;
box-shadow: none !important;
}
.theme-key-ascii .bottom-nav button.active {
background: #ffffff !important;
color: #000000 !important;
}
}
/* ---------- ASCII progress bar ---------- *
* Empty track: (low-contrast dotted fill).
* Filled span: (solid white blocks).
*/
.theme-key-ascii .progress {
height: 14px;
border: 1px solid #ffffff;
border-radius: 0 !important;
background-color: #000000;
background-image: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.22) 0,
rgba(255, 255, 255, 0.22) 1px,
transparent 1px,
transparent 4px
);
position: relative;
overflow: hidden;
font-family: var(--font-mono);
}
.theme-key-ascii .progress span {
border-radius: 0 !important;
background: #ffffff !important;
background-image: repeating-linear-gradient(
90deg,
rgba(0, 0, 0, 0.0) 0,
rgba(0, 0, 0, 0.0) 5px,
rgba(0, 0, 0, 0.35) 5px,
rgba(0, 0, 0, 0.35) 6px
) !important;
box-shadow: none;
}
/* Indeterminate scanning effect for any progress lacking a width-set span. */
@keyframes ascii-scan {
0% { background-position: 0 0; }
100% { background-position: 12px 0; }
}
/* ---------- ASCII spinner replacement ---------- */
.theme-key-ascii .ui-spinner,
.theme-key-ascii .telegram-button-spinner,
.theme-key-ascii .brand-mark-spinner {
border: none !important;
border-radius: 0 !important;
width: 1ch !important;
height: 1em !important;
background: transparent !important;
position: relative;
animation: none !important;
color: currentColor;
font-family: var(--font-mono);
font-weight: 700;
text-align: center;
vertical-align: middle;
overflow: visible;
}
.theme-key-ascii .ui-spinner::before,
.theme-key-ascii .telegram-button-spinner::before,
.theme-key-ascii .brand-mark-spinner::before {
content: "|";
display: inline-block;
animation: ascii-spin 0.8s steps(1) infinite;
font-family: var(--font-mono);
line-height: 1;
}
@keyframes ascii-spin {
0% { content: "|"; }
25% { content: "/"; }
50% { content: "-"; }
75% { content: "\\"; }
100% { content: "|"; }
}
/* Some browsers don't animate content; fallback rotation of a glyph. */
@supports not (animation-name: ascii-spin) {
.theme-key-ascii .ui-spinner::before,
.theme-key-ascii .telegram-button-spinner::before,
.theme-key-ascii .brand-mark-spinner::before {
content: "+";
animation: ascii-spin-rotate 0.8s steps(4) infinite;
}
@keyframes ascii-spin-rotate {
to { transform: rotate(360deg); }
}
}
/* Blinking cursor appended to brand text. */
.theme-key-ascii .login-brand h1::after,
.theme-key-ascii .admin-sidebar-brand strong::after,
.theme-key-ascii .brand-row strong::after {
content: "_";
display: inline-block;
margin-left: 0.2ch;
color: #ffffff;
animation: ascii-blink 1s steps(1) infinite;
}
@keyframes ascii-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
/* Section heading prompt prefix. */
.theme-key-ascii .admin-card-head h2::before,
.theme-key-ascii .admin-card-head h3::before,
.theme-key-ascii .card > h2:first-child::before,
.theme-key-ascii .card > h3:first-child::before {
content: "> ";
color: #ffffff;
opacity: 0.85;
font-family: var(--font-mono);
}
/* ---------- Tables ---------- */
.theme-key-ascii .admin-table thead th {
background: #000000;
color: #ffffff;
border-bottom: 1px solid #ffffff;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
}
.theme-key-ascii .admin-table tbody tr {
border-bottom: 1px dashed #ffffff;
}
.theme-key-ascii .admin-table tbody tr:hover {
background: rgba(255, 255, 255, 0.08);
}
/* ---------- Badges / chips ---------- */
.theme-key-ascii .admin-badge,
.theme-key-ascii .admin-cn-badge {
border: 1px solid #ffffff;
border-radius: 0;
background: #000000;
color: #ffffff;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.theme-key-ascii .admin-badge::before,
.theme-key-ascii .admin-cn-badge::before {
content: "[";
}
.theme-key-ascii .admin-badge::after,
.theme-key-ascii .admin-cn-badge::after {
content: "]";
}
/* ---------- Links ---------- */
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]),
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item) {
color: #ffffff;
text-decoration: underline;
text-underline-offset: 2px;
}
.theme-key-ascii a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):hover,
.theme-key-ascii .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):hover {
background: #ffffff;
color: #000000;
text-decoration: none;
}
/* ---------- Selection ---------- */
.theme-key-ascii ::selection {
background: #ffffff;
color: #000000;
}
/* ---------- Scrollbars ---------- */
.theme-key-ascii ::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.theme-key-ascii ::-webkit-scrollbar-track {
background-color: #000000;
background-image: repeating-linear-gradient(
0deg,
#ffffff 0,
#ffffff 1px,
transparent 1px,
transparent 4px
);
}
.theme-key-ascii ::-webkit-scrollbar-thumb {
background: #ffffff;
border: 1px solid #000000;
}
.theme-key-ascii ::-webkit-scrollbar-thumb:active {
background: #b0b0b0;
}
.theme-key-ascii ::-webkit-scrollbar-corner {
background: #000000;
}
/* ---------- Lucide icons: render as crisp white outlines ---------- */
.theme-key-ascii svg.lucide,
.theme-key-ascii svg[class*="lucide-"] {
color: #ffffff !important;
stroke: #ffffff !important;
fill: none !important;
stroke-width: 1.75;
filter: none;
}
.theme-key-ascii .btn-primary svg.lucide,
.theme-key-ascii .bottom-nav button.active svg.lucide,
.theme-key-ascii .period-card.active svg.lucide,
.theme-key-ascii .method-card.active svg.lucide,
.theme-key-ascii .option-row.active svg.lucide,
.theme-key-ascii .admin-btn-primary svg.lucide,
.theme-key-ascii .admin-nav-item.active svg.lucide,
.theme-key-ascii .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-ascii .admin-revenue-period-btn.is-active svg.lucide {
color: #000000 !important;
stroke: #000000 !important;
}
/* ---------- Toast / language select polish ---------- */
.theme-key-ascii .toast {
background: #000000;
border: 1px solid #ffffff;
color: #ffffff;
}
.theme-key-ascii .language-select-item {
border-radius: 0;
}
.theme-key-ascii .language-select-item[data-highlighted],
.theme-key-ascii .language-select-item[data-selected] {
background: #ffffff;
color: #000000 !important;
}
/* ---------- Headings: stronger console feel ---------- */
.theme-key-ascii h1,
.theme-key-ascii h2,
.theme-key-ascii h3,
.theme-key-ascii h4 {
font-family: var(--font-mono);
letter-spacing: 0;
text-transform: none;
}
.theme-key-ascii .admin-header {
background: #000000;
border-bottom: 1px solid #ffffff;
color: #ffffff;
}
.theme-key-ascii .admin-header-title h2,
.theme-key-ascii .admin-header-title small {
color: #ffffff;
}
/* Make any element with role progressbar but no inner span show animated stripes. */
.theme-key-ascii [role="progressbar"]:not(.progress) {
background:
repeating-linear-gradient(
90deg,
#ffffff 0,
#ffffff 6px,
#000000 6px,
#000000 8px
);
animation: ascii-scan 0.6s linear infinite;
border: 1px solid #ffffff;
border-radius: 0;
color: #000000;
}
/* ============================================================
* Console-themed extras
* ============================================================ */
/* ---------- ASCII skeletons ---------- *
* Subtle dark shimmer with a single bright scan line moving across.
*/
@keyframes ascii-skeleton-scan {
0% { background-position: -120% 0; }
100% { background-position: 220% 0; }
}
.theme-key-ascii .ui-skeleton,
.theme-key-ascii .admin-skeleton,
.theme-key-ascii .skeleton-line,
.theme-key-ascii .skeleton-dot,
.theme-key-ascii .skeleton-pay-button,
.theme-key-ascii .ui-skeleton-line,
.theme-key-ascii .admin-skeleton-line,
.theme-key-ascii .admin-skeleton-line-strong,
.theme-key-ascii .admin-skeleton-line-soft,
.theme-key-ascii .admin-skeleton-line-short,
.theme-key-ascii .admin-skeleton-line-tiny,
.theme-key-ascii .ui-skeleton-title,
.theme-key-ascii .ui-skeleton-short,
.theme-key-ascii .ui-skeleton-tiny,
.theme-key-ascii .ui-skeleton-badge,
.theme-key-ascii .admin-skeleton-badge,
.theme-key-ascii .admin-skeleton-avatar,
.theme-key-ascii .admin-stat-skeleton-card,
.theme-key-ascii .admin-stat-skeleton-wide,
.theme-key-ascii .admin-cn-card-skeleton--tall {
border-radius: 0 !important;
border: 1px solid #ffffff !important;
background-color: #050505 !important;
background-image: linear-gradient(
90deg,
transparent 0%,
transparent 40%,
rgba(255, 255, 255, 0.18) 50%,
transparent 60%,
transparent 100%
) !important;
background-size: 200% 100% !important;
background-repeat: no-repeat !important;
color: #ffffff !important;
animation: ascii-skeleton-scan 1.6s linear infinite !important;
}
.theme-key-ascii .admin-skeleton-avatar {
width: 32px !important;
height: 32px !important;
}
/* ---------- Empty / loading state console message ---------- */
.theme-key-ascii .admin-empty {
position: relative;
}
.theme-key-ascii .admin-empty::before {
content: "$ tail -f /var/log/empty.log";
display: block;
font-family: var(--font-mono);
color: var(--muted);
margin-bottom: 8px;
letter-spacing: 0;
}
/* ---------- Buttons: glitch on hover ---------- */
@keyframes ascii-glitch {
0%, 100% { transform: translate(0, 0); clip-path: inset(0 0 0 0); }
20% { transform: translate(-1px, 0); clip-path: inset(20% 0 50% 0); }
40% { transform: translate(1px, 0); clip-path: inset(40% 0 30% 0); }
60% { transform: translate(-1px, 0); clip-path: inset(10% 0 70% 0); }
80% { transform: translate(1px, 0); clip-path: inset(60% 0 10% 0); }
}
.theme-key-ascii .btn:hover:not(:disabled)::after,
.theme-key-ascii .admin-btn:hover:not(:disabled)::after {
content: attr(data-label, "");
pointer-events: none;
}
/* Disable glitch text duplication if the button has no data-label.
* Apply a subtle scanline overlay instead, which is content-agnostic. */
.theme-key-ascii .btn,
.theme-key-ascii .admin-btn,
.theme-key-ascii .admin-nav-item,
.theme-key-ascii .bottom-nav button {
overflow: hidden;
}
.theme-key-ascii .btn:hover:not(:disabled)::before,
.theme-key-ascii .admin-btn:hover:not(:disabled)::before,
.theme-key-ascii .admin-nav-item:hover::before,
.theme-key-ascii .bottom-nav button:hover::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.4) 0,
rgba(0, 0, 0, 0.4) 1px,
transparent 1px,
transparent 3px
);
animation: ascii-glitch 0.6s steps(1) infinite;
z-index: 1;
}
/* ---------- Bottom nav active markers "> item <" ---------- */
.theme-key-ascii .bottom-nav button.active::before,
.theme-key-ascii .admin-nav-item.active::before {
content: ">";
position: absolute;
left: 6px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
color: #000000;
animation: ascii-blink 1s steps(1) infinite;
z-index: 2;
}
.theme-key-ascii .bottom-nav button.active::after,
.theme-key-ascii .admin-nav-item.active::after {
content: "<";
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
font-family: var(--font-mono);
color: #000000;
animation: ascii-blink 1s steps(1) infinite;
animation-delay: 0.5s;
z-index: 2;
}
.theme-key-ascii .bottom-nav button.active,
.theme-key-ascii .admin-nav-item.active {
position: relative;
}
/* On the mobile bottom-bar (compact) hide the markers to avoid overlap. */
@media (max-width: 1023px) {
.theme-key-ascii .bottom-nav button.active::before,
.theme-key-ascii .bottom-nav button.active::after {
content: none;
}
}
/* ---------- ASCII block progress fill ---------- *
* The actual fill renders as alternating blocks via the existing span
* gradient. We also overlay a slow scanning highlight to make it feel
* "live", and add a soft typed counter to the right edge.
*/
/* (duplicate progress fill rules removed — see definition above) */
/* ---------- Headings: subtle CRT glitch on hover ---------- */
@keyframes ascii-heading-jitter {
0%, 92%, 100% { transform: translate(0, 0); }
93% { transform: translate(-1px, 0); }
94% { transform: translate(1px, 0); }
95% { transform: translate(0, -1px); }
96% { transform: translate(0, 1px); }
}
.theme-key-ascii h1,
.theme-key-ascii h2,
.theme-key-ascii h3,
.theme-key-ascii .login-brand h1,
.theme-key-ascii .admin-sidebar-brand strong,
.theme-key-ascii .admin-card-head h2,
.theme-key-ascii .admin-card-head h3 {
display: inline-block;
animation: ascii-heading-jitter 7s steps(1) infinite;
}
/* ---------- App-shell boot banner ---------- *
* A non-blocking strip at the very top of the viewport that displays a
* typed "booting…" line, then settles. Pure CSS so it cannot interfere
* with any DOM. The animation runs once on mount.
*/
@keyframes ascii-boot-type {
0% { width: 0; }
85% { width: 28ch; }
100% { width: 28ch; }
}
@keyframes ascii-boot-fade {
0%, 70% { opacity: 1; }
100% { opacity: 0; visibility: hidden; }
}
.theme-key-ascii.app-shell::after {
content: "$ remnawave --start --tty=0";
position: fixed;
top: 0;
left: 0;
z-index: 9999;
display: block;
padding: 2px 8px;
max-width: 28ch;
overflow: hidden;
white-space: nowrap;
background: #000000;
color: #ffffff;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
border-right: 1px solid #ffffff;
border-bottom: 1px solid #ffffff;
pointer-events: none;
animation:
ascii-boot-type 1.6s steps(28) 1 both,
ascii-boot-fade 3s linear 1.6s 1 forwards;
}
/* ---------- Toggle / checkbox squareification (best-effort) ---------- */
.theme-key-ascii input[type="checkbox"],
.theme-key-ascii input[type="radio"] {
appearance: none;
-webkit-appearance: none;
width: 1em;
height: 1em;
border: 1px solid #ffffff;
background: #000000;
border-radius: 0 !important;
position: relative;
vertical-align: middle;
cursor: pointer;
}
.theme-key-ascii input[type="checkbox"]:checked::after,
.theme-key-ascii input[type="radio"]:checked::after {
content: "x";
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-mono);
font-weight: 700;
color: #ffffff;
line-height: 1;
}
/* ---------- Code-like "$ " prefix on toast messages ---------- */
.theme-key-ascii .toast::before {
content: "$ ";
color: #ffffff;
font-family: var(--font-mono);
font-weight: 700;
}
/* ---------- Disabled state — strikethrough hatching ---------- */
.theme-key-ascii .btn:disabled,
.theme-key-ascii .admin-btn:disabled,
.theme-key-ascii button:disabled {
background-image: repeating-linear-gradient(
-45deg,
transparent 0,
transparent 4px,
rgba(255, 255, 255, 0.18) 4px,
rgba(255, 255, 255, 0.18) 5px
);
color: var(--dim) !important;
border-color: var(--dim) !important;
cursor: not-allowed;
}
/* ============================================================
* Square everything: drop all rounded corners on touched surfaces.
* ============================================================ */
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.period-card, .method-card, .settings-row, .option-row,
.tariff-selected-card, .tariff-action-card, .tariff-warning-card,
.topup-carryover-note, .language-select-content, .language-select-item,
.language-select-trigger, .bottom-nav, .bottom-nav button,
.field-error-tooltip,
.admin-card, .admin-card-head, .admin-card-body,
.admin-stat-card, .admin-stat-skeleton-card, .admin-stat-skeleton-wide,
.admin-revenue-panel, .admin-empty,
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content, .admin-select-item,
.admin-cn-card, .admin-cn-badge, .admin-badge,
.admin-cn-card-skeleton--tall,
.admin-input, .admin-textarea, .admin-btn, .admin-chip,
.admin-tabs-trigger, .admin-tabs-list,
.admin-nav-item, .admin-revenue-period-btn, .admin-mobile-toggle,
.admin-header, .admin-sidebar, .admin-sidebar-brand,
.admin-dialog,
.admin-theme-editor-section,
[data-slot="card"], [data-slot="card-header"],
[data-slot="card-content"], [data-slot="card-footer"]
),
.theme-key-ascii :is(
.card, .dialog-card, .toast,
.btn, .input,
.admin-card, .admin-card-head, .admin-card-body,
.admin-stat-card, .admin-revenue-panel, .admin-empty,
.admin-tariff-card, .admin-toolbar-card, .admin-table-card,
.admin-panel-dash-card,
.admin-select-trigger, .admin-select-content,
.admin-cn-card,
.admin-input, .admin-textarea, .admin-btn,
.admin-nav-item, .admin-tabs-trigger
) * {
border-radius: 0 !important;
}
.theme-key-ascii img,
.theme-key-ascii .admin-avatar,
.theme-key-ascii .admin-skeleton-avatar {
border-radius: 0 !important;
}
/* ============================================================
* Console-style tables: cell borders, header underline,
* row separator using dashed line.
* ============================================================ */
.theme-key-ascii .admin-table,
.theme-key-ascii table {
border-collapse: collapse;
border: 1px solid #ffffff;
font-family: var(--font-mono);
}
.theme-key-ascii .admin-table th,
.theme-key-ascii .admin-table td,
.theme-key-ascii table th,
.theme-key-ascii table td {
border: 1px solid #ffffff;
border-radius: 0 !important;
padding: 6px 10px;
}
.theme-key-ascii .admin-table thead th,
.theme-key-ascii table thead th {
background: #000000;
border-bottom: 2px solid #ffffff;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.theme-key-ascii .admin-table tbody tr,
.theme-key-ascii table tbody tr {
border-bottom: 1px solid #ffffff;
}
.theme-key-ascii .admin-table tbody tr:hover,
.theme-key-ascii table tbody tr:hover {
background: rgba(255, 255, 255, 0.07);
}
.theme-key-ascii .admin-table tbody tr:hover td,
.theme-key-ascii table tbody tr:hover td {
color: #ffffff;
}
+17
View File
@@ -0,0 +1,17 @@
{
"key": "ascii",
"names": {
"ru": "ASCII",
"en": "ASCII"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"style_preset": "ascii"
}
}
+27
View File
@@ -0,0 +1,27 @@
{
"key": "dark",
"names": {
"ru": "Темная",
"en": "Dark"
},
"enabled": true,
"default": true,
"use_primary_accent": true,
"use_in_admin": true,
"assets_version": 1,
"tokens": {
"color_scheme": "dark",
"bg": "#03070b",
"panel": "#111820",
"panel_2": "#0b1118",
"panel_3": "#17212b",
"border": "rgba(255, 255, 255, 0.12)",
"border_strong": "rgba(255, 255, 255, 0.2)",
"text": "#f2f7f4",
"muted": "#a9b4b0",
"dim": "#68736f",
"danger": "#ff6b6b",
"blue": "#2d9cff",
"radius": "8px"
}
}
+46
View File
@@ -0,0 +1,46 @@
.theme-key-light {
color-scheme: light;
--accent: #047857;
--bg: #f7f8fb;
--panel: #ffffff;
--panel-2: #f1f5f9;
--panel-3: #e8edf3;
--border: rgba(15, 23, 42, 0.11);
--border-strong: rgba(15, 23, 42, 0.2);
--text: #0f172a;
--muted: #475569;
--dim: #64748b;
--danger: #dc2626;
--blue: #2563eb;
--radius: 8px;
--accent-contrast: #ffffff;
--surface-sheen: rgba(15, 23, 42, 0.035);
--surface-sheen-soft: rgba(15, 23, 42, 0.012);
--surface-hover: rgba(15, 23, 42, 0.045);
--surface-muted: rgba(15, 23, 42, 0.035);
--surface-subtle-border: rgba(15, 23, 42, 0.1);
--overlay-scrim: rgba(15, 23, 42, 0.34);
--nav-bg: rgba(255, 255, 255, 0.88);
--rail-bg: rgba(255, 255, 255, 0.72);
--shadow-soft: 0 16px 38px rgba(15, 23, 42, 0.08);
--shadow-strong: 0 24px 64px rgba(15, 23, 42, 0.16);
--shadow-popover: 0 18px 34px rgba(15, 23, 42, 0.14);
--inset-highlight: rgba(255, 255, 255, 0.75);
--admin-bg: #f7f8fb;
--admin-surface: #ffffff;
--admin-surface-2: #f1f5f9;
--admin-elev: #e8edf3;
--admin-border: rgba(15, 23, 42, 0.1);
--admin-border-strong: rgba(15, 23, 42, 0.18);
--admin-text: #0f172a;
--admin-muted: #64748b;
--admin-dim: #94a3b8;
}
.theme-key-light.app-shell {
background: var(--bg) !important;
}
.theme-key-light .phone-screen {
background: var(--bg);
}
+16
View File
@@ -0,0 +1,16 @@
{
"key": "light",
"names": {
"ru": "Светлая",
"en": "Light"
},
"enabled": true,
"default": false,
"use_primary_accent": true,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 1,
"tokens": {
"color_scheme": "light"
}
}
@@ -0,0 +1,9 @@
Windows 95 theme button icons
=============================
These PNG icons were sourced from the Windows 98 Icon Viewer:
https://win98icons.alexmeub.com/
Original filenames were normalized for theme CSS usage. They are loaded by
`bot/app/web/themes/windows95/style.css` through `/webapp-theme-assets/...`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 393 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

+850
View File
@@ -0,0 +1,850 @@
.theme-key-windows95 {
color-scheme: light;
image-rendering: pixelated;
--accent: #000080;
--bg: #008080;
--panel: #c0c0c0;
--panel-2: #dfdfdf;
--panel-3: #808080;
--border: #808080;
--border-strong: #000000;
--text: #000000;
--muted: #202020;
--dim: #404040;
--danger: #800000;
--blue: #000080;
--radius: 0px;
--font-sans: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif;
--font-logo: "MS Sans Serif", Tahoma, "Segoe UI", sans-serif;
--font-mono: Consolas, "Courier New", monospace;
--accent-contrast: #ffffff;
--surface-sheen: transparent;
--surface-sheen-soft: transparent;
--surface-hover: rgba(0, 0, 128, 0.14);
--surface-muted: #c0c0c0;
--surface-subtle-border: #808080;
--overlay-scrim: rgba(0, 0, 0, 0.35);
--nav-bg: #c0c0c0;
--rail-bg: #c0c0c0;
--shadow-soft: 2px 2px 0 #000000;
--shadow-strong: 3px 3px 0 #000000;
--shadow-popover: 2px 2px 0 #000000;
--inset-highlight: #ffffff;
--admin-bg: #008080;
--admin-surface: #c0c0c0;
--admin-surface-2: #dfdfdf;
--admin-elev: #808080;
--admin-border: #808080;
--admin-border-strong: #000000;
--admin-text: #000000;
--admin-muted: #202020;
--admin-dim: #404040;
--win95-icon-arrow-left: url("/webapp-theme-assets/windows95/icons/arrow-left.png");
--win95-icon-arrow-right: url("/webapp-theme-assets/windows95/icons/arrow-right.png");
--win95-icon-bitcoin: url("/webapp-theme-assets/windows95/icons/bitcoin.png");
--win95-icon-check: url("/webapp-theme-assets/windows95/icons/check.png");
--win95-icon-chevrons: url("/webapp-theme-assets/windows95/icons/chevrons.png");
--win95-icon-coins: url("/webapp-theme-assets/windows95/icons/coins.png");
--win95-icon-dashboard: url("/webapp-theme-assets/windows95/icons/dashboard.png");
--win95-icon-database: url("/webapp-theme-assets/windows95/icons/database.png");
--win95-icon-download: url("/webapp-theme-assets/windows95/icons/download.png");
--win95-icon-error: url("/webapp-theme-assets/windows95/icons/error.png");
--win95-icon-file-text: url("/webapp-theme-assets/windows95/icons/file-text.png");
--win95-icon-folder: url("/webapp-theme-assets/windows95/icons/folder.png");
--win95-icon-gift: url("/webapp-theme-assets/windows95/icons/gift.png");
--win95-icon-globe: url("/webapp-theme-assets/windows95/icons/globe.png");
--win95-icon-help: url("/webapp-theme-assets/windows95/icons/help.png");
--win95-icon-home: url("/webapp-theme-assets/windows95/icons/home.png");
--win95-icon-info: url("/webapp-theme-assets/windows95/icons/info.png");
--win95-icon-key: url("/webapp-theme-assets/windows95/icons/key.png");
--win95-icon-lock: url("/webapp-theme-assets/windows95/icons/lock.png");
--win95-icon-megaphone: url("/webapp-theme-assets/windows95/icons/megaphone.png");
--win95-icon-paintbrush: url("/webapp-theme-assets/windows95/icons/paintbrush.png");
--win95-icon-payment-card: url("/webapp-theme-assets/windows95/icons/payment-card.png");
--win95-icon-print: url("/webapp-theme-assets/windows95/icons/print.png");
--win95-icon-refresh: url("/webapp-theme-assets/windows95/icons/refresh.png");
--win95-icon-save: url("/webapp-theme-assets/windows95/icons/save.png");
--win95-icon-search: url("/webapp-theme-assets/windows95/icons/search.png");
--win95-icon-send: url("/webapp-theme-assets/windows95/icons/send.png");
--win95-icon-settings: url("/webapp-theme-assets/windows95/icons/settings.png");
--win95-icon-shield: url("/webapp-theme-assets/windows95/icons/shield.png");
--win95-icon-smartphone: url("/webapp-theme-assets/windows95/icons/smartphone.png");
--win95-icon-sliders: url("/webapp-theme-assets/windows95/icons/sliders.png");
--win95-icon-sparkles: url("/webapp-theme-assets/windows95/icons/sparkles.png");
--win95-icon-tag: url("/webapp-theme-assets/windows95/icons/tag.png");
--win95-icon-ticket: url("/webapp-theme-assets/windows95/icons/ticket.png");
--win95-icon-trash: url("/webapp-theme-assets/windows95/icons/trash.png");
--win95-icon-user: url("/webapp-theme-assets/windows95/icons/user.png");
--win95-icon-users: url("/webapp-theme-assets/windows95/icons/users.png");
--win95-icon-warning: url("/webapp-theme-assets/windows95/icons/warning.png");
--win95-icon-x: url("/webapp-theme-assets/windows95/icons/x.png");
}
.theme-key-windows95.app-shell {
background: var(--bg) !important;
}
.theme-key-windows95 svg.lucide-arrow-left,
.theme-key-windows95 svg.lucide-arrow-right,
.theme-key-windows95 svg.lucide-bitcoin,
.theme-key-windows95 svg.lucide-check,
.theme-key-windows95 svg.lucide-check-circle-2,
.theme-key-windows95 svg.lucide-chevrons-up-down,
.theme-key-windows95 svg.lucide-circle-x,
.theme-key-windows95 svg.lucide-coins,
.theme-key-windows95 svg.lucide-copy,
.theme-key-windows95 svg.lucide-credit-card,
.theme-key-windows95 svg.lucide-database,
.theme-key-windows95 svg.lucide-download,
.theme-key-windows95 svg.lucide-earth,
.theme-key-windows95 svg.lucide-external-link,
.theme-key-windows95 svg.lucide-file-text,
.theme-key-windows95 svg.lucide-gift,
.theme-key-windows95 svg.lucide-globe-2,
.theme-key-windows95 svg.lucide-home,
.theme-key-windows95 svg.lucide-house,
.theme-key-windows95 svg.lucide-info,
.theme-key-windows95 svg.lucide-key,
.theme-key-windows95 svg.lucide-layout-dashboard,
.theme-key-windows95 svg.lucide-lock-keyhole,
.theme-key-windows95 svg.lucide-mail,
.theme-key-windows95 svg.lucide-megaphone,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-paintbrush,
.theme-key-windows95 svg.lucide-plus,
.theme-key-windows95 svg.lucide-refresh-cw,
.theme-key-windows95 svg.lucide-save,
.theme-key-windows95 svg.lucide-search,
.theme-key-windows95 svg.lucide-send,
.theme-key-windows95 svg.lucide-settings,
.theme-key-windows95 svg.lucide-shield,
.theme-key-windows95 svg.lucide-sliders,
.theme-key-windows95 svg.lucide-smartphone,
.theme-key-windows95 svg.lucide-sparkles,
.theme-key-windows95 svg.lucide-tag,
.theme-key-windows95 svg.lucide-ticket,
.theme-key-windows95 svg.lucide-trash-2,
.theme-key-windows95 svg.lucide-triangle-alert,
.theme-key-windows95 svg.lucide-user,
.theme-key-windows95 svg.lucide-user-round,
.theme-key-windows95 svg.lucide-users,
.theme-key-windows95 svg.lucide-users-round,
.theme-key-windows95 svg.lucide-wallet-cards,
.theme-key-windows95 svg.lucide-x {
flex: 0 0 auto;
width: 22px !important;
height: 22px !important;
color: transparent !important;
stroke: transparent !important;
fill: transparent !important;
background-image: var(--win95-button-icon);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
image-rendering: pixelated;
}
.theme-key-windows95 svg.lucide-arrow-left {
--win95-button-icon: var(--win95-icon-arrow-left);
}
.theme-key-windows95 svg.lucide-arrow-right {
--win95-button-icon: var(--win95-icon-arrow-right);
}
.theme-key-windows95 svg.lucide-bitcoin {
--win95-button-icon: var(--win95-icon-bitcoin);
}
.theme-key-windows95 svg.lucide-check,
.theme-key-windows95 svg.lucide-check-circle-2,
.theme-key-windows95 svg.lucide-circle-check,
.theme-key-windows95 svg.lucide-circle-check-big {
--win95-button-icon: var(--win95-icon-check);
}
.theme-key-windows95 svg.lucide-chevrons-up-down {
--win95-button-icon: var(--win95-icon-chevrons);
}
.theme-key-windows95 svg.lucide-coins {
--win95-button-icon: var(--win95-icon-coins);
}
.theme-key-windows95 svg.lucide-circle-x,
.theme-key-windows95 svg.lucide-x {
--win95-button-icon: var(--win95-icon-x);
}
.theme-key-windows95 svg.lucide-copy {
--win95-button-icon: var(--win95-icon-file-text);
}
.theme-key-windows95 svg.lucide-credit-card,
.theme-key-windows95 svg.lucide-wallet-cards {
--win95-button-icon: var(--win95-icon-payment-card);
}
.theme-key-windows95 svg.lucide-database {
--win95-button-icon: var(--win95-icon-database);
}
.theme-key-windows95 svg.lucide-download {
--win95-button-icon: var(--win95-icon-download);
}
.theme-key-windows95 svg.lucide-earth,
.theme-key-windows95 svg.lucide-external-link,
.theme-key-windows95 svg.lucide-globe-2 {
--win95-button-icon: var(--win95-icon-globe);
}
.theme-key-windows95 svg.lucide-file-text {
--win95-button-icon: var(--win95-icon-file-text);
}
.theme-key-windows95 svg.lucide-gift {
--win95-button-icon: var(--win95-icon-gift);
}
.theme-key-windows95 svg.lucide-home,
.theme-key-windows95 svg.lucide-house {
--win95-button-icon: var(--win95-icon-home);
}
.theme-key-windows95 svg.lucide-info {
--win95-button-icon: var(--win95-icon-info);
}
.theme-key-windows95 svg.lucide-key {
--win95-button-icon: var(--win95-icon-key);
}
.theme-key-windows95 svg.lucide-layout-dashboard {
--win95-button-icon: var(--win95-icon-dashboard);
}
.theme-key-windows95 svg.lucide-lock-keyhole {
--win95-button-icon: var(--win95-icon-lock);
}
.theme-key-windows95 svg.lucide-megaphone {
--win95-button-icon: var(--win95-icon-megaphone);
}
.theme-key-windows95 svg.lucide-mail,
.theme-key-windows95 svg.lucide-message-square,
.theme-key-windows95 svg.lucide-send {
--win95-button-icon: var(--win95-icon-send);
}
.theme-key-windows95 svg.lucide-paintbrush {
--win95-button-icon: var(--win95-icon-paintbrush);
}
.theme-key-windows95 svg.lucide-plus {
--win95-button-icon: var(--win95-icon-folder);
}
.theme-key-windows95 svg.lucide-refresh-cw {
--win95-button-icon: var(--win95-icon-refresh);
}
.theme-key-windows95 svg.lucide-save {
--win95-button-icon: var(--win95-icon-save);
}
.theme-key-windows95 svg.lucide-search {
--win95-button-icon: var(--win95-icon-search);
}
.theme-key-windows95 svg.lucide-settings {
--win95-button-icon: var(--win95-icon-settings);
}
.theme-key-windows95 svg.lucide-shield {
--win95-button-icon: var(--win95-icon-shield);
}
.theme-key-windows95 svg.lucide-sliders {
--win95-button-icon: var(--win95-icon-sliders);
}
.theme-key-windows95 svg.lucide-smartphone {
--win95-button-icon: var(--win95-icon-smartphone);
}
.theme-key-windows95 svg.lucide-sparkles {
--win95-button-icon: var(--win95-icon-sparkles);
}
.theme-key-windows95 svg.lucide-tag {
--win95-button-icon: var(--win95-icon-tag);
}
.theme-key-windows95 svg.lucide-ticket {
--win95-button-icon: var(--win95-icon-ticket);
}
.theme-key-windows95 svg.lucide-trash-2 {
--win95-button-icon: var(--win95-icon-trash);
}
.theme-key-windows95 svg.lucide-triangle-alert {
--win95-button-icon: var(--win95-icon-warning);
}
.theme-key-windows95 svg.lucide-user,
.theme-key-windows95 svg.lucide-user-round {
--win95-button-icon: var(--win95-icon-user);
}
.theme-key-windows95 svg.lucide-users,
.theme-key-windows95 svg.lucide-users-round {
--win95-button-icon: var(--win95-icon-users);
}
.theme-key-windows95 :is(
svg.lucide-arrow-left,
svg.lucide-arrow-right,
svg.lucide-bitcoin,
svg.lucide-check,
svg.lucide-check-circle-2,
svg.lucide-chevrons-up-down,
svg.lucide-circle-check,
svg.lucide-circle-check-big,
svg.lucide-circle-x,
svg.lucide-coins,
svg.lucide-copy,
svg.lucide-credit-card,
svg.lucide-database,
svg.lucide-download,
svg.lucide-earth,
svg.lucide-external-link,
svg.lucide-file-text,
svg.lucide-gift,
svg.lucide-globe-2,
svg.lucide-home,
svg.lucide-house,
svg.lucide-info,
svg.lucide-key,
svg.lucide-layout-dashboard,
svg.lucide-lock-keyhole,
svg.lucide-mail,
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
svg.lucide-smartphone,
svg.lucide-sparkles,
svg.lucide-tag,
svg.lucide-ticket,
svg.lucide-trash-2,
svg.lucide-triangle-alert,
svg.lucide-user,
svg.lucide-user-round,
svg.lucide-users,
svg.lucide-users-round,
svg.lucide-wallet-cards,
svg.lucide-x
) *,
.theme-key-windows95 :is(
svg.lucide-arrow-left,
svg.lucide-arrow-right,
svg.lucide-bitcoin,
svg.lucide-check,
svg.lucide-check-circle-2,
svg.lucide-chevrons-up-down,
svg.lucide-circle-check,
svg.lucide-circle-check-big,
svg.lucide-circle-x,
svg.lucide-coins,
svg.lucide-copy,
svg.lucide-credit-card,
svg.lucide-database,
svg.lucide-download,
svg.lucide-earth,
svg.lucide-external-link,
svg.lucide-file-text,
svg.lucide-gift,
svg.lucide-globe-2,
svg.lucide-home,
svg.lucide-house,
svg.lucide-info,
svg.lucide-key,
svg.lucide-layout-dashboard,
svg.lucide-lock-keyhole,
svg.lucide-mail,
svg.lucide-megaphone,
svg.lucide-message-square,
svg.lucide-paintbrush,
svg.lucide-plus,
svg.lucide-refresh-cw,
svg.lucide-save,
svg.lucide-search,
svg.lucide-send,
svg.lucide-settings,
svg.lucide-shield,
svg.lucide-sliders,
svg.lucide-smartphone,
svg.lucide-sparkles,
svg.lucide-tag,
svg.lucide-ticket,
svg.lucide-trash-2,
svg.lucide-triangle-alert,
svg.lucide-user,
svg.lucide-user-round,
svg.lucide-users,
svg.lucide-users-round,
svg.lucide-wallet-cards,
svg.lucide-x
) {
color: transparent !important;
stroke: transparent !important;
fill: transparent !important;
}
.theme-key-windows95,
.theme-key-windows95 button,
.theme-key-windows95 input,
.theme-key-windows95 textarea {
font-synthesis: none;
}
.theme-key-windows95 .phone-screen {
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 8px 8px;
}
.theme-key-windows95 .card,
.theme-key-windows95 .period-card,
.theme-key-windows95 .method-card,
.theme-key-windows95 .settings-row,
.theme-key-windows95 .option-row,
.theme-key-windows95 .tariff-selected-card,
.theme-key-windows95 .tariff-action-card,
.theme-key-windows95 .tariff-warning-card,
.theme-key-windows95 .topup-carryover-note,
.theme-key-windows95 .input,
.theme-key-windows95 .dialog-card,
.theme-key-windows95 .language-select-content,
.theme-key-windows95 .bottom-nav,
.theme-key-windows95 .toast {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .dialog-card,
.theme-key-windows95 .language-select-content,
.theme-key-windows95 .toast {
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080,
2px 2px 0 #000000;
}
.theme-key-windows95 .card::before,
.theme-key-windows95 .dialog-card::before {
content: "";
display: block;
height: 18px;
margin: -14px -14px 12px;
background: linear-gradient(90deg, var(--accent), #1084d0);
border-bottom: 2px solid #000000;
}
.theme-key-windows95 .dialog-card::before {
margin: -18px -18px 14px;
}
.theme-key-windows95 .btn,
.theme-key-windows95 .language-select-trigger,
.theme-key-windows95 .bottom-nav button {
min-height: 34px;
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--panel);
color: var(--text);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
transform: none;
}
@media (min-width: 1024px) {
.theme-key-windows95 .bottom-nav {
border-right: 2px solid #404040 !important;
background: var(--rail-bg) !important;
backdrop-filter: none !important;
box-shadow:
inset 1px 1px 0 #ffffff,
inset -1px -1px 0 #808080 !important;
}
.theme-key-windows95 .bottom-nav button {
min-height: 34px !important;
border: 2px solid !important;
border-color: #ffffff #404040 #404040 #ffffff !important;
border-radius: 0 !important;
background: var(--panel) !important;
color: var(--text) !important;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080 !important;
}
.theme-key-windows95 .bottom-nav button.active {
background: var(--accent) !important;
color: #ffffff !important;
border-color: #ffffff #404040 #404040 #ffffff !important;
}
.theme-key-windows95 .bottom-nav button:active {
border-color: #404040 #ffffff #ffffff #404040 !important;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf !important;
}
}
.theme-key-windows95 .btn:active:not(:disabled),
.theme-key-windows95 .bottom-nav button:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .btn-primary,
.theme-key-windows95 .bottom-nav button.active,
.theme-key-windows95 .period-card.active,
.theme-key-windows95 .method-card.active,
.theme-key-windows95 .option-row.active {
background: var(--accent);
color: #ffffff;
}
.theme-key-windows95 .card-heading-accent,
.theme-key-windows95 .brand-row strong,
.theme-key-windows95 .login-brand h1,
.theme-key-windows95 .bottom-nav button.active {
color: var(--accent);
}
.theme-key-windows95 .btn-primary,
.theme-key-windows95 .bottom-nav button.active {
color: #ffffff;
}
.theme-key-windows95 .progress {
height: 14px;
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
border-radius: 0;
background: #ffffff;
}
.theme-key-windows95 .progress span {
border-radius: 0;
background: repeating-linear-gradient(
90deg,
var(--accent) 0,
var(--accent) 8px,
#ffffff 8px,
#ffffff 10px
);
box-shadow: none;
}
.theme-key-windows95 .language-select-item {
border-radius: 0;
}
.theme-key-windows95 .language-select-item[data-highlighted],
.theme-key-windows95 .language-select-item[data-selected] {
background: var(--accent);
color: #ffffff !important;
}
.theme-key-windows95 .field-error-tooltip {
border-radius: 0;
}
.theme-key-windows95 .admin-screen-wrap {
background: var(--admin-bg);
}
.theme-key-windows95 .admin-sidebar,
.theme-key-windows95 .admin-header,
.theme-key-windows95 .admin-card,
.theme-key-windows95 .admin-stat-card,
.theme-key-windows95 .admin-revenue-panel,
.theme-key-windows95 .admin-empty,
.theme-key-windows95 .admin-tariff-card,
.theme-key-windows95 .admin-toolbar-card,
.theme-key-windows95 .admin-table-card,
.theme-key-windows95 .admin-panel-dash-card,
.theme-key-windows95 .admin-select-trigger,
.theme-key-windows95 .admin-select-content,
.theme-key-windows95 .admin-cn-card[data-slot="card"],
.theme-key-windows95 .admin-dialog .dialog-card,
.theme-key-windows95 .admin-theme-editor-section {
border-width: 2px;
border-style: solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--admin-surface);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .admin-header,
.theme-key-windows95 .admin-sidebar-brand,
.theme-key-windows95 .admin-card-head {
border-bottom: 2px solid #000000;
}
.theme-key-windows95 .admin-header {
background: linear-gradient(90deg, var(--accent), #1084d0);
color: #ffffff;
}
.theme-key-windows95 .admin-header-title h2,
.theme-key-windows95 .admin-header-title small {
color: #ffffff;
}
.theme-key-windows95 .admin-nav-item,
.theme-key-windows95 .admin-btn,
.theme-key-windows95 .admin-chip,
.theme-key-windows95 .admin-tabs-trigger,
.theme-key-windows95 .admin-revenue-period-btn,
.theme-key-windows95 .admin-mobile-toggle {
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
border-radius: 0;
background: var(--admin-surface);
color: var(--admin-text);
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 .admin-nav-item:hover,
.theme-key-windows95 .admin-select-item[data-highlighted],
.theme-key-windows95 .admin-select-item:hover,
.theme-key-windows95 .admin-table tbody tr:hover {
background: color-mix(in srgb, var(--accent) 20%, var(--admin-surface));
}
.theme-key-windows95 .admin-nav-item.active,
.theme-key-windows95 .admin-btn-primary,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"],
.theme-key-windows95 .admin-revenue-period-btn.is-active {
background: var(--accent);
color: #ffffff;
border-color: #ffffff #404040 #404040 #ffffff;
}
.theme-key-windows95 .admin-input,
.theme-key-windows95 .admin-textarea,
.theme-key-windows95 .admin-screen-wrap textarea,
.theme-key-windows95 .admin-dialog textarea,
.theme-key-windows95 .input {
border: 2px solid;
border-color: #404040 #ffffff #ffffff #404040;
border-radius: 0;
background: #ffffff;
color: #000000;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .admin-cn-badge,
.theme-key-windows95 .admin-badge {
border-radius: 0;
}
.theme-key-windows95 ::selection {
background: var(--accent);
color: #ffffff;
}
.theme-key-windows95 .btn:focus-visible,
.theme-key-windows95 .admin-btn:focus-visible,
.theme-key-windows95 .admin-nav-item:focus-visible,
.theme-key-windows95 .admin-tabs-trigger:focus-visible,
.theme-key-windows95 .admin-revenue-period-btn:focus-visible,
.theme-key-windows95 .admin-mobile-toggle:focus-visible,
.theme-key-windows95 .language-select-trigger:focus-visible,
.theme-key-windows95 .bottom-nav button:focus-visible {
outline: 1px dotted #000000;
outline-offset: -4px;
}
.theme-key-windows95 .admin-input:focus,
.theme-key-windows95 .admin-textarea:focus,
.theme-key-windows95 .admin-screen-wrap textarea:focus,
.theme-key-windows95 .admin-dialog textarea:focus,
.theme-key-windows95 .input:focus {
outline: none;
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .admin-header-title h2,
.theme-key-windows95 .admin-sidebar-brand strong,
.theme-key-windows95 .admin-card-head h3,
.theme-key-windows95 .admin-card-head h2,
.theme-key-windows95 .login-brand h1,
.theme-key-windows95 .brand-row strong {
font-weight: 700;
letter-spacing: 0;
}
.theme-key-windows95 .admin-nav-item:hover,
.theme-key-windows95 .admin-select-item[data-highlighted],
.theme-key-windows95 .admin-select-item:hover {
background: color-mix(in srgb, var(--accent) 30%, var(--admin-surface));
}
.theme-key-windows95 .admin-nav-item.active,
.theme-key-windows95 .admin-btn-primary,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"],
.theme-key-windows95 .admin-revenue-period-btn.is-active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #000000,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 .btn-primary svg.lucide,
.theme-key-windows95 .bottom-nav button.active svg.lucide,
.theme-key-windows95 .period-card.active svg.lucide,
.theme-key-windows95 .method-card.active svg.lucide,
.theme-key-windows95 .option-row.active svg.lucide,
.theme-key-windows95 .admin-btn-primary svg.lucide,
.theme-key-windows95 .admin-nav-item.active svg.lucide,
.theme-key-windows95 .admin-tabs-trigger[data-state="active"] svg.lucide,
.theme-key-windows95 .admin-revenue-period-btn.is-active svg.lucide,
.theme-key-windows95 .admin-header svg.lucide {
filter: brightness(0) invert(1);
}
.theme-key-windows95 svg.lucide-activity,
.theme-key-windows95 svg.lucide-calendar-days,
.theme-key-windows95 svg.lucide-chevron-down,
.theme-key-windows95 svg.lucide-chevron-left,
.theme-key-windows95 svg.lucide-chevron-right,
.theme-key-windows95 svg.lucide-chevron-up,
.theme-key-windows95 svg.lucide-circle,
.theme-key-windows95 svg.lucide-crown,
.theme-key-windows95 svg.lucide-eye,
.theme-key-windows95 svg.lucide-eye-off,
.theme-key-windows95 svg.lucide-map,
.theme-key-windows95 svg.lucide-menu,
.theme-key-windows95 svg.lucide-mouse-pointer-click,
.theme-key-windows95 svg.lucide-qr-code,
.theme-key-windows95 svg.lucide-radio,
.theme-key-windows95 svg.lucide-repeat-2,
.theme-key-windows95 svg.lucide-server,
.theme-key-windows95 svg.lucide-trending-down,
.theme-key-windows95 svg.lucide-trending-up,
.theme-key-windows95 svg.lucide-user-minus,
.theme-key-windows95 svg.lucide-user-plus,
.theme-key-windows95 svg.lucide-zap {
color: var(--text);
stroke-width: 2.5;
}
.theme-key-windows95 ::-webkit-scrollbar {
width: 16px;
height: 16px;
}
.theme-key-windows95 ::-webkit-scrollbar-track {
background-color: #c0c0c0;
background-image:
linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%),
linear-gradient(45deg, #ffffff 25%, transparent 25%, transparent 75%, #ffffff 75%);
background-size: 4px 4px;
background-position: 0 0, 2px 2px;
}
.theme-key-windows95 ::-webkit-scrollbar-thumb {
background: var(--panel);
border: 2px solid;
border-color: #ffffff #404040 #404040 #ffffff;
box-shadow:
inset 1px 1px 0 #dfdfdf,
inset -1px -1px 0 #808080;
}
.theme-key-windows95 ::-webkit-scrollbar-thumb:active {
border-color: #404040 #ffffff #ffffff #404040;
box-shadow:
inset 1px 1px 0 #808080,
inset -1px -1px 0 #dfdfdf;
}
.theme-key-windows95 ::-webkit-scrollbar-corner {
background: var(--panel);
}
.theme-key-windows95 .progress span {
background: repeating-linear-gradient(
90deg,
var(--accent) 0,
var(--accent) 6px,
#1084d0 6px,
#1084d0 8px
);
}
.theme-key-windows95 .card::before,
.theme-key-windows95 .dialog-card::before {
height: 20px;
}
.theme-key-windows95 .admin-table tbody tr {
border-bottom: 1px solid #808080;
}
.theme-key-windows95 .admin-badge,
.theme-key-windows95 .admin-cn-badge {
border: 1px solid #000000;
background: var(--panel);
color: var(--text);
font-weight: 700;
}
.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item),
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]) {
color: var(--accent);
text-decoration: underline;
}
.theme-key-windows95 .admin-screen-wrap a:not(.admin-btn):not(.admin-nav-item):visited,
.theme-key-windows95 a:not(.btn):not(.bottom-nav button):not([class*="-trigger"]):visited {
color: #800080;
}
+17
View File
@@ -0,0 +1,17 @@
{
"key": "windows95",
"names": {
"ru": "Windows 95",
"en": "Windows 95"
},
"enabled": true,
"default": false,
"use_primary_accent": false,
"use_in_admin": true,
"css_file": "style.css",
"assets_version": 1,
"tokens": {
"color_scheme": "light",
"style_preset": "win95"
}
}
+11
View File
@@ -76,6 +76,17 @@ WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60
WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30
WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024
WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024
WEBAPP_THEME_CSS_MAX_BYTES = 512 * 1024
WEBAPP_THEME_ASSET_MAX_BYTES = 1024 * 1024
WEBAPP_THEME_ASSET_CONTENT_TYPES = {
".gif": "image/gif",
".ico": "image/x-icon",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
}
WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024
WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60
WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4
+119
View File
@@ -1,6 +1,13 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import (
default_webapp_theme_asset_file,
default_webapp_theme_css_files,
ensure_default_webapp_theme_descriptor_files,
public_themes_catalog_payload,
)
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
@@ -10,6 +17,111 @@ async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]:
return _safe_theme_relative_path(raw_path, allowed_suffixes={".css"}, max_length=180)
def _safe_theme_asset_relative_path(raw_path: str) -> Optional[Path]:
return _safe_theme_relative_path(
raw_path,
allowed_suffixes=set(WEBAPP_THEME_ASSET_CONTENT_TYPES),
max_length=220,
)
def _safe_theme_relative_path(
raw_path: str,
*,
allowed_suffixes: set[str],
max_length: int,
) -> Optional[Path]:
value = str(raw_path or "").replace("\\", "/").strip().lstrip("/")
if not value or len(value) > max_length or "\x00" in value:
return None
parts = [part for part in value.split("/") if part]
if len(parts) < 2 or any(part in {".", ".."} for part in parts):
return None
if any(not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", part) for part in parts):
return None
rel_path = Path(*parts)
if rel_path.suffix.lower() not in allowed_suffixes:
return None
return rel_path
async def theme_css_asset_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR)
rel_path = _safe_theme_css_relative_path(request.match_info.get("path") or "")
if rel_path is None:
raise web.HTTPNotFound(text="theme_css_not_found")
root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve()
path = (root / rel_path).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="theme_css_not_found") from None
try:
if path.stat().st_size > WEBAPP_THEME_CSS_MAX_BYTES:
raise web.HTTPNotFound(text="theme_css_too_large")
text = path.read_text(encoding="utf-8")
except OSError:
defaults = default_webapp_theme_css_files()
text = defaults.get(rel_path.as_posix())
if text is None:
raise web.HTTPNotFound(text="theme_css_not_found") from None
response = web.Response(text=text, content_type="text/css", charset="utf-8")
response.headers["Cache-Control"] = "no-cache"
return response
async def theme_asset_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
ensure_default_webapp_theme_descriptor_files(settings.WEBAPP_THEMES_DIR)
rel_path = _safe_theme_asset_relative_path(request.match_info.get("path") or "")
if rel_path is None:
raise web.HTTPNotFound(text="theme_asset_not_found")
root = Path(settings.WEBAPP_THEMES_DIR).expanduser().resolve()
path = (root / rel_path).resolve()
try:
path.relative_to(root)
except ValueError:
raise web.HTTPNotFound(text="theme_asset_not_found") from None
suffix = rel_path.suffix.lower()
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(suffix)
if not content_type:
raise web.HTTPNotFound(text="theme_asset_not_found")
try:
if path.stat().st_size > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_too_large")
body = path.read_bytes()
except OSError:
fallback = default_webapp_theme_asset_file(rel_path)
if fallback is None:
raise web.HTTPNotFound(text="theme_asset_not_found") from None
body, fallback_suffix = fallback
content_type = WEBAPP_THEME_ASSET_CONTENT_TYPES.get(fallback_suffix, content_type)
if not body or len(body) > WEBAPP_THEME_ASSET_MAX_BYTES:
raise web.HTTPNotFound(text="theme_asset_not_found")
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = "public, max-age=3600"
return response
def _resolve_webapp_logo_url(settings: Settings) -> str:
raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip()
if not raw_logo_url:
@@ -628,9 +740,16 @@ async def index_route(request: web.Request) -> web.Response:
html = TEMPLATE_PATH.read_text(encoding="utf-8")
cached = _get_cached_webapp_settings(request)
themes_catalog = settings.webapp_themes_catalog
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"themesCatalog": public_themes_catalog_payload(
themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"themesDir": settings.WEBAPP_THEMES_DIR,
"logoUrl": cached["logo_url"],
"logoEmoji": settings.WEBAPP_LOGO_EMOJI,
"logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT,
+2
View File
@@ -20,6 +20,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_route)
app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route)
+7
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from config.webapp_themes_config import public_themes_catalog_payload
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
@@ -96,6 +98,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
+26
View File
@@ -7,6 +7,10 @@ from pydantic import BaseModel, Field, ValidationError, computed_field, field_va
from pydantic_settings import BaseSettings, SettingsConfigDict
from config.tariffs_config import TariffsConfig, load_tariffs_config
from config.webapp_themes_config import (
WebappThemesConfig,
resolved_webapp_themes_catalog,
)
def _split_csv(value: Optional[str]) -> List[str]:
@@ -351,6 +355,19 @@ class Settings(BaseSettings):
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_THEMES_DIR: str = Field(
default="data/themes",
description=(
"Directory with per-theme folders. Each theme lives in "
"<key>/theme.json with optional CSS/assets next to it."
),
)
WEBAPP_DEFAULT_THEME: Optional[str] = Field(
default=None,
description=(
"Override the descriptor-marked default theme when set to an existing theme key."
),
)
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_LOGO_EMOJI: str = Field(default="🫥")
WEBAPP_LOGO_EMOJI_FONT: str = Field(
@@ -810,6 +827,15 @@ class Settings(BaseSettings):
def tariffs_config(self) -> Optional[TariffsConfig]:
return load_tariffs_config(self.TARIFFS_CONFIG_PATH)
@computed_field
@property
def webapp_themes_catalog(self) -> WebappThemesConfig:
return resolved_webapp_themes_catalog(
primary_accent=self.WEBAPP_PRIMARY_COLOR or "#00fe7a",
env_default_theme=self.WEBAPP_DEFAULT_THEME,
theme_dir=self.WEBAPP_THEMES_DIR,
)
@computed_field
@property
def referral_bonus_inviter(self) -> Dict[int, int]:
+586
View File
@@ -0,0 +1,586 @@
"""File-backed catalog of Web App UI themes."""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Literal, Optional
from pydantic import BaseModel, Field, model_validator
logger = logging.getLogger(__name__)
ColorScheme = Literal["light", "dark"]
class ThemeTokens(BaseModel):
"""CSS design tokens for the subscription Mini App shell."""
model_config = {"extra": "ignore"}
color_scheme: ColorScheme = "dark"
style_preset: Optional[str] = None
accent: Optional[str] = None
bg: Optional[str] = None
panel: Optional[str] = None
panel_2: Optional[str] = None
panel_3: Optional[str] = None
border: Optional[str] = None
border_strong: Optional[str] = None
text: Optional[str] = None
muted: Optional[str] = None
dim: Optional[str] = None
danger: Optional[str] = None
blue: Optional[str] = None
radius: Optional[str] = None
font_sans: Optional[str] = None
font_logo: Optional[str] = None
font_mono: Optional[str] = None
admin_bg: Optional[str] = None
admin_surface: Optional[str] = None
admin_surface_2: Optional[str] = None
admin_elev: Optional[str] = None
admin_border: Optional[str] = None
admin_border_strong: Optional[str] = None
admin_text: Optional[str] = None
admin_muted: Optional[str] = None
admin_dim: Optional[str] = None
class WebappTheme(BaseModel):
"""Single theme descriptor loaded from WEBAPP_THEMES_DIR/<key>/theme.json."""
model_config = {"extra": "ignore"}
key: str = Field(min_length=1, max_length=64)
names: Dict[str, str] = Field(default_factory=dict)
enabled: bool = True
default: bool = False
use_primary_accent: bool = True
use_in_admin: bool = True
css_file: Optional[str] = None
assets_version: int = 1
tokens: ThemeTokens = Field(default_factory=ThemeTokens)
class WebappThemesConfig(BaseModel):
"""Runtime catalog assembled from individual theme descriptor files."""
model_config = {"extra": "ignore"}
default_theme: str = "dark"
themes: List[WebappTheme] = Field(default_factory=list)
@model_validator(mode="after")
def _validate_default_and_keys(self) -> WebappThemesConfig:
keys = [t.key for t in self.themes]
if len(keys) != len(set(keys)):
raise ValueError("duplicate theme keys")
if self.themes and self.default_theme not in keys:
raise ValueError("default_theme must match a theme key")
return self
def theme_by_key(self, key: str) -> Optional[WebappTheme]:
for theme in self.themes:
if theme.key == key:
return theme
return None
def enabled_themes(self) -> List[WebappTheme]:
return [theme for theme in self.themes if theme.enabled]
DEFAULT_THEME_KEYS = ("dark", "light", "windows95", "ascii")
THEME_DESCRIPTOR_FILENAME = "theme.json"
DEFAULT_THEMES_SOURCE_DIR = Path(__file__).resolve().parents[1] / "bot" / "app" / "web" / "themes"
def _safe_theme_key(value: str) -> Optional[str]:
key = str(value or "").strip()
if re.fullmatch(r"[A-Za-z0-9_-]{1,64}", key):
return key
return None
def _theme_dir_path(theme_dir: str | Path, key: str) -> Path:
safe_key = _safe_theme_key(key)
if not safe_key:
raise ValueError(f"invalid theme key: {key!r}")
return Path(theme_dir).expanduser() / safe_key
def _theme_file_path(theme_dir: str | Path, key: str) -> Path:
return _theme_dir_path(theme_dir, key) / THEME_DESCRIPTOR_FILENAME
def default_webapp_theme_css_files() -> Dict[str, str]:
"""Read default theme CSS from repository files."""
out: Dict[str, str] = {}
for key in DEFAULT_THEME_KEYS:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
for source_path in sorted(source_dir.rglob("*.css")):
rel_path = Path(key) / source_path.relative_to(source_dir)
try:
content = source_path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning(
"Default webapp theme CSS source file is missing: %s (%s)",
source_path,
exc,
)
continue
out[rel_path.as_posix()] = content if content.endswith("\n") else f"{content}\n"
return out
def default_webapp_theme_asset_file(rel_path: str | Path) -> Optional[tuple[bytes, str]]:
"""Read a default theme asset from the repository theme folder."""
relative = Path(rel_path)
if relative.is_absolute() or len(relative.parts) < 2 or ".." in relative.parts:
return None
source_path = (DEFAULT_THEMES_SOURCE_DIR / relative).resolve()
try:
source_path.relative_to(DEFAULT_THEMES_SOURCE_DIR.resolve())
except ValueError:
return None
try:
return source_path.read_bytes(), source_path.suffix.lower()
except OSError:
return None
def default_webapp_theme_descriptors() -> Dict[str, Dict[str, Any]]:
"""Read default theme descriptors from repository files."""
out: Dict[str, Dict[str, Any]] = {}
for key in DEFAULT_THEME_KEYS:
source_path = DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME
try:
raw = json.loads(source_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning(
"Default webapp theme descriptor is missing or invalid: %s (%s)",
source_path,
exc,
)
continue
if not isinstance(raw, dict):
continue
safe_key = _safe_theme_key(str(raw.get("key") or source_path.parent.name))
if safe_key:
raw["key"] = safe_key
out[safe_key] = raw
return out
def _theme_from_descriptor(path: Path, raw: Any) -> Optional[WebappTheme]:
if not isinstance(raw, dict):
logger.warning("Ignoring theme descriptor %s: expected JSON object", path)
return None
data = dict(raw)
data["key"] = data.get("key") or (
path.parent.name if path.name == THEME_DESCRIPTOR_FILENAME else path.stem
)
safe_key = _safe_theme_key(str(data["key"]))
if not safe_key:
logger.warning("Ignoring theme descriptor %s: invalid theme key %r", path, data["key"])
return None
data["key"] = safe_key
try:
return WebappTheme.model_validate(data)
except ValueError as exc:
logger.warning("Ignoring theme descriptor %s: %s", path, exc)
return None
def load_webapp_theme_file(path: str | Path) -> Optional[WebappTheme]:
theme_path = Path(path).expanduser()
try:
raw = json.loads(theme_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to load webapp theme descriptor from %s: %s", theme_path, exc)
return None
return _theme_from_descriptor(theme_path, raw)
def load_webapp_theme_dir(theme_dir: str | Path) -> List[WebappTheme]:
root = Path(theme_dir).expanduser()
if not root.exists():
return []
themes_by_key: Dict[str, WebappTheme] = {}
for path in sorted(root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}")):
if path.parent.name.startswith("_"):
continue
theme = load_webapp_theme_file(path)
if theme is None:
continue
if theme.key in themes_by_key:
logger.warning("Ignoring duplicate webapp theme key %s from %s", theme.key, path)
continue
themes_by_key[theme.key] = theme
return list(themes_by_key.values())
def _config_with_synced_default_flags(config: WebappThemesConfig) -> WebappThemesConfig:
data = config.model_dump(mode="json", exclude_none=True)
default_theme = str(data.get("default_theme") or "dark")
themes = data.get("themes", [])
for theme in themes:
if isinstance(theme, dict):
theme["default"] = theme.get("key") == default_theme
def _sort_key(item: tuple[int, Any]) -> tuple[int, int]:
idx, theme = item
if not isinstance(theme, dict):
return (len(THEME_DISPLAY_ORDER), idx)
try:
priority = THEME_DISPLAY_ORDER.index(str(theme.get("key") or ""))
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, idx)
data["themes"] = [theme for _, theme in sorted(enumerate(themes), key=_sort_key)]
return WebappThemesConfig.model_validate(data)
THEME_DISPLAY_ORDER = ("dark", "light")
def _theme_sort_key(theme: WebappTheme, index: int) -> tuple[int, int]:
try:
priority = THEME_DISPLAY_ORDER.index(theme.key)
except ValueError:
priority = len(THEME_DISPLAY_ORDER)
return (priority, index)
def _sorted_themes(themes: List[WebappTheme]) -> List[WebappTheme]:
return [theme for _, theme in sorted(
((_theme_sort_key(t, i), t) for i, t in enumerate(themes)),
key=lambda pair: pair[0],
)]
def _themes_config_from_list(
default_theme: Optional[str],
themes: List[WebappTheme],
) -> WebappThemesConfig:
keys = {theme.key for theme in themes}
descriptor_default = next(
(theme.key for theme in themes if theme.default and theme.key not in DEFAULT_THEME_KEYS),
None,
) or next((theme.key for theme in themes if theme.default), None)
resolved_default = default_theme or descriptor_default or "dark"
if themes and resolved_default not in keys:
resolved_default = "dark" if "dark" in keys else themes[0].key
config = WebappThemesConfig(default_theme=resolved_default, themes=_sorted_themes(themes))
return _config_with_synced_default_flags(config)
def _write_webapp_theme_file(path: Path, theme: WebappTheme) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
data = theme.model_dump(mode="json", exclude_none=True)
payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n"
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
try:
tmp_path.write_text(payload, encoding="utf-8")
tmp_path.replace(path)
except PermissionError:
if tmp_path.exists():
try:
tmp_path.unlink()
except OSError:
pass
path.write_text(payload, encoding="utf-8")
def _copy_default_theme_assets(key: str, target_dir: Path, *, overwrite: bool = False) -> None:
source_dir = DEFAULT_THEMES_SOURCE_DIR / key
if not source_dir.exists():
return
for source_path in sorted(source_dir.rglob("*")):
if not source_path.is_file() or source_path.name == THEME_DESCRIPTOR_FILENAME:
continue
rel_path = source_path.relative_to(source_dir)
target_path = target_dir / rel_path
if target_path.exists() and not overwrite:
continue
try:
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_bytes(source_path.read_bytes())
except OSError as exc:
logger.warning("Could not create default webapp theme asset %s: %s", target_path, exc)
def _builtin_theme_assets_need_refresh(key: str, target_dir: Path) -> bool:
style_path = target_dir / "style.css"
try:
style = style_path.read_text(encoding="utf-8")
except OSError:
return True
if key == "light":
return ".theme-key-light.app-shell" not in style
if key == "ascii":
return (
".theme-key-ascii" not in style
or "ascii-spin" not in style
or "ascii-skeleton-scan" not in style
or "ascii-boot-type" not in style
or "Console-style tables" not in style
)
if key != "windows95":
return False
required_icons = (
"arrow-right.png",
"dashboard.png",
"megaphone.png",
"paintbrush.png",
"sliders.png",
"sparkles.png",
"tag.png",
)
return (
"lucide-house" not in style
or "lucide-earth" not in style
or "lucide-circle-check" not in style
or "border-radius: 0 !important" not in style
or any(not (target_dir / "icons" / icon).exists() for icon in required_icons)
)
def ensure_default_webapp_theme_descriptor_files(theme_dir: str | Path | None) -> None:
"""Seed source-controlled default theme folders into the mounted data directory."""
if not theme_dir:
return
root = Path(theme_dir).expanduser()
try:
root.mkdir(parents=True, exist_ok=True)
except OSError as exc:
logger.warning("Could not create webapp themes directory at %s: %s", root, exc)
return
existing_has_default = any(theme.default for theme in load_webapp_theme_dir(root))
existing_by_key = {theme.key: theme for theme in load_webapp_theme_dir(root)}
for key, descriptor in default_webapp_theme_descriptors().items():
theme_dir_path = _theme_dir_path(root, key)
path = _theme_file_path(root, key)
existing = existing_by_key.get(key)
source_assets_version = int(descriptor.get("assets_version") or 1)
should_sync_assets = (
existing is not None
and key in DEFAULT_THEME_KEYS
and (
int(existing.assets_version or 0) < source_assets_version
or _builtin_theme_assets_need_refresh(key, theme_dir_path)
)
)
if not path.exists():
seed_descriptor = dict(descriptor)
if existing_has_default:
seed_descriptor["default"] = False
theme = _theme_from_descriptor(path, seed_descriptor)
if theme is not None:
try:
_write_webapp_theme_file(path, theme)
except OSError as exc:
logger.warning(
"Could not create default webapp theme descriptor %s: %s",
path,
exc,
)
elif should_sync_assets and existing is not None:
data = existing.model_dump(mode="json", exclude_none=True)
data["assets_version"] = source_assets_version
if descriptor.get("css_file"):
data["css_file"] = descriptor["css_file"]
try:
theme = WebappTheme.model_validate(data)
_write_webapp_theme_file(path, theme)
except (OSError, ValueError) as exc:
logger.warning("Could not update default webapp theme descriptor %s: %s", path, exc)
_copy_default_theme_assets(key, theme_dir_path, overwrite=should_sync_assets)
def write_webapp_theme_dir(
theme_dir: str | Path,
config: WebappThemesConfig,
*,
delete_missing: bool = False,
) -> None:
"""Write one theme.json descriptor per theme into WEBAPP_THEMES_DIR/<key>."""
root = Path(theme_dir).expanduser()
root.mkdir(parents=True, exist_ok=True)
normalized = _config_with_synced_default_flags(config)
keep_paths = set()
for theme in normalized.themes:
path = _theme_file_path(root, theme.key)
_write_webapp_theme_file(path, theme)
keep_paths.add(path.resolve())
if not delete_missing:
return
for path in root.glob(f"*/{THEME_DESCRIPTOR_FILENAME}"):
if path.parent.name.startswith("_") or path.resolve() in keep_paths:
continue
try:
path.unlink()
if not any(path.parent.iterdir()):
path.parent.rmdir()
except OSError as exc:
logger.warning("Could not delete removed webapp theme descriptor %s: %s", path, exc)
def ensure_webapp_core_themes(
config: WebappThemesConfig, primary_accent: str
) -> tuple[WebappThemesConfig, bool]:
"""Keep dark, light and Windows 95 themes available without clobbering custom edits."""
data = config.model_dump(mode="json", exclude_none=True)
themes = data.setdefault("themes", [])
by_key = {str(theme.get("key")): theme for theme in themes if isinstance(theme, dict)}
changed = False
for builtin in builtin_webapp_themes_config(primary_accent).themes:
builtin_data = builtin.model_dump(mode="json", exclude_none=True)
existing = by_key.get(builtin.key)
if existing is None:
themes.append(builtin_data)
by_key[builtin.key] = builtin_data
changed = True
continue
if existing.get("enabled") is False:
existing["enabled"] = True
changed = True
if "use_primary_accent" not in existing:
existing["use_primary_accent"] = builtin_data.get("use_primary_accent", True)
changed = True
if "use_in_admin" not in existing:
existing["use_in_admin"] = builtin_data.get("use_in_admin", True)
changed = True
if int(existing.get("assets_version") or 0) < int(builtin_data.get("assets_version") or 1):
existing["assets_version"] = builtin_data.get("assets_version", 1)
changed = True
if builtin.key in {"light", "windows95", "ascii"} and not existing.get("css_file"):
existing["css_file"] = builtin_data.get("css_file")
changed = True
tokens = existing.setdefault("tokens", {})
builtin_tokens = builtin_data.get("tokens", {})
for token_key in ("color_scheme", "style_preset"):
if token_key in builtin_tokens and not tokens.get(token_key):
tokens[token_key] = builtin_tokens[token_key]
changed = True
keys = {str(theme.get("key")) for theme in themes if isinstance(theme, dict)}
if themes and data.get("default_theme") not in keys:
data["default_theme"] = "dark" if "dark" in keys else str(themes[0].get("key") or "dark")
changed = True
normalized = _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
if normalized.model_dump(mode="json", exclude_none=True) != data:
changed = True
return normalized, changed
def builtin_webapp_themes_config(primary_accent: str) -> WebappThemesConfig:
"""Default catalog backed by repository theme descriptor files."""
accent = (primary_accent or "#00fe7a").strip() or "#00fe7a"
themes: List[WebappTheme] = []
descriptors = default_webapp_theme_descriptors()
for key in DEFAULT_THEME_KEYS:
raw = descriptors.get(key)
if not raw:
continue
theme = _theme_from_descriptor(
DEFAULT_THEMES_SOURCE_DIR / key / THEME_DESCRIPTOR_FILENAME,
raw,
)
if theme is None:
continue
if theme.key == "dark" and not theme.tokens.accent:
theme.tokens.accent = accent
themes.append(theme)
return _themes_config_from_list(None, themes)
def apply_webapp_theme_env_overrides(
config: WebappThemesConfig, env_default_theme: Optional[str]
) -> WebappThemesConfig:
"""If WEBAPP_DEFAULT_THEME is set and matches a theme key, override the default theme."""
raw = (env_default_theme or "").strip()
if not raw:
return config
if config.theme_by_key(raw) is None:
logger.warning("WEBAPP_DEFAULT_THEME=%r ignored: no such theme in catalog", raw)
return config
data = config.model_dump(mode="json", exclude_none=True)
data["default_theme"] = raw
return _config_with_synced_default_flags(WebappThemesConfig.model_validate(data))
def resolved_webapp_themes_catalog(
*,
theme_dir: str | Path,
primary_accent: str,
env_default_theme: Optional[str],
) -> WebappThemesConfig:
"""Load themes from WEBAPP_THEMES_DIR, seeding defaults when possible."""
ensure_default_webapp_theme_descriptor_files(theme_dir)
themes = load_webapp_theme_dir(theme_dir)
config = _themes_config_from_list(None, themes)
config, changed = ensure_webapp_core_themes(config, primary_accent)
if changed:
try:
write_webapp_theme_dir(theme_dir, config, delete_missing=False)
except OSError as exc:
logger.warning("Could not update webapp theme descriptors in %s: %s", theme_dir, exc)
return apply_webapp_theme_env_overrides(config, env_default_theme)
def merge_primary_accent_into_theme_tokens(
theme: WebappTheme, primary_accent: str, *, only_if_token_missing: bool = True
) -> ThemeTokens:
"""Fill accent from WEBAPP_PRIMARY_COLOR when theme tokens omit accent."""
base = theme.tokens.model_copy(deep=True)
accent = (primary_accent or "").strip()
if not accent:
return base
if only_if_token_missing and base.accent:
return base
base.accent = accent
return base
def public_theme_payload(theme: WebappTheme, primary_accent: str) -> Dict[str, object]:
tokens = (
merge_primary_accent_into_theme_tokens(theme, primary_accent)
if theme.use_primary_accent
else theme.tokens
)
payload: Dict[str, object] = {
"key": theme.key,
"names": dict(theme.names),
"enabled": bool(theme.enabled),
"use_primary_accent": bool(theme.use_primary_accent),
"use_in_admin": bool(theme.use_in_admin),
"tokens": tokens.model_dump(mode="json", exclude_none=True),
}
if theme.css_file:
payload["css_file"] = theme.css_file
return payload
def public_themes_catalog_payload(
config: WebappThemesConfig, primary_accent: str, *, enabled_only: bool = False
) -> Dict[str, object]:
themes = [theme for theme in config.themes if not enabled_only or theme.enabled]
return {
"default_theme": config.default_theme,
"themes": [public_theme_payload(theme, primary_accent) for theme in themes],
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Activate",
"wa_settings_avatar_alt": "User avatar",
"wa_settings_language": "Language",
"wa_settings_theme": "Appearance theme",
"wa_settings_theme_follow_default": "Server default",
"wa_settings_theme_update_failed": "Failed to update theme",
"wa_settings_link_telegram": "Telegram linked",
"wa_settings_not_linked": "Not linked",
"wa_settings_link_email": "Email linked",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Logs",
"admin_nav_system": "System",
"admin_nav_tariffs": "Tariffs",
"admin_nav_themes": "Themes",
"admin_nav_settings": "Settings",
"admin_section_stats_title": "Dashboard",
"admin_section_stats_subtitle": "Audience, revenue, Remnawave panel, and recent payments",
@@ -869,6 +873,8 @@
"admin_section_logs_subtitle": "User events and admin actions",
"admin_section_tariffs_title": "Tariffs",
"admin_section_tariffs_subtitle": "Sales catalog, periods, packages, and limits",
"admin_section_themes_title": "Web App themes",
"admin_section_themes_subtitle": "Colors, fonts, and Mini App appearance",
"admin_section_settings_title": "App Settings",
"admin_section_settings_subtitle": "Overrides for .env, applied instantly",
"admin_filter_all": "All",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " groups",
"admin_id": "ID",
"admin_status_default": "Default",
"admin_status_current": "Current",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Disabled",
"admin_tariffs_stat_disabled_hint": "Hidden from showcase",
"admin_tariffs_catalog_empty": "The catalog is empty. Add your first tariff; a catalog JSON file will be created after saving.",
"admin_themes_catalog_title": "Web App themes",
"admin_themes_catalog_sub": "Select the current theme from a card; edit appearance through files in the theme folder",
"admin_themes_use_primary_accent": "Use primary accent color",
"admin_themes_use_in_admin": "Use in admin panel",
"admin_themes_catalog_empty": "The catalog is empty. Add a theme folder to data/themes and refresh.",
"admin_themes_saved": "Themes saved",
"admin_themes_save_failed": "Failed to save themes",
"admin_no_description": "No description",
"admin_tariff_model_traffic": "Traffic",
"admin_tariff_model_periods": "Periods",
+14
View File
@@ -768,6 +768,9 @@
"wa_activate": "Активировать",
"wa_settings_avatar_alt": "Аватар пользователя",
"wa_settings_language": "Выбор языка",
"wa_settings_theme": "Тема оформления",
"wa_settings_theme_follow_default": "По умолчанию (сервер)",
"wa_settings_theme_update_failed": "Не удалось обновить тему",
"wa_settings_link_telegram": "Привязка Telegram",
"wa_settings_not_linked": "Не привязан",
"wa_settings_link_email": "Привязка почты",
@@ -852,6 +855,7 @@
"admin_nav_logs": "Логи",
"admin_nav_system": "Система",
"admin_nav_tariffs": "Тарифы",
"admin_nav_themes": "Темы",
"admin_nav_settings": "Настройки",
"admin_section_stats_title": "Дашборд",
"admin_section_stats_subtitle": "Аудитория, доходы, панель Remnawave и последние платежи",
@@ -869,6 +873,8 @@
"admin_section_logs_subtitle": "События пользователей и админ-действия",
"admin_section_tariffs_title": "Тарифы",
"admin_section_tariffs_subtitle": "Каталог продаж, периоды, пакеты и лимиты",
"admin_section_themes_title": "Темы Web App",
"admin_section_themes_subtitle": "Цвета, шрифты и темы оформления Mini App",
"admin_section_settings_title": "Настройки приложения",
"admin_section_settings_subtitle": "Оверрайды над .env, применяются мгновенно",
"admin_filter_all": "Все",
@@ -1047,6 +1053,7 @@
"admin_stats_queue_groups": " групп",
"admin_id": "ID",
"admin_status_default": "По умолчанию",
"admin_status_current": "Текущая",
"admin_tariff_squads": "Squads",
"admin_tariff_premium": "Premium",
"admin_settings_badge_secret": "Secret",
@@ -1221,6 +1228,13 @@
"admin_tariffs_stat_disabled": "Отключено",
"admin_tariffs_stat_disabled_hint": "Скрыто с витрины",
"admin_tariffs_catalog_empty": "Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога.",
"admin_themes_catalog_title": "Темы Web App",
"admin_themes_catalog_sub": "Текущая тема выбирается карточкой; внешний вид редактируется файлами в папке темы",
"admin_themes_use_primary_accent": "Протягивать акцентный цвет",
"admin_themes_use_in_admin": "Использовать в админке",
"admin_themes_catalog_empty": "Каталог пуст. Добавьте папку темы в data/themes и обновите список.",
"admin_themes_saved": "Темы сохранены",
"admin_themes_save_failed": "Не удалось сохранить темы",
"admin_no_description": "Без описания",
"admin_tariff_model_traffic": "Трафик",
"admin_tariff_model_periods": "Периоды",
+127
View File
@@ -222,6 +222,133 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(response.text, "console.log('minified');")
async def test_theme_css_asset_route_serves_file_from_configured_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom").mkdir()
(themes_dir / "custom" / "theme.css").write_text(
".theme-key-custom { --bg: red; }", encoding="utf-8"
)
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/theme.css"},
)
response = await subscription_webapp.theme_css_asset_route(request)
self.assertEqual(response.content_type, "text/css")
self.assertEqual(response.headers["Cache-Control"], "no-cache")
self.assertIn("--bg: red", response.text)
async def test_theme_css_asset_route_serves_default_theme_asset_from_theme_folder(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "light/style.css"},
)
response = await subscription_webapp.theme_css_asset_route(request)
self.assertEqual(response.content_type, "text/css")
self.assertIn(".theme-key-light", response.text)
self.assertTrue((Path(tmpdir) / "light" / "theme.json").exists())
self.assertTrue((Path(tmpdir) / "light" / "style.css").exists())
async def test_theme_css_asset_route_rejects_path_traversal(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "../secret.css"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_css_asset_route(request)
async def test_theme_asset_route_serves_image_from_configured_directory(self):
with tempfile.TemporaryDirectory() as tmpdir:
themes_dir = Path(tmpdir)
(themes_dir / "custom" / "icons").mkdir(parents=True)
(themes_dir / "custom" / "icons" / "save.png").write_bytes(b"png-bytes")
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=str(themes_dir),
)
},
match_info={"path": "custom/icons/save.png"},
)
response = await subscription_webapp.theme_asset_route(request)
self.assertEqual(response.content_type, "image/png")
self.assertEqual(response.headers["Cache-Control"], "public, max-age=3600")
self.assertEqual(response.body, b"png-bytes")
async def test_theme_asset_route_serves_default_theme_icon_from_theme_folder(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "windows95/icons/save.png"},
)
response = await subscription_webapp.theme_asset_route(request)
self.assertEqual(response.content_type, "image/png")
self.assertGreater(len(response.body), 0)
self.assertTrue((Path(tmpdir) / "windows95" / "theme.json").exists())
self.assertTrue((Path(tmpdir) / "windows95" / "icons" / "save.png").exists())
async def test_theme_asset_route_rejects_path_traversal(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "../secret.png"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_asset_route(request)
async def test_theme_asset_route_rejects_non_image_suffix(self):
with tempfile.TemporaryDirectory() as tmpdir:
request = SimpleNamespace(
app={
"settings": SimpleNamespace(
WEBAPP_ENABLED=True,
WEBAPP_THEMES_DIR=tmpdir,
)
},
match_info={"path": "custom/icons/readme.txt"},
)
with self.assertRaises(webapp_assets.web.HTTPNotFound):
await subscription_webapp.theme_asset_route(request)
def test_webapp_logo_disk_cache_roundtrip(self):
with tempfile.TemporaryDirectory() as tmpdir:
logo_url = "https://cdn.example.com/logo.png"
+3
View File
@@ -59,6 +59,7 @@ class WebAppRouteContractTests(unittest.TestCase):
("GET", "/webapp-logo"): "webapp_logo_route",
("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route",
("GET", "/subscription_webapp.css"): "css_asset_route",
("GET", "/webapp-theme-css/{path}"): "theme_css_asset_route",
("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route",
("GET", "/subscription_webapp.js"): "js_asset_route",
("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route",
@@ -136,6 +137,8 @@ class WebAppRouteContractTests(unittest.TestCase):
("PATCH", "/api/admin/settings"): "admin_settings_patch_route",
("GET", "/api/admin/tariffs"): "admin_tariffs_get_route",
("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route",
("GET", "/api/admin/themes"): "admin_themes_get_route",
("PUT", "/api/admin/themes"): "admin_themes_save_route",
("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route",
}
+371
View File
@@ -0,0 +1,371 @@
import json
import tempfile
import unittest
from pathlib import Path
from config.webapp_themes_config import (
WebappThemesConfig,
apply_webapp_theme_env_overrides,
builtin_webapp_themes_config,
default_webapp_theme_descriptors,
ensure_webapp_core_themes,
load_webapp_theme_dir,
public_themes_catalog_payload,
resolved_webapp_themes_catalog,
write_webapp_theme_dir,
)
class WebappThemesConfigTests(unittest.TestCase):
def test_builtin_has_core_themes(self):
cfg = builtin_webapp_themes_config("#abcdef")
self.assertEqual(cfg.default_theme, "dark")
keys = {theme.key for theme in cfg.themes}
self.assertEqual(keys, {"dark", "light", "windows95", "ascii"})
dark = cfg.theme_by_key("dark")
self.assertIsNotNone(dark)
self.assertTrue(dark.default)
self.assertEqual(dark.tokens.accent, "#abcdef")
win95 = cfg.theme_by_key("windows95")
self.assertIsNotNone(win95)
self.assertEqual(cfg.theme_by_key("light").css_file, "style.css")
self.assertEqual(win95.css_file, "style.css")
self.assertEqual(win95.tokens.style_preset, "win95")
self.assertFalse(win95.use_primary_accent)
self.assertTrue(win95.use_in_admin)
self.assertEqual(win95.assets_version, 1)
ascii_theme = cfg.theme_by_key("ascii")
self.assertIsNotNone(ascii_theme)
self.assertEqual(ascii_theme.css_file, "style.css")
self.assertFalse(ascii_theme.use_primary_accent)
self.assertTrue(ascii_theme.use_in_admin)
self.assertEqual(ascii_theme.assets_version, 1)
def test_env_override_default_theme(self):
cfg = builtin_webapp_themes_config("#00fe7a")
out = apply_webapp_theme_env_overrides(cfg, "light")
self.assertEqual(out.default_theme, "light")
self.assertTrue(out.theme_by_key("light").default)
self.assertFalse(out.theme_by_key("dark").default)
def test_core_themes_are_merged_when_missing(self):
cfg = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"enabled": True,
"default": True,
"use_primary_accent": False,
"tokens": {"color_scheme": "dark"},
}
],
)
merged, changed = ensure_webapp_core_themes(cfg, "#00fe7a")
self.assertTrue(changed)
self.assertEqual(
{theme.key for theme in merged.themes},
{"custom", "dark", "light", "windows95", "ascii"},
)
self.assertEqual(merged.default_theme, "custom")
self.assertTrue(merged.theme_by_key("custom").default)
self.assertEqual(merged.theme_by_key("light").css_file, "style.css")
self.assertTrue(merged.theme_by_key("custom").use_in_admin)
self.assertFalse(merged.theme_by_key("custom").use_primary_accent)
def test_default_theme_descriptors_are_read_from_source_files(self):
descriptors = default_webapp_theme_descriptors()
self.assertEqual(set(descriptors), {"dark", "light", "windows95", "ascii"})
self.assertTrue(descriptors["dark"]["default"])
self.assertEqual(descriptors["windows95"]["css_file"], "style.css")
self.assertEqual(descriptors["ascii"]["css_file"], "style.css")
def test_resolved_creates_default_files_when_missing(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
cfg = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#abc123",
env_default_theme=None,
)
self.assertTrue((themes_dir / "dark" / "theme.json").exists())
self.assertTrue((themes_dir / "light" / "theme.json").exists())
self.assertTrue((themes_dir / "light" / "style.css").exists())
self.assertTrue((themes_dir / "windows95" / "theme.json").exists())
self.assertTrue((themes_dir / "windows95" / "style.css").exists())
self.assertTrue((themes_dir / "windows95" / "icons" / "save.png").exists())
self.assertTrue((themes_dir / "ascii" / "theme.json").exists())
self.assertTrue((themes_dir / "ascii" / "style.css").exists())
self.assertEqual(cfg.default_theme, "dark")
self.assertIsNone(cfg.theme_by_key("dark").tokens.accent)
def test_load_theme_dir_uses_filename_as_key_when_missing(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
themes_dir.mkdir()
(themes_dir / "custom").mkdir()
(themes_dir / "custom" / "theme.json").write_text(
json.dumps(
{
"names": {"en": "Custom"},
"enabled": True,
"css_file": "style.css",
"tokens": {"color_scheme": "light"},
}
),
encoding="utf-8",
)
themes = load_webapp_theme_dir(themes_dir)
self.assertEqual(len(themes), 1)
self.assertEqual(themes[0].key, "custom")
self.assertEqual(themes[0].names["en"], "Custom")
def test_resolved_catalog_includes_custom_mounted_theme_descriptor(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
themes_dir.mkdir()
(themes_dir / "neon").mkdir()
(themes_dir / "neon" / "theme.json").write_text(
json.dumps(
{
"names": {"en": "Neon"},
"enabled": True,
"default": True,
"css_file": "style.css",
"tokens": {"color_scheme": "dark"},
}
),
encoding="utf-8",
)
cfg = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
self.assertEqual(cfg.default_theme, "neon")
self.assertIsNotNone(cfg.theme_by_key("neon"))
self.assertEqual(
{theme.key for theme in cfg.themes},
{"dark", "light", "windows95", "ascii", "neon"},
)
def test_env_default_overrides_descriptor_default(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
themes_dir.mkdir()
(themes_dir / "neon").mkdir()
(themes_dir / "neon" / "theme.json").write_text(
json.dumps(
{
"names": {"en": "Neon"},
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark"},
}
),
encoding="utf-8",
)
cfg = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme="windows95",
)
self.assertEqual(cfg.default_theme, "windows95")
self.assertTrue(cfg.theme_by_key("windows95").default)
self.assertFalse(cfg.theme_by_key("neon").default)
def test_custom_descriptor_default_wins_over_seeded_core_default(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
themes_dir.mkdir()
(themes_dir / "dark").mkdir()
(themes_dir / "dark" / "theme.json").write_text(
json.dumps(
{
"key": "dark",
"names": {"en": "Dark"},
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark"},
}
),
encoding="utf-8",
)
(themes_dir / "neon").mkdir()
(themes_dir / "neon" / "theme.json").write_text(
json.dumps(
{
"names": {"en": "Neon"},
"enabled": True,
"default": True,
"tokens": {"color_scheme": "dark"},
}
),
encoding="utf-8",
)
cfg = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
self.assertEqual(cfg.default_theme, "neon")
self.assertTrue(cfg.theme_by_key("neon").default)
self.assertFalse(cfg.theme_by_key("dark").default)
def test_theme_dir_writer_writes_descriptors_with_single_default(self):
with tempfile.TemporaryDirectory() as tmp:
cfg = builtin_webapp_themes_config("#00fe7a")
cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes)
themes_dir = Path(tmp) / "themes"
write_webapp_theme_dir(themes_dir, cfg)
dark = json.loads((themes_dir / "dark" / "theme.json").read_text(encoding="utf-8"))
win95 = json.loads(
(themes_dir / "windows95" / "theme.json").read_text(encoding="utf-8")
)
self.assertFalse(dark["default"])
self.assertTrue(win95["default"])
resolved = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
self.assertEqual(resolved.default_theme, "windows95")
def test_resolved_falls_back_to_dark_when_saved_theme_is_missing(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
cfg = builtin_webapp_themes_config("#00fe7a")
cfg = WebappThemesConfig(default_theme="windows95", themes=cfg.themes)
write_webapp_theme_dir(themes_dir, cfg)
windows_theme = themes_dir / "windows95" / "theme.json"
windows_theme.unlink()
resolved = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
self.assertEqual(resolved.default_theme, "dark")
self.assertTrue(resolved.theme_by_key("dark").default)
def test_public_payload_injects_primary_accent_when_enabled(self):
cfg = builtin_webapp_themes_config("#abc123")
payload = public_themes_catalog_payload(cfg, "#abc123")
light = next(theme for theme in payload["themes"] if theme["key"] == "light")
dark = next(theme for theme in payload["themes"] if theme["key"] == "dark")
win95 = next(theme for theme in payload["themes"] if theme["key"] == "windows95")
self.assertEqual(light["css_file"], "style.css")
self.assertEqual(light["tokens"]["accent"], "#abc123")
self.assertEqual(dark["tokens"]["accent"], "#abc123")
self.assertFalse(win95["use_primary_accent"])
self.assertTrue(win95["use_in_admin"])
self.assertNotIn("accent", win95["tokens"])
def test_public_payload_keeps_admin_usage_flag(self):
cfg = WebappThemesConfig(
default_theme="custom",
themes=[
{
"key": "custom",
"names": {"en": "Custom"},
"enabled": True,
"default": True,
"use_in_admin": False,
"tokens": {"color_scheme": "dark"},
}
],
)
payload = public_themes_catalog_payload(cfg, "#abc123")
self.assertFalse(payload["themes"][0]["use_in_admin"])
def test_resolved_refreshes_stale_builtin_windows95_assets(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
stale_theme_dir = themes_dir / "windows95"
stale_theme_dir.mkdir(parents=True)
(stale_theme_dir / "theme.json").write_text(
json.dumps(
{
"key": "windows95",
"names": {"en": "Windows 95"},
"enabled": True,
"default": False,
"use_primary_accent": True,
"css_file": "style.css",
"assets_version": 1,
"tokens": {"color_scheme": "light", "style_preset": "win95"},
}
),
encoding="utf-8",
)
(stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8")
cfg = resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
descriptor = json.loads((stale_theme_dir / "theme.json").read_text(encoding="utf-8"))
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
self.assertEqual(
descriptor["assets_version"],
cfg.theme_by_key("windows95").assets_version,
)
self.assertEqual(descriptor["assets_version"], 1)
self.assertIn("lucide-house", css)
self.assertIn("lucide-earth", css)
self.assertIn("lucide-circle-check", css)
self.assertIn("lucide-circle-check-big", css)
self.assertTrue((stale_theme_dir / "icons" / "dashboard.png").exists())
def test_resolved_refreshes_stale_builtin_light_assets(self):
with tempfile.TemporaryDirectory() as tmp:
themes_dir = Path(tmp) / "themes"
stale_theme_dir = themes_dir / "light"
stale_theme_dir.mkdir(parents=True)
(stale_theme_dir / "theme.json").write_text(
json.dumps(
{
"key": "light",
"names": {"en": "Light"},
"enabled": True,
"default": False,
"use_primary_accent": True,
"css_file": "style.css",
"assets_version": 1,
"tokens": {"color_scheme": "light"},
}
),
encoding="utf-8",
)
(stale_theme_dir / "style.css").write_text("/* stale */", encoding="utf-8")
resolved_webapp_themes_catalog(
theme_dir=themes_dir,
primary_accent="#00fe7a",
env_default_theme=None,
)
css = (stale_theme_dir / "style.css").read_text(encoding="utf-8")
self.assertIn(".theme-key-light.app-shell", css)