@bakapiano/ccsm 0.17.11 → 0.18.0

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.
@@ -124,7 +124,8 @@ body.is-app:not(.is-wco) .session-pane {
124
124
  title-bar border. Give a little breathing room. Sessions has its
125
125
  own full-bleed terminal pane; About has its own hero header. */
126
126
  body.is-app:not(.is-wco) [data-panel="configure"],
127
- body.is-app:not(.is-wco) [data-panel="launch"] {
127
+ body.is-app:not(.is-wco) [data-panel="launch"],
128
+ body.is-app:not(.is-wco) [data-panel="remote"] {
128
129
  padding-top: var(--s-4);
129
130
  }
130
131
  /* Sidebar nav rows (New Session / Settings) also need a top gap in
@@ -149,22 +150,22 @@ body.is-wco .page-title-bar {
149
150
  }
150
151
  body.is-wco .page-title-bar,
151
152
  body.is-wco .sidebar-top {
152
- /* env(titlebar-area-height) is Chromium's reported safe-area height, but
153
- in practice Edge on Windows often paints the OS controls overlay a few
154
- px taller than that value so sizing our top band to exactly env()
155
- leaves a sliver of the strip BELOW (session-tabs) still under the
156
- overlay and the kebab gets clipped. Take the max with 40px so we
157
- always reserve at least the default page-title-bar height regardless
158
- of what env() reports. Matches non-WCO modes too. */
159
- height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
160
- min-height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
161
- max-height: calc(max(40px, env(titlebar-area-height, 40px)) * var(--anti-zoom, 1));
153
+ /* --titlebar-h is set from JS reading
154
+ navigator.windowControlsOverlay.getTitlebarAreaRect().height the
155
+ OS-reported strip the overlay reserves for us. CSS env() and a 32px
156
+ baseline are layered fallbacks when the JS API is unavailable. No
157
+ min-floor on the JS path: the OS knows its own caption height best,
158
+ and over-padding (the previous max(40px, …)) made the chrome look
159
+ visibly chunkier than the rest of the window. */
160
+ height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
161
+ min-height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
162
+ max-height: calc(var(--titlebar-h, env(titlebar-area-height, 32px)) * var(--anti-zoom, 1));
162
163
  }
163
164
  body.is-wco .sidebar-brand,
164
165
  body.is-wco .sidebar-brand-button,
165
166
  body.is-wco .collapse-toggle {
166
- height: max(40px, env(titlebar-area-height, 40px));
167
- min-height: max(40px, env(titlebar-area-height, 40px));
167
+ height: var(--titlebar-h, env(titlebar-area-height, 32px));
168
+ min-height: var(--titlebar-h, env(titlebar-area-height, 32px));
168
169
  }
169
170
  /* terminals.css uses the .tab-panel's gap (s-4) plus a -s-4 margin-top on
170
171
  .session-tabs to close that gap, so the tab strip visually flushes
@@ -749,8 +749,15 @@
749
749
  display: flex;
750
750
  flex-direction: column;
751
751
  gap: var(--s-4);
752
- padding: 4px var(--s-2) var(--s-4) 4px;
753
- margin: -4px calc(-1 * var(--s-2)) 0 -4px;
752
+ /* Negative right margin = -var(--s-4) cancels .main's padding-right
753
+ so the scroll container and therefore the scrollbar — reach the
754
+ full window edge. Padding-right = var(--s-4) preserves the same
755
+ visual gap between content and the right edge that was there
756
+ before. Top padding bumped to var(--s-3) so the first section title
757
+ has visible breathing room from the page-title-bar separator above
758
+ it (4px was almost flush). Negative margin-top stays in sync. */
759
+ padding: var(--s-4) var(--s-4) var(--s-4) 4px;
760
+ margin: -4px calc(-1 * var(--s-4)) 0 -4px;
754
761
  }
