feat(theme): локальный выбор и показанная тема handoff (#13)

This commit is contained in:
Panfilov Konstantin committed 2026-10-21 20:42:28 +03:00
1 parent 3740839321
commit 9c06977e03
11 files changed
+163 -49

No files matched your search

+2
View File
@@ -198,6 +198,8 @@ $basePickerAccept = 'image/jpeg,image/png,image/gif,image/webp,image/bmp,image/a
function redirectToTour(record) { function redirectToTour(record) {
let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key); let targetUrl = record.meta.redirectBase + encodeURIComponent(record.key);
if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm; if (record.meta.redirectUtm) targetUrl += '&' + record.meta.redirectUtm;
const shownTheme = window.ZoomzillaTheme.current();
targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme);
window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; }); window.PublicNavigationGuard.allowNavigation(() => { window.location.href = targetUrl; });
} }
+70
View File
@@ -0,0 +1,70 @@
<?php
/** Общий inline runtime API/независимого PHP-преленда. Переменные задаёт вызывающий layout. */
$themeInitial = isset($themeInitial) && $themeInitial === 'dark' ? 'dark' : 'light';
$themeSurface = isset($themeSurface) && in_array($themeSurface, ['public', 'private', 'preland'], true) ? $themeSurface : 'public';
?>
<script>
(function (initial, surface) {
'use strict';
if (window.ZoomzillaTheme) { window.ZoomzillaTheme.refresh(); return; }
const key = 'theme_choice';
let shown = initial;
const canonical = value => typeof value === 'string' && value.length <= 128 && /^v1:(light|dark):(manual|preland):[A-Za-z0-9_-]{16,64}(?![\s\S])/.test(value) ? value : null;
const themeOf = value => value.split(':')[1];
function read() {
try {
for (const part of document.cookie.split(';')) {
const cookie = part.trim();
if (!cookie.startsWith(key + '=')) continue;
const value = canonical(decodeURIComponent(cookie.slice(key.length + 1)));
if (value) return value;
}
} catch (_) {}
try { return canonical(window.localStorage.getItem(key)); } catch (_) { return null; }
}
function apply(theme) {
shown = theme;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
if (document.body) document.body.classList.toggle('theme-dark', theme === 'dark');
document.querySelectorAll('[data-theme-choice]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.themeChoice === theme)));
}
function uuid() {
if (window.crypto && typeof window.crypto.randomUUID === 'function') return window.crypto.randomUUID();
const bytes = new Uint8Array(16);
if (window.crypto && window.crypto.getRandomValues) window.crypto.getRandomValues(bytes);
else for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256);
bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128;
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('');
return hex.slice(0, 8) + '-' + hex.slice(8, 12) + '-' + hex.slice(12, 16) + '-' + hex.slice(16, 20) + '-' + hex.slice(20);
}
function refresh() { const value = read(); apply(value ? themeOf(value) : initial); }
function choose(theme) {
if (theme !== 'light' && theme !== 'dark') return;
const from = shown;
const value = 'v1:' + theme + ':manual:' + uuid().replace(/-/g, '');
try { document.cookie = key + '=' + encodeURIComponent(value) + '; Expires=Tue, 19 Jan 2038 03:14:07 GMT; Path=/; SameSite=Lax' + (location.protocol === 'https:' ? '; Secure' : ''); } catch (_) {}
try { window.localStorage.setItem(key, value); } catch (_) {}
apply(theme);
if (from === theme) return;
const detail = {schema_version: 1, event_id: uuid(), from, to: theme, source: 'manual', surface, host: location.hostname, path: location.pathname, occurred_at: new Date().toISOString()};
try { document.dispatchEvent(new CustomEvent('theme_changed', {detail})); } catch (_) {}
}
window.ZoomzillaTheme = Object.freeze({current: () => shown, choose, refresh});
// Pointer-клик не сбрасывает ввод/selection; клавиатурная навигация остаётся штатной.
document.addEventListener('mousedown', event => {
const button = event.target instanceof Element ? event.target.closest('[data-theme-choice]') : null;
const active = document.activeElement;
if (button && active && (active.matches('input,textarea') || active.isContentEditable)) event.preventDefault();
});
document.addEventListener('click', event => {
const button = event.target instanceof Element ? event.target.closest('[data-theme-choice]') : null;
if (button && !button.disabled) choose(button.dataset.themeChoice);
});
window.addEventListener('storage', event => { if (event.key === key || event.key === null) refresh(); });
window.addEventListener('pageshow', refresh);
document.addEventListener('DOMContentLoaded', refresh);
document.addEventListener('pjax:end', refresh);
refresh();
})(<?= json_encode($themeInitial, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>, <?= json_encode($themeSurface, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>);
</script>
+16
View File
@@ -0,0 +1,16 @@
<?php
/** preland#13: только оформление; не меняет рекламный набор и не подключает Yii. */
function prelandThemePreference(): ?array
{
$value = $_COOKIE['theme_choice'] ?? null;
if (!is_string($value) || strlen($value) > 128
|| !preg_match('/\Av1:(light|dark):(manual|preland):([A-Za-z0-9_-]{16,64})\z/', $value, $match)) {
return null;
}
return ['theme' => $match[1], 'source' => $match[2], 'revision' => $match[3]];
}
function prelandThemeResolve(string $fallback): string
{
return prelandThemePreference()['theme'] ?? $fallback;
}
+27
View File
@@ -0,0 +1,27 @@
<style>
/* api#260 / preland#13: утверждённый общий control, без замены DOM страницы. */
html[data-theme="light"] { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }
body.theme-dark { background-color: #000; }
.theme-switch { width:88px;height:44px;border-radius:14px;display:flex;flex:0 0 88px;box-shadow:inset 0 0 0 1px #d8d9e1;background:#f1f2f6;isolation:isolate; }
.theme-switch button { position:relative;width:44px;height:44px;padding:10px;display:flex;align-items:center;justify-content:center;border:0;background:transparent;border-radius:14px;cursor:pointer;flex:none;color:#000; }
.theme-switch button::before { content:'';position:absolute;inset:4px;border-radius:10px;z-index:-1; }
.theme-switch button[aria-pressed="true"]::before { background:#fff;box-shadow:0 1px 4px #10182818; }
.theme-switch button:focus-visible { outline:2px solid #7754df;outline-offset:3px; }
.theme-switch svg { display:block;width:24px;height:24px;flex:none;opacity:.6;pointer-events:none; }
.theme-switch button[aria-pressed="true"] svg { opacity:1; }
.theme-dark .theme-switch { background:#0b0b10;box-shadow:inset 0 0 0 1px #39343f; }
.theme-dark .theme-switch button { color:#fff; }
.theme-dark .theme-switch button[aria-pressed="true"]::before { background:#30283c;box-shadow:inset 0 0 0 1px #655374; }
.header .navbar>.container>div.d-flex { gap:24px;flex-shrink:0; }
.header .btn-header { margin-right:0!important; }
body:not(.theme-dark) .header img[src$="/coin-wh.svg"] { filter:brightness(.35) sepia(.6) saturate(4) hue-rotate(228deg); }
@media(max-width:767.98px) {
.header .navbar>.container>div.d-flex { gap:12px; }
.theme-switch { width:64px;height:30px;flex-basis:64px;border-radius:9px; }
.theme-switch button { width:32px;height:30px;padding:5px 6px;border-radius:9px; }
.theme-switch button::before { inset:3px;border-radius:6px; }
.theme-switch button::after { content:'';position:absolute;top:-7px;bottom:-7px;left:0;right:0; }
.theme-switch svg { width:20px;height:20px; }
}
</style>
+15
View File
@@ -0,0 +1,15 @@
<?php
/** Одинаковый SSR control в Yii и standalone PHP; переводы передаются явно. */
$themeLabels = $themeLabels ?? ['group' => 'Тема оформления', 'light' => 'Светлая тема', 'dark' => 'Тёмная тема'];
$themeInitial = isset($themeInitial) && $themeInitial === 'dark' ? 'dark' : 'light';
$themeEscape = static fn(string $value): string => htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
?>
<div class="theme-switch" role="group" aria-label="<?= $themeEscape($themeLabels['group']) ?>">
<button type="button" data-theme-choice="light" aria-label="<?= $themeEscape($themeLabels['light']) ?>" title="<?= $themeEscape($themeLabels['light']) ?>" aria-pressed="<?= $themeInitial === 'light' ? 'true' : 'false' ?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M10 3.33333V2.5M14.7141 5.28571L15.3033 4.69645M16.6667 10H17.5M14.7141 14.7143L15.3033 15.3035M10 16.6667V17.5M5.28605 14.7139L4.6968 15.3032M3.33333 10H2.5M5.28605 5.28605L4.6968 4.6968M14.1667 10C14.1667 12.3012 12.3012 14.1667 10 14.1667C7.69881 14.1667 5.83333 12.3012 5.83333 10C5.83333 7.69881 7.69881 5.83333 10 5.83333C12.3012 5.83333 14.1667 7.69881 14.1667 10Z" stroke="currentColor" stroke-linecap="square"/></svg>
</button>
<button type="button" data-theme-choice="dark" aria-label="<?= $themeEscape($themeLabels['dark']) ?>" title="<?= $themeEscape($themeLabels['dark']) ?>" aria-pressed="<?= $themeInitial === 'dark' ? 'true' : 'false' ?>">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M11.1504 3.43439C11.4811 4.1663 11.667 4.97841 11.667 5.8338C11.6668 9.05529 9.05455 11.6668 5.83301 11.6668C4.97779 11.6668 4.16636 11.4809 3.43457 11.1502C3.97969 14.2838 6.71044 16.6668 10 16.6668C13.6819 16.6668 16.667 13.6817 16.667 9.99981C16.6669 6.71047 14.2838 3.97958 11.1504 3.43439Z" stroke="currentColor" stroke-linejoin="round"/></svg>
</button>
</div>
<script>if(window.ZoomzillaTheme)window.ZoomzillaTheme.refresh();</script>
+3
View File
@@ -3,6 +3,7 @@
require_once __DIR__ . '/i18n.php'; require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/components/asset-url.php'; require_once __DIR__ . '/components/asset-url.php';
require_once __DIR__ . '/components/site-domain.php'; require_once __DIR__ . '/components/site-domain.php';
require_once __DIR__ . '/components/theme-preference.php';
$configFile = __DIR__ . '/config.json'; $configFile = __DIR__ . '/config.json';
$config = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : []; $config = file_exists($configFile) ? json_decode(file_get_contents($configFile), true) : [];
@@ -70,6 +71,8 @@ if (isset($config['utm']) && is_array($config['utm'])) {
} }
$UTM = $get_params; $UTM = $get_params;
$themeInitial = prelandThemeResolve(($UTM['theme'] ?? 'light') === 'dark' ? 'dark' : 'light');
$themeSurface = 'preland';
$host = $config['host'] ?? ($_SERVER['HTTP_HOST'] ?? ''); $host = $config['host'] ?? ($_SERVER['HTTP_HOST'] ?? '');
$apiHost = $host ? (preg_match('#^https?://#', $host) ? rtrim($host, '/') : 'https://' . rtrim($host, '/')) : ''; $apiHost = $host ? (preg_match('#^https?://#', $host) ? rtrim($host, '/') : 'https://' . rtrim($host, '/')) : '';
+3
View File
@@ -370,4 +370,7 @@ return [
'х4' => '4×', 'х4' => '4×',
'—' => '—', '—' => '—',
'₽' => '$', '₽' => '$',
'Тема оформления' => 'Appearance',
'Светлая тема' => 'Light theme',
'Тёмная тема' => 'Dark theme',
]; ];
+1 -1
View File
@@ -724,7 +724,7 @@
"source": "https://zoomstage.online/images/sprite.svg" "source": "https://zoomstage.online/images/sprite.svg"
}, },
"style.css": { "style.css": {
"sha256": "cc88246e4efb4f2884d0f268b897950cd75620998e0d3ad9985076ce1021abfc", "sha256": "839d51ff9e4541fc1a585c6c715a2d6b26685eefb615c90f372a4d82e24b1f63",
"source": "https://zoomstage.online/style.css" "source": "https://zoomstage.online/style.css"
}, },
"style.min.c.css": { "style.min.c.css": {
+1 -47
View File
@@ -108,39 +108,6 @@ body {
border: none; border: none;
} }
.theme-toggle {
align-items: center;
background-color: #F8F9FB;
border: 1px solid #E5E5E7;
padding: 4px;
box-sizing: border-box;
border-radius: 16px;
}
.theme-toggle .btn {
padding: 0;
border: none;
border-radius: 0;
background-color: transparent;
display: flex;
justify-content: center;
align-items: center;
width: 32px;
height: 32px;
outline: none !important;
box-shadow: none;
color: #728490;
}
.theme-toggle .btn:not(.dark) {
color: #990694;
}
.theme-toggle .btn svg {
width: 20px;
height: 20px;
}
.image-compare { .image-compare {
border-radius: 24px; border-radius: 24px;
} }
@@ -2137,15 +2104,6 @@ body:not(.theme-dark) .theme-light-hidden {
background-image: url(../images/dark-bg.jpg); background-image: url(../images/dark-bg.jpg);
} }
.theme-dark .theme-toggle {
background-color: #0B0B10;
border-color: #323539;
}
.theme-dark .theme-toggle .btn:not(.dark) {
color: #323539;
}
.theme-dark .btn-header { .theme-dark .btn-header {
border-color: #fff; border-color: #fff;
color: #fff; color: #fff;
@@ -2368,11 +2326,7 @@ body:not(.theme-dark) .theme-light-hidden {
border-radius: 8px; border-radius: 8px;
} }
.theme-toggle { .image-examples {
border-radius: 8px;
}
.image-examples {
display: block; display: block;
text-align: center; text-align: center;
} }
+5 -1
View File
@@ -3,6 +3,8 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<?php require __DIR__ . '/components/theme-style.php'; ?>
<?php require __DIR__ . '/components/theme-client.php'; ?>
<title><?= $page_title ?? $default_title ?></title> <title><?= $page_title ?? $default_title ?></title>
@@ -51,7 +53,8 @@
<style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style> <style><?= require __DIR__ . '/components/upload-picker-style.php'; ?></style>
<?php require_once __DIR__ . '/components/browser-upload.php'; ?> <?php require_once __DIR__ . '/components/browser-upload.php'; ?>
</head> </head>
<body class="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'theme-dark' : '' ?>" style="<?= (isset($UTM['theme']) && $UTM['theme'] == 'dark') ? 'background: black' : '' ?>"> <body class="<?= $themeInitial === 'dark' ? 'theme-dark' : '' ?>">
<script>window.ZoomzillaTheme.refresh();</script>
<div class="wrapper"> <div class="wrapper">
<header class="header"> <header class="header">
<nav class="navbar navbar-dark py-1 py-md-3 px-0"> <nav class="navbar navbar-dark py-1 py-md-3 px-0">
@@ -61,6 +64,7 @@
<img src="<?= prelandAssetUrl('docs/logo-alt.svg') ?>" width="182" height="47" alt="Логотип <?= htmlspecialchars(prelandSiteDomain($_SERVER), ENT_QUOTES, 'UTF-8') ?>"> <img src="<?= prelandAssetUrl('docs/logo-alt.svg') ?>" width="182" height="47" alt="Логотип <?= htmlspecialchars(prelandSiteDomain($_SERVER), ENT_QUOTES, 'UTF-8') ?>">
</span> </span>
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<?php $themeLabels = ['group' => Yii::t('app', 'Тема оформления'), 'light' => Yii::t('app', 'Светлая тема'), 'dark' => Yii::t('app', 'Тёмная тема')]; require __DIR__ . '/components/theme-switch.php'; ?>
</div> </div>
</div> </div>
</nav> </nav>
+20
View File
@@ -0,0 +1,20 @@
<?php
/** preland#13: local manual не переписывает рекламную UTM; строгий standalone canonical format. */
require dirname(__DIR__) . '/components/theme-preference.php';
$revision = str_repeat('a', 16);
$checks = 0;
foreach (['light','dark'] as $theme) foreach (['manual','preland'] as $source) {
$_COOKIE['theme_choice'] = "v1:$theme:$source:$revision";
if (prelandThemePreference() !== ['theme'=>$theme,'source'=>$source,'revision'=>$revision]
|| prelandThemeResolve($theme === 'light' ? 'dark' : 'light') !== $theme) throw new RuntimeException('Canonical preference не побеждает fallback');
$checks++;
}
foreach ([null, [], false, 1, '', "v1:light:manual:$revision\n", 'v1:dark:other:'.$revision, 'v1:dark:manual:'.str_repeat('a',15), 'v1:dark:manual:'.str_repeat('a',65), str_repeat('a',129)] as $value) {
$_COOKIE['theme_choice'] = $value;
if (prelandThemePreference() !== null || prelandThemeResolve('light') !== 'light') throw new RuntimeException('Невалидная preference принята');
$checks++;
}
$source = file_get_contents(dirname(__DIR__) . '/components/form.php');
if (!str_contains($source, 'const shownTheme = window.ZoomzillaTheme.current();')
|| !str_contains($source, "targetUrl += '&shown_theme=' + encodeURIComponent(shownTheme)")) throw new RuntimeException('Handoff должен читать показанную тему в момент навигации');
echo "OK: $checks standalone theme cases + dynamic shown_theme handoff\n";