[object Object]

← back to Dw Launches

command-center suite: view switcher (Dashboard/Compose/Streams/Calendar/Drafts/Approvals), density sliders, Hootsuite-style streams board, dashboard landing, approvals queue, launches open as modal-rig movable modals; preserves all compose logic + gates

a2037e74afdba3c1e3f303200fd669b72299f887 · 2026-06-19 10:34:28 -0700 · SteveStudio2

Files touched

Diff

commit a2037e74afdba3c1e3f303200fd669b72299f887
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Fri Jun 19 10:34:28 2026 -0700

    command-center suite: view switcher (Dashboard/Compose/Streams/Calendar/Drafts/Approvals), density sliders, Hootsuite-style streams board, dashboard landing, approvals queue, launches open as modal-rig movable modals; preserves all compose logic + gates
---
 public/index.html | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 423 insertions(+), 20 deletions(-)

diff --git a/public/index.html b/public/index.html
index 48d15b6..dba74e3 100644
--- a/public/index.html
+++ b/public/index.html
@@ -631,6 +631,145 @@
     top:72px;
     align-self:flex-start;
   }
+
+  /* ─── modal-rig theming (CSS vars it reads) ──────────────────── */
+  :root{ --card:var(--panel); --border:var(--line); }
+
+  /* ─── View switcher (Compose | Streams | Calendar …) ─────────── */
+  .topbar-right{display:flex;align-items:center;gap:18px}
+  .viewswitch{display:flex;gap:2px}
+  .viewswitch button{
+    background:transparent;border:none;color:var(--muted);
+    padding:8px 14px;border-radius:var(--radius-sm);cursor:pointer;
+    font-size:12.5px;font-family:var(--font);letter-spacing:.3px;
+    transition:color .15s, background .15s;
+  }
+  .viewswitch button:hover{color:var(--ink-2);background:var(--panel2)}
+  .viewswitch button.active{color:var(--ink);background:var(--panel2);box-shadow:inset 0 -2px 0 var(--gold)}
+  .viewswitch button .pill{
+    display:inline-block;min-width:16px;text-align:center;margin-left:6px;
+    font-size:9.5px;padding:1px 5px;border-radius:999px;
+    background:var(--gold-faint2);color:var(--gold);
+  }
+
+  /* ─── Density slider bar (above grids/streams) ───────────────── */
+  .density-bar{
+    display:flex;align-items:center;gap:12px;
+    max-width:1520px;margin:0 auto;padding:0 28px;
+  }
+  .density-bar .controls{
+    display:flex;align-items:center;gap:10px;margin-left:auto;
+    background:var(--panel2);border:1px solid var(--line);
+    border-radius:999px;padding:6px 14px;
+  }
+  .density-bar label{margin:0;text-transform:uppercase;font-size:10px;letter-spacing:.6px;color:var(--muted)}
+  .density-bar input[type=range]{
+    width:150px;-webkit-appearance:none;appearance:none;height:3px;
+    background:var(--line);border-radius:3px;cursor:pointer;padding:0;
+  }
+  .density-bar input[type=range]::-webkit-slider-thumb{
+    -webkit-appearance:none;appearance:none;width:14px;height:14px;border-radius:50%;
+    background:var(--gold);cursor:pointer;border:2px solid var(--bg);
+  }
+  .density-bar .selwrap{display:flex;align-items:center;gap:8px}
+  .density-bar select{width:auto;min-width:130px;padding:6px 26px 6px 10px;font-size:12px}
+
+  /* ─── Dashboard ──────────────────────────────────────────────── */
+  .dash-wrap{max-width:1520px;margin:0 auto;padding:20px 28px}
+  .stat-grid{
+    display:grid;
+    grid-template-columns:repeat(auto-fill,minmax(var(--statmin,200px),1fr));
+    gap:14px;margin-bottom:24px;
+  }
+  .stat{
+    background:var(--panel);border:1px solid var(--line);
+    border-radius:var(--radius-lg);padding:18px 20px;
+  }
+  .stat .v{font-family:var(--serif);font-size:34px;line-height:1;color:var(--ink)}
+  .stat .v.gold{color:var(--gold)}
+  .stat .v.warn{color:var(--warn)}
+  .stat .v.ok{color:#5ab880}
+  .stat .v.info{color:#6aacda}
+  .stat .k{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-top:8px}
+  .dash-section-title{
+    font-family:var(--serif);font-size:15px;color:var(--ink);
+    margin:4px 0 14px;letter-spacing:.3px;
+  }
+  .chan-grid{
+    display:grid;
+    grid-template-columns:repeat(auto-fill,minmax(var(--chanmin,220px),1fr));
+    gap:12px;
+  }
+  .chan-tile{
+    background:var(--panel);border:1px solid var(--line);
+    border-radius:var(--radius);padding:14px 16px;
+    cursor:pointer;transition:border-color .15s, background .15s;
+  }
+  .chan-tile:hover{border-color:var(--gold-dim);background:var(--panel2)}
+  .chan-tile .ch-head{display:flex;align-items:center;gap:8px;margin-bottom:10px}
+  .chan-tile .av{width:9px;height:9px;border-radius:50%;flex-shrink:0}
+  .chan-tile .ch-name{font-size:13px;color:var(--ink)}
+  .chan-tile .ch-stats{display:flex;gap:14px;font-size:11px;color:var(--ink-2)}
+  .chan-tile .ch-stats b{color:var(--ink);font-size:14px;font-weight:500;display:block}
+  .chan-tile .ch-stats .lab{font-size:9px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted)}
+  .chan-tile.off{opacity:.5}
+
+  /* ─── Streams board (Hootsuite-style columns) ────────────────── */
+  .streams-board{
+    max-width:1520px;margin:0 auto;padding:16px 28px 40px;
+    display:grid;
+    grid-template-columns:repeat(var(--cols,4),minmax(0,1fr));
+    gap:14px;
+    align-items:start;
+  }
+  .stream-col{
+    background:var(--panel);border:1px solid var(--line);
+    border-radius:var(--radius-lg);overflow:hidden;min-width:0;
+  }
+  .stream-col .sc-head{
+    display:flex;align-items:center;gap:8px;
+    padding:12px 14px;border-bottom:1px solid var(--line-soft);
+    background:var(--panel2);position:sticky;top:0;
+  }
+  .stream-col .sc-head .av{width:9px;height:9px;border-radius:50%}
+  .stream-col .sc-head .nm{font-size:12.5px;color:var(--ink);font-family:var(--serif)}
+  .stream-col .sc-head .ct{margin-left:auto;font-size:10px;color:var(--muted)}
+  .stream-col .sc-head .off{font-size:9px;color:var(--warn);border:1px solid var(--warn-faint);background:var(--warn-faint);padding:1px 5px;border-radius:4px}
+  .stream-col .sc-body{padding:10px;display:flex;flex-direction:column;gap:14px}
+  .stream-seg .seg-label{
+    font-size:9.5px;text-transform:uppercase;letter-spacing:1px;
+    color:var(--gold-dim);margin-bottom:7px;display:flex;align-items:center;gap:6px;
+  }
+  .stream-seg .seg-label .n{color:var(--muted)}
+  .stream-seg .seg-empty{font-size:11px;color:var(--muted);font-style:italic;padding:2px 0 6px}
+  .stream-card{
+    background:var(--panel2);border:1px solid var(--line);
+    border-left:3px solid var(--gold);border-radius:var(--radius-sm);
+    padding:9px 11px;margin-bottom:7px;cursor:pointer;
+    transition:border-color .15s, background .15s;
+  }
+  .stream-card:hover{border-color:var(--gold-dim);background:var(--panel3)}
+  .stream-card .st-title{font-size:12px;color:var(--ink);font-weight:500;line-height:1.3}
+  .stream-card .st-body{font-size:11px;color:var(--ink-2);margin-top:4px;line-height:1.4;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .stream-card .st-meta{display:flex;align-items:center;gap:6px;margin-top:7px;flex-wrap:wrap}
+  .stream-card .st-when{font-size:10px;color:var(--muted)}
+
+  /* ─── Approvals view ─────────────────────────────────────────── */
+  .approvals-wrap{max-width:1100px;margin:0 auto;padding:20px 28px}
+  .appr-card{
+    background:var(--panel);border:1px solid var(--line);
+    border-radius:var(--radius);padding:15px 17px;margin-bottom:10px;
+    display:flex;align-items:flex-start;gap:14px;
+  }
+  .appr-card.pending{border-color:rgba(184,146,58,.4)}
+  .appr-card .ac-main{flex:1;min-width:0}
+  .appr-card .ac-title{font-size:13.5px;color:var(--ink);font-weight:500}
+  .appr-card .ac-body{font-size:12px;color:var(--ink-2);margin-top:5px;line-height:1.45;
+    display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+  .appr-card .ac-meta{font-size:11px;color:var(--muted);margin-top:7px;display:flex;gap:10px;flex-wrap:wrap;align-items:center}
+  .appr-card .ac-actions{display:flex;flex-direction:column;gap:7px;flex:0 0 auto;width:170px}
+  .appr-card .ac-actions button{width:100%}
 </style>
 </head>
 <body>
@@ -639,13 +778,27 @@
     <div class="brand-eyebrow">Launches · Social Composer</div>
     <div class="brand-name">Designer Wallcoverings<span class="dot">.</span></div>
   </div>
-  <nav class="tabs">
-    <button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
-    <button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
-    <button id="nav-drafts" onclick="showView('drafts')">Drafts &amp; Queue</button>
-  </nav>
+  <div class="topbar-right">
+    <nav class="viewswitch tabs" id="viewswitch">
+      <button id="nav-dashboard" onclick="showView('dashboard')">Dashboard</button>
+      <button id="nav-compose" class="active" onclick="showView('compose')">Compose</button>
+      <button id="nav-streams" onclick="showView('streams')">Streams</button>
+      <button id="nav-calendar" onclick="showView('calendar')">Calendar</button>
+      <button id="nav-drafts" onclick="showView('drafts')">Drafts &amp; Queue</button>
+      <button id="nav-approvals" onclick="showView('approvals')">Approvals<span class="pill hide" id="appr-pill">0</span></button>
+    </nav>
+  </div>
 </header>
 
+<!-- ============ DASHBOARD VIEW ============ -->
+<div id="view-dashboard" class="hide">
+  <div class="dash-wrap">
+    <div class="stat-grid" id="dash-stats"></div>
+    <div class="dash-section-title">Per-channel</div>
+    <div class="chan-grid" id="dash-channels"></div>
+  </div>
+</div>
+
 <!-- ============ COMPOSE VIEW ============ -->
 <div id="view-compose">
   <div class="wrap">
@@ -775,16 +928,43 @@
 
 <!-- ============ DRAFTS VIEW ============ -->
 <div id="view-drafts" class="hide">
-  <div class="wrap"><div class="col">
-    <div class="card">
-      <h3>Drafts &amp; Queue</h3>
-      <div id="drafts-list"></div>
+  <div class="density-bar" style="padding-top:6px;padding-bottom:12px">
+    <span class="muted" style="font-size:12px">Every launch — click a card to open as a movable modal, or load into Compose</span>
+    <div class="controls">
+      <label for="drafts-density">Density</label>
+      <input type="range" id="drafts-density" min="200" max="460" step="20" value="300">
     </div>
-  </div></div>
+  </div>
+  <div class="dash-wrap" style="padding-top:0">
+    <div id="drafts-list" class="chan-grid" style="--chanmin:var(--draftmin,300px)"></div>
+  </div>
+</div>
+
+<!-- ============ STREAMS VIEW (Hootsuite-style columns) ============ -->
+<div id="view-streams" class="hide">
+  <div class="density-bar" style="padding-top:6px;padding-bottom:12px">
+    <span class="muted" style="font-size:12px">Channel streams — scheduled · pending · published · drafts</span>
+    <div class="controls">
+      <label for="streams-density">Columns</label>
+      <input type="range" id="streams-density" min="2" max="7" step="1" value="4">
+      <span class="muted" id="streams-density-val" style="font-size:11px;min-width:14px">4</span>
+    </div>
+  </div>
+  <div class="streams-board" id="streams-board"></div>
+</div>
+
+<!-- ============ APPROVALS VIEW ============ -->
+<div id="view-approvals" class="hide">
+  <div class="approvals-wrap">
+    <h3 style="font-family:var(--serif);font-size:15px;color:var(--ink);margin-bottom:6px;font-weight:400">Approvals queue</h3>
+    <p class="muted" style="margin-bottom:16px">Pending sign-off and approved-awaiting-publish. Nothing goes live without explicit Steve sign-off (STEVE-GO). Publish is simulated only.</p>
+    <div id="approvals-list"></div>
+  </div>
 </div>
 
 <div id="toast" class="toast hide"></div>
 
+<script src="/modal-rig.js"></script>
 <script>
 const CHAR_LIMITS = { instagram:2200, facebook:63206, linkedin:3000, pinterest:500, tiktok:2200, twitter:280, youtube:5000 };
 const COLORS = { instagram:'#E1306C', facebook:'#1877F2', linkedin:'#0A66C2', pinterest:'#E60023', tiktok:'#25F4EE', twitter:'#1DA1F2', youtube:'#FF0000' };
@@ -1106,13 +1286,19 @@ async function publish(){
 }
 
 // ---------- views ----------
+const VIEWS=['dashboard','compose','streams','calendar','drafts','approvals'];
 function showView(v){
-  ['compose','calendar','drafts'].forEach(x=>{
+  if(!VIEWS.includes(v)) v='compose';
+  VIEWS.forEach(x=>{
     document.getElementById('view-'+x).classList.toggle('hide',x!==v);
     document.getElementById('nav-'+x).classList.toggle('active',x===v);
   });
+  try{ localStorage.setItem('dwlaunch:view', v); }catch(e){}
+  if(v==='dashboard') renderDashboard();
+  if(v==='streams') renderStreams();
   if(v==='calendar') renderCalendar();
   if(v==='drafts') renderDrafts();
+  if(v==='approvals') renderApprovals();
 }
 
 // 8. calendar
@@ -1139,30 +1325,247 @@ async function renderCalendar(){
   }
 }
 
-// drafts list — admin cards w/ created date+time
+const BADGE_MAP={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
+function fmtWhen(iso){ if(!iso) return '—'; try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch(e){ return '—'; } }
+
+// drafts list — admin cards w/ created date+time, open as movable modal
 async function renderDrafts(){
   const {launches}=await api('/api/launches');
   const el=document.getElementById('drafts-list'); el.innerHTML = launches.length?'':'<span class="muted">No launches yet.</span>';
   launches.forEach(l=>{
     const d=document.createElement('div'); d.className='launchcard';
-    const created=l.created_at?new Date(l.created_at):null;
-    const whenStr=created?created.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):'—';
-    const m={draft:'b-draft',pending_approval:'b-pending',approved:'b-approved',published:'b-published'};
-    d.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:flex-start">
+    const whenStr=fmtWhen(l.created_at);
+    d.innerHTML=`<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
         <strong>${esc(l.title)}</strong>
-        <span class="badge ${m[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span></div>
+        <span class="badge ${BADGE_MAP[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span></div>
       <div class="muted" style="margin-top:4px">${(l.channelsSelected||[]).map(chanName).join(' · ')||'no channels'}</div>
-      <div class="when" title="${l.created_at||''}">🕓 ${whenStr}</div>`;
-    d.onclick=()=>{ state=l; activeChanTab=null; activePvTab=null; showView('compose'); hydrate(); toast('Loaded: '+l.title); };
+      <div class="when" title="${esc(l.created_at||'')}">🕓 ${whenStr}</div>`;
+    d.onclick=()=>openLaunchModal(l.id);
     el.appendChild(d);
   });
 }
 
+// ---------- dashboard ----------
+async function renderDashboard(){
+  const dash=await api('/api/dashboard');
+  const c=dash.counts;
+  const cards=[
+    {v:c.scheduledToday,k:'Scheduled today',cls:'gold'},
+    {v:c.pendingApproval,k:'Pending approval',cls:'warn'},
+    {v:c.publishedThisWeek,k:'Published this week',cls:'info'},
+    {v:c.approved,k:'Approved (awaiting publish)',cls:'ok'},
+    {v:c.drafts,k:'Drafts',cls:''},
+    {v:c.totalLaunches,k:'Total launches',cls:''}
+  ];
+  document.getElementById('dash-stats').innerHTML = cards.map(x=>
+    `<div class="stat"><div class="v ${x.cls}">${x.v}</div><div class="k">${x.k}</div></div>`).join('');
+  updateApprovalsPill(c.pendingApproval+c.approved);
+  const cg=document.getElementById('dash-channels'); cg.innerHTML='';
+  dash.perChannel.forEach(p=>{
+    const t=document.createElement('div'); t.className='chan-tile'+(p.connected?'':' off');
+    t.innerHTML=`<div class="ch-head"><span class="av" style="background:${COLORS[p.id]}"></span>
+        <span class="ch-name">${esc(p.name)}</span>
+        ${p.connected?'':'<span class="connect" style="margin-left:auto;font-size:9px;color:var(--warn)">not connected</span>'}</div>
+      <div class="ch-stats">
+        <div><b>${p.scheduled}</b><span class="lab">sched</span></div>
+        <div><b>${p.pending}</b><span class="lab">pending</span></div>
+        <div><b>${p.published}</b><span class="lab">live</span></div>
+        <div><b>${p.drafts}</b><span class="lab">draft</span></div>
+      </div>`;
+    t.onclick=()=>{ showView('streams'); };
+    cg.appendChild(t);
+  });
+}
+function updateApprovalsPill(n){
+  const p=document.getElementById('appr-pill');
+  if(n>0){ p.textContent=n; p.classList.remove('hide'); } else p.classList.add('hide');
+}
+
+// ---------- streams (Hootsuite-style columns) ----------
+const SEGS=[['scheduled','Scheduled'],['pending','Pending approval'],['published','Published'],['drafts','Drafts']];
+async function renderStreams(){
+  const {columns}=await api('/api/streams');
+  const board=document.getElementById('streams-board'); board.innerHTML='';
+  columns.forEach(col=>{
+    const total=col.scheduled.length+col.pending.length+col.published.length+col.drafts.length;
+    const c=document.createElement('div'); c.className='stream-col';
+    let head=`<div class="sc-head"><span class="av" style="background:${COLORS[col.channel]}"></span>
+        <span class="nm">${esc(col.name)}</span>
+        ${col.connected?'':'<span class="off">connect</span>'}
+        <span class="ct">${total}</span></div>`;
+    let segHtml='';
+    SEGS.forEach(([key,label])=>{
+      const items=col[key]||[];
+      segHtml+=`<div class="stream-seg"><div class="seg-label">${label} <span class="n">· ${items.length}</span></div>`;
+      if(!items.length){ segHtml+=`<div class="seg-empty">—</div>`; }
+      else segHtml+=items.map(it=>streamCard(it)).join('');
+      segHtml+=`</div>`;
+    });
+    c.innerHTML=head+`<div class="sc-body">${segHtml}</div>`;
+    board.appendChild(c);
+  });
+  // wire card clicks (delegation)
+  board.querySelectorAll('.stream-card').forEach(el=>{
+    el.onclick=()=>openLaunchModal(el.getAttribute('data-id'));
+  });
+}
+function streamCard(it){
+  const when = it.datetime ? new Date(it.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})
+    : (it.mode?it.mode:'no schedule');
+  return `<div class="stream-card" data-id="${esc(it.launchId)}" style="border-left-color:${COLORS[it.channel]||'var(--gold)'}"
+      title="created ${esc(it.created_at||'')}">
+    <div class="st-title">${esc(it.title)}</div>
+    ${it.body?`<div class="st-body">${esc(it.body)}</div>`:''}
+    <div class="st-meta">
+      <span class="badge ${BADGE_MAP[it.status]||'b-draft'}">${(it.status||'draft').replace('_',' ')}</span>
+      <span class="st-when">🕓 ${when}</span>
+    </div>
+  </div>`;
+}
+
+// ---------- approvals ----------
+async function renderApprovals(){
+  const {launches}=await api('/api/launches');
+  const queue=launches.filter(l=>l.status==='pending_approval'||l.status==='approved');
+  updateApprovalsPill(queue.length);
+  const el=document.getElementById('approvals-list');
+  el.innerHTML = queue.length?'':'<span class="muted">Nothing awaiting sign-off. Submit a launch from Compose to populate this queue.</span>';
+  queue.forEach(l=>{
+    const pending=l.status==='pending_approval';
+    const body=(l.global&&l.global.body)||'';
+    const d=document.createElement('div'); d.className='appr-card'+(pending?' pending':'');
+    d.innerHTML=`<div class="ac-main">
+        <div class="ac-title">${esc(l.title)}</div>
+        ${body?`<div class="ac-body">${esc(body)}</div>`:''}
+        <div class="ac-meta">
+          <span class="badge ${BADGE_MAP[l.status]}">${l.status.replace('_',' ')}</span>
+          <span>${(l.channelsSelected||[]).map(chanName).join(' · ')||'no channels'}</span>
+          <span title="${esc(l.created_at||'')}">🕓 ${fmtWhen(l.created_at)}</span>
+          ${l.approval&&l.approval.approved?`<span style="color:#5ab880">✓ ${esc(l.approval.by)} · ${fmtWhen(l.approval.at)}</span>`:''}
+        </div>
+      </div>
+      <div class="ac-actions">
+        <button class="btn ghost" data-act="open" data-id="${esc(l.id)}">Open</button>
+        ${pending?`<button class="btn btn-approve" data-act="approve" data-id="${esc(l.id)}">Steve sign-off ✓</button>`
+          :`<button class="btn btn-publish" data-act="publish" data-id="${esc(l.id)}">Publish (simulated)</button>`}
+      </div>`;
+    el.appendChild(d);
+  });
+  el.querySelectorAll('button[data-act]').forEach(b=>{
+    const id=b.getAttribute('data-id'), act=b.getAttribute('data-act');
+    b.onclick=async()=>{
+      if(act==='open') return openLaunchModal(id);
+      if(act==='approve'){ await approveById(id); renderApprovals(); }
+      if(act==='publish'){ await publishById(id); renderApprovals(); }
+    };
+  });
+}
+
+// ---------- id-based approval/publish (used by Approvals + modal) ----------
+async function approveById(id){
+  const token=prompt('Steve sign-off required to unlock publish. Enter sign-off token (STEVE-GO):');
+  if(token===null) return false;
+  try{
+    const j=await api(`/api/launches/${id}/approve`,{method:'POST',body:JSON.stringify({token,by:'Steve'})});
+    if(state&&state.id===id) state=j.launch;
+    toast('Signed off ✓ — publish unlocked (still simulated).'); return true;
+  }catch(e){ toast('Sign-off rejected: '+e.message,true); return false; }
+}
+async function publishById(id){
+  try{
+    const j=await api(`/api/launches/${id}/publish`,{method:'POST'});
+    if(state&&state.id===id) state=j.launch;
+    toast('SIMULATED publish — no live API called. '+(j.note||'')); return true;
+  }catch(e){ toast('Publish blocked: '+e.message,true); return false; }
+}
+
+// ---------- launch as movable modal (modal-rig) ----------
+async function openLaunchModal(id){
+  let l;
+  try{ l=(await api('/api/launches/'+id)).launch; }
+  catch(e){ toast('Could not load launch.',true); return; }
+  const approved=l.approval&&l.approval.approved;
+  const chans=(l.channelsSelected&&l.channelsSelected.length)?l.channelsSelected:Object.keys(l.channels||{});
+  const chanRows=chans.map(ch=>{
+    const c=(l.channels&&l.channels[ch])||{};
+    const cs=c.schedule||l.schedule||{};
+    const when=cs.datetime?new Date(cs.datetime).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):(cs.mode||'—');
+    const body=c.body||l.global?.body||'';
+    return `<div style="border:1px solid var(--line-soft);border-radius:6px;padding:10px 12px;margin-bottom:8px;background:var(--panel2)">
+      <div style="display:flex;align-items:center;gap:8px;margin-bottom:5px">
+        <span style="width:8px;height:8px;border-radius:50%;background:${COLORS[ch]}"></span>
+        <strong style="font-size:12px">${esc(chanName(ch))}</strong>
+        <span class="muted" style="margin-left:auto;font-size:10px">🕓 ${esc(when)}</span>
+      </div>
+      <div style="font-size:12px;color:var(--ink-2);white-space:pre-wrap;line-height:1.45">${esc(body.slice(0,400))}${body.length>400?'…':''}</div>
+    </div>`;
+  }).join('') || '<span class="muted">No channels selected.</span>';
+
+  const bodyHtml=`
+    <div style="display:flex;align-items:center;gap:10px;margin-bottom:10px">
+      <span class="badge ${BADGE_MAP[l.status]||'b-draft'}">${(l.status||'draft').replace('_',' ')}</span>
+      <span class="muted" style="font-size:11px" title="${esc(l.created_at||'')}">created 🕓 ${fmtWhen(l.created_at)}</span>
+      ${approved?`<span class="muted" style="font-size:11px;color:#5ab880">✓ signed off by ${esc(l.approval.by)}</span>`:''}
+    </div>
+    <div id="ml-sec-head" style="font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--gold-dim);margin-bottom:8px">Per-channel</div>
+    <div id="ml-sec-body">${chanRows}</div>
+    <div style="display:flex;gap:8px;margin-top:14px;flex-wrap:wrap">
+      <button class="btn ghost" id="ml-edit">Load into Compose</button>
+      ${l.status==='draft'?`<button class="btn btn-submit" id="ml-submit">Submit for approval</button>`:''}
+      ${(l.status==='pending_approval')?`<button class="btn btn-approve" id="ml-approve">Steve sign-off ✓</button>`:''}
+      <button class="btn btn-publish" id="ml-publish" ${approved?'':'disabled'}>${approved?'Publish (simulated)':'Publish (gated)'}</button>
+    </div>
+    ${approved?'':'<div class="gatebar warn" style="margin-top:10px"><strong>Publish is gated.</strong><div class="muted">Requires explicit Steve sign-off (STEVE-GO). Simulated only — no live API.</div></div>'}`;
+
+  const m=ModalRig.open({ key:'launch-viewer', title:l.title||'Launch', bodyHtml, width:560, height:560, bg:'var(--panel)' });
+  const sh=m.body.querySelector('#ml-sec-head'), sb=m.body.querySelector('#ml-sec-body');
+  if(sh&&sb) ModalRig.section(sh, sb, 'launch-viewer:perchan');
+  const edit=m.body.querySelector('#ml-edit');
+  if(edit) edit.onclick=()=>{ state=l; activeChanTab=null; activePvTab=null; m.close(); showView('compose'); hydrate(); toast('Loaded: '+l.title); };
+  const sub=m.body.querySelector('#ml-submit');
+  if(sub) sub.onclick=async()=>{ try{ await api(`/api/launches/${id}/submit-approval`,{method:'POST'}); m.close(); toast('Submitted for approval.'); refreshActiveView(); }catch(e){ toast('Failed: '+e.message,true); } };
+  const appr=m.body.querySelector('#ml-approve');
+  if(appr) appr.onclick=async()=>{ if(await approveById(id)){ m.close(); refreshActiveView(); } };
+  const pub=m.body.querySelector('#ml-publish');
+  if(pub) pub.onclick=async()=>{ if(await publishById(id)){ m.close(); refreshActiveView(); } };
+}
+function refreshActiveView(){
+  const v=localStorage.getItem('dwlaunch:view')||'compose';
+  if(v==='dashboard')renderDashboard(); else if(v==='streams')renderStreams();
+  else if(v==='drafts')renderDrafts(); else if(v==='approvals')renderApprovals();
+  else if(v==='compose'){ renderState(); }
+}
+
+// ---------- density sliders (persisted) ----------
+function wireDensity(){
+  const sd=document.getElementById('streams-density'), sdv=document.getElementById('streams-density-val');
+  const savedCols=localStorage.getItem('dwlaunch:streamCols');
+  if(savedCols){ sd.value=savedCols; }
+  const applyCols=()=>{ document.getElementById('streams-board').style.setProperty('--cols',sd.value); sdv.textContent=sd.value; };
+  applyCols();
+  sd.addEventListener('input',()=>{ applyCols(); localStorage.setItem('dwlaunch:streamCols',sd.value); });
+
+  const dd=document.getElementById('drafts-density');
+  const savedDraft=localStorage.getItem('dwlaunch:draftMin');
+  if(savedDraft){ dd.value=savedDraft; }
+  const applyDraft=()=>{ document.documentElement.style.setProperty('--draftmin',dd.value+'px'); document.getElementById('drafts-list').style.setProperty('--chanmin',dd.value+'px'); };
+  applyDraft();
+  dd.addEventListener('input',()=>{ applyDraft(); localStorage.setItem('dwlaunch:draftMin',dd.value); });
+}
+
 function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
 
 // wire global media drop
 wireDrop('global-drop','global-file',()=>state.global.media);
-init();
+wireDensity();
+init().then(()=>{
+  // restore last-used view (persisted), default Compose
+  const saved=localStorage.getItem('dwlaunch:view');
+  if(saved && VIEWS.includes(saved) && saved!=='compose') showView(saved);
+  else { // refresh the approvals pill on load even when starting on Compose
+    api('/api/dashboard').then(d=>updateApprovalsPill((d.counts.pendingApproval||0)+(d.counts.approved||0))).catch(()=>{});
+  }
+});
 </script>
 </body>
 </html>

← c49590a server: add /api/streams (per-channel segmented board) + /ap  ·  back to Dw Launches  ·  README: document command-center suite (views, density slider bd7e300 →