Add DOS MQTT demo
Browse files
README.md
CHANGED
|
@@ -1,10 +1,23 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: DOS MQTT LAB
|
| 3 |
+
emoji: 🖥️
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# DOS MQTT LAB
|
| 11 |
+
|
| 12 |
+
A static, browser-based MQTT demo with a DOS 6.22-inspired two-terminal interface.
|
| 13 |
+
|
| 14 |
+
- **A (background listener):** subscribes to the shared MQTT topic; after each received message it waits 1 second and prints an echo.
|
| 15 |
+
- **B (foreground sender):** publishes a timestamped message immediately on connection, then every 5 seconds.
|
| 16 |
+
- MQTT.js connects over secure WebSocket to the public HiveMQ test broker by default. No account or credentials are required.
|
| 17 |
+
- The page creates a random topic for each visit. To test between devices or browser tabs, copy the topic and use the same value in both.
|
| 18 |
+
|
| 19 |
+
The DOS panels are browser-rendered terminal windows; MQTT networking and the five-second scheduler run in JavaScript because the browser provides the public WebSocket connection. This is a DOS-style demo, not a DOSBox/js-dos virtual machine or native DOS executable.
|
| 20 |
+
|
| 21 |
+
## Static hosting
|
| 22 |
+
|
| 23 |
+
Serve this directory as static files. `index.html` loads MQTT.js from unpkg and the Google Fonts stylesheet; use a network connection for those assets and the public MQTT broker.
|
app.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
(() => {
|
| 2 |
+
const $ = (id) => document.getElementById(id);
|
| 3 |
+
const brokerInput = $('broker');
|
| 4 |
+
const topicInput = $('topic');
|
| 5 |
+
const connectButton = $('connectButton');
|
| 6 |
+
const copyButton = $('copyButton');
|
| 7 |
+
const aLog = $('aLog');
|
| 8 |
+
const bLog = $('bLog');
|
| 9 |
+
const session = Math.random().toString(36).slice(2, 9);
|
| 10 |
+
topicInput.value = `dosmqtt/demo/${session}`;
|
| 11 |
+
|
| 12 |
+
let client;
|
| 13 |
+
let timer;
|
| 14 |
+
let sent = 0;
|
| 15 |
+
let received = 0;
|
| 16 |
+
let toastTimer;
|
| 17 |
+
|
| 18 |
+
function time() {
|
| 19 |
+
return new Date().toLocaleTimeString('zh-TW', { hour12: false });
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
$('clock').textContent = time();
|
| 23 |
+
window.setInterval(() => { $('clock').textContent = time(); }, 1000);
|
| 24 |
+
|
| 25 |
+
function line(target, text, kind = '') {
|
| 26 |
+
const row = document.createElement('div');
|
| 27 |
+
row.className = `screen-line ${kind}`;
|
| 28 |
+
row.textContent = text;
|
| 29 |
+
target.append(row);
|
| 30 |
+
while (target.children.length > 100) target.firstElementChild.remove();
|
| 31 |
+
target.scrollTop = target.scrollHeight;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function setStatus(connected, label = connected ? 'CONNECTED' : 'DISCONNECTED') {
|
| 35 |
+
$('connectionText').textContent = label;
|
| 36 |
+
$('liveDot').classList.toggle('on', connected);
|
| 37 |
+
$('aState').textContent = connected ? 'LISTENING' : 'STOPPED';
|
| 38 |
+
$('bState').textContent = connected ? 'RUNNING' : 'STOPPED';
|
| 39 |
+
$('aState').classList.toggle('on', connected);
|
| 40 |
+
$('bState').classList.toggle('on', connected);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function closeClient() {
|
| 44 |
+
window.clearInterval(timer);
|
| 45 |
+
timer = undefined;
|
| 46 |
+
if (client) {
|
| 47 |
+
client.removeAllListeners();
|
| 48 |
+
client.end(true);
|
| 49 |
+
client = undefined;
|
| 50 |
+
}
|
| 51 |
+
setStatus(false);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function publish() {
|
| 55 |
+
if (!client?.connected) return;
|
| 56 |
+
const payload = JSON.stringify({ from: 'B', sentAt: new Date().toISOString(), text: `Hello from DOS B at ${time()}` });
|
| 57 |
+
client.publish(topicInput.value.trim(), payload, { qos: 0, retain: false }, (error) => {
|
| 58 |
+
if (error) {
|
| 59 |
+
line(bLog, `[${time()}] PUBLISH ERROR: ${error.message}`, 'error');
|
| 60 |
+
return;
|
| 61 |
+
}
|
| 62 |
+
sent += 1;
|
| 63 |
+
$('sentCount').textContent = String(sent).padStart(3, '0');
|
| 64 |
+
line(bLog, `[${time()}] TX #${String(sent).padStart(3, '0')} ${payload}`, 'message');
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function connect() {
|
| 69 |
+
const broker = brokerInput.value.trim();
|
| 70 |
+
const topic = topicInput.value.trim();
|
| 71 |
+
if (!window.mqtt) {
|
| 72 |
+
line(aLog, `[${time()}] MQTT client library failed to load. Check your connection and reload.`, 'error');
|
| 73 |
+
return;
|
| 74 |
+
}
|
| 75 |
+
if (!/^wss:\/\//i.test(broker) || !topic || topic.includes('#') || topic.includes('+')) {
|
| 76 |
+
line(aLog, `[${time()}] Enter a valid WSS broker and a single MQTT topic.`, 'error');
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
closeClient();
|
| 80 |
+
connectButton.disabled = true;
|
| 81 |
+
connectButton.innerHTML = '<span class="button-icon">◌</span>正在連線…';
|
| 82 |
+
line(aLog, `[${time()}] Connecting ${broker}`, 'system');
|
| 83 |
+
line(bLog, `[${time()}] Connecting ${broker}`, 'system');
|
| 84 |
+
client = window.mqtt.connect(broker, {
|
| 85 |
+
clientId: `dosmqtt_${session}_${Math.random().toString(16).slice(2, 8)}`,
|
| 86 |
+
clean: true,
|
| 87 |
+
connectTimeout: 10000,
|
| 88 |
+
reconnectPeriod: 3000,
|
| 89 |
+
keepalive: 30,
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
client.on('connect', () => {
|
| 93 |
+
setStatus(true);
|
| 94 |
+
connectButton.disabled = false;
|
| 95 |
+
connectButton.innerHTML = '<span class="button-icon">■</span>中斷連線';
|
| 96 |
+
line(aLog, `[${time()}] Connected. SUB ${topic}`, 'system');
|
| 97 |
+
line(bLog, `[${time()}] Connected. B.EXE starts (5 sec interval).`, 'system');
|
| 98 |
+
client.subscribe(topic, { qos: 0 }, (error) => {
|
| 99 |
+
if (error) line(aLog, `[${time()}] SUBSCRIBE ERROR: ${error.message}`, 'error');
|
| 100 |
+
else {
|
| 101 |
+
window.clearInterval(timer);
|
| 102 |
+
publish();
|
| 103 |
+
timer = window.setInterval(publish, 5000);
|
| 104 |
+
}
|
| 105 |
+
});
|
| 106 |
+
});
|
| 107 |
+
|
| 108 |
+
client.on('message', (_topic, payload) => {
|
| 109 |
+
const raw = payload.toString();
|
| 110 |
+
line(aLog, `[${time()}] RX ${raw}`, 'message');
|
| 111 |
+
window.setTimeout(() => {
|
| 112 |
+
received += 1;
|
| 113 |
+
$('receivedCount').textContent = String(received).padStart(3, '0');
|
| 114 |
+
let text = raw;
|
| 115 |
+
try { text = JSON.parse(raw).text || raw; } catch { /* Plain-text MQTT payloads are supported too. */ }
|
| 116 |
+
line(aLog, `[${time()}] ECHO: ${text}`, 'echo');
|
| 117 |
+
}, 1000);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
client.on('reconnect', () => setStatus(false, 'RECONNECTING'));
|
| 121 |
+
client.on('offline', () => setStatus(false, 'OFFLINE'));
|
| 122 |
+
client.on('error', (error) => {
|
| 123 |
+
line(aLog, `[${time()}] MQTT ERROR: ${error.message}`, 'error');
|
| 124 |
+
line(bLog, `[${time()}] MQTT ERROR: ${error.message}`, 'error');
|
| 125 |
+
connectButton.disabled = false;
|
| 126 |
+
connectButton.innerHTML = '<span class="button-icon">▶</span>連線並啟動';
|
| 127 |
+
});
|
| 128 |
+
client.on('close', () => {
|
| 129 |
+
if (client) {
|
| 130 |
+
window.clearInterval(timer);
|
| 131 |
+
timer = undefined;
|
| 132 |
+
setStatus(false, 'DISCONNECTED');
|
| 133 |
+
connectButton.disabled = false;
|
| 134 |
+
connectButton.innerHTML = '<span class="button-icon">▶</span>重新連線';
|
| 135 |
+
}
|
| 136 |
+
});
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
connectButton.addEventListener('click', () => {
|
| 140 |
+
if (client) {
|
| 141 |
+
closeClient();
|
| 142 |
+
connectButton.disabled = false;
|
| 143 |
+
connectButton.innerHTML = '<span class="button-icon">▶</span>連線並啟動';
|
| 144 |
+
line(aLog, `[${time()}] Listener stopped.`, 'dim');
|
| 145 |
+
line(bLog, `[${time()}] Sender stopped.`, 'dim');
|
| 146 |
+
} else connect();
|
| 147 |
+
});
|
| 148 |
+
|
| 149 |
+
copyButton.addEventListener('click', async () => {
|
| 150 |
+
try {
|
| 151 |
+
await navigator.clipboard.writeText(topicInput.value.trim());
|
| 152 |
+
copyButton.textContent = '已複製 ✓';
|
| 153 |
+
window.clearTimeout(toastTimer);
|
| 154 |
+
$('toast').textContent = 'Topic 已複製到剪貼簿';
|
| 155 |
+
$('toast').classList.add('show');
|
| 156 |
+
toastTimer = window.setTimeout(() => $('toast').classList.remove('show'), 1800);
|
| 157 |
+
window.setTimeout(() => { copyButton.innerHTML = '複製 topic <span aria-hidden="true">↗</span>'; }, 1800);
|
| 158 |
+
} catch {
|
| 159 |
+
topicInput.select();
|
| 160 |
+
document.execCommand('copy');
|
| 161 |
+
$('toast').textContent = `Topic: ${topicInput.value}`;
|
| 162 |
+
$('toast').classList.add('show');
|
| 163 |
+
window.setTimeout(() => $('toast').classList.remove('show'), 2500);
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
window.addEventListener('pagehide', closeClient);
|
| 168 |
+
})();
|
index.html
CHANGED
|
@@ -1,19 +1,69 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="zh-Hant">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<meta name="theme-color" content="#080d0a">
|
| 7 |
+
<meta name="description" content="DOS 6.22 風格的 MQTT 即時收送示範。">
|
| 8 |
+
<title>DOS MQTT LAB — 6.22</title>
|
| 9 |
+
<link rel="stylesheet" href="style.css">
|
| 10 |
+
<script src="https://unpkg.com/mqtt@5.10.4/dist/mqtt.min.js" defer></script>
|
| 11 |
+
<script src="app.js" defer></script>
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<main class="shell">
|
| 15 |
+
<header class="topbar">
|
| 16 |
+
<div class="brand"><span class="brand-mark">D:</span><span>DOS MQTT LAB <small>REAL-TIME LINK / 6.22</small></span></div>
|
| 17 |
+
<div class="clock"><span class="live-dot" id="liveDot"></span><span id="connectionText">DISCONNECTED</span><span class="divider">|</span><span id="clock">--:--:--</span></div>
|
| 18 |
+
</header>
|
| 19 |
+
|
| 20 |
+
<section class="intro">
|
| 21 |
+
<div class="eyebrow">PUBLIC MQTT · WEBSOCKET · LIVE DEMO</div>
|
| 22 |
+
<h1>兩台 DOS,<span>一條訊息線。</span></h1>
|
| 23 |
+
<p>前景工作 B 每 5 秒送出一筆附時間的訊息;背景工作 A 收到後等待 1 秒,再回顯到螢幕。</p>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<section class="control-panel panel">
|
| 27 |
+
<div class="panel-heading"><span class="prompt">C:\></span><h2>連線設定</h2><span class="panel-tag">PUBLIC TEST BROKER</span></div>
|
| 28 |
+
<div class="controls">
|
| 29 |
+
<label class="field"><span>MQTT broker (WSS)</span><input id="broker" value="wss://broker.hivemq.com:8884/mqtt" spellcheck="false" autocomplete="off"></label>
|
| 30 |
+
<label class="field topic-field"><span>共用 topic</span><input id="topic" spellcheck="false" autocomplete="off"></label>
|
| 31 |
+
<button class="connect-button" id="connectButton" type="button"><span class="button-icon">▶</span>連線並啟動</button>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="connection-note"><span class="keycap">TIP</span>公開測試 broker 不保證服務可用;topic 使用隨機識別碼,方便兩個瀏覽器分頁互相測試。</div>
|
| 34 |
+
</section>
|
| 35 |
+
|
| 36 |
+
<section class="workstations" aria-label="DOS 工作視窗">
|
| 37 |
+
<article class="terminal-card panel">
|
| 38 |
+
<div class="terminal-title"><span class="terminal-icon">A</span><div><h2>工作 A <span class="role">BACKGROUND / LISTENER</span></h2><p>訂閱訊息 · 1 秒後 echo</p></div><span class="state-pill" id="aState">STOPPED</span></div>
|
| 39 |
+
<div class="terminal-screen" id="aLog" aria-live="polite" aria-label="工作 A 訊息記錄">
|
| 40 |
+
<div class="screen-line dim">MS-DOS Version 6.22</div>
|
| 41 |
+
<div class="screen-line">C:\MQTT> A.EXE --listen</div>
|
| 42 |
+
<div class="screen-line dim">等待 broker 連線…</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="terminal-footer"><span>RX <b id="receivedCount">000</b></span><span>DELAY <b>1000 ms</b></span><span class="cursor">_</span></div>
|
| 45 |
+
</article>
|
| 46 |
+
|
| 47 |
+
<div class="link-column" aria-hidden="true"><div class="link-line"></div><div class="link-symbol">⇄</div><span>MQTT</span></div>
|
| 48 |
+
|
| 49 |
+
<article class="terminal-card panel">
|
| 50 |
+
<div class="terminal-title"><span class="terminal-icon blue">B</span><div><h2>工作 B <span class="role">FOREGROUND / SENDER</span></h2><p>送出訊息 · 每 5 秒一次</p></div><span class="state-pill" id="bState">STOPPED</span></div>
|
| 51 |
+
<div class="terminal-screen" id="bLog" aria-live="polite" aria-label="工作 B 訊息記錄">
|
| 52 |
+
<div class="screen-line dim">MS-DOS Version 6.22</div>
|
| 53 |
+
<div class="screen-line">C:\MQTT> B.EXE --interval 5</div>
|
| 54 |
+
<div class="screen-line dim">等待 broker 連線…</div>
|
| 55 |
+
</div>
|
| 56 |
+
<div class="terminal-footer"><span>TX <b id="sentCount">000</b></span><span>INTERVAL <b>5000 ms</b></span><span class="cursor blue-cursor">_</span></div>
|
| 57 |
+
</article>
|
| 58 |
+
</section>
|
| 59 |
+
|
| 60 |
+
<section class="bottom-row">
|
| 61 |
+
<div class="how panel"><div class="mini-heading"><span>01—03</span><h2>執行流程</h2></div><ol><li><b>連線</b><span>A 訂閱共用 topic,B 建立 MQTT 連線。</span></li><li><b>傳送</b><span>B 每 5 秒送出一段含目前時間的訊息。</span></li><li><b>回顯</b><span>A 收到後延遲 1 秒,再顯示 echo。</span></li></ol></div>
|
| 62 |
+
<div class="share panel"><div class="mini-heading"><span>TEST</span><h2>跨分頁觀察</h2></div><p>複製同一個 topic 到另一台裝置或瀏覽器分頁。兩邊都��下連線後,就能看見互相傳送的訊息。</p><button class="copy-button" id="copyButton" type="button">複製 topic <span aria-hidden="true">↗</span></button></div>
|
| 63 |
+
</section>
|
| 64 |
+
|
| 65 |
+
<footer><span>DOS MQTT LAB <b>v1.0</b></span><span>MQTT over secure WebSocket · Browser-based DOS-style demo</span><span>NO CREDENTIALS REQUIRED</span></footer>
|
| 66 |
+
</main>
|
| 67 |
+
<div class="toast" id="toast" role="status" aria-live="polite"></div>
|
| 68 |
+
</body>
|
| 69 |
</html>
|
style.css
CHANGED
|
@@ -1,28 +1,15 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
}
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
}
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
.card {
|
| 19 |
-
max-width: 620px;
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
.card p:last-child {
|
| 27 |
-
margin-bottom: 0;
|
| 28 |
-
}
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
:root{font-family:'Space Grotesk',sans-serif;color:#e5eee6;background:#080d0a;font-synthesis:none;text-rendering:optimizeLegibility;--green:#a5f26c;--muted:#77847b;--line:#28352c;--panel:#101712;--blue:#82bcff}
|
| 4 |
+
*{box-sizing:border-box}
|
| 5 |
+
body{margin:0;min-width:320px;background:radial-gradient(ellipse at 48% -30%,#203224 0,transparent 50%),#080d0a}
|
| 6 |
+
button,input{font:inherit}
|
| 7 |
+
.shell{max-width:1160px;margin:auto;padding:0 36px}
|
| 8 |
+
.topbar{height:78px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #253029;font-family:'DM Mono',monospace;font-size:11px;letter-spacing:.07em}
|
| 9 |
+
.brand{display:flex;align-items:center;gap:12px;color:#eaf3e9;font-weight:500}.brand-mark{display:grid;place-items:center;width:35px;height:35px;background:var(--green);color:#11190e;font-weight:500;font-size:15px;letter-spacing:-.08em}.brand small{display:block;color:#718076;font-size:9px;margin-top:5px;letter-spacing:.14em}.clock{display:flex;align-items:center;gap:9px;color:#abb6ad}.live-dot{width:7px;height:7px;border-radius:50%;background:#657168}.live-dot.on{background:var(--green);box-shadow:0 0 10px #a5f26c88}.divider{color:#425047}
|
| 10 |
+
.intro{padding:55px 0 30px}.eyebrow,.panel-tag,.role,.mini-heading>span{font:10px 'DM Mono',monospace;letter-spacing:.12em;color:var(--green)}h1{font-size:clamp(32px,5vw,51px);line-height:1.1;letter-spacing:-.055em;margin:14px 0 12px;font-weight:600}h1 span{color:var(--green)}.intro p{max-width:680px;color:#9ba79c;font-size:15px;line-height:1.7;margin:0}
|
| 11 |
+
.panel{border:1px solid var(--line);background:linear-gradient(135deg,#121a14,#0e1511);border-radius:5px}.control-panel{padding:18px 20px 14px}.panel-heading{display:flex;align-items:center;gap:10px}.prompt{font:12px 'DM Mono',monospace;color:var(--green)}.panel-heading h2,.mini-heading h2{font-size:13px;font-weight:600;margin:0}.panel-tag{margin-left:auto;font-size:9px;color:#829181}.controls{display:grid;grid-template-columns:1.15fr 1fr auto;align-items:end;gap:11px;margin-top:19px}.field{display:flex;flex-direction:column;gap:7px;color:#849087;font:10px 'DM Mono',monospace}.field input{width:100%;height:39px;padding:0 11px;color:#cbd6cc;background:#080d0a;border:1px solid #303d33;border-radius:3px;outline:none;font:11px 'DM Mono',monospace}.field input:focus{border-color:#78bd53}.connect-button,.copy-button{height:39px;border:0;border-radius:3px;background:var(--green);color:#10170d;font-size:12px;font-weight:700;cursor:pointer;padding:0 16px;transition:filter .2s,transform .2s;white-space:nowrap}.connect-button:hover,.copy-button:hover{filter:brightness(1.08);transform:translateY(-1px)}.connect-button:disabled{opacity:.75;cursor:wait}.button-icon{margin-right:8px;font-size:10px}.connection-note{display:flex;align-items:center;gap:8px;margin-top:12px;color:#748076;font-size:10px}.keycap{font:9px 'DM Mono',monospace;color:#8d9a8f;padding:3px 5px;border:1px solid #354239;border-radius:2px}
|
| 12 |
+
.workstations{display:grid;grid-template-columns:minmax(0,1fr) 64px minmax(0,1fr);align-items:center;margin-top:18px}.terminal-card{overflow:hidden}.terminal-title{display:flex;align-items:center;gap:11px;padding:15px 15px 13px;border-bottom:1px solid #263229}.terminal-icon{display:grid;place-items:center;width:30px;height:30px;background:#20351c;color:var(--green);font:15px 'DM Mono',monospace}.terminal-icon.blue{background:#172b3c;color:var(--blue)}.terminal-title h2{font-size:13px;margin:0;font-weight:600}.role{font-size:8px;margin-left:6px;color:#829181;letter-spacing:.05em}.terminal-title p{color:#829087;margin:4px 0 0;font-size:10px}.state-pill{margin-left:auto;border:1px solid #344139;color:#879388;font:9px 'DM Mono',monospace;padding:5px 7px;border-radius:2px}.state-pill.on{border-color:#527a3a;color:var(--green)}.terminal-screen{height:194px;padding:13px 14px;background:#080d0a;color:#c2d2c2;overflow-y:auto;font:11px/1.75 'DM Mono',monospace}.screen-line{white-space:pre-wrap;overflow-wrap:anywhere}.screen-line.dim{color:#69796d}.screen-line.system{color:#94ae91}.screen-line.message{color:#d6edce}.screen-line.error{color:#ff9a88}.screen-line.echo{color:var(--green)}.terminal-footer{display:flex;align-items:center;gap:18px;border-top:1px solid #263229;padding:10px 13px;color:#758278;font:9px 'DM Mono',monospace}.terminal-footer b{color:#d2ddd1;font-weight:400;margin-left:3px}.cursor{margin-left:auto;color:var(--green);animation:blink 1s steps(2,start) infinite}.blue-cursor{color:var(--blue)}@keyframes blink{to{visibility:hidden}}.link-column{display:flex;align-items:center;flex-direction:column;gap:5px;color:#69766d;font:8px 'DM Mono',monospace}.link-line{width:100%;height:1px;background:linear-gradient(90deg,#a5f26c55,#82bcff55)}.link-symbol{position:absolute;display:grid;place-items:center;width:27px;height:27px;border:1px solid #36453a;border-radius:50%;background:#101712;color:#b9d6ac;font-size:13px}
|
| 13 |
+
.bottom-row{display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-top:17px}.how,.share{padding:15px 17px}.mini-heading{display:flex;align-items:center;gap:10px}.mini-heading>span{font-size:9px}.mini-heading h2{font-size:12px}.how ol{list-style:none;margin:13px 0 1px;padding:0;display:grid;gap:8px}.how li{display:grid;grid-template-columns:65px 1fr;gap:8px;font-size:10px}.how li b{color:#d7e2d6;font-weight:500}.how li span,.share p{color:#869388;line-height:1.55}.share p{font-size:10px;margin:12px 0}.copy-button{height:31px;padding:0 10px;font-size:10px}.copy-button span{margin-left:8px}footer{display:flex;justify-content:space-between;gap:10px;padding:20px 0 25px;color:#66736a;font:8px 'DM Mono',monospace;letter-spacing:.04em}footer b{font-weight:400;color:#97a398;margin-left:5px}.toast{position:fixed;bottom:22px;left:50%;transform:translate(-50%,14px);padding:10px 14px;background:#dbf9ca;color:#162011;border-radius:3px;font-size:12px;opacity:0;pointer-events:none;transition:all .2s}.toast.show{transform:translate(-50%,0);opacity:1}
|
| 14 |
+
@media(max-width:800px){.shell{padding:0 20px}.controls{grid-template-columns:1fr 1fr}.connect-button{grid-column:1/-1}.workstations{grid-template-columns:1fr;gap:12px}.link-column{height:16px;flex-direction:row;justify-content:center}.link-column .link-line{width:42px}.link-symbol{position:static}.bottom-row{grid-template-columns:1fr}.intro{padding-top:42px}}
|
| 15 |
+
@media(max-width:480px){.shell{padding:0 14px}.topbar{height:65px}.clock{font-size:9px;gap:6px}.brand{font-size:10px}.brand-mark{width:31px;height:31px}.controls{grid-template-columns:1fr}.connect-button{grid-column:auto}.intro p{font-size:13px}.terminal-title{padding:12px 10px}.terminal-screen{height:180px;padding:11px;font-size:10px}.terminal-footer{gap:10px;font-size:8px}.role{display:block;margin:4px 0 0}footer{flex-wrap:wrap;line-height:1.6}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|