zero-http 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +314 -115
  2. package/documentation/full-server.js +82 -1
  3. package/documentation/public/data/api.json +154 -33
  4. package/documentation/public/data/examples.json +35 -11
  5. package/documentation/public/data/options.json +14 -8
  6. package/documentation/public/index.html +109 -17
  7. package/documentation/public/scripts/data-sections.js +4 -4
  8. package/documentation/public/scripts/helpers.js +4 -4
  9. package/documentation/public/scripts/playground.js +201 -0
  10. package/documentation/public/scripts/ui.js +6 -6
  11. package/documentation/public/scripts/uploads.js +9 -9
  12. package/documentation/public/styles.css +12 -12
  13. package/documentation/public/vendor/icons/compress.svg +27 -0
  14. package/documentation/public/vendor/icons/https.svg +23 -0
  15. package/documentation/public/vendor/icons/router.svg +29 -0
  16. package/documentation/public/vendor/icons/sse.svg +25 -0
  17. package/documentation/public/vendor/icons/websocket.svg +21 -0
  18. package/index.js +21 -4
  19. package/lib/app.js +145 -15
  20. package/lib/body/json.js +3 -0
  21. package/lib/body/multipart.js +2 -0
  22. package/lib/body/raw.js +3 -0
  23. package/lib/body/text.js +3 -0
  24. package/lib/body/urlencoded.js +3 -0
  25. package/lib/{fetch.js → fetch/index.js} +30 -1
  26. package/lib/http/index.js +9 -0
  27. package/lib/{request.js → http/request.js} +7 -1
  28. package/lib/{response.js → http/response.js} +70 -1
  29. package/lib/middleware/compress.js +194 -0
  30. package/lib/middleware/index.js +12 -0
  31. package/lib/router/index.js +278 -0
  32. package/lib/sse/index.js +8 -0
  33. package/lib/sse/stream.js +322 -0
  34. package/lib/ws/connection.js +440 -0
  35. package/lib/ws/handshake.js +122 -0
  36. package/lib/ws/index.js +12 -0
  37. package/package.json +1 -1
  38. package/lib/router.js +0 -87
  39. /package/lib/{cors.js → middleware/cors.js} +0 -0
  40. /package/lib/{logger.js → middleware/logger.js} +0 -0
  41. /package/lib/{rateLimit.js → middleware/rateLimit.js} +0 -0
  42. /package/lib/{static.js → middleware/static.js} +0 -0
@@ -6,9 +6,9 @@
6
6
  <meta name="viewport" content="width=device-width,initial-scale=1" />
7
7
  <title>zero-http — demo & reference</title>
8
8
  <meta name="description"
9
- content="zero-http is a zero-dependency, Express-like HTTP server for Node.js with built-in parsers, static serving, and upload handling." />
9
+ content="zero-http is a zero-dependency, Express-like HTTP/HTTPS server for Node.js with built-in WebSocket, Server-Sent Events, body parsers, response compression, static serving, and upload handling." />
10
10
  <meta name="keywords"
11
- content="nodejs,http,server,express,expressjs,middleware,body-parser,multipart,uploads,static files,fetch,http client" />
11
+ content="nodejs,http,https,server,express,expressjs,middleware,body-parser,multipart,uploads,static files,fetch,http client,websocket,sse,server-sent events,compression,router" />
12
12
  <meta name="author" content="Anthony Wiedman" />
13
13
 
14
14
  <link rel="stylesheet" href="/styles.css" />
@@ -16,11 +16,11 @@
16
16
  <link rel="stylesheet" href="/vendor/prism-toolbar.css" />
17
17
  <link rel="stylesheet" href="/prism-overrides.css" />
18
18
 
19
- <script src="/vendor/prism.min.js" async></script>
20
- <script src="/vendor/prism-json.min.js" async></script>
21
- <script src="/vendor/prism-javascript.min.js" async></script>
22
- <script src="/vendor/prism-copy-to-clipboard.min.js" async></script>
23
- <script src="/vendor/prism-toolbar.min.js" async></script>
19
+ <script src="/vendor/prism.min.js" defer></script>
20
+ <script src="/vendor/prism-json.min.js" defer></script>
21
+ <script src="/vendor/prism-javascript.min.js" defer></script>
22
+ <script src="/vendor/prism-copy-to-clipboard.min.js" defer></script>
23
+ <script src="/vendor/prism-toolbar.min.js" defer></script>
24
24
 
