HelloSun commited on
Commit
20321f1
·
verified ·
1 Parent(s): f529b5b

Add DOS MQTT demo

Browse files
Files changed (4) hide show
  1. README.md +18 -5
  2. app.js +168 -0
  3. index.html +67 -17
  4. style.css +15 -28
README.md CHANGED
@@ -1,10 +1,23 @@
1
  ---
2
- title: Dosmqtt
3
- emoji: 🏢
4
- colorFrom: indigo
5
- colorTo: purple
6
  sdk: static
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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:\&gt;</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&gt; 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&gt; 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
- body {
2
- padding: 2rem;
3
- font-family: -apple-system, BlinkMacSystemFont, "Arial", sans-serif;
4
- }
5
-
6
- h1 {
7
- font-size: 16px;
8
- margin-top: 0;
9
- }
10
-
11
- p {
12
- color: rgb(107, 114, 128);
13
- font-size: 15px;
14
- margin-bottom: 10px;
15
- margin-top: 5px;
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}}