feat(web-ui): nested bins tree + DragonFlame CSS restored (complete)

This commit is contained in:
OpenCode 2026-06-03 03:48:29 +00:00
parent cb9ef9c14e
commit 00a7af7c54
3 changed files with 912 additions and 37 deletions

View file

@ -1,5 +1,8 @@
// screens-library.jsx
function buildBinTree(f){const m={};f.forEach(b=>{m[b.id]={...b,children:[]};});const r=[];f.forEach(b=>{if(b.parent_id&&m[b.parent_id])m[b.parent_id].children.push(m[b.id]);else r.push(m[b.id]);});return r;}
function collectDescendantIds(id,f){const s=new Set([id]);let c=true;while(c){c=false;f.forEach(b=>{if(b.parent_id&&s.has(b.parent_id)&&!s.has(b.id)){s.add(b.id);c=true;}});}return s;}
function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
const PROJECTS = window.ZAMPP_DATA?.PROJECTS || [];
const [bins, setBins] = React.useState(window.ZAMPP_DATA?.BINS || []);
@ -285,12 +288,13 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
assets = assets.filter(function(a) { return a.status === filter; });
}
if (search) assets = assets.filter(function(a) { return a.name.toLowerCase().includes(search.toLowerCase()); });
if (selectedBinId) assets = assets.filter(function(a) { return a.bin_id === selectedBinId; });
if (selectedBinId) { var sids=collectDescendantIds(selectedBinId,BINS); assets=assets.filter(function(a){return sids.has(a.bin_id);}); }
const activeBin = selectedBinId ? BINS.find(b => b.id === selectedBinId) : null;
const displayTitle = activeBin
? (openProject ? openProject.name + ' · ' : '') + activeBin.name
: (openProject ? openProject.name : 'All Assets');
const binTree=React.useMemo(function(){return buildBinTree(BINS);},[BINS]);
const errorCount = ALL_ASSETS.filter(function(a) { return a.status === 'error'; }).length;
const recentCount = ALL_ASSETS.filter(function(a) { return (Date.now() - new Date(a.created_at)) < 86400000; }).length;
@ -329,45 +333,30 @@ function Library({ navigate, onOpenAsset, openProject, onClearProject }) {
</button>
</div>
<div className="rail-list">
{creatingBin && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input
className="field-input"
autoFocus
value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) {
if (e.key === 'Enter') submitBin(newBinName);
if (e.key === 'Escape') { setCreatingBin(false); }
}}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name"
style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }}
/>
</div>
)}
{!creatingBin && BINS.length === 0 ? (
<div style={{ fontSize: 11, color: 'var(--text-3)', padding: '6px 8px', fontStyle: 'italic' }}>
{openProject ? 'No bins yet: click + to create one.' : 'Open a project to manage bins.'}
</div>
) : BINS.map(function(b) {
const isActive = selectedBinId === b.id;
const isDragTarget = draggingAssetId !== null && dragOverBinId === b.id;
return (
<div key={b.id}
className={'rail-item' + (isActive ? ' active' : '') + (isDragTarget ? ' droppable' : '')}
onClick={function() { setSelectedBinId(isActive ? null : b.id); }}
onDragOver={function(e) { onBinDragOver(b.id, e); }}
onDrop={function(e) { onBinDrop(b.id, e); }}
onDragLeave={onBinDragLeave}
style={{ cursor: 'pointer' }}
title={isActive ? 'Click to clear bin filter' : 'Filter to this bin'}>
<Icon name={binIcon(b.icon)} size={13} className="rail-icon" />
<span>{b.name}</span>
<span className="rail-count">{b.count}</span>
</div>
);
})}
) : (
<BinTreeNodes nodes={binTree} depth={0}
selectedBinId={selectedBinId} setSelectedBinId={setSelectedBinId}
draggingAssetId={draggingAssetId} dragOverBinId={dragOverBinId}
onBinDragOver={onBinDragOver} onBinDrop={onBinDrop} onBinDragLeave={onBinDragLeave}
expandedBins={expandedBins} toggleBinExpanded={toggleBinExpanded}
creatingBin={creatingBin} creatingChildOf={creatingChildOf}
newBinName={newBinName} setNewBinName={setNewBinName}
submitBin={submitBin} setCreatingBin={setCreatingBin} setCreatingChildOf={setCreatingChildOf}
createSubBin={createSubBin} openProject={openProject} />
)}
{creatingBin && creatingChildOf === null && (
<div style={{ padding: '4px 6px', display: 'flex', gap: 4, alignItems: 'center' }}>
<input className="field-input" autoFocus value={newBinName}
onChange={function(e) { setNewBinName(e.target.value); }}
onKeyDown={function(e) { if (e.key==='Enter') submitBin(newBinName); if (e.key==='Escape') { setCreatingBin(false); } }}
onBlur={function() { submitBin(newBinName); }}
placeholder="Bin name" style={{ fontSize: 12, height: 26, padding: '0 6px', flex: 1 }} />
</div>
)}
</div>
</div>
<div>
@ -873,5 +862,6 @@ function DownloadWarningModal({ asset, onClose, onConfirm }) {
);
}
function BinTreeNodes(p){var nodes=p.nodes,depth=p.depth,selId=p.selectedBinId,setSel=p.setSelectedBinId;var drag=p.draggingAssetId,over=p.dragOverBinId;var onOver=p.onBinDragOver,onDrop=p.onBinDrop,onLeave=p.onBinDragLeave;var expanded=p.expandedBins,toggle=p.toggleBinExpanded;var creat=p.creatingBin,parentOf=p.creatingChildOf;var newName=p.newBinName,setName=p.setNewBinName;var submit=p.submitBin,setCreat=p.setCreatingBin,setParent=p.setCreatingChildOf;var createSub=p.createSubBin,proj=p.openProject;if(!nodes||!nodes.length)return null;return nodes.map(function(b){var act=selId===b.id,idt=drag!==null&&over===b.id,hasC=b.children&&b.children.length>0,exp=expanded.has(b.id),isCk=creat&&parentOf===b.id,ind=depth*14;return React.createElement(React.Fragment,{key:b.id},React.createElement("div",{className:"rail-item"+(act?" active":"")+(idt?" droppable":""),onClick:function(){setSel(act?null:b.id);},onDragOver:function(e){onOver(b.id,e);},onDrop:function(e){onDrop(b.id,e);},onDragLeave:onLeave,style:{cursor:"pointer",paddingLeft:8+ind}},React.createElement("span",{style:{display:"inline-flex",alignItems:"center",width:16,height:16,flexShrink:0,marginRight:2,color:hasC?"var(--text-3)":"transparent",transition:"transform 120ms",transform:hasC&&exp?"rotate(90deg)":"none"},onClick:function(e){if(!hasC)return;e.stopPropagation();toggle(b.id);}},hasC&&React.createElement("svg",{width:8,height:8,viewBox:"0 0 8 8",fill:"currentColor"},React.createElement("path",{d:"M2 1l4 3-4 3V1z"}))),React.createElement(Icon,{name:binIcon(b.icon),size:13,className:"rail-icon",style:{marginRight:4}}),React.createElement("span",{style:{flex:1,overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap"}},b.name),React.createElement("span",{className:"rail-count"},b.count),proj&&React.createElement("button",{className:"icon-btn bin-add-child-btn","aria-label":"Create sub-bin",onClick:function(e){e.stopPropagation();createSub(b.id);},style:{opacity:0,transition:"opacity 100ms",marginLeft:2,flexShrink:0},onFocus:function(e){e.currentTarget.style.opacity="1";},onBlur:function(e){e.currentTarget.style.opacity="";}},React.createElement(Icon,{name:"plus",size:10}))),isCk&&React.createElement("div",{style:{paddingLeft:8+ind+14,paddingRight:6,paddingTop:4,paddingBottom:4,display:"flex",gap:4,alignItems:"center"}},React.createElement("input",{className:"field-input",autoFocus:true,value:newName,onChange:function(e){setName(e.target.value);},onKeyDown:function(e){if(e.key==="Enter")submit(newName);if(e.key==="Escape"){setCreat(false);setParent(null);}},onBlur:function(){submit(newName);},placeholder:"Sub-bin name",style:{fontSize:12,height:26,padding:"0 6px",flex:1}})),hasC&&exp&&React.createElement(BinTreeNodes,Object.assign({},p,{nodes:b.children,depth:depth+1})));});}
window.Library = Library;
window.AssetCard = AssetCard;

View file

@ -1 +1,883 @@
/* see repo */
/* responsive + polish fixes */
.page-header h1 { white-space: nowrap; }
.page-header .subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex-shrink: 1;
}
.page-header { gap: 12px; flex-wrap: wrap; }
.status-pip span { white-space: nowrap; }
.status-pip { white-space: nowrap; }
.rail-item { min-width: 0; }
.rail-item > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-item > span.rail-count, .rail-item > .rail-color-dot { overflow: visible; flex-shrink: 0; }
@media (max-width: 1100px) {
.dash-stat-row { grid-template-columns: repeat(2, 1fr); }
.jobs-stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 1280px) {
.recorder-row {
grid-template-columns: 180px 1fr;
grid-template-rows: auto auto;
}
.recorder-stats { grid-column: 2 / 3; grid-row: 2; }
.recorder-actions { grid-column: 1 / 3; grid-row: 3; justify-content: flex-end; padding-top: 4px; border-top: 1px solid var(--border); }
}
.capture-stat-label, .recorder-stat .stat-label, .dash-stat-label, .dash-stat-sub {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar > * { flex-shrink: 0; }
.topbar .crumb { min-width: 0; overflow: hidden; }
.topbar .crumb > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar .spacer { flex: 1; min-width: 8px; }
@media (max-width: 1100px) {
.topbar .search-wrap { display: none; }
}
@media (max-width: 900px) {
.topbar .status-pip span:not(.dot) { display: none; }
}
.library-toolbar { flex-wrap: wrap; }
.library-toolbar .search { width: 200px; }
@media (max-width: 1280px) {
.page-body > div[style*="grid-template-columns: 440px"] {
grid-template-columns: 1fr !important;
}
}
.audio-meter.v {
border-radius: 99px;
background: rgba(255,255,255,0.04);
padding: 3px;
}
.recorder-preview { min-height: 56px; }
.activity-text .target { word-break: break-word; }
.asset-card .meta .sub { overflow: hidden; min-width: 0; flex-wrap: wrap; }
/* #52 - duration mono badge in the meta row had no shrink behaviour, so on
narrow cards it overlapped the project text. Force the duration column to
never overflow and let the project label ellipsize. */
.asset-card .meta .sub > .duration { flex-shrink: 0; margin-left: auto; }
.asset-card .meta .sub > :not(.duration) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.dash-stat-label, .dash-stat-mono, .dash-stat-sub { position: relative; z-index: 1; }
.dash-sparkline { z-index: 0; }
/* ============================================================
Search bar polish - give it a real container so it doesn't
read as floating text on the topbar background.
============================================================ */
.topbar .search,
.search-wrap .search {
background: var(--bg-2);
border: 1px solid var(--border-strong);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 1px 0 rgba(0, 0, 0, 0.25);
color: var(--text-1);
transition: background 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.topbar .search:hover,
.search-wrap .search:hover {
background: var(--bg-3);
border-color: var(--border-stronger);
}
.topbar .search:focus-within,
.search-wrap .search:focus-within,
.topbar .search.is-open,
.search-wrap .search.is-open {
background: var(--bg-2);
border-color: var(--accent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 0 0 3px var(--accent-soft);
}
.topbar .search input::placeholder,
.search-wrap .search input::placeholder {
color: var(--text-3);
}
.topbar .search .search-icon,
.search-wrap .search .search-icon {
color: var(--text-2);
}
.topbar .search:focus-within .search-icon,
.search-wrap .search:focus-within .search-icon {
color: var(--accent-text);
}
.topbar .search .kbd,
.search-wrap .search .kbd {
background: var(--bg-1);
border-color: var(--border-stronger);
color: var(--text-2);
}
/* Library-local "Filter assets" search - same container treatment,
keep its compact width. */
.library-toolbar .search {
background: var(--bg-2);
border: 1px solid var(--border-strong);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 1px 0 rgba(0, 0, 0, 0.25);
}
.library-toolbar .search:hover { background: var(--bg-3); border-color: var(--border-stronger); }
.library-toolbar .search:focus-within {
border-color: var(--accent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.04),
0 0 0 3px var(--accent-soft);
}
/* Open-state dropdown: visually connect it to the input. */
.search-wrap .search.is-open {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.search-results {
background: var(--bg-2);
border-color: var(--border-stronger);
border-top-left-radius: 0;
border-top-right-radius: 0;
margin-top: -1px;
padding: 6px;
}
.search-result {
padding: 8px 10px;
}
.search-result + .search-result { margin-top: 1px; }
.search-result:hover { background: var(--hover-strong); }
.search-result.active {
background: var(--accent-soft);
outline: 1px solid var(--accent-soft-2);
outline-offset: -1px;
}
/* ============================================================
Right-click context menu - pop it forward off the page so it
reads as a menu, not a floating list.
============================================================ */
.ctx-menu {
background: var(--bg-2);
border: 1px solid var(--border-stronger);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 18px 40px rgba(0, 0, 0, 0.55),
0 4px 10px rgba(0, 0, 0, 0.35);
padding: 6px;
min-width: 240px;
animation: ctxFadeIn 90ms ease-out both;
}
@keyframes ctxFadeIn {
from { opacity: 0; transform: translateY(-2px) scale(0.985); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
.ctx-menu .ctx-header {
padding: 8px 10px 8px;
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-3);
border-bottom: 1px solid var(--border);
margin: 0 -2px 6px;
}
.ctx-menu .ctx-divider {
background: var(--border-strong);
margin: 6px 2px;
}
.ctx-menu .ctx-section-label {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-4);
padding: 8px 10px 4px;
}
.ctx-menu button {
padding: 7px 10px;
border-radius: 5px;
gap: 10px;
color: var(--text-1);
}
.ctx-menu button + button { margin-top: 1px; }
.ctx-menu button:hover:not(:disabled) {
background: var(--accent-soft);
color: var(--accent-text);
}
.ctx-menu button:hover:not(:disabled) svg { color: var(--accent); }
.ctx-menu button:disabled { color: var(--text-3); }
.ctx-menu button:disabled svg { color: var(--text-4); }
.ctx-menu button.danger:hover:not(:disabled) {
background: var(--danger-soft);
color: var(--danger);
}
.ctx-menu button.danger:hover:not(:disabled) svg { color: var(--danger); }
/* Row-popover menu (Users page etc.) - match the same polish so the
app feels consistent. */
.row-menu {
background: var(--bg-2);
border: 1px solid var(--border-stronger);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 14px 32px rgba(0, 0, 0, 0.5);
padding: 6px;
}
.row-menu button { padding: 7px 10px; border-radius: 5px; }
.row-menu button:hover { background: var(--accent-soft); color: var(--accent-text); }
.row-menu button.danger:hover { background: var(--danger-soft); color: var(--danger); }
/* ============================================================
Sidebar brand logo - replace the gradient "D" tile with the
actual dragon-coiled-D logo. mix-blend-mode: screen drops the
light-gray PNG background so only the black silhouette + blue
flame remain over the dark sidebar.
============================================================ */
.brand-logo {
width: 32px;
height: 32px;
flex-shrink: 0;
object-fit: contain;
/* Convert the dark logo to white so it pops on the dark sidebar.
brightness(0) collapses everything to black, invert(1) flips to white.
Works on both the original dark PNG and any transparent white PNG. */
filter: brightness(0) invert(1) drop-shadow(0 0 6px rgba(232, 130, 28, 0.30));
}
.sidebar-header:hover .brand-logo {
filter: brightness(0) invert(1) drop-shadow(0 0 10px rgba(232, 130, 28, 0.55));
}
/* ============================================================
Launcher home - full-bleed landing page with the logo as hero
and big section tiles.
============================================================ */
.launcher {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background:
radial-gradient(1100px 500px at 50% -10%, rgba(232, 130, 28, 0.07), transparent 60%),
var(--bg-0);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 32px 64px;
}
.launcher-inner {
width: 100%;
max-width: 1160px;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
.launcher-hero {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
text-align: center;
margin-top: 8px;
}
/* Logo wrapper holds the animated pulse halo behind the image. */
.launcher-logo-wrap {
position: relative;
display: inline-grid;
place-items: center;
width: 52px;
height: 52px;
flex-shrink: 0;
}
.launcher-logo-pulse {
position: absolute;
width: 80px;
height: 80px;
border-radius: 50%;
background: radial-gradient(circle, var(--accent-soft) 0%, transparent 70%);
animation: logoPulse 3s ease-in-out infinite;
z-index: 0;
}
@keyframes logoPulse {
0%, 100% { transform: scale(1); opacity: 0.6; }
50% { transform: scale(1.15); opacity: 1; }
}
.launcher-logo {
position: relative;
z-index: 1;
width: 52px;
height: 52px;
object-fit: contain;
filter:
brightness(0) invert(1)
drop-shadow(0 0 8px rgba(232, 130, 28, 0.35));
animation: launcherLogoIn 400ms cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
@keyframes launcherLogoIn {
from { opacity: 0; transform: scale(0.88); }
to { opacity: 1; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.launcher-logo-pulse { animation: none; opacity: 0.5; }
.launcher-logo { animation: none; }
}
.launcher-wordmark {
margin: 0;
font-size: 44px;
font-weight: 700;
letter-spacing: 0.12em;
line-height: 1;
color: var(--text-1);
text-shadow: 0 0 32px rgba(232, 130, 28, 0.15);
}
.launcher-kicker {
margin: 2px 0 0;
color: var(--accent);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.launcher-tagline {
margin: 0;
color: var(--text-3);
font-size: 13.5px;
letter-spacing: 0.02em;
}
@media (max-width: 480px) {
.launcher-tagline { font-size: 11.5px; letter-spacing: 0; }
}
.launcher-tagline-motto {
margin-top: 6px;
color: var(--accent);
font-style: italic;
font-size: 15px;
letter-spacing: 0.04em;
}
.launcher-grid {
width: 100%;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
@media (max-width: 960px) { .launcher-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 620px) { .launcher-grid { grid-template-columns: 1fr; } }
/* Settings sits on its own centered row beneath the main grid. */
.launcher-tile-secondary {
min-height: 120px;
padding: 16px 18px 18px;
}
.launcher-tile-secondary .launcher-tile-icon {
width: 34px;
height: 34px;
border-radius: 8px;
margin-bottom: 4px;
}
.launcher-tile-secondary .launcher-tile-icon svg {
width: 17px;
height: 17px;
}
.launcher-tile-secondary .launcher-tile-label {
font-size: 15px;
}
.launcher-tile-secondary .launcher-tile-desc {
font-size: 12px;
}
/* Settings row: holds the 4 secondary tiles. */
.launcher-settings-row {
width: 100%;
display: flex;
justify-content: center;
}
.launcher-tile-settings {
width: 100%;
max-width: calc((100% - 28px) / 3);
}
@media (max-width: 960px) { .launcher-tile-settings { max-width: calc((100% - 14px) / 2); } }
@media (max-width: 620px) { .launcher-tile-settings { max-width: 100%; } }
.launcher-tile {
position: relative;
display: grid;
grid-template-areas:
"icon arrow"
"label label"
"sub sub"
"desc desc";
grid-template-columns: 1fr auto;
align-items: start;
gap: 6px;
text-align: left;
padding: 20px 22px 22px;
border-radius: var(--r-lg);
background:
linear-gradient(180deg, rgba(255,255,255,0.04), transparent 45%),
var(--bg-1);
border: 1px solid var(--border);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.03),
0 12px 28px rgba(0, 0, 0, 0.28);
color: var(--text-1);
cursor: pointer;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
min-height: 168px;
overflow: hidden;
isolation: isolate;
}
.launcher-tile::before {
/* Tinted accent halo that brightens on hover. Sits behind content
(z-index lower than children which inherit 1). */
content: "";
position: absolute;
inset: -1px;
border-radius: inherit;
pointer-events: none;
background: radial-gradient(120% 80% at 0% 0%, var(--tile-tint, transparent), transparent 60%);
opacity: 0;
transition: opacity 160ms ease;
z-index: 0;
}
.launcher-tile > * { position: relative; z-index: 1; }
.launcher-tile:hover {
transform: translateY(-2px);
border-color: var(--border-stronger);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.05),
0 18px 40px rgba(0, 0, 0, 0.42);
}
.launcher-tile:hover::before { opacity: 1; }
.launcher-tile:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.launcher-tile:active { transform: translateY(0); }
.launcher-tile-icon {
grid-area: icon;
width: 44px; height: 44px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--tile-icon-bg, var(--bg-3));
color: var(--tile-icon-fg, var(--text-1));
border: 1px solid var(--tile-icon-border, var(--border-strong));
margin-bottom: 6px;
}
.launcher-tile-icon svg { width: 22px; height: 22px; }
.launcher-tile-label {
grid-area: label;
font-size: 18px;
font-weight: 600;
letter-spacing: -0.01em;
}
.launcher-tile-sub {
grid-area: sub;
font-size: 11.5px;
font-family: var(--font-mono);
color: var(--tile-sub-fg, var(--text-3));
text-transform: uppercase;
letter-spacing: 0.06em;
}
.launcher-tile-desc {
grid-area: desc;
font-size: 12.5px;
color: var(--text-3);
line-height: 1.5;
margin-top: 4px;
}
.launcher-tile-arrow {
grid-area: arrow;
align-self: start;
color: var(--text-4);
transform: translateX(-4px);
transition: transform 140ms ease, color 140ms ease;
}
.launcher-tile:hover .launcher-tile-arrow {
transform: translateX(0);
color: var(--tile-icon-fg, var(--accent-text));
}
/* Tone variants - colour the icon tile + halo, leave the body text
neutral so the tile reads as a button, not a banner. */
.launcher-tile.tone-accent {
--tile-tint: rgba(232, 130, 28, 0.15);
--tile-icon-bg: var(--accent-soft);
--tile-icon-fg: var(--accent-text);
--tile-icon-border: rgba(232, 130, 28, 0.28);
}
.launcher-tile.tone-live {
--tile-tint: rgba(255, 59, 48, 0.18);
--tile-icon-bg: var(--live-soft);
--tile-icon-fg: var(--live);
--tile-icon-border: rgba(255, 59, 48, 0.30);
}
.launcher-tile.tone-purple {
--tile-tint: rgba(181, 124, 250, 0.18);
--tile-icon-bg: var(--purple-soft);
--tile-icon-fg: var(--purple);
--tile-icon-border: rgba(181, 124, 250, 0.30);
}
.launcher-tile.tone-success {
--tile-tint: rgba(45, 212, 168, 0.16);
--tile-icon-bg: var(--success-soft);
--tile-icon-fg: var(--success);
--tile-icon-border: rgba(45, 212, 168, 0.30);
}
.launcher-tile.tone-warn {
--tile-tint: rgba(245, 166, 35, 0.18);
--tile-icon-bg: var(--warning-soft);
--tile-icon-fg: var(--warning);
--tile-icon-border: rgba(245, 166, 35, 0.30);
}
.launcher-tile.tone-neutral {
--tile-tint: rgba(255, 255, 255, 0.06);
--tile-icon-bg: var(--bg-3);
--tile-icon-fg: var(--text-1);
--tile-icon-border: var(--border-stronger);
}
.launcher-tile.tone-ghost {
--tile-tint: rgba(255, 255, 255, 0.04);
--tile-icon-bg: transparent;
--tile-icon-fg: var(--text-2);
--tile-icon-border: var(--border);
background:
linear-gradient(180deg, rgba(255,255,255,0.02), transparent 50%),
var(--bg-1);
}
.launcher-status {
display: flex;
gap: 16px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
margin-top: 4px;
}
.launcher-status-pip {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-family: var(--font-mono);
color: var(--text-3);
letter-spacing: 0.02em;
padding: 6px 12px;
border-radius: 99px;
border: 1px solid var(--border);
background: var(--bg-1);
}
.launcher-status-pip .dot {
width: 6px; height: 6px; border-radius: 50%;
box-shadow: 0 0 0 3px var(--success-soft);
}
.launcher-status-pip .muted { color: var(--text-4); margin-left: 2px; }
.launcher-status-pip.live .dot {
box-shadow: 0 0 0 3px var(--live-soft);
animation: pulse 1.6s ease-in-out infinite;
}
.launcher-footer {
margin-top: 20px;
text-align: center;
font-size: 11px;
letter-spacing: 0.04em;
color: var(--text-4);
}
/* ============================================================
Motion layer entry stagger for launcher tiles.
Respects prefers-reduced-motion.
============================================================ */
@media (prefers-reduced-motion: no-preference) {
.launcher-tile {
animation: tileEnter 360ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.launcher-grid .launcher-tile:nth-child(1) { animation-delay: 60ms; }
.launcher-grid .launcher-tile:nth-child(2) { animation-delay: 110ms; }
.launcher-grid .launcher-tile:nth-child(3) { animation-delay: 160ms; }
.launcher-grid .launcher-tile:nth-child(4) { animation-delay: 210ms; }
.launcher-grid .launcher-tile:nth-child(5) { animation-delay: 250ms; }
.launcher-grid .launcher-tile:nth-child(6) { animation-delay: 290ms; }
.launcher-settings-row .launcher-tile { animation-delay: 320ms; }
@keyframes tileEnter {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
}
/* Tactile press feedback on high-stakes operational buttons. */
.btn-record:active,
button.btn.primary:active {
transform: scale(0.97);
transition: transform 80ms ease-out;
}
/* Smooth active-item transition in sidebar nav. */
.nav-item {
transition: background 150ms ease-out, color 150ms ease-out;
}
/* ============================================================
Recorder row - signal indicator with a pulsing dot when
actually receiving frames. Closes part of #2.
============================================================ */
.signal-val {
display: inline-flex;
align-items: center;
gap: 6px;
}
.signal-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.04);
}
.signal-dot.receiving {
animation: signalPulse 1.4s ease-in-out infinite;
}
@keyframes signalPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(45, 212, 168, 0.6); }
50% { box-shadow: 0 0 0 6px rgba(45, 212, 168, 0); }
}
/* ============================================================
BMD card diagram - rendered inside the Cluster node panel.
The SVG is generated by bmd-card.js; styles live here so
they inherit the app CSS custom properties at render time.
============================================================ */
.bmd-card-diagram {
width: 100%;
overflow: hidden;
}
.bmd-card-svg {
width: 100%;
height: auto;
display: block;
}
.bmd-card-body {
fill: var(--bg-3, #1e2130);
stroke: var(--border, #2d3147);
stroke-width: 1;
}
.bmd-card-bracket {
fill: var(--bg-1, #13151f);
stroke: var(--border, #2d3147);
stroke-width: 1.5;
}
.bmd-card-trace {
stroke: rgba(232, 130, 28, 0.10);
stroke-width: 0.5;
fill: none;
}
.bmd-port-group {
transition: opacity 0.15s;
}
.bmd-port-group:hover {
opacity: 0.85;
}
.bmd-port-ring {
fill: var(--bg-1, #13151f);
stroke: var(--border, #2d3147);
stroke-width: 1.5;
}
.bmd-port-pin {
fill: var(--text-4, #4a4f61);
}
.bmd-port-label {
fill: var(--text-1, #e8eaf6);
font-size: 10px;
font-weight: 600;
font-family: var(--font-mono, monospace);
}
.bmd-port-sublabel {
fill: var(--text-3, #7a8194);
font-size: 8.5px;
font-family: var(--font-mono, monospace);
}
.bmd-card-model {
fill: var(--text-4, #4a4f61);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
font-family: var(--font-mono, monospace);
}
/* Signal presence dot overlaid on each BNC connector */
.bmd-port-signal {
opacity: 0.95;
filter: drop-shadow(0 0 2px currentColor);
}
.bmd-port-signal--pulse {
animation: bmdPortPulse 1.4s ease-in-out infinite;
}
@keyframes bmdPortPulse {
0%, 100% { opacity: 0.95; r: 4; }
50% { opacity: 0.6; r: 5; }
}
/* ========== Mobile sidebar (issue #134) ========== */
.topbar-menu { display: none; }
@media (max-width: 768px) {
.topbar-menu { display: grid; place-items: center; }
.app { grid-template-columns: 0 1fr; }
.app[data-sidebar="expanded"] { grid-template-columns: 220px 1fr; }
.app[data-sidebar="expanded"] .sidebar {
position: fixed;
top: 0; left: 0;
height: 100vh;
width: 220px;
z-index: 200;
box-shadow: 0 0 40px rgba(0,0,0,0.6);
}
.app[data-sidebar="collapsed"] .sidebar { display: none; }
.app[data-sidebar="expanded"]::before {
content: "";
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 199;
}
}
/* ========== Sidebar collapse/expand (issue #142) ========== */
.sidebar-header { gap: 8px; }
.sidebar-toggle {
flex-shrink: 0;
margin-left: auto;
width: 28px; height: 28px;
display: grid; place-items: center;
}
.app[data-sidebar="collapsed"] .brand-name,
.app[data-sidebar="collapsed"] .brand-sub,
.app[data-sidebar="collapsed"] .nav-item > span,
.app[data-sidebar="collapsed"] .nav-section-label,
.app[data-sidebar="collapsed"] .nav-badge,
.app[data-sidebar="collapsed"] .nav-item.has-children > .nav-caret,
.app[data-sidebar="collapsed"] .nav-children,
.app[data-sidebar="collapsed"] .user-meta {
display: none !important;
}
.app[data-sidebar="collapsed"] .brand-link { gap: 0; justify-content: center; flex: 0; }
.app[data-sidebar="collapsed"] .sidebar-header { padding: 0 6px; justify-content: center; }
.app[data-sidebar="collapsed"] .sidebar-toggle { margin: 0; }
.app[data-sidebar="collapsed"] .nav-item { justify-content: center; padding: 0; }
.app[data-sidebar="collapsed"] .sidebar-footer { padding: 10px 6px; justify-content: center; }
.app[data-sidebar="collapsed"] .sidebar-footer .icon-btn { display: none; }
.app[data-sidebar="collapsed"] .nav-item { position: relative; }
.app[data-sidebar="collapsed"] .nav-item:hover::after {
content: attr(data-tip);
position: absolute;
left: 100%;
margin-left: 8px;
top: 50%;
transform: translateY(-50%);
background: var(--bg-2);
border: 1px solid var(--border);
color: var(--text-1);
font-size: 12px;
padding: 4px 8px;
radius: var(--r-sm);
white-space: nowrap;
z-index: 100;
pointer-events: none;
}
/* ── Resource utilization cards (screens-resources.jsx) ── */
.res-nodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.res-node-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.res-node-name {
font-size: 13px;
font-weight: 600;
color: var(--text-1);
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 2px;
}
.res-node-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 3px var(--success-soft);
flex-shrink: 0;
}
.res-metric {
display: flex;
flex-direction: column;
gap: 4px;
}
.res-metric-label {
font-size: 11px;
font-weight: 500;
color: var(--text-3);
font-family: var(--font-mono);
display: flex;
align-items: baseline;
gap: 6px;
}
.res-metric-sub {
color: var(--text-4);
font-weight: 400;
}
.res-bar-wrap {
display: flex;
align-items: center;
gap: 8px;
}
.res-bar {
flex: 1;
height: 6px;
background: var(--bg-4);
border-radius: 99px;
overflow: hidden;
}
.res-bar-fill {
height: 100%;
border-radius: 99px;
transition: width 0.6s ease;
}
.res-bar-pct {
font-size: 11px;
font-family: var(--font-mono);
color: var(--text-3);
min-width: 32px;
text-align: right;
}
.res-mock-note {
font-size: 11px;
color: var(--warning);
background: var(--warning-soft);
border-radius: var(--r-sm);
padding: 6px 10px;
margin-bottom: 10px;
font-family: var(--font-mono);
}

View file

@ -1066,6 +1066,9 @@
.rail-item .rail-icon { color: var(--text-3); }
.rail-item .rail-count { margin-left: auto; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-3); }
.rail-color-dot { width: 8px; height: 8px; border-radius: 50%; }
/* Show sub-bin create button only on hover of the parent rail-item */
.rail-item:hover .bin-add-child-btn { opacity: 1 !important; }
.bin-add-child-btn { padding: 0 2px; height: 18px; min-width: 18px; }
.library-main {
display: flex; flex-direction: column;