25
25
  <script src="/scripts/helpers.js" defer></script>
26
26
  <script src="/scripts/uploads.js" defer></script>
@@ -89,6 +89,8 @@
89
89
  <li class="toc-sub-item"><a href="#upload-testing">Upload multipart data</a></li>
90
90
  <li class="toc-sub-item"><a href="#parser-tests">Parser tests</a></li>
91
91
  <li class="toc-sub-item"><a href="#proxy-test">Proxy test</a></li>
92
+ <li class="toc-sub-item"><a href="#ws-chat">WebSocket chat</a></li>
93
+ <li class="toc-sub-item"><a href="#sse-viewer">SSE viewer</a></li>
92
94
  </ul>
93
95
  </li>
94
96
  </ul>
@@ -112,40 +114,80 @@
112
114
  <img class="icon-svg" src="/vendor/icons/zero.svg" alt="" aria-hidden="true"
113
115
  width="48" height="48" />
114
116
  </div>
115
- <h5>Zero-dependency</h5>
116
- <p>Small, Express-like API without external deps.</p>
117
+ <div class="feature-text"><h5>Zero-dependency</h5>
118
+ <p>Small, Express-like API without external deps.</p></div>
117
119
  </div>
118
120
  <div class="feature-card">
119
121
  <div class="feature-icon">
120
122
  <img class="icon-svg" src="/vendor/icons/plug.svg" alt="" aria-hidden="true"
121
123
  width="48" height="48" />
122
124
  </div>
123
- <h5>Pluggable parsers</h5>
124
- <p>json(), urlencoded(), text(), raw() built-in.</p>
125
+ <div class="feature-text"><h5>Pluggable parsers</h5>
126
+ <p>json(), urlencoded(), text(), raw() built-in.</p></div>
125
127
  </div>
126
128
  <div class="feature-card">
127
129
  <div class="feature-icon">
128
130
  <img class="icon-svg" src="/vendor/icons/stream.svg" alt="" aria-hidden="true"
129
131
  width="48" height="48" />
130
132
  </div>
131
- <h5>Streaming uploads</h5>
132
- <p>Multipart streaming to disk for large files.</p>
133
+ <div class="feature-text"><h5>Streaming uploads</h5>
134
+ <p>Multipart streaming to disk for large files.</p></div>
133
135
  </div>
134
136
  <div class="feature-card">
135
137
  <div class="feature-icon">
136
138
  <img class="icon-svg" src="/vendor/icons/static.svg" alt="" aria-hidden="true"
137
139
  width="48" height="48" />
138
140
  </div>
139
- <h5>Static serving</h5>
140
- <p>Correct Content-Type handling and caching options.</p>
141
+ <div class="feature-text"><h5>Static serving</h5>
142
+ <p>Content-Type handling and cache options.</p></div>
141
143
  </div>
142
144
  <div class="feature-card">
143
145
  <div class="feature-icon">
144
146
  <img class="icon-svg" src="/vendor/icons/fetch.svg" alt="" aria-hidden="true"
145
147
  width="48" height="48" />
146
148
  </div>
147
- <h5>Built-in fetch</h5>
148
- <p>Small server-side fetch replacement for HTTP requests.</p>
149
+ <div class="feature-text"><h5>Built-in fetch</h5>
150
+ <p>Server-side fetch with TLS passthrough.</p></div>
151
+ </div>
152
+ <div class="feature-card">
153
+ <div class="feature-icon">
154
+ <img class="icon-svg" src="/vendor/icons/websocket.svg" alt="" aria-hidden="true"
155
+ width="48" height="48" />
156
+ </div>
157
+ <div class="feature-text"><h5>WebSocket</h5>
158
+ <p>RFC 6455 with ping/pong &amp; sub-protocols.</p></div>
159
+ </div>
160
+ <div class="feature-card">
161
+ <div class="feature-icon">
162
+ <img class="icon-svg" src="/vendor/icons/sse.svg" alt="" aria-hidden="true"
163
+ width="48" height="48" />
164
+ </div>
165
+ <div class="feature-text"><h5>Server-Sent Events</h5>
166
+ <p>Auto-IDs, named events, keep-alive.</p></div>
167
+ </div>
168
+ <div class="feature-card">
169
+ <div class="feature-icon">
170
+ <img class="icon-svg" src="/vendor/icons/compress.svg" alt="" aria-hidden="true"
171
+ width="48" height="48" />
172
+ </div>
173
+ <div class="feature-text"><h5>Compression</h5>
174
+ <p>Gzip/deflate with threshold &amp; filter.</p></div>
175
+ </div>
176
+ <div class="feature-card">
177
+ <div class="feature-icon">
178
+ <img class="icon-svg" src="/vendor/icons/https.svg" alt="" aria-hidden="true"
179
+ width="48" height="48" />
180
+ </div>
181
+ <div class="feature-text"><h5>HTTPS &amp; TLS</h5>
182
+ <p>Native HTTPS, req.secure, secure routing.</p></div>
183
+ </div>
184
+ <div class="feature-card">
185
+ <div class="feature-icon">
186
+ <img class="icon-svg" src="/vendor/icons/router.svg" alt="" aria-hidden="true"
187
+ width="48" height="48" />
188
+ </div>
189
+ <div class="feature-text"><h5>Modular Router</h5>
190
+ <p>Sub-apps, chaining, introspection.</p></div>
149
191
  </div>
