Add theme toggle to login screen
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 |
@@ -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;
|
||||||
|
|||||||
@@ -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 > 0" />
|
||||||
|
<roundcube:object name="version" condition="config:display_product_info == 2" />
|
||||||
|
<roundcube:if condition="config:support_url" />
|
||||||
|
• <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" />
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user