alpha-gate 0.1.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.
Files changed (140) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -0
  4. package/bin/alpha-gate.mjs +75 -0
  5. package/deploy/backup.sh +45 -0
  6. package/deploy/deploy.sh +21 -0
  7. package/deploy/dev.sh +56 -0
  8. package/deploy/lib/statedir.sh +11 -0
  9. package/deploy/teardown.sh +17 -0
  10. package/docs/ONBOARDING.md +16 -0
  11. package/docs/PRINCIPLES.md +195 -0
  12. package/docs/README.md +54 -0
  13. package/docs/UPLOADING.md +14 -0
  14. package/docs/integrate/activation.md +56 -0
  15. package/docs/integrate/sparkle-go.md +106 -0
  16. package/docs/integrate/sparkle-swift.md +63 -0
  17. package/docs/maintain/backup.md +52 -0
  18. package/docs/maintain/migrate-account.md +95 -0
  19. package/docs/maintain/teardown.md +46 -0
  20. package/docs/maintain/troubleshooting.md +102 -0
  21. package/docs/maintain/updating.md +92 -0
  22. package/docs/operate/add-users.md +55 -0
  23. package/docs/operate/channels.md +54 -0
  24. package/docs/operate/email.md +51 -0
  25. package/docs/operate/monitoring.md +56 -0
  26. package/docs/operate/publish.md +90 -0
  27. package/docs/operate/remove-users.md +47 -0
  28. package/docs/setup/cloudflare-account.md +41 -0
  29. package/docs/setup/deploy.md +84 -0
  30. package/docs/setup/install.md +65 -0
  31. package/migrations/0001_clients.sql +12 -0
  32. package/migrations/0002_builds_streams.sql +30 -0
  33. package/migrations/0003_access_log.sql +14 -0
  34. package/migrations/0004_meta.sql +5 -0
  35. package/migrations/0005_admin_audit.sql +14 -0
  36. package/migrations/0006_build_dmg.sql +6 -0
  37. package/migrations/0007_access_requests.sql +12 -0
  38. package/migrations/0008_build_rollback_target.sql +7 -0
  39. package/migrations/0009_hidden.sql +6 -0
  40. package/migrations/0010_purged_at.sql +5 -0
  41. package/package.json +65 -0
  42. package/publish.sh +302 -0
  43. package/release.json +7 -0
  44. package/src/auth/access-jwt.ts +210 -0
  45. package/src/auth/token-gate.ts +27 -0
  46. package/src/core/appcast.ts +107 -0
  47. package/src/core/audit-chain.ts +145 -0
  48. package/src/core/invite-template.ts +127 -0
  49. package/src/core/no-build.ts +160 -0
  50. package/src/core/resolver.ts +60 -0
  51. package/src/core/tokens.ts +51 -0
  52. package/src/core/types.ts +76 -0
  53. package/src/core/validation.ts +60 -0
  54. package/src/core/verdict.ts +195 -0
  55. package/src/core/version.ts +113 -0
  56. package/src/cron.ts +37 -0
  57. package/src/db/access-log.ts +168 -0
  58. package/src/db/access-requests.ts +90 -0
  59. package/src/db/admin-audit.ts +101 -0
  60. package/src/db/builds.ts +169 -0
  61. package/src/db/client.ts +80 -0
  62. package/src/db/clients.ts +103 -0
  63. package/src/db/meta.ts +30 -0
  64. package/src/db/streams.ts +85 -0
  65. package/src/deploy/cli.ts +181 -0
  66. package/src/deploy/commands/deploy.ts +392 -0
  67. package/src/deploy/commands/dev.ts +190 -0
  68. package/src/deploy/commands/teardown.ts +159 -0
  69. package/src/deploy/core/args.ts +205 -0
  70. package/src/deploy/core/colors.ts +49 -0
  71. package/src/deploy/core/config.ts +65 -0
  72. package/src/deploy/core/parse.ts +51 -0
  73. package/src/deploy/core/paths.ts +27 -0
  74. package/src/deploy/core/plan.ts +138 -0
  75. package/src/deploy/core/result.ts +13 -0
  76. package/src/deploy/core/state.ts +64 -0
  77. package/src/deploy/core/table.ts +49 -0
  78. package/src/deploy/core/types.ts +39 -0
  79. package/src/deploy/core/ui.ts +107 -0
  80. package/src/deploy/seams/clock.ts +10 -0
  81. package/src/deploy/seams/files.ts +52 -0
  82. package/src/deploy/seams/io.ts +56 -0
  83. package/src/deploy/seams/wrangler.ts +100 -0
  84. package/src/deploy/ui-preview.ts +112 -0
  85. package/src/deps.ts +41 -0
  86. package/src/dev/admin-entry.ts +104 -0
  87. package/src/env.ts +47 -0
  88. package/src/lib/clock.ts +17 -0
  89. package/src/lib/hosts.ts +27 -0
  90. package/src/lib/text.ts +6 -0
  91. package/src/r2/builds-bucket.ts +50 -0
  92. package/src/r2/keys.ts +27 -0
  93. package/src/routes/admin/admin-context.ts +9 -0
  94. package/src/routes/admin/audit-fields.ts +22 -0
  95. package/src/routes/admin/branding.tsx +161 -0
  96. package/src/routes/admin/builds.tsx +388 -0
  97. package/src/routes/admin/clients.tsx +396 -0
  98. package/src/routes/admin/confirm.tsx +80 -0
  99. package/src/routes/admin/flash.ts +72 -0
  100. package/src/routes/admin/form.ts +43 -0
  101. package/src/routes/admin/index.ts +146 -0
  102. package/src/routes/admin/invite.ts +57 -0
  103. package/src/routes/admin/middleware.ts +52 -0
  104. package/src/routes/admin/negotiate.ts +13 -0
  105. package/src/routes/admin/pending.tsx +73 -0
  106. package/src/routes/admin/read-model.ts +518 -0
  107. package/src/routes/admin/streams.tsx +182 -0
  108. package/src/routes/admin/theme.ts +34 -0
  109. package/src/routes/admin/upload.tsx +320 -0
  110. package/src/routes/admin/views.tsx +293 -0
  111. package/src/routes/app/access.tsx +38 -0
  112. package/src/routes/app/app-context.ts +8 -0
  113. package/src/routes/app/appcast.ts +68 -0
  114. package/src/routes/app/assets.ts +25 -0
  115. package/src/routes/app/download.ts +45 -0
  116. package/src/routes/app/get.tsx +35 -0
  117. package/src/routes/app/index.ts +31 -0
  118. package/src/routes/app/resolve.ts +16 -0
  119. package/src/services/anchor.ts +54 -0
  120. package/src/services/audit.ts +19 -0
  121. package/src/services/branding.ts +51 -0
  122. package/src/services/email-cloudflare.ts +80 -0
  123. package/src/services/email.ts +68 -0
  124. package/src/services/self-update.ts +62 -0
  125. package/src/views/access-page.tsx +39 -0
  126. package/src/views/admin/ci-page.tsx +76 -0
  127. package/src/views/admin/combobox.tsx +191 -0
  128. package/src/views/admin/forms.tsx +37 -0
  129. package/src/views/admin/layout.tsx +496 -0
  130. package/src/views/admin/manage-pages.tsx +223 -0
  131. package/src/views/admin/manage.tsx +1120 -0
  132. package/src/views/admin/plist-extract.ts +233 -0
  133. package/src/views/admin/read-pages.tsx +1098 -0
  134. package/src/views/admin/setup-page.tsx +108 -0
  135. package/src/views/admin/table-enhance.ts +176 -0
  136. package/src/views/admin/ui.tsx +159 -0
  137. package/src/views/get-page.tsx +39 -0
  138. package/src/views/layout.tsx +57 -0
  139. package/src/worker.ts +23 -0
  140. package/tsconfig.json +35 -0
@@ -0,0 +1,496 @@
1
+ import type { Child, FC } from "hono/jsx";
2
+ import { COMBOBOX_SCRIPT } from "./combobox";
3
+ import { TABLE_ENHANCE_SCRIPT } from "./table-enhance";
4
+
5
+ // The back-office chrome — "quiet instrument": one continuous sheet structured by hairlines and a
6
+ // strict type hierarchy, color spent almost exclusively on state, mono only where data is machine-ish
7
+ // (build numbers, tokens, channels, timestamps). No cards, no shadows; elevation is two hairline
8
+ // weights. The serving map (dashboard) is the one decorated object; everything else is restraint.
9
+ // Pure JSX over props; light + dark from one token set. Theme follows the OS by default, with a
10
+ // per-operator override (the sidebar toggle → a `theme` cookie → data-theme on <html>).
11
+
12
+ // The dark rules exist ONCE and apply two ways: under prefers-color-scheme when the operator hasn't
13
+ // forced light, and unconditionally under a forced data-theme="dark". `root` is the selector prefix.
14
+ const darkRules = (root: string) => `
15
+ ${root}{color-scheme:dark;
16
+ --paper:#202126; --pane:#1a1b1f; --inset:#27282e;
17
+ --ink:#e9eaee; --ink2:#b1b3ba; --ink3:#8f9199;
18
+ --line:#33343b; --line2:#4a4b53;
19
+ --accent:#5cc6d8; --accent-ink:#0b262c; --accent-weak:#173237;
20
+ --ok:#5dbd72; --ok-dot:#3fa85c; --ok-weak:#15251a;
21
+ --warn:#d9a521; --warn-dot:#c69026; --warn-weak:#2b2310;
22
+ --danger:#f38175; --danger-dot:#e05d51; --danger-weak:#331a17;
23
+ --crit-bg:#f38175; --crit-ink:#33100b;
24
+ }
25
+ ${root} pre{background:#141519;border:1px solid var(--line)}
26
+ ${root} .btn-danger{color:#2a0f0b}
27
+ `;
28
+
29
+ const styles = `
30
+ *{margin:0;padding:0;box-sizing:border-box}
31
+ :root{
32
+ color-scheme:light dark;
33
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
34
+ --mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,Consolas,monospace;
35
+ --paper:#fcfcfd; --pane:#f4f4f6; --inset:#f1f1f4;
36
+ --ink:#1b1c1e; --ink2:#4c4e54; --ink3:#6e7076;
37
+ --line:#e7e8eb; --line2:#cfd0d6;
38
+ --accent:#0e6e7e; --accent-ink:#ffffff; --accent-weak:#e3eff1;
39
+ --ok:#1a7f37; --ok-dot:#2da44e; --ok-weak:#e9f4ec;
40
+ --warn:#95650a; --warn-dot:#dba714; --warn-weak:#faf1d8;
41
+ --danger:#b42318; --danger-dot:#d6493e; --danger-weak:#fdecec;
42
+ --crit-bg:#b42318; --crit-ink:#ffffff;
43
+ }
44
+ :root[data-theme=light]{color-scheme:light}
45
+ @media (prefers-color-scheme:dark){${darkRules(":root:not([data-theme=light])")}}
46
+ ${darkRules(":root[data-theme=dark]")}
47
+ body{display:flex;min-height:100vh;background:var(--paper);color:var(--ink);
48
+ font:13px/1.5 var(--sans);-webkit-font-smoothing:antialiased;font-variant-numeric:tabular-nums}
49
+ ::selection{background:var(--accent-weak)}
50
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:3px}
51
+ @media (prefers-reduced-motion:reduce){*{transition:none!important}}
52
+ a{color:inherit}
53
+ .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;
54
+ clip:rect(0 0 0 0);white-space:nowrap;border:0}
55
+ .skip{position:absolute;left:8px;top:-48px;z-index:10;background:var(--paper);color:var(--ink);
56
+ padding:8px 12px;border:1px solid var(--line2);border-radius:5px;text-decoration:none;transition:top .15s}
57
+ .skip:focus{top:8px}
58
+
59
+ /* ————— sidebar ————— */
60
+ aside{width:212px;flex:none;background:var(--pane);border-right:1px solid var(--line);
61
+ padding:18px 14px;display:flex;flex-direction:column;gap:22px;
62
+ position:sticky;top:0;height:100vh;overflow-y:auto}
63
+ .brand{display:flex;gap:9px;align-items:center;padding:2px 8px;text-decoration:none}
64
+ .brand svg{width:17px;height:17px;color:var(--ink3);flex:none}
65
+ .brand .dotmark{fill:var(--accent)}
66
+ .brand b{display:block;font-size:13px;font-weight:600;letter-spacing:-.01em}
67
+ .brand span{display:block;font:10.5px var(--mono);color:var(--ink3);margin-top:1px}
68
+ nav.primary{display:flex;flex-direction:column;gap:18px}
69
+ nav.primary h6{font:600 10px var(--sans);letter-spacing:.1em;text-transform:uppercase;
70
+ color:var(--ink3);padding:0 8px;margin-bottom:5px}
71
+ nav.primary a{display:flex;align-items:center;gap:6px;padding:4.5px 8px;border-radius:5px;
72
+ color:var(--ink2);text-decoration:none;font-size:13px;position:relative;transition:background .1s}
73
+ nav.primary a:hover{color:var(--ink);background:var(--inset)}
74
+ nav.primary a[aria-current]{color:var(--ink);font-weight:600}
75
+ nav.primary a[aria-current]::before{content:"";position:absolute;left:-8px;top:6px;bottom:6px;
76
+ width:2px;border-radius:2px;background:var(--accent)}
77
+ .chip{margin-left:auto;font:600 10.5px var(--mono);color:var(--warn);
78
+ background:var(--warn-weak);padding:1px 6px;border-radius:999px}
79
+ .theme{margin-top:auto;display:flex;border:1px solid var(--line2);border-radius:5px;overflow:hidden}
80
+ .theme button{flex:1;font:500 10.5px var(--sans);padding:4px 0;border:none;border-radius:0;
81
+ background:transparent;color:var(--ink3);cursor:pointer}
82
+ .theme button+button{border-left:1px solid var(--line2)}
83
+ .theme button:hover{color:var(--ink);background:var(--inset)}
84
+ .theme button[aria-pressed=true]{background:var(--inset);color:var(--ink);font-weight:600}
85
+
86
+ /* ————— main + page head ————— */
87
+ main{flex:1;min-width:0;max-width:1180px;padding:26px 36px 56px}
88
+ .pagehead{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
89
+ h1{font-size:19px;font-weight:650;letter-spacing:-.013em}
90
+ .sub{color:var(--ink3);font-size:12.5px}
91
+ .inv{margin-left:auto;font-size:12px;color:var(--ink3)}
92
+ .inv a{color:inherit;text-decoration:none}
93
+ .inv a:hover{color:var(--accent)}
94
+ .crumb{font-size:12px;color:var(--ink3);margin-bottom:6px}
95
+ .crumb a{color:var(--ink3);text-decoration:none}
96
+ .crumb a:hover{color:var(--accent);text-decoration:underline}
97
+
98
+ /* ————— sections (slabs, not cards) ————— */
99
+ section{margin-top:36px}
100
+ .slab{display:flex;align-items:baseline;justify-content:space-between;gap:12px;
101
+ border-bottom:1px solid var(--line2);padding-bottom:7px}
102
+ .slab h2{font:600 10.5px var(--sans);letter-spacing:.1em;text-transform:uppercase;color:var(--ink3)}
103
+ .slab .hint{font-size:11.5px;color:var(--ink3)}
104
+ .slab a{font-size:11.5px;color:var(--ink3);text-decoration:none}
105
+ .slab a:hover{color:var(--accent)}
106
+
107
+ /* ————— canonical version lockup ————— */
108
+ .lk{font-family:var(--mono);font-size:12px;white-space:nowrap;text-decoration:none;color:var(--ink)}
109
+ .lk b{font-weight:600}
110
+ .lk i{font-style:normal;font-weight:400;color:var(--ink3)}
111
+ .lk.dim,.lk.dim b{color:var(--ink3);font-weight:400}
112
+ a.lk:hover b{text-decoration:underline}
113
+ .num{font-family:var(--mono);font-weight:600}
114
+
115
+ /* ————— state tags (exceptions only) ————— */
116
+ .tag{display:inline-block;font:500 10.5px var(--mono);padding:1px 7px 2px;
117
+ border:1px solid;border-radius:4px;white-space:nowrap;vertical-align:1px}
118
+ .tag.warn{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent)}
119
+ .tag.mut{color:var(--ink3);border-color:var(--line2)}
120
+ .tag.acc{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
121
+ .tag.crit{color:var(--crit-ink);background:var(--crit-bg);border-color:var(--crit-bg);font-weight:600}
122
+
123
+ /* ————— state dots ————— */
124
+ .dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex:none}
125
+ .dot.ok{background:var(--ok-dot)}
126
+ .dot.warn{background:var(--warn-dot)}
127
+ .dot.off{background:transparent;box-shadow:inset 0 0 0 1.5px var(--warn-dot)}
128
+ .dot.req{background:var(--accent)}
129
+
130
+ /* ————— text roles ————— */
131
+ .mut{color:var(--ink3)}
132
+ .t{font:11.5px var(--mono);color:var(--ink3);white-space:nowrap}
133
+ .vd{font-size:12.5px}
134
+ .vd .lk{font-size:12.5px}
135
+ .vd .tag{margin-left:2px}
136
+
137
+ /* ————— notices + callouts ————— */
138
+ .notice{margin-top:14px;border:1px solid color-mix(in srgb,var(--ok) 30%,transparent);
139
+ background:var(--ok-weak);color:var(--ok);border-radius:8px;padding:8px 12px;font-size:12.5px}
140
+ .callout{border-radius:8px;padding:10px 13px;margin:14px 0;font-size:12.5px;line-height:1.55}
141
+ .callout.warn{background:var(--warn-weak);color:var(--warn);
142
+ border:1px solid color-mix(in srgb,var(--warn) 30%,transparent)}
143
+ .callout.ok{background:var(--ok-weak);color:var(--ok);
144
+ border:1px solid color-mix(in srgb,var(--ok) 30%,transparent)}
145
+ .callout.danger{background:var(--danger-weak);color:var(--danger);
146
+ border:1px solid color-mix(in srgb,var(--danger) 30%,transparent)}
147
+ .callout a{color:inherit}
148
+
149
+ /* ————— verdict strip (detail pages) ————— */
150
+ .verdict{display:flex;gap:10px;align-items:baseline;border-top:1px solid var(--line2);
151
+ border-bottom:1px solid var(--line2);padding:12px 0;margin-top:18px;font-size:13px}
152
+ .verdict .dot{align-self:center}
153
+ .verdict .lk{font-size:13px}
154
+
155
+ /* ————— tables ————— */
156
+ .tbl{overflow-x:auto}
157
+ table{width:100%;border-collapse:collapse}
158
+ th{font:600 10px var(--sans);letter-spacing:.09em;text-transform:uppercase;color:var(--ink3);
159
+ text-align:left;padding:7px 10px;border-bottom:1px solid var(--line2);white-space:nowrap}
160
+ td{padding:8px 10px;border-bottom:1px solid var(--line);font-size:12.5px;vertical-align:baseline}
161
+ tbody tr:hover{background:var(--inset)}
162
+ tr[hidden]{display:none}
163
+ td.r,th.r{text-align:right}
164
+ tr.dim td{color:var(--ink3)}
165
+ tr.dim td a{color:var(--ink3)}
166
+ td .who{font-weight:600;color:var(--ink);text-decoration:none}
167
+ td .who:hover{text-decoration:underline}
168
+ td .lbl{color:var(--ink3);margin-left:7px;font-size:12px}
169
+ td.chs{font-family:var(--mono);font-size:11.5px}
170
+ td.chs a{color:var(--ink2);text-decoration:none}
171
+ td.chs a:hover{color:var(--ink);text-decoration:underline}
172
+ th.th-sort{cursor:pointer;user-select:none}
173
+ th.th-sort::after{content:"↕";opacity:.35;margin-left:.35em;font-weight:400}
174
+ th.th-sort[aria-sort="ascending"]::after{content:"↑";opacity:.9}
175
+ th.th-sort[aria-sort="descending"]::after{content:"↓";opacity:.9}
176
+ .tfoot{display:flex;gap:8px;margin-top:10px;font-size:11.5px;color:var(--ink3)}
177
+ .tfoot a{color:inherit}
178
+ .tfoot a:hover{color:var(--accent)}
179
+ .empty{color:var(--ink3);padding:14px 0;font-size:12.5px}
180
+
181
+ /* ————— filter + inline forms ————— */
182
+ .filters{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-top:14px;font-size:12.5px}
183
+ .filters label{display:inline-flex;gap:5px;align-items:center;color:var(--ink2)}
184
+ .filters a{color:var(--ink3)}
185
+ form.inline{display:inline-flex;gap:6px;align-items:center}
186
+
187
+ /* ————— combobox (searchable entity picker; see combobox.tsx) ————— */
188
+ .cbx{position:relative;display:inline-flex;flex-direction:column;gap:6px;min-width:17rem}
189
+ .cbx.on select{display:none}
190
+ .cbx-input{font:12.5px var(--sans);color:var(--ink);background:var(--paper);
191
+ border:1px solid var(--line2);border-radius:5px;padding:5px 8px}
192
+ .cbx-input::placeholder{color:var(--ink3)}
193
+ .cbx-list{position:absolute;top:100%;left:0;right:0;margin-top:4px;list-style:none;
194
+ background:var(--paper);border:1px solid var(--line2);border-radius:5px;
195
+ max-height:220px;overflow-y:auto;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.10)}
196
+ .cbx-list li{padding:6px 10px;font-size:12.5px;cursor:pointer;font-family:var(--mono)}
197
+ .cbx-list li:hover,.cbx-list li.act{background:var(--inset)}
198
+ .cbx-list li.none{color:var(--ink3);cursor:default;font-family:var(--sans)}
199
+ .cbx-chips{display:flex;flex-wrap:wrap;gap:6px;max-width:24rem}
200
+ .cbx-chip{font:500 11px var(--mono);border:1px solid var(--line2);border-radius:4px;
201
+ background:var(--inset);color:var(--ink2);padding:2px 8px;cursor:pointer}
202
+ .cbx-chip:hover{color:var(--danger);border-color:color-mix(in srgb,var(--danger) 40%,var(--line2))}
203
+ .cbx.err .cbx-input{border-color:var(--danger);
204
+ outline:2px solid color-mix(in srgb,var(--danger) 35%,transparent)}
205
+
206
+ /* ————— controls ————— */
207
+ input,select,textarea{font:12.5px var(--sans);color:var(--ink);background:var(--paper);
208
+ border:1px solid var(--line2);border-radius:5px;padding:5px 8px}
209
+ input[type=file]{padding:4px}
210
+ input.mono,textarea.mono{font-family:var(--mono);font-size:12px}
211
+ textarea{width:100%;min-height:5rem;resize:vertical;line-height:1.5}
212
+ input::placeholder,textarea::placeholder{color:var(--ink3)}
213
+ button,.btn{font:500 12.5px var(--sans);padding:5px 11px;border-radius:5px;line-height:1.45;
214
+ border:1px solid var(--line2);background:var(--paper);color:var(--ink);cursor:pointer;
215
+ text-decoration:none;display:inline-block;white-space:nowrap}
216
+ button:hover,.btn:hover{background:var(--inset)}
217
+ .btn-primary{background:var(--accent);border-color:var(--accent);color:var(--accent-ink);font-weight:600}
218
+ .btn-primary:hover{background:var(--accent);filter:brightness(1.06)}
219
+ .btn-danger{background:var(--danger);border-color:var(--danger);color:#fff;font-weight:600}
220
+ .btn-danger:hover{background:var(--danger);filter:brightness(1.06)}
221
+ .actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:14px}
222
+
223
+ /* ————— labeled form fields ————— */
224
+ .field{display:flex;flex-direction:column;gap:4px;margin:0}
225
+ .field>span{font:600 11px var(--sans);letter-spacing:.02em;color:var(--ink2)}
226
+ .field>span i{font-style:normal;font-weight:400;color:var(--ink3)}
227
+ .field input,.field select{max-width:26rem}
228
+ .frow{display:flex;gap:14px;align-items:flex-end;flex-wrap:wrap;margin-top:14px}
229
+ fieldset{border:none;margin-top:22px}
230
+ fieldset legend{font:600 10.5px var(--sans);letter-spacing:.1em;text-transform:uppercase;
231
+ color:var(--ink3);border-bottom:1px solid var(--line2);width:100%;padding-bottom:7px;margin-bottom:12px}
232
+ .fhint{font-size:12px;color:var(--ink3);margin-top:6px;max-width:64ch}
233
+
234
+ /* ————— facts (key-value) ————— */
235
+ .facts{margin-top:0}
236
+ .facts div{display:grid;grid-template-columns:150px 1fr;gap:14px;padding:9px 0;
237
+ border-bottom:1px solid var(--line);font-size:12.5px;align-items:baseline}
238
+ .facts dt{color:var(--ink3)}
239
+ .facts dd{min-width:0;overflow-wrap:anywhere}
240
+ .facts code{font:12px var(--mono)}
241
+
242
+ /* ————— entity rows (channel/build membership lists) ————— */
243
+ .rows{list-style:none}
244
+ .rows li{display:flex;gap:12px;align-items:center;padding:9px 0;border-bottom:1px solid var(--line);
245
+ font-size:12.5px}
246
+ .rows li form{margin-left:auto}
247
+ .rows li .t{margin-left:auto}
248
+ .rows li .t+form{margin-left:14px}
249
+
250
+ /* ————— danger zone ————— */
251
+ .dangerzone{margin-top:40px}
252
+ .dangerzone .slab{border-bottom-color:color-mix(in srgb,var(--danger) 35%,transparent)}
253
+ .dangerzone .slab h2{color:var(--danger)}
254
+ .dangerzone p{font-size:12.5px;color:var(--ink3);margin-top:10px;max-width:64ch}
255
+
256
+ /* ————— token / code blocks ————— */
257
+ .token{display:block;font:12px var(--mono);background:var(--inset);border:1px solid var(--line);
258
+ border-radius:5px;padding:9px 11px;word-break:break-all;user-select:all;margin-top:8px}
259
+ code{font-family:var(--mono);font-size:.92em}
260
+ pre{background:#17181c;color:#e9eaee;padding:12px 14px;border-radius:8px;overflow-x:auto;
261
+ font-size:11.5px;line-height:1.6;margin-top:10px}
262
+ pre code{font-size:inherit}
263
+
264
+ /* ————— serving map (signature) ————— */
265
+ .map{list-style:none}
266
+ .map li{display:grid;grid-template-columns:14px 84px minmax(36px,1fr) auto minmax(36px,1fr) auto;
267
+ gap:0 14px;align-items:center;padding:16px 0;border-bottom:1px solid var(--line)}
268
+ .map .ch{font:500 13px var(--mono);color:var(--ink);text-decoration:none}
269
+ .map .ch:hover{text-decoration:underline}
270
+ .rail{height:1px;background:var(--line2);position:relative;min-width:36px}
271
+ .rail::after{content:"";position:absolute;right:0;top:-3px;
272
+ border-left:6px solid var(--line2);border-top:3.5px solid transparent;border-bottom:3.5px solid transparent}
273
+ .rail.dash{background:repeating-linear-gradient(90deg,var(--line2) 0 5px,transparent 5px 10px)}
274
+ .rail.dash::after{opacity:.55}
275
+ .served{display:flex;align-items:center;gap:8px;min-width:272px}
276
+ .served .lk{font-size:13px;overflow:hidden;text-overflow:ellipsis}
277
+ .served .none{font-size:12.5px;color:var(--warn)}
278
+ .served .none i{font-style:normal;color:var(--ink3)}
279
+ .aud{text-align:right;text-decoration:none;font-size:12.5px;color:var(--ink);min-width:232px}
280
+ .aud b{font-weight:600}
281
+ .aud em{font-style:normal;color:var(--ink3);margin-left:6px;font-size:12px}
282
+ .aud em.w{color:var(--warn)}
283
+ a.aud:hover b{text-decoration:underline}
284
+ .map li.offrow{background:none}
285
+ .map li.offrow .ch{color:var(--warn);font-size:12px;white-space:nowrap}
286
+
287
+ /* ————— attention + recent (dashboard) ————— */
288
+ .cols{display:grid;grid-template-columns:1.55fr 1fr;gap:56px}
289
+ .attn{list-style:none}
290
+ .attn li{display:grid;grid-template-columns:14px 1fr auto;gap:0 12px;
291
+ padding:12px 0;border-bottom:1px solid var(--line);align-items:start}
292
+ .attn .dot{margin-top:5.5px}
293
+ .attn b{font-weight:600;font-size:13px}
294
+ .attn b a{color:inherit;text-decoration:none}
295
+ .attn b a:hover{text-decoration:underline}
296
+ .attn p{color:var(--ink3);font-size:12.5px;margin-top:1px;max-width:54ch}
297
+ .attn p .lk{font-size:11.5px;color:var(--ink3)}
298
+ .attn p a{color:var(--ink2);text-decoration:underline;text-underline-offset:2px;
299
+ text-decoration-color:var(--line2)}
300
+ .attn p a:hover{color:var(--ink)}
301
+ .fix{font-size:12.5px;font-weight:500;color:var(--accent);text-decoration:none;
302
+ white-space:nowrap;margin-top:2px}
303
+ .fix:hover{text-decoration:underline}
304
+ .rec{list-style:none}
305
+ .rec li{display:grid;grid-template-columns:86px 1fr;gap:12px;padding:7px 0;
306
+ font-size:12.5px;align-items:baseline;color:var(--ink2)}
307
+ .rec li a{color:var(--ink);text-decoration:none}
308
+ .rec li a:hover{text-decoration:underline}
309
+ .rec .lk{font-size:11.5px}
310
+ .allgood{display:flex;gap:10px;align-items:center;padding:14px 0;color:var(--ink3);font-size:12.5px}
311
+
312
+ /* ————— chain seal ————— */
313
+ .seal{display:flex;gap:8px;align-items:center;font-size:11.5px;color:var(--ink3)}
314
+ .seal .dot{width:7px;height:7px}
315
+ .seal.bad{color:var(--danger);font-weight:600}
316
+
317
+ /* ————— aliases + page-specific bits ————— */
318
+ .muted{color:var(--ink3)}
319
+ section p{margin-top:10px;font-size:12.5px}
320
+ .modes{display:flex;gap:1.25rem;margin:18px 0 0}
321
+ .modes label{display:inline-flex;align-items:center;gap:.4rem;font-weight:500}
322
+ .rollback-only{display:none}
323
+ form:has(#mode-rollback:checked) .rollback-only{display:block}
324
+
325
+ /* ————— small screens ————— */
326
+ @media (max-width: 48rem){
327
+ body{flex-direction:column}
328
+ aside{position:static;width:auto;height:auto;flex-direction:row;align-items:center;gap:16px;
329
+ border-right:none;border-bottom:1px solid var(--line);overflow-x:auto}
330
+ nav.primary{flex-direction:row;gap:14px}
331
+ nav.primary h6{display:none}
332
+ main{padding:20px 18px 40px}
333
+ .cols{grid-template-columns:1fr}
334
+ .map li{grid-template-columns:14px 76px minmax(20px,1fr) auto}
335
+ .map li .rail:nth-of-type(2){display:none}
336
+ .map li .aud{grid-column:2 / -1;text-align:left;margin-top:6px;min-width:0}
337
+ .served{min-width:0}
338
+ }
339
+ `;
340
+
341
+ // Sets aria-current on the active nav link (progressive enhancement: the server renders no active
342
+ // state, so the nav is fully usable without JS; this only adds the highlight). Longest-prefix match,
343
+ // with /admin matched only exactly so the Overview link doesn't claim every sub-page.
344
+ const activeNavScript = `
345
+ (function () {
346
+ var path = location.pathname, best = null, len = -1;
347
+ document.querySelectorAll('nav.primary a').forEach(function (a) {
348
+ var h = a.getAttribute('href');
349
+ var match = h === path || (h !== '/admin' && path.indexOf(h + '/') === 0);
350
+ if (match && h.length > len) { best = a; len = h.length; }
351
+ });
352
+ if (best) best.setAttribute('aria-current', 'page');
353
+ })();
354
+ `;
355
+
356
+ // The gate glyph: two posts and the accent dot passing between them. Doubles as the favicon.
357
+ const FAVICON =
358
+ "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2016%2016'%3E%3Cpath%20d='M4.5%202.5v11M11.5%202.5v11'%20stroke='%236e7076'%20stroke-width='1.8'%20stroke-linecap='round'/%3E%3Ccircle%20cx='8'%20cy='8'%20r='2.4'%20fill='%230e6e7e'/%3E%3C/svg%3E";
359
+
360
+ const NAV: readonly { label: string; items: readonly [string, string][] }[] = [
361
+ {
362
+ label: "Operate",
363
+ items: [
364
+ ["/admin", "Overview"],
365
+ ["/admin/users", "Users"],
366
+ ["/admin/builds", "Builds"],
367
+ ["/admin/streams", "Channels"],
368
+ ["/admin/pending", "Requests"],
369
+ ],
370
+ },
371
+ {
372
+ label: "Publish",
373
+ items: [
374
+ ["/admin/upload", "Upload"],
375
+ ["/admin/ci", "CI publish"],
376
+ ],
377
+ },
378
+ {
379
+ label: "Logs",
380
+ items: [
381
+ ["/admin/activity", "Activity"],
382
+ ["/admin/audit", "Audit"],
383
+ ],
384
+ },
385
+ {
386
+ label: "Configure",
387
+ items: [
388
+ ["/admin/setup", "Setup"],
389
+ ["/admin/settings", "Settings"],
390
+ ],
391
+ },
392
+ ];
393
+
394
+ /** Per-request chrome data every page threads through: flash notice, instance slug, requests chip. */
395
+ export interface Chrome {
396
+ notice?: string | null | undefined;
397
+ instance?: string | undefined;
398
+ /** Pending access requests → the amber chip on the Requests nav item. */
399
+ pending?: number | undefined;
400
+ /** The operator's theme override (the `theme` cookie); undefined = follow the OS. */
401
+ theme?: "light" | "dark" | undefined;
402
+ /** The current path — where the theme toggle returns to after its POST. */
403
+ path?: string | undefined;
404
+ }
405
+
406
+ // Applies the theme cookie before first paint, so pages rendered WITHOUT chrome (confirmations,
407
+ // invite results) still honor the override. The server-rendered data-theme covers the JS-off case
408
+ // on every page that threads chrome. Hand-written string — keep it free of outer references.
409
+ const THEME_SCRIPT = `(function(){var m=document.cookie.match(/(?:^|; )theme=(dark|light)(?:;|$)/);if(m)document.documentElement.setAttribute("data-theme",m[1]);})();`;
410
+
411
+ export const AdminLayout: FC<{
412
+ title: string;
413
+ chrome?: Chrome | undefined;
414
+ /** Extra page-head content rendered beside the h1 (subtitle, inventory line). */
415
+ head?: Child;
416
+ /** Breadcrumb line rendered above the h1 (e.g. Users / alice@…). */
417
+ crumb?: Child;
418
+ children?: Child;
419
+ }> = ({ title, chrome, head, crumb, children }) => (
420
+ <html lang="en" data-theme={chrome?.theme}>
421
+ <head>
422
+ <meta charset="utf-8" />
423
+ <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
424
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
425
+ <title>{title} · Alpha Gate</title>
426
+ <link rel="icon" href={FAVICON} />
427
+ <style dangerouslySetInnerHTML={{ __html: styles }} />
428
+ </head>
429
+ <body>
430
+ <a href="#main" class="skip">
431
+ Skip to content
432
+ </a>
433
+ <aside>
434
+ <a class="brand" href="/admin">
435
+ <svg viewBox="0 0 16 16" aria-hidden="true">
436
+ <path
437
+ d="M4.5 2.5v11M11.5 2.5v11"
438
+ stroke="currentColor"
439
+ stroke-width="1.8"
440
+ stroke-linecap="round"
441
+ />
442
+ <circle class="dotmark" cx="8" cy="8" r="2.4" />
443
+ </svg>
444
+ <div>
445
+ <b>Alpha Gate</b>
446
+ {chrome?.instance ? <span>{chrome.instance}</span> : null}
447
+ </div>
448
+ </a>
449
+ <nav class="primary" aria-label="Primary">
450
+ {NAV.map((group) => (
451
+ <div>
452
+ <h6>{group.label}</h6>
453
+ {group.items.map(([href, label]) => (
454
+ <a href={href}>
455
+ {label}
456
+ {label === "Requests" && (chrome?.pending ?? 0) > 0 ? (
457
+ <span class="chip">{chrome?.pending}</span>
458
+ ) : null}
459
+ </a>
460
+ ))}
461
+ </div>
462
+ ))}
463
+ </nav>
464
+ <form method="post" action="/admin/theme" class="theme" aria-label="Theme">
465
+ <input type="hidden" name="return_to" value={chrome?.path ?? "/admin"} />
466
+ {(["light", "system", "dark"] as const).map((value) => (
467
+ <button
468
+ type="submit"
469
+ name="value"
470
+ value={value}
471
+ aria-pressed={(chrome?.theme ?? "system") === value ? "true" : "false"}
472
+ >
473
+ {value === "light" ? "Light" : value === "dark" ? "Dark" : "System"}
474
+ </button>
475
+ ))}
476
+ </form>
477
+ </aside>
478
+ <main id="main">
479
+ {crumb ? <p class="crumb">{crumb}</p> : null}
480
+ <header class="pagehead">
481
+ <h1>{title}</h1>
482
+ {head}
483
+ </header>
484
+ {chrome?.notice ? (
485
+ <p class="notice" role="status">
486
+ {chrome.notice}
487
+ </p>
488
+ ) : null}
489
+ {children}
490
+ </main>
491
+ <script dangerouslySetInnerHTML={{ __html: activeNavScript }} />
492
+ <script dangerouslySetInnerHTML={{ __html: TABLE_ENHANCE_SCRIPT }} />
493
+ <script dangerouslySetInnerHTML={{ __html: COMBOBOX_SCRIPT }} />
494
+ </body>
495
+ </html>
496
+ );