150
192
  </div>
151
193
  </div>
@@ -401,6 +443,56 @@ node test/test.js
401
443
  </div>
402
444
  </div>
403
445
 
446
+ <div class="card play-card constrained">
447
+ <div class="card-head">
448
+ <h3 id="ws-chat">WebSocket Chat</h3>
449
+ <div class="small muted">Connect to the built-in WebSocket server and send messages in real time.</div>
450
+ </div>
451
+ <div class="card-body">
452
+ <div class="playgrid" style="grid-template-columns:1fr">
453
+ <div class="play" id="wsChat">
454
+ <div style="display:flex;gap:8px;align-items:end;margin-bottom:8px">
455
+ <label style="flex:1">Name
456
+ <input id="wsName" class="textInput" placeholder="anon" value="" />
457
+ </label>
458
+ <button id="wsConnectBtn" class="btn primary" type="button">Connect</button>
459
+ <button id="wsDisconnectBtn" class="btn warn" type="button" disabled>Disconnect</button>
460
+ </div>
461
+ <div id="wsMessages" class="result mono" style="min-height:120px;max-height:260px;overflow-y:auto;margin-bottom:8px;white-space:pre-wrap"></div>
462
+ <div style="display:flex;gap:8px">
463
+ <input id="wsMsgInput" class="textInput" placeholder="Type a message…" disabled style="flex:1" />
464
+ <button id="wsSendBtn" class="btn" type="button" disabled>Send</button>
465
+ </div>
466
+ </div>
467
+ </div>
468
+ </div>
469
+ </div>
470
+
471
+ <div class="card play-card constrained">
472
+ <div class="card-head">
473
+ <h3 id="sse-viewer">SSE Event Viewer</h3>
474
+ <div class="small muted">Subscribe to the Server-Sent Events endpoint and view incoming events live. Use the broadcast form to push a custom event to all subscribers.</div>
475
+ </div>
476
+ <div class="card-body">
477
+ <div class="playgrid" style="grid-template-columns:1fr">
478
+ <div class="play" id="sseViewer">
479
+ <div style="display:flex;gap:8px;align-items:end;margin-bottom:8px">
480
+ <button id="sseConnectBtn" class="btn primary" type="button">Connect</button>
481
+ <button id="sseDisconnectBtn" class="btn warn" type="button" disabled>Disconnect</button>
482
+ <span id="sseStatus" class="small muted" style="margin-left:8px">Disconnected</span>
483
+ </div>
484
+ <div id="sseMessages" class="result mono" style="min-height:120px;max-height:260px;overflow-y:auto;margin-bottom:8px;white-space:pre-wrap"></div>
485
+ <div style="display:flex;gap:8px;align-items:end">
486
+ <label style="flex:1">Broadcast message (JSON)
487
+ <textarea id="sseBroadcastInput" class="textInput" rows="2">{"hello":"world"}</textarea>
488
+ </label>
489
+ <button id="sseBroadcastBtn" class="btn" type="button">Broadcast</button>
490
+ </div>
491
+ </div>
492
+ </div>
493
+ </div>
494
+ </div>
495
+
404
496
  <div class="card footnote" style="max-width:1300px;margin:18px auto;">
