mobile: fix edge beam (transform-rotate) + ONU master-detail layout
Edge glow: iOS Safari won't animate an @property-driven conic-gradient angle, so the beam sat static / off the border on mobile. Reworked to the robust technique: a .beam child masked to the border ring contains an oversized conic-gradient ::before spun with transform: rotate 0→360deg; the glow halo is a drop-shadow on the parent (survives the child mask). Works on iOS/Safari; honors prefers-reduced-motion. ONU info on narrow screens: was stacking the picker above the detail, so selecting an ONU meant a long scroll to find it. Now master-detail — show the picker OR the detail (not both), toggled by .show-detail on #view-onu-info via a shared @media(max-width:860px) rule; selecting shows the detail with a "← ONU list" back button, tab/sub-tab nav resets to the picker. (Earlier commit already de-compacted the list: extra filters + two-line rows.) Docs: CLAUDE.md §15 (edge glow technique) + §17 (master-detail). Verified offscreen at 414px: beam element present; panel-left/panel-main display toggles correctly on select/back; back button visible on mobile; rendered both states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2046fad0f8
commit
6f4a315947
4 changed files with 81 additions and 31 deletions
22
CLAUDE.md
22
CLAUDE.md
|
|
@ -779,13 +779,15 @@ port of the iOS `Format.bps`.
|
||||||
|
|
||||||
### Animated edge glow
|
### Animated edge glow
|
||||||
|
|
||||||
`#edge-glow` is a fixed, pointer-events-none full-viewport element. A
|
`#edge-glow` (fixed, pointer-events-none, full-viewport) holds a `.beam`
|
||||||
`conic-gradient(from var(--beam-angle), …)` with a white-hot core is masked
|
child masked to a 3px border ring (`mask` + `mask-composite: exclude`); the
|
||||||
to just the 3px border ring (the standard `mask` + `mask-composite:
|
ring contains a `::before` that is an oversized `conic-gradient` with a
|
||||||
exclude` border trick) and animated by spinning the `@property
|
white-hot core, spun with **`transform: rotate` 0→360°**, so the beam travels
|
||||||
--beam-angle` 0→360°, so a neon beam travels around the screen edge.
|
around the screen edge. (It deliberately does *not* use an `@property`-
|
||||||
Respects `prefers-reduced-motion`. z-index sits below the scanline overlay
|
animated gradient angle — iOS Safari won't animate that, which left the beam
|
||||||
(9999) and modals (10000).
|
static on mobile.) The glow halo is a `drop-shadow` on the parent so it
|
||||||
|
survives the child's mask. Respects `prefers-reduced-motion`; z-index sits
|
||||||
|
below the scanline overlay (9999) and modals (10000).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -874,7 +876,11 @@ deep-link here via `openOnuInfo`.
|
||||||
The left list/search supports name/address/serial + equipment/version +
|
The left list/search supports name/address/serial + equipment/version +
|
||||||
PON-mode + status filters; rows are two-line (serial+status, then
|
PON-mode + status filters; rows are two-line (serial+status, then
|
||||||
equipment · version · last-seen). Parent OLT and upstream switch are one
|
equipment · version · last-seen). Parent OLT and upstream switch are one
|
||||||
combined card.
|
combined card. On narrow screens it's **master-detail**: the picker and the
|
||||||
|
detail don't stack (which buried the detail under a long list) — selecting an
|
||||||
|
ONU swaps to the detail (`.show-detail` on `#view-onu-info`, via a
|
||||||
|
`@media (max-width:860px)` rule in the shared CSS) with a "← ONU list" back
|
||||||
|
button; navigating in via the tab/sub-tab resets to the picker.
|
||||||
|
|
||||||
### Single-ONU upgrade
|
### Single-ONU upgrade
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -416,39 +416,55 @@ button.danger:hover:not(:disabled) {
|
||||||
.modal-type-label { display: block; }
|
.modal-type-label { display: block; }
|
||||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
|
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 2px; }
|
||||||
|
|
||||||
/* -------- Animated neon edge glow (beam travels around the border) -------- */
|
/* -------- Animated neon edge glow (beam travels around the border) --------
|
||||||
@property --beam-angle {
|
A rotating conic-gradient (transform: rotate — works on iOS Safari, unlike
|
||||||
syntax: "<angle>";
|
an @property-animated gradient angle) clipped to a thin border ring. The
|
||||||
initial-value: 0deg;
|
glow halo is a drop-shadow on the parent so it survives the child's mask. */
|
||||||
inherits: false;
|
|
||||||
}
|
|
||||||
#edge-glow {
|
#edge-glow {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 9998;
|
z-index: 9998;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
filter: drop-shadow(0 0 6px var(--neon)) drop-shadow(0 0 16px var(--cyan));
|
||||||
|
}
|
||||||
|
#edge-glow .beam {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
padding: 3px; /* thickness of the glowing border band */
|
padding: 3px; /* thickness of the glowing border band */
|
||||||
background: conic-gradient(
|
overflow: hidden;
|
||||||
from var(--beam-angle),
|
|
||||||
transparent 0deg,
|
|
||||||
transparent 38deg,
|
|
||||||
var(--cyan) 64deg,
|
|
||||||
#ffffff 90deg,
|
|
||||||
var(--neon) 116deg,
|
|
||||||
transparent 142deg,
|
|
||||||
transparent 360deg
|
|
||||||
);
|
|
||||||
/* Keep only the padding ring (border band); punch out the interior. */
|
/* Keep only the padding ring (border band); punch out the interior. */
|
||||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
-webkit-mask-composite: xor;
|
-webkit-mask-composite: xor;
|
||||||
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
mask-composite: exclude;
|
mask-composite: exclude;
|
||||||
filter: drop-shadow(0 0 8px var(--neon)) drop-shadow(0 0 22px var(--cyan));
|
|
||||||
animation: beamspin 7s linear infinite;
|
|
||||||
}
|
}
|
||||||
@keyframes beamspin { to { --beam-angle: 360deg; } }
|
#edge-glow .beam::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 200vmax; /* > viewport diagonal at any rotation */
|
||||||
|
height: 200vmax;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
transparent 0deg,
|
||||||
|
transparent 40deg,
|
||||||
|
var(--cyan) 66deg,
|
||||||
|
#ffffff 90deg,
|
||||||
|
var(--neon) 114deg,
|
||||||
|
transparent 140deg,
|
||||||
|
transparent 360deg
|
||||||
|
);
|
||||||
|
animation: beamspin 7s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
@keyframes beamspin {
|
||||||
|
from { transform: translate(-50%, -50%) rotate(0deg); }
|
||||||
|
to { transform: translate(-50%, -50%) rotate(360deg); }
|
||||||
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
#edge-glow { animation: none; }
|
#edge-glow .beam::before { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------- Top tabs -------- */
|
/* -------- Top tabs -------- */
|
||||||
|
|
@ -562,6 +578,19 @@ svg.spark { vertical-align: middle; opacity: 0.9; }
|
||||||
.olt-modal { width: min(960px, 95vw); }
|
.olt-modal { width: min(960px, 95vw); }
|
||||||
.olt-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
|
.olt-modal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 12px; }
|
||||||
.olt-onu-list { max-height: 42vh; margin-top: 6px; }
|
.olt-onu-list { max-height: 42vh; margin-top: 6px; }
|
||||||
|
|
||||||
|
/* "Back to list" button only matters in the narrow master-detail layout. */
|
||||||
|
.onu-detail-back { display: none; align-self: flex-start; }
|
||||||
|
|
||||||
|
/* Narrow screens: the ONU info page is master-detail — show the picker OR
|
||||||
|
the selected ONU's detail, never both stacked (which buried the detail
|
||||||
|
under a long list). `.show-detail` is toggled in app.js on select / back. */
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
#view-onu-info .panel-main { display: none; }
|
||||||
|
#view-onu-info.show-detail .panel-left { display: none; }
|
||||||
|
#view-onu-info.show-detail .panel-main { display: flex; flex-direction: column; }
|
||||||
|
#view-onu-info .onu-detail-back { display: inline-block; }
|
||||||
|
}
|
||||||
.detail-card {
|
.detail-card {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,7 @@ for (const tab of $$('.tab')) {
|
||||||
showView(view);
|
showView(view);
|
||||||
// Dashboard loads itself on first visit; other tabs load on demand.
|
// Dashboard loads itself on first visit; other tabs load on demand.
|
||||||
if (view === '#view-dashboard' && !state.dashboard) loadDashboard();
|
if (view === '#view-dashboard' && !state.dashboard) loadDashboard();
|
||||||
|
if (view === '#view-onu-info') $('#view-onu-info').classList.remove('show-detail');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1857,10 +1858,22 @@ for (const st of $$('.subtab')) {
|
||||||
st.addEventListener('click', () => {
|
st.addEventListener('click', () => {
|
||||||
const view = st.dataset.subview;
|
const view = st.dataset.subview;
|
||||||
showView(view);
|
showView(view);
|
||||||
if (view === '#view-onu-info' && state.fleet.length) renderOnuInfoList();
|
if (view === '#view-onu-info') {
|
||||||
|
$('#view-onu-info').classList.remove('show-detail'); // land on the picker
|
||||||
|
if (state.fleet.length) renderOnuInfoList();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Back to list" in the narrow master-detail layout (delegated — the detail
|
||||||
|
// re-renders several times).
|
||||||
|
$('#onu-info-detail').addEventListener('click', (e) => {
|
||||||
|
if (e.target.closest('.onu-detail-back')) {
|
||||||
|
e.preventDefault();
|
||||||
|
$('#view-onu-info').classList.remove('show-detail');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// -------- ONU info / stats page --------
|
// -------- ONU info / stats page --------
|
||||||
|
|
||||||
$('#onu-info-load').addEventListener('click', async () => {
|
$('#onu-info-load').addEventListener('click', async () => {
|
||||||
|
|
@ -1937,6 +1950,8 @@ function renderOnuInfoList() {
|
||||||
|
|
||||||
function selectOnuInfo(onuId) {
|
function selectOnuInfo(onuId) {
|
||||||
state.onuInfoSelected = onuId;
|
state.onuInfoSelected = onuId;
|
||||||
|
// Narrow layout: switch from the picker to the detail (master-detail).
|
||||||
|
$('#view-onu-info').classList.add('show-detail');
|
||||||
for (const row of $$('#onu-info-list .onu-info-row')) {
|
for (const row of $$('#onu-info-list .onu-info-row')) {
|
||||||
row.classList.toggle('selected', row.dataset.id === onuId);
|
row.classList.toggle('selected', row.dataset.id === onuId);
|
||||||
}
|
}
|
||||||
|
|
@ -1994,7 +2009,7 @@ async function renderOnuDetail(onuId) {
|
||||||
const cfg = state.fleet.find((c) => c._id === onuId);
|
const cfg = state.fleet.find((c) => c._id === onuId);
|
||||||
if (!cfg) { el.innerHTML = '<p class="muted">ONU not found in the loaded fleet — reload the fleet.</p>'; return; }
|
if (!cfg) { el.innerHTML = '<p class="muted">ONU not found in the loaded fleet — reload the fleet.</p>'; return; }
|
||||||
|
|
||||||
const head = `<h2>${escapeHtml(cfg._id)}</h2>`;
|
const head = `<button class="ghost onu-detail-back" type="button">← ONU list</button><h2>${escapeHtml(cfg._id)}</h2>`;
|
||||||
// Identity + link render instantly from the loaded fleet; the rest streams
|
// Identity + link render instantly from the loaded fleet; the rest streams
|
||||||
// in from one getOnuDetail call.
|
// in from one getOnuDetail call.
|
||||||
el.innerHTML = `${head}<div class="detail-grid">
|
el.innerHTML = `${head}<div class="detail-grid">
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Animated neon beam that travels around the screen edge. -->
|
<!-- Animated neon beam that travels around the screen edge. -->
|
||||||
<div id="edge-glow" aria-hidden="true"></div>
|
<div id="edge-glow" aria-hidden="true"><div class="beam"></div></div>
|
||||||
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">PON Fleet Upgrader</div>
|
<div class="brand">PON Fleet Upgrader</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue