@bakapiano/ccsm 0.17.10 → 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.
@@ -226,55 +226,146 @@
226
226
  min-height: 0;
227
227
  height: 100%;
228
228
  margin: 0 calc(-1 * var(--s-4));
229
- /* Cancel the tab-panel gap above so the terminal sits flush under the
230
- title bar with no white band. */
231
- margin-top: calc(-1 * var(--s-4));
232
229
  background: var(--bg-elev);
233
230
  overflow: hidden;
234
231
  }
235
- .session-actions {
232
+
233
+ /* Session tabs · sits between the page-title-bar and the session-pane.
234
+ One row of small tab buttons; the active one has a darker bg + accent
235
+ underline. Last child is a "+" button that bounces to /launch. The
236
+ strip is full-bleed (matches .session-pane horizontal extents). */
237
+ .session-tabs {
236
238
  display: flex;
237
239
  align-items: stretch;
238
- justify-content: flex-start;
239
240
  gap: 0;
240
- height: calc(24px * var(--anti-zoom, 1));
241
- min-height: calc(24px * var(--anti-zoom, 1));
242
- max-height: calc(24px * var(--anti-zoom, 1));
241
+ height: 30px;
243
242
  flex-shrink: 0;
244
- box-sizing: border-box;
245
- padding: 0;
246
- background: var(--accent);
247
- border-top: 0;
243
+ padding: 0 2px 0 0;
244
+ /* Negative bottom margin cancels the tab-panel's gap to the
245
+ session-pane underneath, so the strip sits flush against the
246
+ terminal. Negative horizontal margin cancels .main's padding so
247
+ the strip is full-bleed like the terminal underneath. */
248
+ margin: 0 calc(-1 * var(--s-4)) calc(-1 * var(--s-4));
249
+ background: #2d2a26;
250
+ border-bottom: 0;
251
+ }
252
+ .session-tabs-list {
253
+ display: flex;
254
+ align-items: stretch;
255
+ gap: 0;
256
+ flex: 1;
257
+ min-width: 0;
258
+ overflow: hidden;
248
259
  }
249
- .session-actions .action {
250
- display: inline-flex;
260
+ .session-tabs-right {
261
+ display: flex;
251
262
  align-items: center;
252
- gap: 5px;
253
- height: 100%;
254
- padding: 0 10px;
255
- font-size: 11px;
256
- font-weight: 500;
257
- line-height: 1;
258
- color: rgba(255, 255, 255, 0.85);
259
- background: transparent;
263
+ flex-shrink: 0;
264
+ padding-right: 2px;
265
+ }
266
+ /* Close the gap to the page-title-bar above — only when there IS one.
267
+ In standalone PWA the title-bar is display:none and .session-tabs is
268
+ the first tab-panel child; a negative margin-top there would push
269
+ the strip up over the OS title-bar border. */
270
+ .page-title-bar + .session-tabs {
271
+ margin-top: calc(-1 * var(--s-4));
272
+ }
273
+ .session-tab {
274
+ appearance: none;
275
+ background: #423d37;
260
276
  border: 0;
261
- border-radius: 0;
277
+ border-bottom: 2px solid transparent;
278
+ margin-bottom: -1px; /* overlap container border-bottom */
279
+ padding: 0 10px;
280
+ display: inline-flex;
281
+ align-items: center;
282
+ gap: 6px;
283
+ font: inherit;
284
+ font-size: 12px;
285
+ color: rgba(255, 255, 255, 0.65);
262
286
  cursor: pointer;
287
+ max-width: 200px;
288
+ min-width: 0;
263
289
  transition: background-color .12s, color .12s;
264
290
  }
265
- .session-actions .action:hover {
291
+ .session-tab:hover { background: #4f4942; color: #fff; }
292
+ .session-tab.is-active {
293
+ background: var(--ink);
266
294
  color: #fff;
267
- background: rgba(255, 255, 255, 0.14);
295
+ border-bottom-color: var(--ink);
268
296
  }
269
- .session-actions .action.danger:hover {
297
+ .session-tab-icon { display: inline-flex; flex-shrink: 0; }
298
+ .session-tab-icon svg { width: 14px; height: 14px; }
299
+ .session-tab-icon img { width: 14px; height: 14px; }
300
+ .session-tab-label {
301
+ white-space: nowrap;
302
+ overflow: hidden;
303
+ text-overflow: ellipsis;
304
+ min-width: 0;
305
+ }
306
+ .session-tab-meta { color: rgba(255, 255, 255, 0.5); font-size: 11px; }
307
+ .session-tab.is-active .session-tab-meta { color: rgba(255, 255, 255, 0.6); }
308
+ .session-tab-add {
309
+ background: transparent;
310
+ max-width: none;
311
+ padding: 0 8px;
270
312
  color: #fff;
271
- background: rgba(0, 0, 0, 0.22);
272
313
  }
273
- .session-actions .action svg {
274
- width: 13px;
275
- height: 13px;
276
- stroke-width: 1.75;
314
+ .session-tab-add:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
315
+ .session-tab-add svg { width: 14px; height: 14px; }
316
+
317
+ /* Kebab in the page-title-bar (top-right). Compact 24px square so it
318
+ doesn't dominate the masthead. In WCO mode the title-bar already
319
+ reserves padding-right for OS controls, so this slides cleanly to
320
+ the left of them. */
321
+ .session-menu-btn {
322
+ appearance: none;
323
+ background: transparent;
324
+ border: 0;
325
+ width: 26px;
326
+ height: 26px;
327
+ border-radius: 5px;
328
+ display: inline-flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ color: #fff;
332
+ cursor: pointer;
333
+ flex-shrink: 0;
334
+ transition: background-color .12s, color .12s;
277
335
  }
336
+ .session-menu-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
337
+ .session-menu-btn svg { width: 16px; height: 16px; }
338
+
339
+ .session-menu {
340
+ background: var(--bg-elev);
341
+ border: 1px solid var(--border);
342
+ border-radius: 6px;
343
+ padding: 4px;
344
+ box-shadow: var(--shadow-md, 0 4px 16px rgba(0,0,0,0.08));
345
+ display: flex;
346
+ flex-direction: column;
347
+ gap: 2px;
348
+ }
349
+ .session-menu-item {
350
+ appearance: none;
351
+ background: transparent;
352
+ border: 0;
353
+ padding: 7px 10px;
354
+ border-radius: 4px;
355
+ display: flex;
356
+ align-items: center;
357
+ gap: 8px;
358
+ font: inherit;
359
+ font-size: 13px;
360
+ color: var(--ink);
361
+ cursor: pointer;
362
+ text-align: left;
363
+ }
364
+ .session-menu-item:hover { background: var(--bg); }
365
+ .session-menu-item.danger { color: var(--danger, #b73f3f); }
366
+ .session-menu-item.danger:hover { background: rgba(183, 63, 63, 0.08); }
367
+ .session-menu-item svg { width: 14px; height: 14px; }
368
+
278
369
  .session-pane-head {
279
370
  display: flex;
280
371
  align-items: center;
@@ -327,11 +418,25 @@
327
418
  height: 100%;
328
419
  }
329
420
  .session-pane-body .terminal-empty {
330
- background: var(--bg);
421
+ background: #1a1815;
422
+ color: #e8e3d5;
331
423
  display: flex;
332
424
  flex-direction: column;
333
425
  align-items: center;
334
426
  justify-content: center;
335
427
  gap: var(--s-3);
336
428
  height: 100%;
429
+ font-size: 13px;
430
+ }
431
+ .session-pane-body .terminal-empty .mono {
432
+ color: #e07b6e;
433
+ }
434
+ .session-pane-body .terminal-empty .action.primary {
435
+ background: #e8e3d5;
436
+ color: #1a1815;
437
+ border-color: #e8e3d5;
438
+ }
439
+ .session-pane-body .terminal-empty .action.primary:hover {
440
+ background: #faf9f5;
441
+ border-color: #faf9f5;
337
442
  }
@@ -80,16 +80,34 @@ body.is-app:not(.is-wco) .sidebar-top {
80
80
  body.is-app:not(.is-wco) .page-title-bar {
81
81
  display: none;
82
82
  }
83
+ /* terminals.css uses `.page-title-bar + .session-tabs { margin-top: -s-4 }`
84
+ to flush the tab strip against the in-page title-bar. Adjacent-sibling
85
+ selectors match by DOM order regardless of display, so the rule still
86
+ pulls session-tabs up by 16px even when the title-bar is hidden — the
87
+ top half of the tabs ends up clipped above the viewport / under the OS
88
+ title bar. Reset to 0 in standalone PWA. */
89
+ body.is-app:not(.is-wco) .page-title-bar + .session-tabs {
90
+ margin-top: 0;
91
+ }
83
92
  body.is-app:not(.is-wco) .app {
84
- /* Hairline separator under the OS title bar. Painted as an inset
85
- box-shadow rather than a border so it doesn't change .app's box
86
- model a real border-top added 1px to .app's height, and the
87
- grid's implicit row auto-sized off content (.main reporting 720)
88
- rather than the (now 719px) container, so .main overflowed the
89
- viewport by exactly 1px and put a permanent vertical scrollbar
90
- down the right of the terminal page. Inset shadow draws the same
91
- line with zero layout cost. */
92
- box-shadow: inset 0 1px 0 var(--border);
93
+ /* Hairline separator under the OS title bar. Has to sit ABOVE the
94
+ sidebar's own background an inset box-shadow on .app gets covered
95
+ by .sidebar (which paints var(--ui-bg) across its full area inside
96
+ .app), so the line was invisible. An absolutely-positioned ::before
97
+ paints over both columns; it's outside the grid track so it doesn't
98
+ reintroduce the 1px overflow that a real `border-top: 1px` did. */
99
+ position: relative;
100
+ }
101
+ body.is-app:not(.is-wco) .app::before {
102
+ content: "";
103
+ position: absolute;
104
+ top: 0;
105
+ left: 0;
106
+ right: 0;
107
+ height: 1px;
108
+ background: var(--border);
109
+ z-index: 10;
110
+ pointer-events: none;
93
111
  }
94
112
  /* With page-title-bar hidden, session-pane's `margin-top: calc(-1 *
95
113
  var(--s-4))` — designed to flush it against the (now invisible)
@@ -101,13 +119,21 @@ body.is-app:not(.is-wco) .session-pane {
101
119
  margin-top: 0;
102
120
  height: auto;
103
121
  }
104
- /* Settings page · with the title-bar hidden in standalone its content
105
- would otherwise butt straight against the OS title-bar border. Give
106
- it back a little breathing room (Sessions / Launch / About don't
107
- need this they have their own top chrome or flush-to-edge intent). */
108
- body.is-app:not(.is-wco) [data-panel="configure"] {
122
+ /* Settings + Launch · with the in-page title-bar hidden in standalone
123
+ mode, their content would otherwise butt straight against the OS
124
+ title-bar border. Give a little breathing room. Sessions has its
125
+ own full-bleed terminal pane; About has its own hero header. */
126
+ body.is-app:not(.is-wco) [data-panel="configure"],
127
+ body.is-app:not(.is-wco) [data-panel="launch"],
128
+ body.is-app:not(.is-wco) [data-panel="remote"] {
109
129
  padding-top: var(--s-4);
110
130
  }
131
+ /* Sidebar nav rows (New Session / Settings) also need a top gap in
132
+ standalone — body.is-app zeros .sidebar's padding-top so the in-page
133
+ title-bar can sit flush, but with that title-bar gone in standalone
134
+ mode the nav buttons end up jammed against the OS title-bar border.
135
+ Restore a small inset so they read as a real nav, not as overflow. */
136
+ body.is-app:not(.is-wco) .sidebar { padding-top: var(--s-3); }
111
137
 
112
138
  /* WCO mode only: the browser has hidden its own title bar and floats OS
113
139
  controls (min/max/close) over our content top-right. Our 34px top band
@@ -124,16 +150,39 @@ body.is-wco .page-title-bar {
124
150
  }
125
151
  body.is-wco .page-title-bar,
126
152
  body.is-wco .sidebar-top {
127
- height: calc(34px * var(--anti-zoom, 1));
128
- min-height: calc(34px * var(--anti-zoom, 1));
129
- max-height: calc(34px * 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));
130
163
  }
131
164
  body.is-wco .sidebar-brand,
132
165
  body.is-wco .sidebar-brand-button,
133
166
  body.is-wco .collapse-toggle {
134
- height: 34px;
135
- min-height: 34px;
167
+ height: var(--titlebar-h, env(titlebar-area-height, 32px));
168
+ min-height: var(--titlebar-h, env(titlebar-area-height, 32px));
136
169
  }
170
+ /* terminals.css uses the .tab-panel's gap (s-4) plus a -s-4 margin-top on
171
+ .session-tabs to close that gap, so the tab strip visually flushes
172
+ against the page-title-bar above. In WCO the negative margin pulls the
173
+ tabs row UP by 16px — i.e. its top edge lands ABOVE the OS overlay's
174
+ bottom edge, and the kebab inside gets clipped by the floating window
175
+ controls. Cancel both the gap and the negative margin here so the tabs
176
+ row sits at exactly y=titlebar-area-height (flush below the overlay)
177
+ without losing flushness against the title-bar. */
178
+ body.is-wco .tab-panel { gap: 0; }
179
+ body.is-wco .page-title-bar + .session-tabs { margin-top: 0; }
180
+ /* terminals.css also gives .session-tabs `margin-bottom: -s-4` to close
181
+ the gap to session-pane below — but with tab-panel gap now 0 in WCO
182
+ that negative margin pulls session-pane UP 16px instead, overlapping
183
+ the bottom of the tab labels (kebab + tab text get clipped from
184
+ below). Zero it out too. */
185
+ body.is-wco .session-tabs { margin-bottom: 0; }
137
186
 
138
187
  @media (display-mode: window-controls-overlay) {
139
188
  body.is-wco .page-title-bar {
@@ -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