405
497
  <p class="muted">This demo doubles as a readable API reference and playground — try uploading files,
406
498
  inspect the returned JSON, and use the playground to test parsers.</p>
@@ -12,7 +12,7 @@
12
12
  * highlightAllPre)
13
13
  */
14
14
 
15
- /* ── TOC Helpers ───────────────────────────────────────────────────────────── */
15
+ /* -- TOC Helpers ------------------------------------------------------------- */
16
16
 
17
17
  /**
18
18
  * Find a top-level `<li>` in the sidebar TOC whose link matches the given
@@ -56,7 +56,7 @@ function populateTocSub(href, items)
56
56
  parentLi.appendChild(sub);
57
57
  }
58
58
 
59
- /* ── API Reference ─────────────────────────────────────────────────────────── */
59
+ /* -- API Reference ----------------------------------------------------------- */
60
60
 
61
61
  /**
62
62
  * Render a list of API items into the `#api-items` container and Prism-highlight
@@ -196,7 +196,7 @@ async function loadApiReference()
196
196
  } catch (e) { }
197
197
  }
198
198
 
199
- /* ── Options Table ─────────────────────────────────────────────────────────── */
199
+ /* -- Options Table ----------------------------------------------------------- */
200
200
 
201
201
  /**
202
202
  * Fetch the options JSON and render a `<table>` into `#options-items`.
@@ -236,7 +236,7 @@ async function loadOptions()
236
236
  } catch (e) { console.error('loadOptions error', e); }
237
237
  }
238
238
 
239
- /* ── Code Examples ─────────────────────────────────────────────────────────── */
239
+ /* -- Code Examples ----------------------------------------------------------- */
240
240
 
241
241
  /**
242
242
  * Fetch the examples JSON, render each as a collapsible accordion, and
@@ -4,7 +4,7 @@
4
4
  * documentation scripts. Loaded first so every other module can rely on these.
5
5
  */
6
6
 
7
- /* ── DOM Selectors ─────────────────────────────────────────────────────────── */
7
+ /* -- DOM Selectors ----------------------------------------------------------- */
8
8
 
9
9
  /**
10
10
  * Query a single element by CSS selector.
@@ -34,7 +34,7 @@ function on(el, evt, cb)
34
34
  el.addEventListener(evt, cb);
35
35
  }
36
36
 
37
- /* ── String Utilities ──────────────────────────────────────────────────────── */
37
+ /* -- String Utilities -------------------------------------------------------- */
38
38
 
39
39
  /**
40
40
  * Escape HTML special characters for safe insertion into the DOM.
@@ -76,7 +76,7 @@ function formatBytes(n)
76
76
  return (n / Math.pow(k, i)).toFixed(i ? 1 : 0) + ' ' + sizes[i];
77
77
  }
78
78
 
79
- /* ── JSON / Code Rendering ─────────────────────────────────────────────────── */
79
+ /* -- JSON / Code Rendering --------------------------------------------------- */
80
80
 
81
81
  /**
82
82
  * Build a highlighted `<pre>` block containing pretty-printed JSON.
@@ -100,7 +100,7 @@ function showJsonResult(container, obj)
100
100
  try { highlightAllPre(); } catch (e) { }
101
101
  }
102
102
 
103
- /* ── Prism / Code-Block Helpers ────────────────────────────────────────────── */
103
+ /* -- Prism / Code-Block Helpers ---------------------------------------------- */
104
104
 
