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
@@ -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),