Add theme toggle to login screen

This commit is contained in:
2026-09-19 00:04:04 +03:00
parent c8190b7eec
commit 393c333794
5 changed files with 190 additions and 13 deletions
+23 -11
View File
@@ -44,6 +44,12 @@ Install it with the same procedure above, copying `eternity-wasteland` instead o
$config['skin'] = 'eternity-wasteland'; $config['skin'] = 'eternity-wasteland';
``` ```
### Login Theme Toggle
Wasteland opens in dark mode. Its login screen includes a **Dark mode** toggle that switches to the Eternity light palette without reloading the page. The choice is stored in the browser's `colorMode` cookie for one year and is applied on later visits to the login screen.
Clearing site cookies resets the Wasteland login screen to dark mode. This setting only changes the Wasteland login page; choose the desired Roundcube interface skin in **Settings > Preferences > User Interface > Interface skin** after signing in.
## Docker Install ## Docker Install
### Docker Compose (persistent) ### Docker Compose (persistent)
@@ -54,7 +60,7 @@ Place this repository alongside the Compose file, then bind-mount the skin into
services: services:
roundcube: roundcube:
volumes: volumes:
- ./ep-roundcube-skin/eternity:/var/www/html/skins/eternity:ro - ./ep-roundcube-skin/eternity-wasteland:/var/www/html/skins/eternity-wasteland:ro
- ./roundcube-config/config.inc.php:/var/roundcube/config/config.inc.php:ro - ./roundcube-config/config.inc.php:/var/roundcube/config/config.inc.php:ro
``` ```
@@ -63,7 +69,7 @@ Create `./roundcube-config/config.inc.php` if it does not already exist and incl
```php ```php
<?php <?php
$config['skin'] = 'eternity'; $config['skin'] = 'eternity-wasteland';
``` ```
The configuration mount location can vary for non-official images. Check the image documentation or run `docker compose exec roundcube sh -c 'find / -path "*/config.inc.php" 2>/dev/null'` to identify the active configuration file. Restart the service after changing either mount: The configuration mount location can vary for non-official images. Check the image documentation or run `docker compose exec roundcube sh -c 'find / -path "*/config.inc.php" 2>/dev/null'` to identify the active configuration file. Restart the service after changing either mount:
@@ -79,16 +85,16 @@ Use this method to test the skin on a container that is already running. Replace
1. Copy the skin from this repository into the container: 1. Copy the skin from this repository into the container:
```sh ```sh
docker cp ./eternity roundcube:/var/www/html/skins/eternity docker cp ./eternity-wasteland roundcube:/var/www/html/skins/eternity-wasteland
``` ```
2. Confirm Roundcube can see the skin files: 2. Confirm Roundcube can see the skin files:
```sh ```sh
docker exec roundcube sh -c 'test -f /var/www/html/skins/eternity/meta.json && test -f /var/www/html/skins/eternity/styles/styles.css' docker exec roundcube sh -c 'test -f /var/www/html/skins/eternity-wasteland/meta.json && test -f /var/www/html/skins/eternity-wasteland/styles/styles.css'
``` ```
3. Add `$config['skin'] = 'eternity';` to the active `config.inc.php` in the container, then restart it: 3. Add `$config['skin'] = 'eternity-wasteland';` to the active `config.inc.php` in the container, then restart it:
```sh ```sh
docker restart roundcube docker restart roundcube
@@ -96,7 +102,7 @@ Use this method to test the skin on a container that is already running. Replace
This live-container approach is not persistent: `docker compose up --force-recreate`, `docker rm`, or an image update removes the copied skin and in-container configuration change. Once the skin is confirmed, add the two bind mounts from the Compose example so both survive container recreation. This live-container approach is not persistent: `docker compose up --force-recreate`, `docker rm`, or an image update removes the copied skin and in-container configuration change. Once the skin is confirmed, add the two bind mounts from the Compose example so both survive container recreation.
After either deployment method, hard-refresh the browser or clear its cache. If the old appearance remains, inspect the browser network panel to confirm that `/skins/eternity/styles/styles.css` returns HTTP 200. After either deployment method, hard-refresh the browser or clear its cache. If the old appearance remains, inspect the browser network panel to confirm that `/skins/eternity-wasteland/styles/styles.css` returns HTTP 200.
If instead the page looks entirely unstyled — login credential fields stacked in the wrong place, an oversized logo, or mailbox toolbars/tabs scattered and hard to read — `styles/styles.css` is loading but its `@import` of `../../elastic/styles/styles.css` is not, so none of Elastic's layout rules apply. Confirm that file exists and is readable: If instead the page looks entirely unstyled — login credential fields stacked in the wrong place, an oversized logo, or mailbox toolbars/tabs scattered and hard to read — `styles/styles.css` is loading but its `@import` of `../../elastic/styles/styles.css` is not, so none of Elastic's layout rules apply. Confirm that file exists and is readable:
@@ -111,15 +117,15 @@ If it is `MISSING`, the Elastic skin was installed from source without its LESS
Run these commands on the Docker host, replacing `roundcube` with the container name or ID from `docker ps`. They distinguish an installation issue from an activation issue: Run these commands on the Docker host, replacing `roundcube` with the container name or ID from `docker ps`. They distinguish an installation issue from an activation issue:
```sh ```sh
docker exec roundcube sh -c 'for file in /var/www/html/skins/eternity/meta.json /var/www/html/skins/eternity/styles/styles.css /var/www/html/skins/elastic/styles/styles.css; do test -f "$file" && echo "FOUND $file" || echo "MISSING $file"; done' docker exec roundcube sh -c 'for file in /var/www/html/skins/eternity-wasteland/meta.json /var/www/html/skins/eternity-wasteland/styles/styles.css /var/www/html/skins/elastic/styles/styles.css; do test -f "$file" && echo "FOUND $file" || echo "MISSING $file"; done'
docker exec roundcube sh -c 'grep -RIn "\$config\[.skin.\]" /var/roundcube/config /var/www/html/config 2>/dev/null || true' docker exec roundcube sh -c 'grep -RIn "\$config\[.skin.\]" /var/roundcube/config /var/www/html/config 2>/dev/null || true'
``` ```
The first command must report all three paths as `FOUND`. The second must show `eternity` as the configured skin. On the official image, the equivalent persistent environment setting is: The first command must report all three paths as `FOUND`. The second must show `eternity-wasteland` as the configured skin. On the official image, the equivalent persistent environment setting is:
```yaml ```yaml
environment: environment:
ROUNDCUBEMAIL_SKIN: eternity ROUNDCUBEMAIL_SKIN: eternity-wasteland
``` ```
Roundcube can store a skin preference for each user. A user who previously selected another appearance may continue to receive that preference instead of the newly configured default. Sign out and test in a private browser window or select **Eternity** from Roundcube's **Settings > Preferences > User Interface > Interface skin**, when the preference is available. Roundcube can store a skin preference for each user. A user who previously selected another appearance may continue to receive that preference instead of the newly configured default. Sign out and test in a private browser window or select **Eternity** from Roundcube's **Settings > Preferences > User Interface > Interface skin**, when the preference is available.
@@ -127,10 +133,10 @@ Roundcube can store a skin preference for each user. A user who previously selec
For a browser-level check, open this URL directly, replacing the hostname with the Roundcube site: For a browser-level check, open this URL directly, replacing the hostname with the Roundcube site:
```text ```text
https://webmail.example.com/skins/eternity/styles/styles.css https://webmail.example.com/skins/eternity-wasteland/styles/styles.css
``` ```
It must return CSS beginning with `@import url("../../elastic/styles/styles.css");`. A `404` means the skin is not mounted at the path served by the container. If it returns `200` but Roundcube still looks unchanged, inspect the mailbox page source: it must include a stylesheet URL containing `/skins/eternity/styles/styles.css`. If it instead contains `/skins/elastic/`, the active configuration or the user's stored preference still selects Elastic. It must return CSS beginning with `@import url("../../elastic/styles/styles.css");`. A `404` means the skin is not mounted at the path served by the container. If it returns `200` but Roundcube still looks unchanged, inspect the mailbox page source: it must include a stylesheet URL containing `/skins/eternity-wasteland/styles/styles.css`. If it instead contains `/skins/elastic/`, the active configuration or the user's stored preference still selects Elastic.
## Structure ## Structure
@@ -138,6 +144,12 @@ It must return CSS beginning with `@import url("../../elastic/styles/styles.css"
eternity/ eternity/
meta.json Skin registration and Elastic inheritance meta.json Skin registration and Elastic inheritance
styles/styles.css Visual overrides for Elastic styles/styles.css Visual overrides for Elastic
eternity-wasteland/
images/logo.svg High-contrast Wasteland login mark
meta.json Skin registration and Elastic inheritance
styles/styles.css Dark and light Wasteland visual overrides
templates/login.html Login form with the theme toggle
watermark.html Wasteland login background
``` ```
`styles/styles.css` imports Elastic before applying Eternity's overrides. Do not remove the import: the base skin provides the complete component layout and image assets. `styles/styles.css` imports Elastic before applying Eternity's overrides. Do not remove the import: the base skin provides the complete component layout and image assets.
+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 60" role="img" aria-label="Eternity Project">
<circle cx="30" cy="30" r="22" fill="none" stroke="#e7e2d3" stroke-width="2"/>
<circle cx="21" cy="21" r="3" fill="#e4632c"/>
<circle cx="38" cy="26" r="2" fill="#8fae3d"/>
<circle cx="26" cy="40" r="2.5" fill="#e4632c"/>
<line x1="21" y1="21" x2="38" y2="26" stroke="#e7e2d3" stroke-width="1"/>
<line x1="21" y1="21" x2="26" y2="40" stroke="#e7e2d3" stroke-width="1"/>
<text x="66" y="30" font-family="'Space Grotesk','Helvetica Neue',sans-serif" font-size="24" font-weight="700" letter-spacing="1" fill="#e7e2d3">ETERNITY</text>
<text x="67" y="46" font-family="'DM Mono','Courier New',monospace" font-size="11" letter-spacing="2" fill="#a89a86">PROJECT / FI</text>
</svg>

After

Width:  |  Height:  |  Size: 793 B

+90
View File
@@ -32,6 +32,17 @@
--border-radius: 0; --border-radius: 0;
} }
html[data-theme="light"] {
--ink: #11212b;
--paper: #f1f1e9;
--acid: #c7ef4b;
--orange: #ff6b35;
--line: #b9c5bc;
--muted: #59706b;
--field: #fbfbf6;
--flash-error: #ffe3d9;
}
html, html,
body, body,
#layout, #layout,
@@ -49,6 +60,10 @@ body {
background-size: 70px 70px, 7px 7px; background-size: 70px 70px, 7px 7px;
} }
html[data-theme="light"] body {
background-image: linear-gradient(90deg, transparent 49.5%, rgb(17 33 43 / 5%) 50%, transparent 50.5%), linear-gradient(rgb(17 33 43 / 4%) 1px, transparent 1px);
}
body > #layout, body > #layout,
#layout { #layout {
position: relative; position: relative;
@@ -64,6 +79,10 @@ body > #layout,
background: rgb(22 18 16 / 82%); background: rgb(22 18 16 / 82%);
} }
html[data-theme="light"] #layout::before {
background: rgb(241 241 233 / 82%);
}
body a, body a,
body .link, body .link,
#taskmenu a, #taskmenu a,
@@ -281,6 +300,77 @@ select:focus,
box-sizing: border-box; box-sizing: border-box;
} }
/* Elastic's login controls use more specific component rules than the shared
form selectors above, so set the Wasteland surfaces at the login boundary. */
body.task-login,
body.task-login #layout-content,
body.task-login #login-form,
body.task-login #login-footer {
color: var(--ink);
}
body.task-login #login-form .form-control,
body.task-login #login-form .input-group-text,
body.task-login #login-form .input-group .icon {
background-color: var(--field) !important;
color: var(--ink) !important;
}
body.task-login #login-form .form-control::placeholder {
color: var(--muted);
opacity: 1;
}
.theme-toggle {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
color: var(--muted);
cursor: pointer;
font-family: var(--font-family-monospace);
font-size: 11px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.theme-toggle input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.theme-toggle-track {
position: relative;
width: 2.5rem;
height: 1.25rem;
border: 1px solid var(--ink);
background: var(--field);
}
.theme-toggle-track::after {
content: "";
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 0.75rem;
height: 0.75rem;
background: var(--orange);
transition: transform 150ms ease, background-color 150ms ease;
}
.theme-toggle input:checked + .theme-toggle-track::after {
transform: translateX(1.25rem);
background: var(--acid);
}
.theme-toggle input:focus-visible + .theme-toggle-track {
outline: 3px solid var(--acid);
outline-offset: 2px;
}
body.task-login #messagestack { body.task-login #messagestack {
top: auto; top: auto;
bottom: 1rem; bottom: 1rem;
+51
View File
@@ -0,0 +1,51 @@
<roundcube:include file="includes/layout.html" />
<script>
(function () {
var lightMode = document.cookie.indexOf('colorMode=light') > -1;
document.documentElement.setAttribute('data-theme', lightMode ? 'light' : 'dark');
}());
</script>
<h1 class="voice"><roundcube:object name="productname" /> <roundcube:label name="login" /></h1>
<div id="layout-content" class="selected no-navbar" role="main">
<roundcube:object name="logo" src="/images/logo.svg" id="logo" alt="Logo" />
<roundcube:form id="login-form" name="login-form" method="post" class="propform">
<roundcube:object name="loginform" form="login-form" size="40" submit=true class="form-control" />
<label class="theme-toggle" for="login-theme-toggle">
<input id="login-theme-toggle" type="checkbox" role="switch" aria-label="Use dark theme" checked>
<span class="theme-toggle-track" aria-hidden="true"></span>
<span class="theme-toggle-label">Dark mode</span>
</label>
<div id="login-footer" role="contentinfo">
<roundcube:object name="productname" condition="config:display_product_info &gt; 0" />
<roundcube:object name="version" condition="config:display_product_info == 2" />
<roundcube:if condition="config:support_url" />
&nbsp;&bull;&nbsp; <a href="<roundcube:var name='config:support_url' />" target="_blank" rel="noopener" class="support-link"><roundcube:label name="support" /></a>
<roundcube:endif />
<roundcube:container name="loginfooter" id="login-footer" />
</div>
</roundcube:form>
</div>
<script>
(function () {
var toggle = document.getElementById('login-theme-toggle');
var root = document.documentElement;
var darkMode = root.getAttribute('data-theme') !== 'light';
toggle.checked = darkMode;
toggle.addEventListener('change', function () {
var mode = toggle.checked ? 'dark' : 'light';
root.setAttribute('data-theme', mode);
document.cookie = 'colorMode=' + mode + '; path=/; max-age=31536000; samesite=lax';
});
}());
</script>
<noscript>
<p class="noscriptwarning"><roundcube:label name="noscriptwarning" /></p>
</noscript>
<roundcube:include file="includes/footer.html" />
+16 -2
View File
@@ -7,10 +7,24 @@
html, body { height: 100%; overflow: hidden; } html, body { height: 100%; overflow: hidden; }
body { body {
background-color: #161210; background-color: #161210;
background-image: linear-gradient(90deg, transparent 49.5%, rgb(228 98 44 / 7%) 50%, transparent 50.5%), linear-gradient(rgb(228 98 44 / 5%) 1px, transparent 1px); background-image: url(images/logo.svg), linear-gradient(90deg, transparent 49.5%, rgb(228 98 44 / 7%) 50%, transparent 50.5%), linear-gradient(rgb(228 98 44 / 5%) 1px, transparent 1px);
background-size: 70px 70px, 7px 7px; background-position: center, 0 0, 0 0;
background-repeat: no-repeat, repeat, repeat;
background-size: 30%, 70px 70px, 7px 7px;
}
html[data-theme="light"] body {
background-color: #f1f1e9;
background-image: url(images/logo.svg), linear-gradient(90deg, transparent 49.5%, rgb(17 33 43 / 5%) 50%, transparent 50.5%), linear-gradient(rgb(17 33 43 / 4%) 1px, transparent 1px);
} }
</style> </style>
<script>
try {
if (document.cookie.indexOf('colorMode=light') > -1) {
document.documentElement.setAttribute('data-theme', 'light');
}
} catch (error) { }
</script>
</head> </head>
<body></body> <body></body>
</html> </html>