105
105
  /**
106
106
  * Trigger Prism syntax highlighting on all `<pre class="code">` blocks, or
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * playground.js
3
3
  * Echo playground forms — JSON, URL-encoded, and plain-text body parsers.
4
+ * WebSocket chat client and SSE event viewer.
4
5
  *
5
6
  * Depends on: helpers.js (provides $, on, escapeHtml, showJsonResult,
6
7
  * highlightAllPre)
@@ -68,4 +69,204 @@ function initPlayground()
68
69
  playResult.innerHTML = `<pre class="code"><code>${escapeHtml(text)}</code></pre>`;
69
70
  try { highlightAllPre(); } catch (e) { }
70
71
  });
72
+
73
+ /* --- WebSocket Chat --- */
74
+ initWsChat();
75
+
76
+ /* --- SSE Viewer --- */
77
+ initSseViewer();
78
+ }
79
+
80
+ /* ------------------------------------------------------------------ */
81
+ /* WebSocket Chat */
82
+ /* ------------------------------------------------------------------ */
83
+ function initWsChat()
84
+ {
85
+ let ws = null;
86
+ const connectBtn = $('#wsConnectBtn');
87
+ const disconnectBtn = $('#wsDisconnectBtn');
88
+ const nameInput = $('#wsName');
89
+ const msgInput = $('#wsMsgInput');
90
+ const sendBtn = $('#wsSendBtn');
91
+ const messages = $('#wsMessages');
92
+
93
+ if (!connectBtn) return; // guard if elements not in DOM
94
+
95
+ function appendMsg(html)
96
+ {
97
+ const div = document.createElement('div');
98
+ div.innerHTML = html;
99
+ messages.appendChild(div);
100
+ messages.scrollTop = messages.scrollHeight;
101
+ }
102
+
103
+ function setConnected(connected)
104
+ {
105
+ connectBtn.disabled = connected;
106
+ disconnectBtn.disabled = !connected;
107
+ msgInput.disabled = !connected;
108
+ sendBtn.disabled = !connected;
109
+ nameInput.disabled = connected;
110
+ }
111
+
112
+ on(connectBtn, 'click', () =>
113
+ {
114
+ const name = encodeURIComponent(nameInput.value || 'anon');
115
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
116
+ ws = new WebSocket(`${proto}//${location.host}/ws/chat?name=${name}`);
117
+
118
+ ws.onopen = () =>
119
+ {
120
+ setConnected(true);
121
+ appendMsg('<span style="color:#6f6">● Connected</span>');
122
+ };
123
+
124
+ ws.onmessage = (e) =>
125
+ {
126
+ try
127
+ {
128
+ const msg = JSON.parse(e.data);
129
+ if (msg.type === 'system')
130
+ {
131
+ appendMsg(`<span style="color:#aaa">» ${escapeHtml(msg.text)}</span>`);
132
+ } else
133
+ {
134
+ appendMsg(`<strong>${escapeHtml(msg.name)}</strong>: ${escapeHtml(msg.text)}`);
135
+ }
136
+ } catch (_)
137
+ {
138
+ appendMsg(escapeHtml(e.data));
139
+ }
140
+ };
141
+
142
+ ws.onclose = () =>
143
+ {
144
+ setConnected(false);
145
+ appendMsg('<span style="color:#f66">● Disconnected</span>');
146
+ ws = null;
147
+ };
148
+
149
+ ws.onerror = () =>
150
+ {
151
+ appendMsg('<span style="color:#f66">● Connection error</span>');
152
+ };
153
+ });
154
+
155
+ on(disconnectBtn, 'click', () =>
156
+ {
157
+ if (ws) ws.close();
158
+ });
159
+
160
+ on(sendBtn, 'click', () =>
161
+ {
162
+ if (ws && ws.readyState === WebSocket.OPEN && msgInput.value.trim())
163
+ {
164
+ ws.send(msgInput.value.trim());
165
+ msgInput.value = '';
166
+ }
167
+ });
168
+
169
+ on(msgInput, 'keydown', (e) =>
170
+ {
171
+ if (e.key === 'Enter')
172
+ {
173
+ e.preventDefault();
174
+ sendBtn.click();
175
+ }
176
+ });
177
+ }
178
+
179
+ /* ------------------------------------------------------------------ */
180
+ /* SSE Event Viewer */
181
+ /* ------------------------------------------------------------------ */
182
+ function initSseViewer()
183
+ {
184
+ let es = null;
185
+ const connectBtn = $('#sseConnectBtn');
186
+ const disconnectBtn = $('#sseDisconnectBtn');
187
+ const status = $('#sseStatus');
188
+ const messages = $('#sseMessages');
189
+ const broadcastBtn = $('#sseBroadcastBtn');
190
+ const broadcastInput = $('#sseBroadcastInput');
191
+
192
+ if (!connectBtn) return; // guard
193
+
194
+ function appendMsg(html)
195
+ {
196
+ const div = document.createElement('div');
197
+ div.innerHTML = html;
198
+ messages.appendChild(div);
199
+ messages.scrollTop = messages.scrollHeight;
200
+ }
201
+
202
+ function setConnected(connected)
203
+ {
204
+ connectBtn.disabled = connected;
205
+ disconnectBtn.disabled = !connected;
206
+ status.textContent = connected ? 'Connected' : 'Disconnected';
207
+ status.style.color = connected ? '#6f6' : '';
208
+ }
209
+
210
+ on(connectBtn, 'click', () =>
211
+ {
212
+ es = new EventSource('/sse/events');
213
+
214
+ es.onopen = () =>
215
+ {
216
+ setConnected(true);
217
+ appendMsg('<span style="color:#6f6">● SSE connected</span>');
218
+ };
219
+
220
+ es.onmessage = (e) =>
221
+ {
222
+ const ts = new Date().toLocaleTimeString();
223
+ appendMsg(`<span style="color:#aaa">[${ts}]</span> ${escapeHtml(e.data)}`);
224
+ };
225
+
226
+ es.addEventListener('broadcast', (e) =>
227
+ {
228
+ const ts = new Date().toLocaleTimeString();
229
+ appendMsg(`<span style="color:#ff0">[${ts} broadcast]</span> ${escapeHtml(e.data)}`);
230
+ });
231
+
232
+ es.onerror = () =>
233
+ {
234
+ if (es.readyState === EventSource.CLOSED)
235
+ {
236
+ setConnected(false);
237
+ appendMsg('<span style="color:#f66">● SSE closed</span>');
238
+ es = null;
239
+ } else
240
+ {
241
+ appendMsg('<span style="color:#fa0">● SSE reconnecting…</span>');
242
+ }
243
+ };
244
+ });
245
+
246
+ on(disconnectBtn, 'click', () =>
247
+ {
248
+ if (es) { es.close(); es = null; }
249
+ setConnected(false);
250
+ appendMsg('<span style="color:#f66">● Disconnected</span>');
251
+ });
252
+
253
+ on(broadcastBtn, 'click', async () =>
254
+ {
255
+ const raw = broadcastInput.value || '{}';
256
+ let body;
257
+ try { body = JSON.parse(raw); }
258
+ catch (err)
259
+ {
260
+ appendMsg(`<span style="color:#f66">Invalid JSON: ${escapeHtml(err.message)}</span>`);
261
+ return;
262
+ }
263
+
264
+ const r = await fetch('/sse/broadcast', {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify(body),
268
+ });
269
+ const j = await r.json();
270
+ appendMsg(`<span style="color:#aaa">» Broadcast sent to ${j.sent} client(s)</span>`);
271
+ });
71
272
  }