755
762
  /* In a flex column container, items default to flex-shrink:1 which
756
763
  causes the cards to compress instead of pushing the scroll container
@@ -1626,3 +1633,270 @@
1626
1633
  background: var(--bg);
1627
1634
  border: 1px solid var(--border);
1628
1635
  }
1636
+
1637
+ /* ── Remote page ──────────────────────────────────────────────────
1638
+ Uses the existing .settings-scroll + Section + .config-grid + .field
1639
+ + .chip system from ConfigurePage. Only adds the bits that don't
1640
+ already exist: the inline status line per provider, the token-row
1641
+ input + action cluster, the URL row, the CLI log block, and the
1642
+ bulleted security list. */
1643
+
1644
+ .remote-status-line {
1645
+ display: flex;
1646
+ align-items: center;
1647
+ flex-wrap: wrap;
1648
+ gap: var(--s-2);
1649
+ font-size: 12.5px;
1650
+ color: var(--ink-mid);
1651
+ }
1652
+ .remote-status-line .small-mono { font-size: 11px; }
1653
+ .remote-status-line .warn { color: #b86a2a; font-weight: 500; }
1654
+ .remote-status-line .muted { color: var(--ink-muted); }
1655
+ .remote-status-line code {
1656
+ font-family: var(--mono);
1657
+ font-size: 11.5px;
1658
+ background: var(--bg);
1659
+ padding: 1px 5px;
1660
+ border-radius: 3px;
1661
+ }
1662
+
1663
+ .remote-token-row {
1664
+ display: flex;
1665
+ gap: var(--s-2);
1666
+ align-items: center;
1667
+ flex-wrap: wrap;
1668
+ }
1669
+ .remote-token-input {
1670
+ flex: 1;
1671
+ min-width: 240px;
1672
+ font-family: var(--mono);
1673
+ font-size: 12.5px;
1674
+ padding: 7px 11px;
1675
+ border: 1px solid var(--border-strong);
1676
+ border-radius: var(--r-sm);
1677
+ background: var(--bg-elev);
1678
+ color: var(--ink);
1679
+ }
1680
+ .remote-token-input:focus {
1681
+ outline: none;
1682
+ border-color: var(--ink);
1683
+ box-shadow: 0 0 0 1px var(--ink);
1684
+ }
1685
+
1686
+ .remote-url-line {
1687
+ display: flex;
1688
+ gap: var(--s-2);
1689
+ align-items: center;
1690
+ flex-wrap: wrap;
1691
+ }
1692
+ .remote-url-value {
1693
+ flex: 1;
1694
+ min-width: 0;
1695
+ font-family: var(--mono);
1696
+ font-size: 12px;
1697
+ color: var(--ink);
1698
+ background: var(--bg);
1699
+ padding: 6px 10px;
1700
+ border-radius: 4px;
1701
+ border: 1px solid var(--border);
1702
+ overflow: hidden;
1703
+ text-overflow: ellipsis;
1704
+ white-space: nowrap;
1705
+ }
1706
+
1707
+ .remote-log {
1708
+ font-size: 11.5px;
1709
+ color: var(--ink-mid);
1710
+ }
1711
+ .remote-log summary { cursor: pointer; user-select: none; }
1712
+ .remote-log pre {
1713
+ margin-top: var(--s-2);
1714
+ padding: var(--s-3);
1715
+ background: var(--ink);
1716
+ color: var(--bg-elev);
1717
+ border-radius: var(--r-sm);
1718
+ font-family: var(--mono);
1719
+ font-size: 11px;
1720
+ line-height: 1.5;
1721
+ max-height: 220px;
1722
+ overflow: auto;
1723
+ white-space: pre-wrap;
1724
+ word-break: break-all;
1725
+ }
1726
+
1727
+ .remote-empty {
1728
+ margin: 0;
1729
+ font-size: 12.5px;
1730
+ color: var(--ink-muted);
1731
+ padding: var(--s-3);
1732
+ background: var(--bg);
1733
+ border-radius: var(--r-sm);
1734
+ text-align: center;
1735
+ }
1736
+ .remote-devices {
1737
+ display: flex;
1738
+ flex-direction: column;
1739
+ gap: var(--s-4);
1740
+ }
1741
+ .remote-devices-group {
1742
+ display: flex;
1743
+ flex-direction: column;
1744
+ gap: 6px;
1745
+ }
1746
+ .remote-devices-group-head {
1747
+ display: flex;
1748
+ align-items: baseline;
1749
+ gap: var(--s-2);
1750
+ margin-bottom: 2px;
1751
+ }
1752
+ .remote-devices-group-title {
1753
+ font-size: 11px;
1754
+ font-weight: 600;
1755
+ text-transform: uppercase;
1756
+ letter-spacing: 0.06em;
1757
+ color: var(--ink-mid);
1758
+ }
1759
+ .remote-devices-group-count {
1760
+ font-family: var(--mono);
1761
+ font-size: 11px;
1762
+ color: var(--ink-muted);
1763
+ background: var(--bg);
1764
+ padding: 1px 7px;
1765
+ border-radius: 999px;
1766
+ border: 1px solid var(--border);
1767
+ }
1768
+ .remote-devices-group-hint {
1769
+ font-size: 11px;
1770
+ font-style: italic;
1771
+ color: var(--ink-muted);
1772
+ }
1773
+ .remote-device {
1774
+ display: flex;
1775
+ align-items: center;
1776
+ gap: var(--s-3);
1777
+ padding: 10px 12px;
1778
+ background: var(--bg-elev);
1779
+ border: 1px solid var(--border);
1780
+ border-radius: var(--r-sm);
1781
+ }
1782
+ .remote-device.is-pending {
1783
+ border-color: #b86a2a;
1784
+ background: rgba(184, 106, 42, 0.04);
1785
+ }
1786
+ .remote-device.is-rejected {
1787
+ background: var(--bg);
1788
+ opacity: 0.8;
1789
+ }
1790
+ .remote-device-main {
1791
+ flex: 1;
1792
+ min-width: 0;
1793
+ }
1794
+ .remote-device-label {
1795
+ display: flex;
1796
+ align-items: center;
1797
+ gap: 6px;
1798
+ font-size: 13px;
1799
+ font-weight: 500;
1800
+ color: var(--ink);
1801
+ }
1802
+ .remote-device-label .icon-btn {
1803
+ background: transparent;
1804
+ border: 0;
1805
+ padding: 2px;
1806
+ cursor: pointer;
1807
+ color: var(--ink-muted);
1808
+ border-radius: 3px;
1809
+ display: inline-flex;
1810
+ align-items: center;
1811
+ }
1812
+ .remote-device-label .icon-btn:hover { color: var(--ink); background: var(--bg); }
1813
+ .remote-device-meta {
1814
+ font-size: 11.5px;
1815
+ color: var(--ink-mid);
1816
+ margin-top: 2px;
1817
+ display: flex;
1818
+ align-items: baseline;
1819
+ flex-wrap: wrap;
1820
+ gap: 4px;
1821
+ }
1822
+ .remote-device-meta .mono { font-family: var(--mono); font-size: 11px; }
1823
+ .remote-device-ua {
1824
+ font-family: var(--mono);
1825
+ font-size: 11px;
1826
+ color: var(--ink-muted);
1827
+ overflow: hidden;
1828
+ text-overflow: ellipsis;
1829
+ max-width: 380px;
1830
+ white-space: nowrap;
1831
+ }
1832
+ .remote-device-actions {
1833
+ display: flex;
1834
+ gap: 6px;
1835
+ flex-shrink: 0;
1836
+ }
1837
+ .remote-device-actions .action.small { padding: 4px 10px; font-size: 11.5px; }
1838
+
1839
+ /* Remote · "How access works" — three fact rows separated by hairlines,
1840
+ inset on the left by a 2px ink bar so the section reads as quiet
1841
+ reference material instead of an alert. */
1842
+ .remote-facts {
1843
+ margin: 0;
1844
+ padding: 0;
1845
+ display: flex;
1846
+ flex-direction: column;
1847
+ }
1848
+ .remote-fact {
1849
+ position: relative;
1850
+ padding: var(--s-3) var(--s-3) var(--s-3) var(--s-5);
1851
+ border-bottom: 1px solid var(--border);
1852
+ }
1853
+ .remote-fact:first-child { padding-top: var(--s-2); }
1854
+ .remote-fact:last-child { border-bottom: 0; padding-bottom: var(--s-2); }
1855
+ .remote-fact::before {
1856
+ content: "";
1857
+ position: absolute;
1858
+ left: 0;
1859
+ top: var(--s-3);
1860
+ bottom: var(--s-3);
1861
+ width: 2px;
1862
+ background: var(--ink);
1863
+ border-radius: 1px;
1864
+ opacity: 0.35;
1865
+ }
1866
+ .remote-fact dt {
1867
+ font-size: 12.5px;
1868
+ font-weight: 600;
1869
+ color: var(--ink);
1870
+ letter-spacing: -0.005em;
1871
+ margin-bottom: 4px;
1872
+ }
1873
+ .remote-fact dd {
1874
+ margin: 0;
1875
+ font-size: 12px;
1876
+ color: var(--ink-mid);
1877
+ line-height: 1.6;
1878
+ }
1879
+ .remote-fact dd code {
1880
+ font-family: var(--mono);
1881
+ font-size: 11px;
1882
+ background: var(--bg);
1883
+ padding: 1px 5px;
1884
+ border-radius: 3px;
1885
+ }
1886
+ .remote-fact dd strong {
1887
+ color: var(--ink);
1888
+ font-weight: 600;
1889
+ }
1890
+ .remote-fact dd em {
1891
+ font-style: italic;
1892
+ color: var(--ink);
1893
+ }
1894
+ .remote-fact-pill {
1895
+ display: inline-block;
1896
+ font-size: 11px;
1897
+ padding: 1px 7px;
1898
+ border-radius: 999px;
1899
+ background: rgba(184, 106, 42, 0.14);
1900
+ color: #8b4f1f;
1901
+ font-weight: 500;
1902
+ }
package/public/js/api.js CHANGED
@@ -1,17 +1,58 @@
1
1
  // Fetch wrapper + every loader. Loaders push into signals from ./state.js.
2
2
  // Cross-origin (hosted frontend → local backend) flows through httpBase().
3
3
 
4
+ import { signal } from '@preact/signals';
4
5
  import * as S from './state.js';
5
- import { httpBase } from './backend.js';
6
+ import { httpBase, getToken, getDeviceId, isRemoteAccess } from './backend.js';
7
+
8
+ // Global pending-approval signal. Flipped to true whenever any /api
9
+ // call returns 403 {pending:true}; PendingApprovalOverlay watches this
10
+ // and shows the blocking screen. We also stash the server's record so
11
+ // the overlay can display "we recorded you at HH:MM" detail.
12
+ export const pendingDevice = signal(null);
6
13
 
7
14
  export async function api(method, url, body) {
8
15
  const opts = { method, headers: { 'Content-Type': 'application/json' } };
16
+ // When a remote token is configured (Remote page set it OR the page
17
+ // was loaded with ?token= and we stashed it in localStorage), attach
18
+ // it to every API call. The server middleware lets loopback Hosts
19
+ // through without the token; for tunnel-served pages this is the
20
+ // only way past the 401.
21
+ const tok = getToken();
22
+ if (tok) opts.headers['Authorization'] = `Bearer ${tok}`;
23
+ // Always send our device id when one exists in localStorage. The host
24
+ // browser at localhost doesn't strictly need it (loopback bypass),
25
+ // but harmless — the server simply records lastSeen for it. Required
26
+ // for any tunnel-served page to clear the device-approval gate.
27
+ const dev = getDeviceId();
28
+ if (dev) opts.headers['X-Device-Id'] = dev;
9
29
  if (body !== undefined) opts.body = JSON.stringify(body);
10
30
  const r = await fetch(httpBase() + url, opts);
11
31
  const text = await r.text();
12
32
  let json;
13
33
  try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
14
- if (!r.ok) throw new Error(json.error || `HTTP ${r.status}`);
34
+ if (!r.ok) {
35
+ // Surface device-approval pending state. Only matters on remote
36
+ // tabs — host's loopback browser never gets a 401/403 from these
37
+ // checks.
38
+ if (isRemoteAccess()) {
39
+ if (r.status === 403 && json && (json.pending || json.rejected)) {
40
+ pendingDevice.value = { ...json, at: Date.now() };
41
+ } else if (r.status === 401) {
42
+ // Server doesn't recognise our device — either fresh page load
43
+ // (no /api/devices/me hit yet) or our record got deleted /
44
+ // pruned. Drop into the pending overlay; its /me poll will
45
+ // re-register us using the token we still have in localStorage,
46
+ // and the response sets pendingDevice to the correct state.
47
+ pendingDevice.value = { pending: true, at: Date.now() };
48
+ }
49
+ }
50
+ throw new Error(json.error || `HTTP ${r.status}`);
51
+ }
52
+ // PendingApprovalOverlay clears pendingDevice itself based on the
53
+ // /api/devices/me body (which can return 200 with status:'pending'
54
+ // since that endpoint is gate-exempt). Doing an auto-clear here on
55
+ // any 2xx would race the overlay's poll and dismiss it prematurely.
15
56
  return json;
16
57
  }
17
58
 
@@ -1,28 +1,84 @@
1
- // One source of truth for "where is the ccsm backend reachable".
1
+ // One source of truth for "where is the ccsm backend reachable"
2
+ // and "what auth token (if any) do we attach to every request".
2
3
  //
3
- // localhost / 127.0.0.1same-origin (page IS the backend)
4
- // everything else → http://localhost:7777 (hosted frontend
5
- // talks to the user's local backend via CORS)
4
+ // localhost / 127.0.0.1 same-origin (page IS the backend)
5
+ // bakapiano.github.io http://localhost:7777 (the hosted
6
+ // frontend talks to the user's local
7
+ // backend via CORS)
8
+ // anything else (tunnel domain) same-origin (the local backend is
9
+ // serving this frontend over the
10
+ // tunnel; API calls go to the same
11
+ // tunnel URL automatically)
6
12
  //
7
13
  // httpBase is used by fetch(); wsBase is used by WebSocket constructions.
8
14
  // Keep both as functions rather than constants so the values reflect
9
15
  // `location.*` at call time (matters for tests / route changes).
10
16
 
17
+ const HOSTED_HOST = 'bakapiano.github.io';
18
+
11
19
  function isLocal() {
12
20
  return location.hostname === 'localhost' || location.hostname === '127.0.0.1';
13
21
  }
22
+ function isHosted() {
23
+ return location.hostname === HOSTED_HOST;
24
+ }
14
25
 
15
26
  export function httpBase() {
16
- return isLocal() ? '' : 'http://localhost:7777';
27
+ if (isHosted()) return 'http://localhost:7777';
28
+ // Local OR tunnel-served — both same-origin.
29
+ return '';
17
30
  }
18
31
 
19
32
  export function wsBase() {
20
- if (isLocal()) {
21
- return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
22
- }
23
- return 'ws://localhost:7777';
33
+ if (isHosted()) return 'ws://localhost:7777';
34
+ return `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}`;
24
35
  }
25
36
 
26
37
  export function isHostedFrontend() {
27
- return !isLocal();
38
+ return isHosted();
39
+ }
40
+ // True when the page is being served via a remote tunnel — neither the
41
+ // host machine itself (localhost) nor the GH-Pages router. Used to gate
42
+ // off "wake backend" affordances that only work locally.
43
+ export function isRemoteAccess() {
44
+ return !isLocal() && !isHosted();
45
+ }
46
+
47
+ // ── Remote-access bearer token ────────────────────────────────────
48
+ // Persisted in localStorage so it survives reloads on whatever device
49
+ // loaded the share URL. main.js captures a fresh token from `?token=`
50
+ // on first arrival and stashes it via setToken(), then strips the
51
+ // query string from the URL so the secret doesn't sit in the address
52
+ // bar / browser history.
53
+ const LS_KEY = 'ccsm.token';
54
+
55
+ export function getToken() {
56
+ try { return localStorage.getItem(LS_KEY) || null; } catch { return null; }
57
+ }
58
+ export function setToken(t) {
59
+ try {
60
+ if (t) localStorage.setItem(LS_KEY, t);
61
+ else localStorage.removeItem(LS_KEY);
62
+ } catch {}
63
+ }
64
+
65
+ // ── Device id ─────────────────────────────────────────────────────
66
+ // Per-browser-profile UUID that identifies this device to the host
67
+ // machine for the approval flow. Generated once, persisted in
68
+ // localStorage, sent on every API call as X-Device-Id. The host pairs
69
+ // the id with the User-Agent the server records on first sight, so
70
+ // the approval UI can show "iPhone · Safari" instead of a raw uuid.
71
+ const LS_DEVICE = 'ccsm.deviceId';
72
+
73
+ export function getDeviceId() {
74
+ try {
75
+ let id = localStorage.getItem(LS_DEVICE);
76
+ if (!id) {
77
+ id = (crypto.randomUUID && crypto.randomUUID()) || (Math.random().toString(36).slice(2) + Date.now().toString(36));
78
+ localStorage.setItem(LS_DEVICE, id);
79
+ }
80
+ return id;
81
+ } catch {
82
+ return null;
83
+ }
28
84
  }
@@ -1,12 +1,19 @@
1
1
  import { html } from '../html.js';
2
- import { activeTab } from '../state.js';
2
+ import { activeTab, selectTab } from '../state.js';
3
+ import { useEffect } from 'preact/hooks';
4
+ import { isRemoteAccess } from '../backend.js';
5
+ import { PageTitleBar } from './PageTitleBar.js';
3
6
  import { Sidebar } from './Sidebar.js';
4
7
  import { Toast } from './Toast.js';
5
8
  import { DialogHost } from './DialogHost.js';
6
9
  import { HealthOverlay } from './HealthOverlay.js';
10
+ import { PendingApprovalOverlay } from './PendingApprovalOverlay.js';
11
+ import { MobileNavFab } from './MobileNavFab.js';
12
+ import { isMobile, mobileDrawerOpen } from '../state.js';
7
13
  import { SessionsPage } from '../pages/SessionsPage.js';
8
14
  import { LaunchPage } from '../pages/LaunchPage.js';
9
15
  import { ConfigurePage } from '../pages/ConfigurePage.js';
16
+ import { RemotePage } from '../pages/RemotePage.js';
10
17
  import { AboutPage } from '../pages/AboutPage.js';
11
18
 
12
19
  function Panel({ name, children }) {
@@ -14,22 +21,51 @@ function Panel({ name, children }) {
14
21
  return html`<section class="tab-panel" data-panel=${name} data-active=${active || null}>${children}</section>`;
15
22
  }
16
23
 
24
+ // Static placeholder for #remote on tunnel-served pages. Remote / device
25
+ // / tunnel management is loopback-only — the server returns 403 on
26
+ // every relevant endpoint — so even if a user navigates here via URL
27
+ // hash we render a clear "host machine only" message instead of a
28
+ // broken RemotePage spamming the console.
29
+ function RemoteHostOnlyPanel() {
30
+ useEffect(() => {
31
+ // Bounce back to whatever tab they were on before, after a brief
32
+ // moment so the message is readable.
33
+ const t = setTimeout(() => selectTab('sessions'), 2500);
34
+ return () => clearTimeout(t);
35
+ }, []);
36
+ return html`
37
+ <${PageTitleBar} title="Remote" />
38
+ <div class="settings-scroll">
39
+ <p class="remote-empty" style="margin-top:var(--s-6)">
40
+ Remote management is only available on the host machine.
41
+ Bouncing back to Sessions…
42
+ </p>
43
+ </div>`;
44
+ }
45
+
17
46
  export function App() {
18
47
  const tab = activeTab.value;
48
+ const remoteLocked = tab === 'remote' && isRemoteAccess();
49
+ const mobile = isMobile.value;
50
+ const drawer = mobileDrawerOpen.value;
19
51
 
20
52
  return html`
21
- <div class="app">
53
+ <div class=${`app${mobile ? ' is-mobile' : ''}${mobile && drawer ? ' drawer-open' : ''}`}>
22
54
  <${Sidebar} />
23
55
  <main class="main">
24
56
  <div class="content">
25
57
  ${tab === 'sessions' ? html`<${Panel} name="sessions"><${SessionsPage} /></${Panel}>` : null}
26
58
  ${tab === 'launch' ? html`<${Panel} name="launch"><${LaunchPage} /></${Panel}>` : null}
27
59
  ${tab === 'configure' ? html`<${Panel} name="configure"><${ConfigurePage} /></${Panel}>` : null}
60
+ ${tab === 'remote' && !remoteLocked ? html`<${Panel} name="remote"><${RemotePage} /></${Panel}>` : null}
61
+ ${remoteLocked ? html`<${Panel} name="remote"><${RemoteHostOnlyPanel} /></${Panel}>` : null}
28
62
  ${tab === 'about' ? html`<${Panel} name="about"><${AboutPage} /></${Panel}>` : null}
29
63
  </div>
30
64
  </main>
31
65
  <${Toast} />
32
66
  <${DialogHost} />
33
67
  <${HealthOverlay} />
68
+ <${PendingApprovalOverlay} />
69
+ <${MobileNavFab} />
34
70
  </div>`;
35
71
  }
@@ -21,6 +21,7 @@ import { useEffect } from 'preact/hooks';
21
21
  import { serverHealth, hasBootedOnline } from '../state.js';
22
22
  import { pollHealth, refreshAll } from '../api.js';
23
23
  import { BrandMark } from '../icons.js';
24
+ import { isRemoteAccess } from '../backend.js';
24
25
 
25
26
  const THRESHOLD = 3; // failures before we switch from "checking" to "not running"
26
27
  const FAST_POLL_MS = 1500;
@@ -66,6 +67,17 @@ export function HealthOverlay() {
66
67
  <p class="offline-copy">
67
68
  ${count === 0 ? 'Probing localhost:7777.' : `${count} attempt${count > 1 ? 's' : ''}. Hang tight.`}
68
69
  </p>
70
+ ` : isRemoteAccess() ? html`
71
+ <h1 class="offline-title">Host machine offline</h1>
72
+ <p class="offline-copy">
73
+ The ccsm backend you connected to over the tunnel isn't reachable.
74
+ Only the operator at the host machine can restart it — the tunnel
75
+ URL is dead until ccsm is running there again.
76
+ </p>
77
+ <p class="offline-copy" style="margin-top:8px;font-size:12px;color:var(--ink-muted)">
78
+ We'll keep polling and reconnect automatically as soon as the
79
+ backend comes back.
80
+ </p>
69
81
  ` : html`
70
82
  <h1 class="offline-title">Backend not running</h1>
71
83
  <p class="offline-copy">
@@ -0,0 +1,29 @@
1
+ // Phone-only navigation affordance.
2
+ //
3
+ // On viewports ≤ 640px the sidebar is hidden via CSS (.is-mobile body
4
+ // class). Instead, a circular floating button sits bottom-left; tapping
5
+ // it sets mobileDrawerOpen, which the sidebar reads to flip into a
6
+ // full-screen overlay. A backdrop captures taps outside the sidebar
7
+ // and dismisses.
8
+ //
9
+ // Visible only when isMobile signal is true — saves a render branch
10
+ // elsewhere.
11
+
12
+ import { html } from '../html.js';
13
+ import { isMobile, mobileDrawerOpen } from '../state.js';
14
+ import { IconSidebarToggle, IconClose } from '../icons.js';
15
+
16
+ export function MobileNavFab() {
17
+ if (!isMobile.value) return null;
18
+ const open = mobileDrawerOpen.value;
19
+ return html`
20
+ ${open ? html`
21
+ <div class="mobile-nav-backdrop"
22
+ onClick=${() => { mobileDrawerOpen.value = false; }} />
23
+ ` : null}
24
+ <button class=${`mobile-nav-fab${open ? ' is-open' : ''}`}
25
+ aria-label=${open ? 'close navigation' : 'open navigation'}
26
+ onClick=${() => { mobileDrawerOpen.value = !open; }}>
27
+ ${open ? html`<${IconClose} />` : html`<${IconSidebarToggle} />`}
28
+ </button>`;
29
+ }