v0.5.3: dark-mode branding preview + unified button spacing

UI polish:
- Settings → Branding: each logo swatch now previews on a background
  matching where the mark lands (light page / dark page / dark PXE screen)
  regardless of the current page theme, so the Dark slot reads as dark
  even while viewing Settings in light mode.
- Site-wide button spacing: add one rule (`.card .body > button`) giving
  every primary card action button the same gap above it, and drop the
  ad-hoc per-button inline margins (14/16/6px) so the look is uniform.
  Fixes the Hosts → "Bind MAC to target" button butting against the form.

(Boot-menu highlight intentionally unchanged — a rotating-RGB highlight
isn't possible in iPXE's static single-draw menu; deferred to a future
custom-renderer effort.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-06-03 02:50:51 -04:00
co-authored by Claude Opus 4.8
parent a906c47f53
commit 7358013093
4 changed files with 30 additions and 18 deletions
Generated
+8 -8
View File
@@ -2251,7 +2251,7 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "openpxe" name = "openpxe"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2273,7 +2273,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-core" name = "openpxe-core"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@@ -2299,7 +2299,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-dhcp-proxy" name = "openpxe-dhcp-proxy"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2313,7 +2313,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-http-api" name = "openpxe-http-api"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
@@ -2349,7 +2349,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-ipxe-assets" name = "openpxe-ipxe-assets"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"openpxe-core", "openpxe-core",
"rust-embed", "rust-embed",
@@ -2359,7 +2359,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-iso-store" name = "openpxe-iso-store"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bcrypt", "bcrypt",
@@ -2386,7 +2386,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-tftp" name = "openpxe-tftp"
version = "0.5.2" version = "0.5.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes", "bytes",
@@ -2400,7 +2400,7 @@ dependencies = [
[[package]] [[package]]
name = "openpxe-webui" name = "openpxe-webui"
version = "0.5.2" version = "0.5.3"
[[package]] [[package]]
name = "p256" name = "p256"
+1 -1
View File
@@ -12,7 +12,7 @@ members = [
] ]
[workspace.package] [workspace.package]
version = "0.5.2" version = "0.5.3"
edition = "2021" edition = "2021"
rust-version = "1.95" rust-version = "1.95"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+8
View File
@@ -304,6 +304,14 @@ button.ghost { background: transparent; color: var(--fg); border: 1px solid var(
button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); } button.ghost:hover { background: var(--bg-panel-2); color: var(--fg); }
button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); } button.danger { background: transparent; color: var(--err); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); }
button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); } button.danger:hover { background: color-mix(in srgb, var(--err) 14%, transparent); color: var(--err); }
/* v0.5.3: unified spacing for a card's primary action button(s). Any
button that sits as a direct child of a card body (Save, Bind, Add,
Launch, …) gets the same gap above it so it never butts against the
form. Inline buttons inside table rows / toolbars / logo slots /
modal action bars are nested deeper, so the `>` keeps them untouched.
Adjacent action buttons on one row (e.g. Save + Send test) share the
margin and stay aligned. */
.card .body > button { margin-top: 16px; }
label.field { label.field {
display: grid; gap: 4px; margin-bottom: 14px; display: grid; gap: 4px; margin-bottom: 14px;
+13 -9
View File
@@ -904,7 +904,7 @@
protoSelect.addEventListener('change', syncProto); protoSelect.addEventListener('change', syncProto);
// One add button; dispatches to the selected protocol's endpoint. // One add button; dispatches to the selected protocol's endpoint.
const addShare = el('button', {style:'margin-top:14px', onclick: async () => { const addShare = el('button', {onclick: async () => {
if (protoSelect.value === 'smb') { if (protoSelect.value === 'smb') {
if (!smbServer.value || !smbShare.value) { if (!smbServer.value || !smbShare.value) {
shareMsg.replaceChildren(document.createTextNode('Server and share name are required.')); shareMsg.replaceChildren(document.createTextNode('Server and share name are required.'));
@@ -1411,7 +1411,7 @@
// beneath the input row instead of butting against the password // beneath the input row instead of butting against the password
// fields. Mirrors the `Save SSO settings` button below for visual // fields. Mirrors the `Save SSO settings` button below for visual
// parity between the two settings cards. // parity between the two settings cards.
const accountSave = el('button', {style:'margin-top:6px', onclick: async () => { const accountSave = el('button', {onclick: async () => {
accountMsg.textContent = ''; accountMsg.className = 'msg'; accountMsg.textContent = ''; accountMsg.className = 'msg';
if (!currentPw.value) { if (!currentPw.value) {
accountMsg.textContent = 'Current password is required.'; accountMsg.textContent = 'Current password is required.';
@@ -1549,7 +1549,7 @@
}; };
ssoMode.onchange = refreshSsoFields; ssoMode.onchange = refreshSsoFields;
const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'}); const ssoMsg = el('div', {class:'msg', style:'margin-top:8px'});
const ssoSave = el('button', {style:'margin-top:16px', onclick: async () => { const ssoSave = el('button', {onclick: async () => {
ssoMsg.textContent = ''; ssoMsg.className = 'msg'; ssoMsg.textContent = ''; ssoMsg.className = 'msg';
const payload = { const payload = {
enabled: ssoEnabled.checked, enabled: ssoEnabled.checked,
@@ -1630,13 +1630,17 @@
const logoMsg = el('div', {class:'msg', style:'margin-top:10px'}); const logoMsg = el('div', {class:'msg', style:'margin-top:10px'});
const bust = '?v=' + Date.now(); // bust the preview cache after a change const bust = '?v=' + Date.now(); // bust the preview cache after a change
const brandingPresence = status.branding || { light:false, dark:false, client:false }; const brandingPresence = status.branding || { light:false, dark:false, client:false };
// Each swatch previews on a background matching where the mark
// lands (light page / dark page / dark PXE screen), independent of
// the operator's current page theme — so the Dark slot always reads
// as dark even while viewing Settings in light mode.
const slotDefs = [ const slotDefs = [
{ slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1), { slot:'light', title:'Light mode', preview:'/assets/logo.svg?theme=light' + '&' + bust.slice(1),
hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#f4f5f7', hint:'Shown on light-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1), { slot:'dark', title:'Dark mode', preview:'/assets/logo.svg?theme=dark' + '&' + bust.slice(1),
hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Shown on dark-theme pages.', accept:'image/svg+xml,image/png,image/jpeg,image/webp,image/gif' },
{ slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust, { slot:'client', title:'Client', preview:'/branding/pxe-logo' + bust,
hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' }, swatchBg:'#0e1014', hint:'Above the PXE boot menu.', accept:'image/png,image/jpeg,image/webp,image/gif' },
]; ];
const slotCol = (def) => { const slotCol = (def) => {
const set = !!brandingPresence[def.slot]; const set = !!brandingPresence[def.slot];
@@ -1662,7 +1666,7 @@
el('span', {class:'name'}, def.title), el('span', {class:'name'}, def.title),
set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'), set ? el('span', {class:'tag ok'}, 'set') : el('span', {class:'tag'}, 'default'),
]), ]),
el('div', {class:'swatch', style: def.slot === 'light' ? 'background:#f4f5f7' : ''}, el('div', {class:'swatch', style:'background:' + def.swatchBg},
el('img', {src: def.preview, alt: def.title + ' logo'})), el('img', {src: def.preview, alt: def.title + ' logo'})),
el('div', {class:'logo-slot-hint'}, def.hint), el('div', {class:'logo-slot-hint'}, def.hint),
el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [ el('div', {style:'display:flex;gap:6px;flex-wrap:wrap'}, [
@@ -1795,13 +1799,13 @@
smtp_implicit_tls: sTls.checked, smtp_implicit_tls: sTls.checked,
}); });
const saveBtn = el('button', {style:'margin-top:16px', onclick: async () => { const saveBtn = el('button', {onclick: async () => {
nMsg.textContent = 'Saving…'; nMsg.className = 'msg'; nMsg.textContent = 'Saving…'; nMsg.className = 'msg';
const r = await putJSON('/api/notify', collectNotify()); const r = await putJSON('/api/notify', collectNotify());
if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); } if (r.ok) { nMsg.textContent = 'Saved.'; nMsg.className = 'msg ok'; render('settings'); }
else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; } else { nMsg.textContent = 'Save failed: ' + (await r.text()); nMsg.className = 'msg err'; }
}}, 'Save notification settings'); }}, 'Save notification settings');
const testBtn = el('button', {class:'ghost', style:'margin-top:16px;margin-left:8px', const testBtn = el('button', {class:'ghost', style:'margin-left:8px',
onclick: async () => { onclick: async () => {
nMsg.textContent = 'Sending test…'; nMsg.className = 'msg'; nMsg.textContent = 'Sending test…'; nMsg.className = 'msg';
// Save first so the test uses exactly what's on screen. // Save first so the test uses exactly what's on screen.