@@ -14,7 +14,7 @@ document.addEventListener('DOMContentLoaded', () =>
14
14
  initTocToolbar();
15
15
  });
16
16
 
17
- /* ── Feature Tabs ──────────────────────────────────────────────────────────── */
17
+ /* -- Feature Tabs ------------------------------------------------------------ */
18
18
 
19
19
  /**
20
20
  * Wire the feature / server-model tab buttons so clicking one activates
@@ -47,7 +47,7 @@ function initFeatureTabs()
47
47
  });
48
48
  }
49
49
 
50
- /* ── TOC Sidebar Toggle ────────────────────────────────────────────────────── */
50
+ /* -- TOC Sidebar Toggle ------------------------------------------------------ */
51
51
 
52
52
  /**
53
53
  * Wire the hamburger button to toggle the sidebar on both desktop (persistent)
@@ -106,7 +106,7 @@ function initTocSidebar()
106
106
  window.addEventListener('resize', syncAria);
107
107
  }
108
108
 
109
- /* ── TOC Smooth-Scroll Navigation ──────────────────────────────────────────── */
109
+ /* -- TOC Smooth-Scroll Navigation -------------------------------------------- */
110
110
 
111
111
  /**
112
112
  * When clicking a TOC link that points to a `#hash`, auto-open any ancestor
@@ -166,7 +166,7 @@ function initTocNavigation()
166
166
  });
167
167
  }
168
168
 
169
- /* ── TOC Toolbar (scroll-to-top & expand/collapse all) ─────────────────────── */
169
+ /* -- TOC Toolbar (scroll-to-top & expand/collapse all) ----------------------- */
170
170
 
171
171
  /**
172
172
  * Wire the icon-bar buttons at the top of the sidebar:
@@ -179,7 +179,7 @@ function initTocToolbar()
179
179
  const toggleBtn = document.getElementById('toc-toggle-acc');
180
180
  if (!topBtn && !toggleBtn) return;
181
181
 
182
- /* ── Scroll to top ────────────────────────────────────────────────── */
182
+ /* -- Scroll to top -------------------------------------------------- */
183
183
  const brandBtn = document.getElementById('brand-top');
