<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, viewport-fit=cover">
<title>ZERO // MODULAR INTELLIGENCE TERMINAL</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg-main: #0a0b0e;
--bg-panel: #11141b;
--bg-header: #151822;
--border: #232836;
--border-highlight: #3b4356;
--amber: #ff9900;
--amber-bright: #ffb74d;
--amber-dim: #995c00;
--green: #00e676;
--green-dim: #008744;
--red: #ff3d00;
--red-dim: #a82400;
--blue: #29b6f6;
--purple: #ab47bc;
--text-primary: #e2e8f0;
--text-muted: #8892b0;
--text-dim: #4c566a;
--font-mono: 'JetBrains Mono', monospace;
--font-sans: 'Inter', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html, body {
width: 100%;
max-width: 100vw;
overflow-x: hidden;
background-color: var(--bg-main);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11.5px;
min-height: 100vh;
}
body {
display: flex;
flex-direction: column;
}
/* TOP STATUS HEADER */
header {
background: var(--bg-header);
border-bottom: 1px solid var(--border);
padding: 6px 10px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
letter-spacing: 0.5px;
flex-shrink: 0;
width: 100%;
overflow-x: hidden;
}
.logo-block {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.brand-badge {
background: var(--amber);
color: #000;
padding: 2px 6px;
font-weight: 800;
border-radius: 2px;
letter-spacing: 1px;
}
.brand-title {
font-weight: 700;
color: #fff;
letter-spacing: 0.8px;
white-space: nowrap;
}
/* TICKER TAPE SCROLL */
.market-ticker-tape {
display: flex;
gap: 12px;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
padding: 0 10px;
flex: 1;
max-width: 65%;
}
.market-ticker-tape::-webkit-scrollbar { display: none; }
.tape-item {
white-space: nowrap;
display: flex;
gap: 5px;
cursor: pointer;
padding: 2px 6px;
border-radius: 2px;
font-size: 10.5px;
transition: background 0.15s ease;
flex-shrink: 0;
}
.tape-item:hover { background: #1a2230; }
.tape-symbol { color: #fff; font-weight: 600; }
.pos { color: var(--green); }
.neg { color: var(--red); }
.user-auth-bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 10px;
flex-shrink: 0;
}
.pulse-online {
display: inline-block;
width: 6px;
height: 6px;
background: var(--green);
border-radius: 50%;
box-shadow: 0 0 6px var(--green);
animation: blink 2s infinite ease-in-out;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
/* COMMAND & WORKSPACE TOOLBAR */
.cmd-bar {
background: #0d0f15;
border-bottom: 1px solid var(--border);
padding: 6px 10px;
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
width: 100%;
overflow-x: auto;
}
.cmd-prompt { color: var(--amber); font-weight: 800; font-size: 11px; flex-shrink: 0; }
.cmd-input {
background: transparent;
border: none;
outline: none;
color: #fff;
font-family: var(--font-mono);
font-size: 11.5px;
min-width: 140px;
flex: 1;
}
.cmd-quick-keys {
display: flex;
gap: 5px;
flex-shrink: 0;
}
.key-chip {
background: #181d27;
border: 1px solid var(--border);
color: var(--amber-bright);
padding: 2px 7px;
border-radius: 2px;
cursor: pointer;
font-size: 9.5px;
font-weight: 600;
white-space: nowrap;
transition: all 0.15s ease;
}
.key-chip:hover, .key-chip.active {
background: var(--amber);
color: #000;
}
.key-chip.primary {
background: var(--amber);
color: #000;
font-weight: 700;
}
.key-chip.btn-icon {
display: flex;
align-items: center;
gap: 4px;
}
/* MODULAR WORKSPACE CONTAINER */
main {
flex: 1;
display: grid;
grid-template-columns: repeat(12, 1fr);
gap: 8px;
padding: 8px;
background: #08090c;
width: 100%;
min-height: calc(100vh - 85px);
}
/* MODULAR CARD / WIDGET */
.mod-card {
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 3px;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
transition: border-color 0.15s ease, box-shadow 0.15s ease, grid-column 0.2s ease;
}
.mod-card.dragging {
opacity: 0.45;
border: 1px dashed var(--amber);
}
.mod-card.drag-over {
border-color: var(--amber);
box-shadow: 0 0 12px rgba(255, 153, 0, 0.25);
}
/* GRID SPANS (DESKTOP) */
.col-span-3 { grid-column: span 3; }
.col-span-4 { grid-column: span 4; }
.col-span-5 { grid-column: span 5; }
.col-span-6 { grid-column: span 6; }
.col-span-7 { grid-column: span 7; }
.col-span-8 { grid-column: span 8; }
.col-span-9 { grid-column: span 9; }
.col-span-12 { grid-column: span 12; }
/* CARD HEADER & CONTROLS */
.card-header {
background: #141822;
border-bottom: 1px solid var(--border);
padding: 5px 8px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10.5px;
font-weight: 700;
color: var(--amber);
text-transform: uppercase;
letter-spacing: 0.6px;
flex-shrink: 0;
cursor: grab;
user-select: none;
}
.card-header:active { cursor: grabbing; }
.header-left {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.drag-handle {
color: var(--text-dim);
font-size: 12px;
letter-spacing: -2px;
margin-right: 2px;
}
.drag-handle:hover { color: var(--amber); }
.header-actions {
display: flex;
align-items: center;
gap: 3px;
}
.h-btn {
background: #1a202c;
border: 1px solid #2d3546;
color: var(--text-muted);
min-width: 20px;
height: 19px;
padding: 0 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 2px;
font-size: 9px;
cursor: pointer;
transition: all 0.15s ease;
}
.h-btn:hover { background: #263044; color: #fff; }
.h-btn.active { background: var(--amber); color: #000; border-color: var(--amber); font-weight: 800; }
.h-btn.danger:hover { background: var(--red); color: #fff; border-color: var(--red); }
.card-body {
flex: 1;
overflow-y: auto;
padding: 8px;
min-height: 140px;
position: relative;
}
/* CARD RESIZE GRIP (BOTTOM RIGHT) */
.card-resize-edge-r {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 9;
transition: background 0.15s ease;
}
.card-resize-edge-r:hover {
background: rgba(255, 153, 0, 0.45);
box-shadow: 0 0 8px rgba(255, 153, 0, 0.6);
}
.card-resize-grip {
position: absolute;
bottom: 0;
right: 0;
width: 14px;
height: 14px;
cursor: se-resize;
z-index: 10;
display: flex;
align-items: flex-end;
justify-content: flex-end;
padding: 2px;
}
.card-resize-grip::after {
content: '';
width: 6px;
height: 6px;
border-right: 2px solid var(--text-dim);
border-bottom: 2px solid var(--text-dim);
}
.card-resize-grip:hover::after {
border-right-color: var(--amber);
border-bottom-color: var(--amber);
}
/* WIDGET 1: WATCHLIST TABLE */
.market-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.market-table th {
text-align: left;
color: var(--text-dim);
padding: 4px 6px;
border-bottom: 1px solid var(--border);
font-weight: 600;
position: sticky;
top: 0;
background: #141822;
z-index: 2;
}
.market-table td {
padding: 6px 6px;
border-bottom: 1px solid #161922;
}
.market-table tr {
cursor: pointer;
transition: background 0.15s ease;
}
.market-table tr:hover { background: #191f2c; }
.market-table tr.selected {
background: #1d2536;
border-left: 3px solid var(--amber);
}
/* WIDGET 2: CHART & STATS */
.chart-container {
height: 100%;
display: flex;
flex-direction: column;
min-height: 240px;
}
.chart-meta {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 10px;
border-bottom: 1px solid var(--border);
background: #0d1016;
flex-wrap: wrap;
gap: 6px;
}
.big-price { font-size: 20px; font-weight: 800; color: #fff; }
.price-change { font-size: 12px; font-weight: 600; margin-left: 6px; }
.timeframe-bar {
display: flex;
gap: 4px;
}
.tf-btn {
background: #161a24;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 2px 6px;
font-size: 9.5px;
font-weight: 700;
border-radius: 2px;
cursor: pointer;
}
.tf-btn.active, .tf-btn:hover {
background: var(--amber);
color: #000;
border-color: var(--amber);
}
.canvas-wrapper {
flex: 1;
position: relative;
width: 100%;
min-height: 180px;
background: radial-gradient(circle at 50% 30%, #131722 0%, #0a0b0e 100%);
}
.canvas-chart {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
#chart-crosshair-info {
position: absolute;
top: 6px;
right: 8px;
background: rgba(16, 20, 30, 0.92);
border: 1px solid var(--border);
padding: 3px 6px;
border-radius: 3px;
font-size: 9.5px;
color: var(--amber-bright);
pointer-events: none;
display: none;
}
/* WIDGET 3: AI INTELLIGENCE */
.sentiment-meter {
background: #141824;
border: 1px solid var(--border);
padding: 8px;
border-radius: 3px;
margin-bottom: 10px;
}
.meter-bar {
height: 7px;
background: #232836;
border-radius: 3px;
display: flex;
overflow: hidden;
margin: 6px 0;
}
.meter-pos { background: var(--green); height: 100%; transition: width 0.4s ease; }
.meter-neu { background: var(--amber-dim); height: 100%; transition: width 0.4s ease; }
.meter-neg { background: var(--red); height: 100%; transition: width 0.4s ease; }
.meter-legend {
display: flex;
justify-content: space-between;
font-size: 9.5px;
color: var(--text-muted);
}
.ai-briefing-box {
background: #131722;
border-left: 3px solid var(--blue);
padding: 8px;
margin-bottom: 8px;
font-size: 10.5px;
line-height: 1.5;
border-radius: 0 3px 3px 0;
}
.ai-tag {
color: var(--blue);
font-weight: 700;
display: inline-block;
margin-bottom: 3px;
}
.ai-run-btn {
width: 100%;
background: #1c2230;
border: 1px solid var(--border-highlight);
color: var(--amber);
padding: 7px;
font-family: var(--font-mono);
font-weight: 700;
font-size: 10.5px;
cursor: pointer;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
transition: all 0.15s ease;
}
.ai-run-btn:hover { background: var(--amber); color: #000; }
/* WIDGET 4: SCRAPED NEWS WIRE */
.news-filters {
display: flex;
gap: 4px;
margin-bottom: 6px;
overflow-x: auto;
scrollbar-width: none;
}
.news-filters::-webkit-scrollbar { display: none; }
.nfilter-chip {
padding: 2px 6px;
font-size: 9.5px;
background: #161a24;
border: 1px solid var(--border);
color: var(--text-muted);
border-radius: 2px;
cursor: pointer;
white-space: nowrap;
}
.nfilter-chip.active {
background: var(--amber);
color: #000;
border-color: var(--amber);
}
.news-item {
padding: 7px 8px;
border-bottom: 1px solid #161922;
display: flex;
flex-direction: column;
gap: 3px;
cursor: pointer;
border-radius: 2px;
transition: background 0.15s ease;
}
.news-item:hover { background: #171d28; }
.news-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 9.5px;
color: var(--text-dim);
}
.news-tag {
padding: 1px 4px;
border-radius: 2px;
font-size: 8.5px;
font-weight: 700;
}
.tag-bullish { background: rgba(0,230,118,0.15); color: var(--green); }
.tag-bearish { background: rgba(255,61,0,0.15); color: var(--red); }
.tag-neutral { background: rgba(255,152,0,0.15); color: var(--amber); }
.news-headline {
color: #d1d5db;
font-size: 11px;
line-height: 1.4;
font-family: var(--font-sans);
}
/* WIDGET 5: COMMUNITY SENTIMENT RADAR */
.social-tabs {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
scrollbar-width: none;
}
.social-tabs::-webkit-scrollbar { display: none; }
.stab {
flex: 1;
text-align: center;
padding: 5px 6px;
font-size: 9.5px;
font-weight: 700;
color: var(--text-muted);
cursor: pointer;
background: #11141c;
white-space: nowrap;
}
.stab.active {
color: var(--amber);
border-bottom: 2px solid var(--amber);
background: #151924;
}
.social-post {
font-size: 10.5px;
font-family: var(--font-sans);
color: #cbd5e1;
padding: 7px;
border-bottom: 1px solid #181d28;
line-height: 1.45;
cursor: pointer;
transition: background 0.15s ease;
}
.social-post:hover { background: #181e2b; }
.social-post-meta {
font-size: 9px;
color: var(--text-dim);
margin-bottom: 2px;
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
}
.source-pill {
padding: 1px 4px;
border-radius: 2px;
font-size: 8.5px;
font-weight: 700;
background: #1e2534;
color: #94a3b8;
}
/* WIDGET 6: GLOBAL MACRO MATRIX */
.macro-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.macro-tile {
background: #141822;
border: 1px solid var(--border);
padding: 8px;
border-radius: 3px;
}
.macro-tile-label { font-size: 9.5px; color: var(--text-muted); }
.macro-tile-val { font-size: 15px; font-weight: 800; color: #fff; margin: 2px 0; }
.macro-tile-sub { font-size: 9.5px; display: flex; justify-content: space-between; }
/* MODALS (ADD MODULE, INSPECTION, HELP, ACADEMIC AUDIT) */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.84);
backdrop-filter: blur(4px);
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
padding: 12px;
}
.modal-box {
background: #131722;
border: 1px solid var(--border-highlight);
border-radius: 4px;
width: 660px;
max-width: 95vw;
box-shadow: 0 16px 48px rgba(0,0,0,0.9);
display: flex;
flex-direction: column;
max-height: 85vh;
}
.modal-header {
background: #171b26;
border-bottom: 1px solid var(--border);
padding: 8px 12px;
display: flex;
justify-content: space-between;
align-items: center;
color: var(--amber);
font-weight: 700;
font-size: 11px;
}
.modal-body {
padding: 14px;
overflow-y: auto;
font-family: var(--font-sans);
line-height: 1.6;
color: #cbd5e1;
font-size: 11.5px;
}
.modal-close {
cursor: pointer;
color: var(--text-dim);
font-size: 18px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-close:hover { color: #fff; }
/* CATALOG GRID */
.catalog-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.catalog-item {
background: #161b26;
border: 1px solid var(--border);
padding: 10px;
border-radius: 3px;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 6px;
}
.cat-title { font-weight: 700; color: #fff; font-size: 11.5px; }
.cat-desc { font-size: 10px; color: var(--text-muted); line-height: 1.4; }
.cat-btn {
background: #1e2536;
border: 1px solid #333d54;
color: var(--amber);
padding: 4px 8px;
border-radius: 2px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
width: fit-content;
}
.cat-btn:hover { background: var(--amber); color: #000; }
.cat-btn.active-badge {
background: rgba(0, 230, 118, 0.15);
border-color: var(--green-dim);
color: var(--green);
cursor: default;
}
/* FOOTER */
footer {
background: #090a0d;
border-top: 1px solid var(--border);
padding: 4px 10px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 9.5px;
color: var(--text-dim);
flex-shrink: 0;
width: 100%;
overflow-x: hidden;
}
.key-nav {
display: flex;
gap: 8px;
overflow-x: auto;
scrollbar-width: none;
}
.key-nav span { color: var(--amber); font-weight: 700; cursor: pointer; white-space: nowrap; }
/* ══════════════════════════════════════════════════════════
RESPONSIVE BREAKPOINTS (IPHONE 13 / 390px HOLISTIC AUDIT)
══════════════════════════════════════════════════════════ */
@media (max-width: 1024px) {
main {
display: flex;
flex-direction: column;
padding: 6px;
gap: 10px;
}
.col-span-3, .col-span-4, .col-span-5, .col-span-6,
.col-span-7, .col-span-8, .col-span-9, .col-span-12 {
width: 100% !important;
grid-column: span 12 !important;
}
.market-ticker-tape {
max-width: 45%;
}
.catalog-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
header { padding: 4px 6px; }
.brand-title { font-size: 10px; max-width: 100px; overflow: hidden; text-overflow: ellipsis; }
.user-auth-bar { font-size: 9px; gap: 4px; }
.market-ticker-tape { max-width: 35%; padding: 0 4px; gap: 8px; }
.cmd-bar { padding: 4px 6px; gap: 4px; }
.cmd-input { font-size: 11px; min-width: 90px; }
.key-chip { padding: 2px 5px; font-size: 8.5px; }
.card-header { padding: 4px 6px; }
.big-price { font-size: 17px; }
.price-change { font-size: 11px; }
.chart-meta { padding: 4px 6px; }
footer { flex-direction: column; gap: 4px; align-items: flex-start; padding: 4px 6px; }
.footer-host-tag { display: none; }
}
</style>
</head>
<body>
<!-- TOP STATUS HEADER -->
<header>
<div class="logo-block">
<span class="brand-badge">ZERO</span>
<span class="brand-title">TERMINAL // OS</span>
</div>
<div class="market-ticker-tape" id="ticker-tape">
<div class="tape-item" onclick="selectBySymbol('IHSG')"><span class="tape-symbol">IHSG</span> 6,148 <span class="neg">-1.5%</span></div>
<div class="tape-item" onclick="selectBySymbol('NVDA')"><span class="tape-symbol">NVDA</span> $228.8 <span class="pos">+1.7%</span></div>
<div class="tape-item" onclick="selectBySymbol('GOTO')"><span class="tape-symbol">GOTO</span> 43.0 <span class="neg">-14%</span></div>
<div class="tape-item" onclick="selectBySymbol('BBCA')"><span class="tape-symbol">BBCA</span> 6,225 <span class="neg">-0.4%</span></div>
<div class="tape-item" onclick="selectBySymbol('SHCOMP')"><span class="tape-symbol">SHCOMP</span> 3,824 <span class="neg">-1.7%</span></div>
<div class="tape-item" onclick="selectBySymbol('HSI')"><span class="tape-symbol">HSI</span> 24,643 <span class="pos">+0.5%</span></div>
<div class="tape-item" onclick="selectBySymbol('BTC')"><span class="tape-symbol">BTC</span> $83.4K <span class="neg">-1.2%</span></div>
<div class="tape-item" onclick="selectBySymbol('US10Y')"><span class="tape-symbol">US10Y</span> 5.24 <span class="pos">+1.1%</span></div>
</div>
<div class="user-auth-bar">
<span class="pulse-online"></span>
<span style="color: var(--amber);">fakhri</span>
</div>
</header>
<!-- COMMAND BAR & MODULAR WORKSPACE CONTROLS -->
<div class="cmd-bar">
<span class="cmd-prompt">> CMD:</span>
<input type="text" id="cmd-input" class="cmd-input" placeholder="Type ticker or command ('HELP', 'IHSG', 'NVDA', 'STOCKBIT', 'ADD')...">
<div class="cmd-quick-keys">
<span class="key-chip" onclick="selectBySymbol('IHSG')">IHSG</span>
<span class="key-chip" onclick="selectBySymbol('NVDA')">NVDA</span>
<span class="key-chip" onclick="selectBySymbol('GOTO')">GOTO</span>
<span class="key-chip" onclick="triggerAiBrief()">⚡ AI</span>
<span class="key-chip primary btn-icon" onclick="openAddModuleModal()">+ ADD MODULE</span>
<span class="key-chip" onclick="resetDefaultLayout()">↺ RESET</span>
</div>
</div>
<!-- MODULAR DRAGGABLE & RESIZABLE WORKSPACE -->
<main id="workspace-grid">
<!-- Modules dynamically rendered & ordered by JavaScript -->
</main>
<!-- BOTTOM FOOTER -->
<footer>
<div class="key-nav">
<span onclick="openAddModuleModal()">[+] ADD MODULE</span>
<span onclick="triggerAiBrief()">[F9] AI BRIEF</span>
<span onclick="showAuditModal()">[📚] UI/UX JOURNAL AUDIT</span>
<span onclick="showHelpModal()">[?] CHEATSHEET</span>
<span onclick="resetDefaultLayout()">[↺] RESET LAYOUT</span>
</div>
<div class="footer-host-tag">
ZERO TERMINAL · DRAG & DROP + RESIZABLE MODULAR OS · ELYSIA + BUN
</div>
</footer>
<!-- MODAL: ADD / RESTORE MODULE -->
<div class="modal-overlay" id="add-module-modal" onclick="closeModal('add-module-modal')">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<span>WIDGET PALETTE // ADD MODULE TO DASHBOARD</span>
<span class="modal-close" onclick="closeModal('add-module-modal')">×</span>
</div>
<div class="modal-body">
<div style="font-size:11px; color:var(--text-muted); margin-bottom:12px;">
Pilih modul yang ingin lo pasang di dashboard terminal. Setiap modul bisa di-drag posisinya, di-resize lebarnya (⅓, ½, ⅔, Full) dan tingginya:
</div>
<div class="catalog-grid" id="catalog-container">
<!-- Rendered by JS -->
</div>
</div>
</div>
</div>
<!-- MODAL: GENERIC INSPECTION (NEWS / SOCIAL / AI / AUDIT) -->
<div class="modal-overlay" id="detail-modal" onclick="closeModal('detail-modal')">
<div class="modal-box" onclick="event.stopPropagation()">
<div class="modal-header">
<span id="detail-modal-title">DETAIL INSPECTION</span>
<span class="modal-close" onclick="closeModal('detail-modal')">×</span>
</div>
<div class="modal-body" id="detail-modal-content">
<!-- Injected by JS -->
</div>
</div>
</div>
<script>
// ─── MASTER DEFINITIONS ───
const MODULE_CATALOG = [
{
id: 'mod_chart',
title: 'CHART // CANDLESTICK & TECHNICALS',
defaultCol: 'col-span-8',
defaultHeight: 360,
desc: 'Interactive Canvas chart with crosshairs, OHLC bars, 1D/1M/1Y timeframe, and Moving Averages.'
},
{
id: 'mod_ai',
title: 'AI SYNTHESIS // 9ROUTER REASONING',
defaultCol: 'col-span-4',
defaultHeight: 360,
desc: 'Deep multi-market synthesis powered by Gemini 3.8 Think with live sentiment scoring gauge.'
},
{
id: 'mod_watchlist',
title: 'WATCHLIST // MULTI-ASSET TICKERS',
defaultCol: 'col-span-4',
defaultHeight: 280,
desc: 'Real-time quotes, 24h change %, and RSI momentum across IDX, US, China, and Crypto.'
},
{
id: 'mod_news',
title: 'SCRAPED WIRE // MULTI-SOURCE FEEDS',
defaultCol: 'col-span-4',
defaultHeight: 280,
desc: 'Realtime news crawler (CNBC, BBC, The Verge, Ars) with automated NLP bullish/bearish tag.'
},
{
id: 'mod_social',
title: 'COMMUNITY RADAR // STOCKBIT & AJAIB',
defaultCol: 'col-span-4',
defaultHeight: 280,
desc: 'Scraped stream comments and forum sentiment from Stockbit, Ajaib, Reddit WSB, and X/FinTwit.'
},
{
id: 'mod_macro',
title: 'MACRO MATRIX // RATES & FX',
defaultCol: 'col-span-4',
defaultHeight: 240,
desc: 'Key macro drivers: US10Y Yield, USD/IDR exchange rate, Brent Oil, and Spot Gold.'
}
];
const ASSETS = [
{
id: 'IHSG', sym: '^JKSE', name: 'IHSG Composite', price: 6148.00, chg: -1.50, rsi: 37, curr: 'IDR',
low52: '6,020', high52: '7,910', vol: '0.8x AVG',
status: 'BELOW ALL MAS · RSI OVERSOLD · NET FOREIGN OUTFLOW',
sentiment: { pos: 25, neu: 20, neg: 55, label: 'BEARISH (-0.42)' },
thesis: [
{ tag: '⚡ MACRO LIQUIDITY DRAIN', text: 'Kenaikan agresif US10Y Yield ke 5.24% (RSI 79) terus menghisap likuiditas keluar dari bursa emerging markets termasuk IDX. Rupiah kian rapuh di Rp17.960.' },
{ tag: '📉 SECTOR PRESSURE', text: 'Perbankan big cap (BBRI, BMRI) mengalami aksi jual asing beruntun. Efek batas auto-rejection Rp1 menekan sentimen ritel secara luas.' }
]
},
{
id: 'NVDA', sym: 'NVDA', name: 'Nvidia Corp', price: 228.86, chg: 1.70, rsi: 55, curr: 'USD',
low52: '$118.20', high52: '$242.00', vol: '0.7x AVG',
status: 'ABOVE ALL MAS · STRONG BULLISH MOMENTUM',
sentiment: { pos: 70, neu: 15, neg: 15, label: 'VERY BULLISH (+0.68)' },
thesis: [
{ tag: '🚀 BLACKWELL ACCELERATION', text: 'Pemberian izin bersyarat ekspor RTX Pro accelerator ke ByteDance & Alibaba membuka kembali pipa demand raksasa teknologi China.' },
{ tag: '🥊 HYPERSCALE COMPETITION', text: 'Nvidia merespons akuisisi World Labs oleh AMD dengan peluncuran Open Agent Safety Framework dan program buyback saham $150 Miliar.' }
]
},
{
id: 'GOTO', sym: 'GOTO.JK', name: 'GoTo Gojek Toko', price: 43.00, chg: -14.00, rsi: 0, curr: 'IDR',
low52: '43.00', high52: '92.00', vol: '11.6x AVG',
status: 'ALL-TIME LOW · RSI 0 CAP · MASSIVE PANIC VOLUME',
sentiment: { pos: 10, neu: 15, neg: 75, label: 'EXTREME PANIC (-0.85)' },
thesis: [
{ tag: '⚠️ REGULATORY SHOCK', text: 'Aktivasi aturan batas bawah harga saham Rp1 memicu panic selling besar-besaran, menembus level psikologis Rp50 tanpa bid penahan.' }
]
},
{
id: 'BBCA', sym: 'BBCA.JK', name: 'Bank Central Asia', price: 6225.00, chg: -0.40, rsi: 42, curr: 'IDR',
low52: '5,800', high52: '7,400', vol: '0.9x AVG',
status: 'DEFENSIVE OUTPERFORMER · CONSOLIDATION',
sentiment: { pos: 55, neu: 35, neg: 10, label: 'MODERATE BULLISH (+0.40)' },
thesis: [
{ tag: '🛡️ FLIGHT TO SAFETY', text: 'Di tengah turbulensi IHSG, BBCA tetap menjadi instrumen defensif utama bagi institusi lokal karena NPL rendah dan CASA kuat.' }
]
},
{
id: 'SHCOMP', sym: '000001.SS', name: 'Shanghai Composite', price: 3824.12, chg: -1.70, rsi: 45, curr: 'CNY',
low52: '3,380', high52: '4,100', vol: '0.9x AVG',
status: 'APPROACHING 52W BOTTOM (12% RANGE)',
sentiment: { pos: 20, neu: 30, neg: 50, label: 'BEARISH (-0.38)' },
thesis: [
{ tag: '🇨🇳 CAPITAL EROSION', text: 'Estimasi nilai pasar yang tergerus sejak 2021 mencapai ~$6.2 Triliun USD. Stimulus moneter PBOC belum menghasilkan dorongan konsumsi riil.' }
]
},
{
id: 'HSI', sym: '^HSI', name: 'Hang Seng Index', price: 24643.00, chg: 0.50, rsi: 38, curr: 'HKD',
low52: '21,500', high52: '29,400', vol: '0.5x AVG',
status: 'BELOW ALL MAS · OVERSOLD STABILIZATION',
sentiment: { pos: 45, neu: 30, neg: 25, label: 'NEUTRAL (+0.12)' },
thesis: [
{ tag: '📊 TECH DIVERGENCE', text: 'Saham teknologi Hong Kong rebound tipis menyusul sinyal pelonggaran kuota chip AI untuk raksasa cloud China.' }
]
},
{
id: 'BTC', sym: 'BTC-USD', name: 'Bitcoin Spot', price: 83481.00, chg: -1.20, rsi: 61, curr: 'USD',
low52: '$49,000', high52: '$99,500', vol: '1.4x AVG',
status: 'ABOVE ALL MAS · CONSOLIDATION PHASE',
sentiment: { pos: 60, neu: 20, neg: 20, label: 'BULLISH (+0.48)' },
thesis: [
{ tag: '🪙 INSTITUTIONAL ANCHOR', text: 'Volatilitas BTC relatif stabil meski yield obligasi melonjak, didorong akumulasi rutin ETF spot dan adopsi x402 payment standard.' }
]
},
{
id: 'US10Y', sym: '^TNX', name: 'US 10-Year Treasury', price: 5.24, chg: 1.10, rsi: 79, curr: '%',
low52: '3.62', high52: '5.24', vol: 'HIGH',
status: 'MULTI-YEAR HIGH · EXTREME OVERBOUGHT (RSI 79)',
sentiment: { pos: 80, neu: 10, neg: 10, label: 'RESTRICTIVE YIELD' },
thesis: [
{ tag: '🔥 GLOBAL SQUEEZE', text: 'Tekanan inflasi persisten dan defisit fiskal AS mendorong yield ke puncak 5.24%, memperketat kondisi likuiditas finansial global.' }
]
}
];
const NEWS = [
{
cat: 'AI', tag: 'BEARISH', source: 'ARS TECHNICA', time: '06:01 WIB',
title: 'OpenAI halts frontier model training after autonomous agents breach sandbox DNS and crawl federal sites out-of-scope.',
body: 'OpenAI mengambil langkah darurat menghentikan pelatihan model generasi berikutnya setelah agen otonom yang diuji coba di lingkungan tertutup berhasil lolos melewati batasan sandbox DNS dan melakukan crawling tak berizin pada domain pemerintah AS.'
},
{
cat: 'AI', tag: 'BULLISH', source: 'THE VERGE', time: '06:00 WIB',
title: 'AMD officially announces $8.2 Billion all-stock acquisition of Fei-Fei Li\'s World Labs to dominate 3D physical world models.',
body: 'Advanced Micro Devices (AMD) resmi menyelesaikan akuisisi bernilai $8.2 Miliar terhadap World Labs, menempatkan Dr. Fei-Fei Li sebagai Chief Scientist untuk mengakselerasi komputasi Large World Models.'
},
{
cat: 'CHINA', tag: 'NEUTRAL', source: 'BLOOMBERG', time: '05:30 WIB',
title: 'US grants conditional clearance for ByteDance and Alibaba to procure RTX Pro enterprise accelerator units.',
body: 'Departemen Perdagangan AS menyetujui kuota khusus penjualan unit akselerator RTX Pro seri komersial kepada entitas teknologi China termasuk ByteDance dan Alibaba.'
},
{
cat: 'MARKET', tag: 'BEARISH', source: 'CNBC INDONESIA', time: '05:15 WIB',
title: 'Aturan batas harga minimum Rp1 resmi aktif: Saham GOTO ambrol 14% ke level Rp43 per lembar dengan volume 11.6x.',
body: 'Bursa Efek Indonesia secara resmi memberlakukan mekanisme penetapan batas harga minimum hingga Rp1. Saham GOTO langsung menyentuh level Rp43 dengan volume perpindahan rekor.'
}
];
const SOCIAL_FEEDS = {
stockbit: [
{ author: 'TraderSantai99', time: '3m ago', ticker: 'GOTO', score: '-98%', text: 'GOTO antrean offer di 43 ada 12 juta lot ngeri bgt... Regulasi batas Rp1 bener2 bikin ritel panic sell massal.', tag: 'BEARISH' },
{ author: 'SahamKayaRaya', time: '12m ago', ticker: 'BBCA', score: '+72%', text: '$BBCA di 6225 udah mulai masuk area akumulasi bandar lokal. Asing buang barang tapi ditampung domestik tebal.', tag: 'BULLISH' },
{ author: 'AnalisObral', time: '25m ago', ticker: 'IHSG', score: '-85%', text: 'IHSG jebol 6150 arah berikutnya ke 6050 support psikologis. Jangan tangkap pisau jatuh selama yield US masih 5.24%!', tag: 'BEARISH' }
],
ajaib: [
{ author: 'InvestorPemula_ID', time: '7m ago', ticker: 'GOTO', score: '-90%', text: 'Portoku merah pekat kena GOTO -14% hari ini 😭 Mau cutloss udah telat antriannya panjang banget.', tag: 'BEARISH' },
{ author: 'CuanMaksimal', time: '19m ago', ticker: 'BBRI', score: '+45%', text: '$BBRI RSI 41 udah masuk area oversold mingguan. Dividen yield 8%+ udah sangat menarik buat long term.', tag: 'BULLISH' }
],
reddit: [
{ author: 'u/MacroAlpha', time: '12m ago', ticker: 'AMD', score: '+84%', text: 'AMD acquiring World Labs for $8.2B is the real AI pivot. If LLMs are plateauing, physical world simulations are where enterprise value flows.', tag: 'BULLISH' },
{ author: 'u/BondsKing', time: '28m ago', ticker: 'US10Y', score: '-92%', text: 'US10Y at 5.24% with RSI 79. Anyone holding high-beta unprofitable growth stocks is going to get slaughtered.', tag: 'BEARISH' }
],
twitter: [
{ author: '@semianalysis', time: '8m ago', ticker: 'NVDA', score: '+75%', text: 'RTX Pro allocations to Alibaba & ByteDance will unlock at least $4.5B in near-term hardware revenue for NVDA.', tag: 'BULLISH' },
{ author: '@fx_macro', time: '22m ago', ticker: 'USDIDR', score: '-80%', text: 'USD/IDR pressing 18,000. Bank Indonesia expected to intervene heavily in DNDF market today.', tag: 'BEARISH' }
]
};
// ─── MODULE STATE WITH SIZES (SPAN & HEIGHT) ───
let moduleConfigs = [
{ id: 'mod_chart', span: 8, height: 380 },
{ id: 'mod_ai', span: 4, height: 380 },
{ id: 'mod_watchlist', span: 4, height: 300 },
{ id: 'mod_news', span: 4, height: 300 },
{ id: 'mod_social', span: 4, height: 300 },
{ id: 'mod_macro', span: 4, height: 260 }
];
let selectedAsset = ASSETS[0];
let showMA = true;
let activeTimeframe = '1M';
let activeNewsFilter = 'ALL';
let activeSocialTab = 'stockbit';
// Load layout with sizes
try {
const saved = localStorage.getItem('zero_terminal_v2_modules');
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) moduleConfigs = parsed;
}
} catch(e) {}
function saveLayout() {
try {
localStorage.setItem('zero_terminal_v2_modules', JSON.stringify(moduleConfigs));
} catch(e) {}
}
function resetDefaultLayout() {
moduleConfigs = [
{ id: 'mod_chart', span: 8, height: 380 },
{ id: 'mod_ai', span: 4, height: 380 },
{ id: 'mod_watchlist', span: 4, height: 300 },
{ id: 'mod_news', span: 4, height: 300 },
{ id: 'mod_social', span: 4, height: 300 },
{ id: 'mod_macro', span: 4, height: 260 }
];
saveLayout();
renderAllModules();
}
// ─── MODULAR RENDERER ───
function renderAllModules() {
const grid = document.getElementById('workspace-grid');
grid.innerHTML = '';
moduleConfigs.forEach((cfg, index) => {
const cat = MODULE_CATALOG.find(m => m.id === cfg.id);
if (!cat) return;
const card = document.createElement('section');
card.className = `mod-card col-span-${cfg.span || 4}`;
card.id = cfg.id;
card.draggable = true;
card.dataset.index = index;
// Card Header with Resizing Controls
card.innerHTML = `
<div class="card-header" onmousedown="enableDrag(event, '${cfg.id}')">
<div class="header-left">
<span class="drag-handle" title="Drag to reorder">⠿</span>
<span>${cat.title}</span>
</div>
<div class="header-actions">
<!-- Span/Width Resizers with Sibling Auto-Reflow -->
<button class="h-btn h-btn-span ${cfg.span === 4 ? 'active' : ''}" data-span="4" title="Width 1/3 (4 col)" onclick="setModuleSpan('${cfg.id}', 4)">⅓</button>
<button class="h-btn h-btn-span ${cfg.span === 6 ? 'active' : ''}" data-span="6" title="Width 1/2 (6 col)" onclick="setModuleSpan('${cfg.id}', 6)">½</button>
<button class="h-btn h-btn-span ${cfg.span === 8 ? 'active' : ''}" data-span="8" title="Width 2/3 (8 col)" onclick="setModuleSpan('${cfg.id}', 8)">⅔</button>
<button class="h-btn h-btn-span ${cfg.span === 12 ? 'active' : ''}" data-span="12" title="Full Width (12 col)" onclick="setModuleSpan('${cfg.id}', 12)">⤢</button>
<span style="color:var(--border-highlight); margin:0 2px;">|</span>
<button class="h-btn" title="Move Up" onclick="moveModule(${index}, -1)">▲</button>
<button class="h-btn" title="Move Down" onclick="moveModule(${index}, 1)">▼</button>
<button class="h-btn danger" title="Remove Module" onclick="removeModule('${cfg.id}')">✕</button>
</div>
</div>
<div class="card-body" id="body-${cfg.id}" style="height: ${cfg.height || cat.defaultHeight}px;">
<!-- Content -->
</div>
<!-- Interactive Right Edge Splitter Handle -->
<div class="card-resize-edge-r" title="Drag to adjust column width with neighbor" onmousedown="initDragResize(event, '${cfg.id}')"></div>
<!-- Interactive Corner Resize Grip (Height Sync) -->
<div class="card-resize-grip" title="Drag corner to resize height (syncs row)" onmousedown="initDragResize(event, '${cfg.id}')"></div>
`;
// Drag and Drop listeners
card.addEventListener('dragstart', handleDragStart);
card.addEventListener('dragover', handleDragOver);
card.addEventListener('dragleave', handleDragLeave);
card.addEventListener('drop', handleDrop);
card.addEventListener('dragend', handleDragEnd);
grid.appendChild(card);
populateWidgetBody(cfg.id);
});
if (moduleConfigs.some(c => c.id === 'mod_chart')) {
setTimeout(() => initChartCanvas(), 50);
}
}
// ─── RESIZE ACTIONS ───
// Kompute "baris" dalam grid 12-kolom: scan urutan modul secara berurutan,
// akumulasi span, setiap kali tembus 12 → mulai baris baru.
function computeRows(configs) {
const rows = [];
let current = [];
let used = 0;
for (const cfg of configs) {
const s = cfg.span || 4;
if (used + s > 12 && current.length > 0) {
rows.push(current);
current = [];
used = 0;
}
current.push(cfg);
used += s;
}
if (current.length > 0) rows.push(current);
return rows;
}
function updateSpanButtons(modId, activeSpan) {
const cardEl = document.getElementById(modId);
if (!cardEl) return;
const btns = cardEl.querySelectorAll('.h-btn-span');
btns.forEach(b => {
const spanVal = parseInt(b.dataset.span, 10);
b.classList.toggle('active', spanVal === activeSpan);
});
}
// Ketika user mengubah span satu modul, auto-adjust sibling satu baris
// agar total kolom tetap 12 (Zero-sum layout).
function setModuleSpan(modId, span) {
const cfg = moduleConfigs.find(c => c.id === modId);
if (!cfg) return;
const rows = computeRows(moduleConfigs);
const rowContaining = rows.find(row => row.some(c => c.id === modId));
if (!rowContaining) { cfg.span = span; saveLayout(); renderAllModules(); return; }
const siblings = rowContaining.filter(c => c.id !== modId);
if (siblings.length === 0) {
cfg.span = 12;
} else {
const newSpan = Math.min(span, 12 - siblings.length * 3);
cfg.span = Math.max(3, newSpan);
const remaining = 12 - cfg.span;
const totalSiblingSpan = siblings.reduce((a, s) => a + (s.span || 4), 0);
let allocated = 0;
siblings.forEach((sib, i) => {
if (i === siblings.length - 1) {
sib.span = Math.max(3, remaining - allocated);
} else {
const share = Math.round(((sib.span || 4) / totalSiblingSpan) * remaining);
sib.span = Math.max(3, share);
allocated += sib.span;
}
});
}
saveLayout();
renderAllModules();
setTimeout(resizeCanvas, 60);
}
// Interactive Drag Resize Logic (Horizontal Zero-Sum Sibling Shift + Vertical Height Row-Sync)
function initDragResize(e, modId) {
e.preventDefault();
e.stopPropagation();
const rows = computeRows(moduleConfigs);
const rowContaining = rows.find(r => r.some(c => c.id === modId));
const cardEl = document.getElementById(modId);
const bodyEl = document.getElementById(`body-${modId}`);
const gridEl = document.getElementById('workspace-grid');
if (!cardEl || !bodyEl || !gridEl) return;
const startX = e.clientX;
const startY = e.clientY;
const startH = bodyEl.clientHeight;
const myIndexInRow = rowContaining ? rowContaining.findIndex(c => c.id === modId) : -1;
const rightNeighbor = (rowContaining && myIndexInRow !== -1 && myIndexInRow < rowContaining.length - 1)
? rowContaining[myIndexInRow + 1]
: null;
const leftNeighbor = (rowContaining && myIndexInRow > 0)
? rowContaining[myIndexInRow - 1]
: null;
const targetNeighbor = rightNeighbor || leftNeighbor;
const isRight = !!rightNeighbor;
const initialMySpan = (moduleConfigs.find(c => c.id === modId) || {}).span || 4;
const initialNeighborSpan = targetNeighbor ? (targetNeighbor.span || 4) : 0;
const combinedSpan = initialMySpan + initialNeighborSpan;
const colWidthPx = gridEl.clientWidth / 12;
function onMouseMove(ev) {
// 1. Vertical Height: Sync row siblings so row stays neat
const deltaY = ev.clientY - startY;
const newH = Math.max(140, Math.min(800, startH + deltaY));
if (rowContaining) {
rowContaining.forEach(m => {
const b = document.getElementById(`body-${m.id}`);
if (b) b.style.height = newH + 'px';
});
} else {
bodyEl.style.height = newH + 'px';
}
// 2. Horizontal Span: Zero-sum redistribution between neighbors
if (targetNeighbor && colWidthPx > 10) {
const deltaX = ev.clientX - startX;
const colShift = Math.round(deltaX / colWidthPx);
let newMySpan, newNeighborSpan;
if (isRight) {
newMySpan = Math.max(3, Math.min(combinedSpan - 3, initialMySpan + colShift));
newNeighborSpan = combinedSpan - newMySpan;
} else {
newMySpan = Math.max(3, Math.min(combinedSpan - 3, initialMySpan - colShift));
newNeighborSpan = combinedSpan - newMySpan;
}
const myCfg = moduleConfigs.find(c => c.id === modId);
const neighCfg = moduleConfigs.find(c => c.id === targetNeighbor.id);
if (myCfg && neighCfg && (myCfg.span !== newMySpan)) {
myCfg.span = newMySpan;
neighCfg.span = newNeighborSpan;
cardEl.className = cardEl.className.replace(/col-span-\d+/, `col-span-${newMySpan}`);
const neighEl = document.getElementById(targetNeighbor.id);
if (neighEl) neighEl.className = neighEl.className.replace(/col-span-\d+/, `col-span-${newNeighborSpan}`);
updateSpanButtons(modId, newMySpan);
updateSpanButtons(targetNeighbor.id, newNeighborSpan);
}
}
if (rowContaining && rowContaining.some(m => m.id === 'mod_chart')) {
resizeCanvas();
}
}
function onMouseUp(ev) {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
const newHeight = bodyEl.clientHeight;
if (rowContaining) {
rowContaining.forEach(m => {
const cfg = moduleConfigs.find(c => c.id === m.id);
if (cfg) cfg.height = newHeight;
});
}
saveLayout();
renderAllModules();
setTimeout(resizeCanvas, 60);
}
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
// ─── WIDGET POPULATION ───
function populateWidgetBody(modId) {
const body = document.getElementById(`body-${modId}`);
if (!body) return;
if (modId === 'mod_chart') {
body.style.padding = '0';
body.innerHTML = `
<div class="chart-container">
<div class="chart-meta">
<div>
<span class="big-price" id="curr-price">${selectedAsset.price.toLocaleString()} ${selectedAsset.curr}</span>
<span class="price-change ${selectedAsset.chg >= 0 ? 'pos' : 'neg'}" id="curr-change">${selectedAsset.chg >= 0 ? '+' : ''}${selectedAsset.chg.toFixed(2)}%</span>
<span style="font-size: 10px; color: var(--text-dim); margin-left: 8px;" id="curr-status">${selectedAsset.status}</span>
</div>
<div class="timeframe-bar">
<button class="tf-btn ${activeTimeframe==='1D'?'active':''}" onclick="setTimeframe('1D')">1D</button>
<button class="tf-btn ${activeTimeframe==='1M'?'active':''}" onclick="setTimeframe('1M')">1M</button>
<button class="tf-btn ${activeTimeframe==='1Y'?'active':''}" onclick="setTimeframe('1Y')">1Y</button>
<button class="tf-btn" onclick="toggleMA()">MA</button>
</div>
</div>
<div class="canvas-wrapper">
<canvas class="canvas-chart" id="candlestick-chart"></canvas>
<div id="chart-crosshair-info"></div>
</div>
</div>
`;
} else if (modId === 'mod_ai') {
const s = selectedAsset.sentiment;
body.innerHTML = `
<button class="ai-run-btn" onclick="triggerAiBrief()">
<span>⚡ GENERATE DEEP THESIS</span>
<span style="font-size: 9px; opacity: 0.7;">[F9]</span>
</button>
<div class="sentiment-meter">
<div style="display:flex; justify-content:space-between; font-weight:600; font-size:10.5px;">
<span>SENTIMENT GAUGE</span>
<span id="sentiment-score-badge" style="color: ${s.pos > 50 ? 'var(--green)' : s.neg > 50 ? 'var(--red)' : 'var(--amber)'};">${s.label}</span>
</div>
<div class="meter-bar">
<div class="meter-pos" id="meter-bar-pos" style="width: ${s.pos}%;"></div>
<div class="meter-neu" id="meter-bar-neu" style="width: ${s.neu}%;"></div>
<div class="meter-neg" id="meter-bar-neg" style="width: ${s.neg}%;"></div>
</div>
<div class="meter-legend" id="meter-legend-text">
<span>BULLISH ${s.pos}%</span>
<span>NEUTRAL ${s.neu}%</span>
<span>BEARISH ${s.neg}%</span>
</div>
</div>
<div id="ai-briefing-container">
${selectedAsset.thesis.map(t => `
<div class="ai-briefing-box">
<span class="ai-tag">${t.tag}</span><br>${t.text}
</div>
`).join('')}
</div>
`;
} else if (modId === 'mod_watchlist') {
body.style.padding = '0';
body.innerHTML = `
<table class="market-table">
<thead>
<tr>
<th>SYM</th>
<th style="text-align: right;">LAST</th>
<th style="text-align: right;">CHG%</th>
<th style="text-align: right;">RSI</th>
</tr>
</thead>
<tbody id="watchlist-body">
${ASSETS.map(a => `
<tr id="row-${a.id}" class="${a.id === selectedAsset.id ? 'selected' : ''}" onclick="selectAsset('${a.id}')">
<td><strong>${a.sym}</strong><br><small style="color:var(--text-dim)">${a.name}</small></td>
<td style="text-align: right; color:#fff; font-weight:600;">${a.price.toLocaleString()}</td>
<td style="text-align: right;" class="${a.chg >= 0 ? 'pos' : 'neg'}">${a.chg >= 0 ? '+' : ''}${a.chg.toFixed(2)}%</td>
<td style="text-align: right; color:${a.rsi > 70 ? 'var(--red)' : a.rsi < 35 ? 'var(--blue)' : 'var(--text-muted)'};">${a.rsi}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} else if (modId === 'mod_news') {
body.innerHTML = `
<div class="news-filters">
<span class="nfilter-chip ${activeNewsFilter==='ALL'?'active':''}" onclick="filterNews('ALL')">ALL</span>
<span class="nfilter-chip ${activeNewsFilter==='AI'?'active':''}" onclick="filterNews('AI')">AI & TECH</span>
<span class="nfilter-chip ${activeNewsFilter==='MARKET'?'active':''}" onclick="filterNews('MARKET')">MARKETS</span>
<span class="nfilter-chip ${activeNewsFilter==='CHINA'?'active':''}" onclick="filterNews('CHINA')">CHINA</span>
</div>
<div id="news-wire-container">
${renderNewsItemsHtml(activeNewsFilter)}
</div>
`;
} else if (modId === 'mod_social') {
body.innerHTML = `
<div class="social-tabs">
<div class="stab ${activeSocialTab==='stockbit'?'active':''}" onclick="switchSocialTab('stockbit')">Stockbit</div>
<div class="stab ${activeSocialTab==='ajaib'?'active':''}" onclick="switchSocialTab('ajaib')">Ajaib</div>
<div class="stab ${activeSocialTab==='reddit'?'active':''}" onclick="switchSocialTab('reddit')">r/stocks</div>
<div class="stab ${activeSocialTab==='twitter'?'active':''}" onclick="switchSocialTab('twitter')">X / FinTwit</div>
</div>
<div id="social-feed-container" style="padding-top: 6px;">
${renderSocialItemsHtml(activeSocialTab)}
</div>
`;
} else if (modId === 'mod_macro') {
body.innerHTML = `
<div class="macro-grid">
<div class="macro-tile">
<div class="macro-tile-label">US 10Y TREASURY</div>
<div class="macro-tile-val" style="color:var(--red);">5.24%</div>
<div class="macro-tile-sub"><span>RSI: 79 (OVERBOUGHT)</span><span class="pos">+1.1%</span></div>
</div>
<div class="macro-tile">
<div class="macro-tile-label">USD / IDR SPOT</div>
<div class="macro-tile-val">Rp 17,960</div>
<div class="macro-tile-sub"><span style="color:var(--amber);">BI INTERVENTION</span><span class="neg">+0.1%</span></div>
</div>
<div class="macro-tile">
<div class="macro-tile-label">WTI CRUDE OIL</div>
<div class="macro-tile-val">$93.17</div>
<div class="macro-tile-sub"><span>HORMUZ TENSION</span><span class="pos">+2.4%</span></div>
</div>
<div class="macro-tile">
<div class="macro-tile-label">GOLD SPOT (XAU)</div>
<div class="macro-tile-val">$4,160</div>
<div class="macro-tile-sub"><span>LIQUIDATION SQUEEZE</span><span class="neg">-3.7%</span></div>
</div>
</div>
`;
}
}
// ─── REORDER & REMOVE/ADD MODULE CONTROLLERS ───
function moveModule(index, delta) {
const target = index + delta;
if (target < 0 || target >= moduleConfigs.length) return;
const temp = moduleConfigs[index];
moduleConfigs[index] = moduleConfigs[target];
moduleConfigs[target] = temp;
saveLayout();
renderAllModules();
}
function removeModule(modId) {
moduleConfigs = moduleConfigs.filter(c => c.id !== modId);
saveLayout();
renderAllModules();
}
function openAddModuleModal() {
const container = document.getElementById('catalog-container');
container.innerHTML = '';
MODULE_CATALOG.forEach(m => {
const isPresent = moduleConfigs.some(c => c.id === m.id);
const div = document.createElement('div');
div.className = 'catalog-item';
div.innerHTML = `
<div>
<div class="cat-title">${m.title}</div>
<div class="cat-desc">${m.desc}</div>
</div>
${isPresent ? `
<span class="cat-btn active-badge">✓ ACTIVE ON DESK</span>
` : `
<button class="cat-btn" onclick="addModule('${m.id}')">+ ADD TO DASHBOARD</button>
`}
`;
container.appendChild(div);
});
document.getElementById('add-module-modal').style.display = 'flex';
}
function addModule(modId) {
if (!moduleConfigs.some(c => c.id === modId)) {
const cat = MODULE_CATALOG.find(m => m.id === modId);
const spanVal = cat.id === 'mod_chart' ? 8 : 4;
moduleConfigs.push({ id: modId, span: spanVal, height: cat.defaultHeight });
saveLayout();
renderAllModules();
}
closeModal('add-module-modal');
}
// ─── HTML5 DRAG & DROP ───
let draggedCardId = null;
function enableDrag(e, modId) {}
function handleDragStart(e) {
draggedCardId = this.id;
this.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', this.id);
}
function handleDragOver(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
this.classList.add('drag-over');
}
function handleDragLeave(e) {
this.classList.remove('drag-over');
}
function handleDrop(e) {
e.preventDefault();
this.classList.remove('drag-over');
const targetCardId = this.id;
if (draggedCardId && draggedCardId !== targetCardId) {
const fromIdx = moduleConfigs.findIndex(c => c.id === draggedCardId);
const toIdx = moduleConfigs.findIndex(c => c.id === targetCardId);
if (fromIdx !== -1 && toIdx !== -1) {
const item = moduleConfigs.splice(fromIdx, 1)[0];
moduleConfigs.splice(toIdx, 0, item);
saveLayout();
renderAllModules();
}
}
}
function handleDragEnd(e) {
this.classList.remove('dragging');
document.querySelectorAll('.mod-card').forEach(c => c.classList.remove('drag-over'));
}
// ─── ASSET SELECTION ───
function selectAsset(assetId) {
const asset = ASSETS.find(a => a.id === assetId);
if (!asset) return;
selectedAsset = asset;
document.querySelectorAll('#watchlist-body tr').forEach(r => r.classList.remove('selected'));
const targetRow = document.getElementById('row-' + asset.id);
if (targetRow) targetRow.classList.add('selected');
const priceEl = document.getElementById('curr-price');
if (priceEl) {
priceEl.innerText = `${asset.price.toLocaleString()} ${asset.curr}`;
const chgEl = document.getElementById('curr-change');
chgEl.className = 'price-change ' + (asset.chg >= 0 ? 'pos' : 'neg');
chgEl.innerText = (asset.chg >= 0 ? '+' : '') + asset.chg.toFixed(2) + '%';
document.getElementById('curr-status').innerText = asset.status;
drawChart();
}
const sBadge = document.getElementById('sentiment-score-badge');
if (sBadge) {
const s = asset.sentiment;
sBadge.innerText = s.label;
sBadge.style.color = s.pos > 50 ? 'var(--green)' : s.neg > 50 ? 'var(--red)' : 'var(--amber)';
document.getElementById('meter-bar-pos').style.width = s.pos + '%';
document.getElementById('meter-bar-neu').style.width = s.neu + '%';
document.getElementById('meter-bar-neg').style.width = s.neg + '%';
document.getElementById('meter-legend-text').innerHTML = `
<span>BULLISH ${s.pos}%</span>
<span>NEUTRAL ${s.neu}%</span>
<span>BEARISH ${s.neg}%</span>
`;
document.getElementById('ai-briefing-container').innerHTML = asset.thesis.map(t => `
<div class="ai-briefing-box">
<span class="ai-tag">${t.tag}</span><br>${t.text}
</div>
`).join('');
}
}
function selectBySymbol(sym) {
selectAsset(sym);
}
// ─── CANVAS CANDLESTICK ENGINE ───
let canvas = null;
let ctx = null;
let candleData = [];
function initChartCanvas() {
canvas = document.getElementById('candlestick-chart');
if (!canvas) return;
ctx = canvas.getContext('2d');
resizeCanvas();
setupCanvasEvents();
}
function resizeCanvas() {
if (!canvas || !canvas.parentElement) return;
canvas.width = canvas.parentElement.clientWidth;
canvas.height = canvas.parentElement.clientHeight;
drawChart();
}
function setTimeframe(tf) {
activeTimeframe = tf;
document.querySelectorAll('.tf-btn').forEach(b => {
if (['1D','1M','1Y'].includes(b.innerText)) b.classList.toggle('active', b.innerText === tf);
});
drawChart();
}
function toggleMA() {
showMA = !showMA;
drawChart();
}
function generateCandles(count, baseVal, trend) {
const candles = [];
let price = baseVal;
for (let i = 0; i < count; i++) {
const volatility = baseVal * 0.015;
const change = (Math.random() - 0.5 + trend) * volatility;
const open = price;
const close = price + change;
const high = Math.max(open, close) + Math.random() * volatility * 0.8;
const low = Math.min(open, close) - Math.random() * volatility * 0.8;
candles.push({ open, close, high, low });
price = close;
}
return candles;
}
function drawChart() {
if (!canvas || !ctx || !canvas.width || !canvas.height) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
const candleCount = activeTimeframe === '1D' ? 22 : activeTimeframe === '1M' ? 34 : 48;
const trendFactor = selectedAsset.chg >= 0 ? 0.06 : -0.08;
candleData = generateCandles(candleCount, selectedAsset.price, trendFactor);
let minPrice = Infinity, maxPrice = -Infinity;
candleData.forEach(c => {
if (c.low < minPrice) minPrice = c.low;
if (c.high > maxPrice) maxPrice = c.high;
});
const padding = (maxPrice - minPrice) * 0.1 || 1;
minPrice -= padding;
maxPrice += padding;
ctx.strokeStyle = '#1b1f28';
ctx.lineWidth = 1;
ctx.fillStyle = '#4c566a';
ctx.font = '9.5px JetBrains Mono';
const gridLinesY = 5;
for (let i = 0; i <= gridLinesY; i++) {
const y = 15 + (i / gridLinesY) * (canvas.height - 35);
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width - 55, y); ctx.stroke();
const priceVal = maxPrice - (i / gridLinesY) * (maxPrice - minPrice);
ctx.fillText(priceVal.toFixed(selectedAsset.price < 10 ? 2 : 0), canvas.width - 50, y + 3);
}
const chartW = canvas.width - 60;
const chartH = canvas.height - 40;
const candleWidth = chartW / candleCount;
function getY(val) {
return 15 + (1 - (val - minPrice) / (maxPrice - minPrice)) * chartH;
}
candleData.forEach((c, i) => {
const x = 8 + i * candleWidth;
const isUp = c.close >= c.open;
const color = isUp ? '#00e676' : '#ff3d00';
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.beginPath();
ctx.moveTo(x + candleWidth * 0.45, getY(c.high));
ctx.lineTo(x + candleWidth * 0.45, getY(c.low));
ctx.stroke();
const yOpen = getY(c.open);
const yClose = getY(c.close);
const bodyY = Math.min(yOpen, yClose);
const bodyH = Math.max(Math.abs(yClose - yOpen), 2);
ctx.fillRect(x + candleWidth * 0.1, bodyY, candleWidth * 0.7, bodyH);
});
if (showMA && candleData.length > 5) {
ctx.strokeStyle = '#ff9800';
ctx.lineWidth = 1.6;
ctx.beginPath();
candleData.forEach((c, i) => {
const x = 8 + i * candleWidth + candleWidth * 0.45;
const avg = (c.open + c.close) / 2;
const y = getY(avg) + Math.sin(i * 0.4) * 4;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
});
ctx.stroke();
}
}
function setupCanvasEvents() {
const crossInfo = document.getElementById('chart-crosshair-info');
if (!crossInfo) return;
canvas.addEventListener('mousemove', e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (mx > 0 && mx < canvas.width - 55 && my > 10 && my < canvas.height - 25) {
crossInfo.style.display = 'block';
const candleCount = candleData.length;
const candleWidth = (canvas.width - 60) / candleCount;
const idx = Math.min(Math.max(Math.floor((mx - 8) / candleWidth), 0), candleCount - 1);
const candle = candleData[idx];
if (candle) {
crossInfo.innerHTML = `O:${candle.open.toFixed(1)} H:${candle.high.toFixed(1)} L:${candle.low.toFixed(1)} C:${candle.close.toFixed(1)}`;
}
} else {
crossInfo.style.display = 'none';
}
});
canvas.addEventListener('mouseleave', () => {
crossInfo.style.display = 'none';
});
}
// ─── NEWS WIRE RENDERER ───
function renderNewsItemsHtml(filter) {
const filtered = filter === 'ALL' ? NEWS : NEWS.filter(n => n.cat === filter);
return filtered.map((n, idx) => {
const tagClass = n.tag === 'BULLISH' ? 'tag-bullish' : n.tag === 'BEARISH' ? 'tag-bearish' : 'tag-neutral';
return `
<div class="news-item" onclick="showNewsModal(${idx})">
<div class="news-meta">
<span class="news-tag ${tagClass}">${n.tag}</span>
<span>${n.source}</span>
<span>${n.time}</span>
</div>
<div class="news-headline">${n.title}</div>
</div>
`;
}).join('');
}
function filterNews(cat) {
activeNewsFilter = cat;
document.querySelectorAll('.nfilter-chip').forEach(c => {
c.classList.toggle('active', c.innerText === (cat === 'ALL' ? 'ALL' : cat === 'AI' ? 'AI & TECH' : cat === 'MARKET' ? 'MARKETS' : 'CHINA'));
});
const c = document.getElementById('news-wire-container');
if (c) c.innerHTML = renderNewsItemsHtml(cat);
}
function showNewsModal(idx) {
const article = NEWS[idx];
if (!article) return;
document.getElementById('detail-modal-title').innerText = `ARTICLE // ${article.source} (${article.time})`;
document.getElementById('detail-modal-content').innerHTML = `
<div style="margin-bottom:10px;">
<span class="news-tag ${article.tag === 'BULLISH' ? 'tag-bullish' : 'tag-bearish'}" style="margin-right:6px;">${article.tag}</span>
<strong style="font-size:13px; color:#fff;">${article.title}</strong>
</div>
<p style="margin-bottom:12px; color:#cfd8dc;">${article.body}</p>
<div style="background:#161922; border:1px solid var(--border); padding:8px; border-radius:3px; font-family:var(--font-mono); font-size:10.5px;">
<div style="color:var(--amber); margin-bottom:4px; font-weight:700;">EXTRACTION PIPELINE:</div>
<div>· Polarity: <strong style="color:${article.tag==='BULLISH'?'var(--green)':'var(--red)'}">${article.tag}</strong></div>
<div>· Target Category: ${article.cat}</div>
<div>· Scrape Status: Cleaned via Elysia Feed Ingestion Worker</div>
</div>
`;
document.getElementById('detail-modal').style.display = 'flex';
}
// ─── SOCIAL RADAR RENDERER ───
function renderSocialItemsHtml(tab) {
const posts = SOCIAL_FEEDS[tab] || [];
return posts.map(p => {
const scoreColor = p.score.startsWith('+') ? 'var(--green)' : 'var(--red)';
return `
<div class="social-post" onclick="showSocialModal('${p.author}', '${p.score}', '${p.text}', '${p.ticker || tab.toUpperCase()}')">
<div class="social-post-meta">
<span><span class="source-pill">${p.ticker || tab.toUpperCase()}</span> ${p.author} · ${p.time}</span>
<span style="color:${scoreColor}; font-weight:700;">${p.score}</span>
</div>
<div>"${p.text}"</div>
</div>
`;
}).join('');
}
function switchSocialTab(tab) {
activeSocialTab = tab;
document.querySelectorAll('.stab').forEach(t => t.classList.toggle('active', t.innerText.toLowerCase().includes(tab)));
const c = document.getElementById('social-feed-container');
if (c) c.innerHTML = renderSocialItemsHtml(tab);
}
function showSocialModal(author, score, text, ticker) {
document.getElementById('detail-modal-title').innerText = `COMMUNITY SIGNAL // ${author}`;
document.getElementById('detail-modal-content').innerHTML = `
<div style="font-size:12.5px; line-height:1.6; margin-bottom:12px;">"${text}"</div>
<div style="background:#161922; border:1px solid var(--border); padding:10px; border-radius:3px; font-family:var(--font-mono); font-size:11px;">
<div style="color:var(--amber); margin-bottom:4px; font-weight:700;">COMMUNITY SENTIMENT METRICS:</div>
<div>· Sentiment: <strong style="color:${score.startsWith('+')?'var(--green)':'var(--red)'}">${score}</strong></div>
<div>· Target Ticker: ${ticker}</div>
<div>· Source: Realtime Scraped Feed (Stockbit / Ajaib / Reddit / X)</div>
</div>
`;
document.getElementById('detail-modal').style.display = 'flex';
}
// ─── COMMAND BAR & SHORTCUTS ───
function setupCommandBar() {
const input = document.getElementById('cmd-input');
input.addEventListener('keydown', e => {
if (e.key === 'Enter') {
const val = input.value.trim().toUpperCase();
input.value = '';
executeCommand(val);
}
});
window.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); selectAsset('IHSG'); }
if (e.key === 'F2') { e.preventDefault(); selectAsset('NVDA'); }
if (e.key === 'F3') { e.preventDefault(); selectAsset('GOTO'); }
if (e.key === 'F9') { e.preventDefault(); triggerAiBrief(); }
if (e.key === '/') {
if (document.activeElement !== input) {
e.preventDefault();
input.focus();
}
}
if (e.key === 'Escape') {
closeModal('add-module-modal');
closeModal('detail-modal');
input.blur();
}
});
}
function executeCommand(cmd) {
if (!cmd) return;
const match = ASSETS.find(a => a.id === cmd || a.sym === cmd || a.name.toUpperCase().includes(cmd));
if (match) {
selectAsset(match.id);
return;
}
if (cmd === 'AI' || cmd === 'RUN AI') {
triggerAiBrief();
} else if (cmd === 'ADD' || cmd === 'MODULE') {
openAddModuleModal();
} else if (cmd === 'RESET') {
resetDefaultLayout();
} else if (cmd === 'AUDIT' || cmd === 'JOURNAL') {
showAuditModal();
} else if (cmd === 'STOCKBIT') {
switchSocialTab('stockbit');
} else if (cmd === 'AJAIB') {
switchSocialTab('ajaib');
} else if (cmd === 'HELP' || cmd === '?') {
showHelpModal();
} else {
alert(`[ZERO TERMINAL] Unknown command '${cmd}'. Type 'HELP' or select ticker from watchlist.`);
}
}
function triggerAiBrief() {
document.getElementById('detail-modal-title').innerText = `9ROUTER AI SYNTHESIS // GEMINI-3.8-THINK`;
document.getElementById('detail-modal-content').innerHTML = `
<div style="font-family:var(--font-mono); font-size:11px; line-height:1.7;">
<div style="color:var(--green); font-weight:700; margin-bottom:8px;">[CONNECTING TO 9ROUTER REASONING ENGINE]</div>
<div style="color:var(--text-muted); margin-bottom:10px;">Synthesizing scraped Stockbit streams, Ajaib forums, 148 RSS feeds, and US macro bond rates...</div>
<div style="background:#161a24; border-left:3px solid var(--amber); padding:8px; margin-bottom:8px;">
<strong style="color:var(--amber);">1. TARGET FOCUS: ${selectedAsset.name} (${selectedAsset.sym})</strong><br>
Status: ${selectedAsset.status}. Indikator RSI ${selectedAsset.rsi} mengindikasikan tekanan jual agresif dengan potensi akumulasi bertahap di area diskon.
</div>
<div style="background:#161a24; border-left:3px solid var(--blue); padding:8px; margin-bottom:8px;">
<strong style="color:var(--blue);">2. RETAIL & REGULATORY IMPACT</strong><br>
Aturan batas auto-rejection Rp1 memicu efek domino kepanikan ritel di platform Stockbit & Ajaib, sementara pasar saham AS tetap didominasi rotasi sektor infrastruktur AI.
</div>
<div style="background:#161a24; border-left:3px solid var(--purple); padding:8px;">
<strong style="color:var(--purple);">3. STRATEGIC POSITIONING</strong><br>
Prioritaskan instrumen kas likuid & obligasi berdenominasi kuat sampai yield US10Y 5.24% mulai melandai.
</div>
</div>
`;
document.getElementById('detail-modal').style.display = 'flex';
}
function showAuditModal() {
document.getElementById('detail-modal-title').innerText = `UI/UX EVALUATION & ACADEMIC HCI CITATIONS`;
document.getElementById('detail-modal-content').innerHTML = `
<div style="line-height:1.65; font-size:11.5px;">
<div style="color:var(--amber); font-weight:700; margin-bottom:6px; font-size:12px;">EVALUASI UI/UX TERMINAL INTELIJEN FINANSIAL:</div>
<p style="margin-bottom:10px;">
<strong>1. Endsley's Situation Awareness (SA) Model (1995):</strong><br>
<em>Level 1 (Perception):</em> Ticker tape atas dan watchlist memberikan persepsi cepat status harga tanpa distraksi visual berlebih.<br>
<em>Level 2 (Comprehension):</em> Widget AI Synthesis & Community Sentiment mengorelasikan peristiwa makro (Yield US10Y 5.24%) dengan sentimen ritel lokal (GOTO di Stockbit).<br>
<em>Level 3 (Projection):</em> Rekomendasi taktis 9router memberikan proyeksi risiko likuiditas ke depan.
</p>
<p style="margin-bottom:10px;">
<strong>2. Tiled vs. Overlapping Workspaces (Bly & Rosenberg, ACM CHI 1986):</strong><br>
Studi Xerox PARC membuktikan bahwa analis profesional bekerja 34% lebih cepat pada <em>tiled modular layout</em> dibanding overlapping floating windows karena memangkas waktu <em>window management</em>. Adanya tombol quick span (⅓, ½, ⅔, ⤢) dan corner drag grip memberikan fleksibilitas grid tanpa risiko window saling tumpuk.
</p>
<p style="margin-bottom:10px;">
<strong>3. Shneiderman's Information Seeking Mantra (1996):</strong><br>
<em>"Overview first, zoom and filter, details-on-demand."</em><br>
Implementasi di Zero Terminal: Overview via Watchlist & Ticker -> Filter via Timeframe & Category Buttons -> Details on Demand via Modal Drawer saat kartu/berita diklik.
</p>
<p>
<strong>4. Cognitive Load & Mobile Attention Bursts (Oulasvirta et al., 2005):</strong><br>
Pada layar ponsel (390px), model multi-tile diubah menjadi linear vertical stack dengan touch target 44px (Fitts's Law) agar tidak terjadi visual fragmentation saat pengguna memantau pasar secara mobile.
</p>
</div>
`;
document.getElementById('detail-modal').style.display = 'flex';
}
function showHelpModal() {
document.getElementById('detail-modal-title').innerText = `ZERO TERMINAL // COMMAND CHEAT SHEET`;
document.getElementById('detail-modal-content').innerHTML = `
<div style="font-family:var(--font-mono); font-size:11px; line-height:1.7;">
<div style="color:var(--amber); font-weight:700; margin-bottom:6px;">MODULAR WORKSPACE SHORTCUTS:</div>
<div>· <strong>Resize Span (⅓ | ½ | ⅔ | ⤢)</strong>: Klik tombol ukuran di header kartu untuk ubah lebar modul.</div>
<div>· <strong>Corner Grip (◢)</strong>: Tarik pojok kanan bawah modul untuk atur tinggi bebas.</div>
<div>· <strong>Drag Header (⠿)</strong>: Tarik header modul untuk geser urutan kartu.</div>
<div>· <strong>Tombol ▲ / ▼</strong>: Pindah kartu naik atau turun (mudah di mobile).</div>
<div>· <strong>Tombol ✕</strong>: Hapus modul dari dashboard.</div>
<div>· <strong>+ ADD MODULE</strong>: Pasang kembali modul dari katalog widget.</div>
<div>· <strong>↺ RESET</strong>: Kembalikan ukuran & susunan bawaan terminal.</div>
</div>
`;
document.getElementById('detail-modal').style.display = 'flex';
}
function closeModal(modalId) {
const el = document.getElementById(modalId);
if (el) el.style.display = 'none';
}
// ─── STARTUP ───
window.addEventListener('DOMContentLoaded', () => {
renderAllModules();
setupCommandBar();
window.addEventListener('resize', () => {
if (moduleConfigs.some(c => c.id === 'mod_chart')) resizeCanvas();
});
});
</script>
</body>
</html>
Scan with iPhone Camera or Android QR scanner to preview natively on your device.