184
184
 
185
185
  [topBtn, brandBtn].forEach(el =>
@@ -192,7 +192,7 @@ function initTocToolbar()
192
192
  });
193
193
  });
194
194
 
195
- /* ── Expand / Collapse all accordions ─────────────────────────────── */
195
+ /* -- Expand / Collapse all accordions ------------------------------- */
196
196
  if (toggleBtn)
197
197
  {
198
198
  let expanded = false;
@@ -7,14 +7,14 @@
7
7
  * showJsonResult, highlightAllPre)
8
8
  */
9
9
 
10
- /* ── Pagination State ──────────────────────────────────────────────────────── */
10
+ /* -- Pagination State -------------------------------------------------------- */
11
11
 
12
12
  let currentPage = 1;
13
13
  let currentSort = 'mtime';
14
14
  let currentOrder = 'desc';
15
15
  const pageSize = 4;
16
16
 
17
- /* ── Combined Uploads + Trash JSON ─────────────────────────────────────────── */
17
+ /* -- Combined Uploads + Trash JSON ------------------------------------------- */
18
18
 
19
19
  /**
20
20
  * Fetch the combined uploads + trash listing and render it as JSON into the
@@ -30,7 +30,7 @@ async function loadUploadsCombined()
30
30
  } catch (e) { }
31
31
  }
32
32
 
33
- /* ── Convenience: DELETE + Show Result ─────────────────────────────────────── */
33
+ /* -- Convenience: DELETE + Show Result --------------------------------------- */
34
34
 
35
35
  /**
36
36
  * Issue a DELETE request and display the JSON response.
@@ -46,7 +46,7 @@ async function deleteAndShow(path, container)
46
46
  try { await loadUploadsCombined(); } catch (e) { showJsonResult(container, j); }
47
47
  }
48
48
 
49
- /* ── Trash Row Factory ─────────────────────────────────────────────────────── */
49
+ /* -- Trash Row Factory ------------------------------------------------------- */
50
50
 
51
51
  /**
52
52
  * Build a trash-row DOM node with Restore and Delete Permanently buttons.
@@ -90,7 +90,7 @@ function createTrashRow(name)
90
90
  return row;
91
91
  }
92
92
 
93
- /* ── Trash List ────────────────────────────────────────────────────────────── */
93
+ /* -- Trash List -------------------------------------------------------------- */
94
94
 
95
95
  /**
96
96
  * Fetch the trash listing from the server and render rows into `#trashList`.
@@ -125,7 +125,7 @@ function addTrashRow(name)
125
125
  else trashList.appendChild(row);
126
126
  }
127
127
 
128
- /* ── Undo Toast ────────────────────────────────────────────────────────────── */
128
+ /* -- Undo Toast -------------------------------------------------------------- */
129
129
 
130
130
  /**
131
131
  * Display a transient undo panel when a file is moved to trash.
@@ -161,7 +161,7 @@ function showUndo(name)
161
161
  });
162
162
  }
163
163
 
164
- /* ── Upload Card Factory ───────────────────────────────────────────────────── */
164
+ /* -- Upload Card Factory ----------------------------------------------------- */
165
165
 
166
166
  /** Inline SVG placeholder for non-image files. */
167
167
  const PLACEHOLDER_SVG = 'data:image/svg+xml;utf8,' + encodeURIComponent(
@@ -241,7 +241,7 @@ function createUploadCard(f, uploadResult)
241
241
  return card;
242
242
  }
243
243
 
244
- /* ── Paginated Upload List ─────────────────────────────────────────────────── */
244
+ /* -- Paginated Upload List --------------------------------------------------- */
245
245
 
246
246
  /**
247
247
  * Fetch the paginated upload list and render file cards into `#uploadsList`.
@@ -310,7 +310,7 @@ async function loadUploadsList()
310
310
  }
311
311
  }
312
312
 
313
- /* ── Wire Upload Form & Bulk Actions ───────────────────────────────────────── */
313
+ /* -- Wire Upload Form & Bulk Actions ----------------------------------------- */
314
314
 
315
315
  /**
316
316
  * Initialise the upload form (XHR with progress), pagination controls, and
@@ -188,24 +188,24 @@ details.acc[open] > summary:before{transform:rotate(90deg)}
188
188
  .tabs-content{display:block}
189
189
  .tab-panel{display:none;padding:6px 2px;transition:opacity .18s ease}
190
190
  .tab-panel.active{display:block}
191
- .feature-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
192
- .feature-card{background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent);padding:14px;border-radius:12px;border:1px solid rgba(255,255,255,0.03);display:flex;flex-direction:column;align-items:flex-start;gap:10px;transition:transform .12s ease,box-shadow .12s ease}
193
- .feature-card:hover{transform:translateY(-6px);box-shadow:0 12px 36px rgba(0,0,0,0.6)}
194
- .feature-icon{width:56px;height:56px;border-radius:12px;background:linear-gradient(180deg, rgba(88,101,242,0.12), rgba(31,142,253,0.06));display:grid;place-items:center;font-size:20px;color:var(--accent);}
195
- .feature-icon svg{width:34px;height:34px;display:block;fill:var(--accent);}
196
- .feature-icon img.icon-svg{width:40px;height:40px;display:block;object-fit:contain;margin:0 auto}
197
- /* .feature-icon img.icon-svg[src$="/fetch.svg"], .feature-icon img.icon-svg[src$="fetch.svg"]{transform:translateY(-5px)translateX(1px);display:block} */
198
- .feature-icon img.icon-svg[src$="/static.svg"], .feature-icon img.icon-svg[src$="static.svg"]{transform:translateY(2px);display:block}
199
- /* .feature-icon img.icon-svg[src$="/plug.svg"], .feature-icon img.icon-svg[src$="plug.svg"]{transform:translateY(2px);display:block} */
200
- .feature-card h5{margin:0;font-size:15px}
201
- .feature-card p{margin:0;color:var(--muted);font-size:14px}
191
+ .feature-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:8px}
192
+ .feature-card{background:linear-gradient(180deg,rgba(255,255,255,0.02),transparent);padding:10px 12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03);display:flex;flex-direction:row;align-items:center;gap:10px;transition:transform .12s ease,box-shadow .12s ease}
193
+ .feature-card:hover{transform:translateY(-3px);box-shadow:0 8px 24px rgba(0,0,0,0.5)}
194
+ .feature-icon{width:40px;height:40px;min-width:40px;border-radius:10px;background:linear-gradient(180deg, rgba(88,101,242,0.12), rgba(31,142,253,0.06));display:grid;place-items:center;font-size:16px;color:var(--accent);}
195
+ .feature-icon svg{width:26px;height:26px;display:block;fill:var(--accent);}
196
+ .feature-icon img.icon-svg{width:30px;height:30px;display:block;object-fit:contain;margin:0 auto}
197
+ .feature-icon img.icon-svg[src$="/static.svg"], .feature-icon img.icon-svg[src$="static.svg"]{transform:translateY(1px);display:block}
198
+ .feature-card h5{margin:0;font-size:13px;line-height:1.2}
199
+ .feature-card p{margin:0;color:var(--muted);font-size:12px;line-height:1.3}
200
+ .feature-text{display:flex;flex-direction:column;gap:2px;min-width:0}
202
201
  .behavior-list{display:flex;flex-direction:column;gap:10px}
203
202
  .behavior-step{display:flex;gap:12px;align-items:flex-start;padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03);background:linear-gradient(180deg,rgba(255,255,255,0.01),transparent)}
204
203
  .step-num{width:44px;height:44px;border-radius:10px;background:var(--accent);display:flex;align-items:center;justify-content:center;font-weight:800;color:#fff;box-shadow:0 8px 18px rgba(31,142,253,0.08)}
205
204
  .step-body h5{margin:0 0 6px 0}
206
205
  .step-body p{margin:0;color:var(--muted)}
207
206
 
208
- @media(max-width:900px){.feature-grid{grid-template-columns:1fr}.tabs-nav{flex-wrap:wrap}}
207
+ @media(max-width:900px){.feature-grid{grid-template-columns:repeat(auto-fill,minmax(180px,1fr))}.tabs-nav{flex-wrap:wrap}}
208
+ @media(max-width:500px){.feature-grid{grid-template-columns:1fr}}
209
209
 
210
210
  @media(max-width:900px){.playgrid{grid-template-columns:1fr}.ui-shell{padding:16px}}
211
211