@lanes-sh/link 0.6.8 → 0.6.9

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.
@@ -4,12 +4,6 @@ import { capabilityIdForToolName } from '#server/mcp';
4
4
  import { ATTACHMENTS_PATH, stageAttachment } from './attachments.ts';
5
5
  import { allowedHostnamesFor, rebindingRefusal } from './rebinding.ts';
6
6
  import { ANY_ORIGIN, corsAware, type CorsPolicy } from './cors.ts';
7
- import {
8
- DASHBOARD_PATH,
9
- dashboardSessions,
10
- handleDashboard,
11
- servesDashboard,
12
- } from './dashboard.ts';
13
7
  import type { Generation } from './generation.ts';
14
8
  import type { Generations } from './generations.ts';
15
9
  import { callerKey, failedAuthLimiter, FAILED_AUTH_PER_MINUTE, tooManyAttempts } from './edge.ts';
@@ -56,15 +50,6 @@ export interface ServerOptions {
56
50
  readonly authorization?: AuthorizationSurface | undefined;
57
51
  /** Hostnames this endpoint answers to. See `./rebinding.ts`. */
58
52
  readonly allowedHostnames?: readonly string[] | undefined;
59
- /**
60
- * Serve the dashboard.
61
- *
62
- * Off unless asked for, and `serve()` withholds it anyway when the bind
63
- * address is not loopback. `lanes link start` asks; `container.ts` does not.
64
- * See `./dashboard.ts` for why a deployed instance has no browser-shaped door
65
- * to put it behind.
66
- */
67
- readonly dashboard?: boolean | undefined;
68
53
  }
69
54
 
70
55
  type RefusalReason = Extract<AuthOutcome, { ok: false }>['reason'];
@@ -126,7 +111,6 @@ export interface RequestHandler {
126
111
  export function createRequestHandler(options: ServerOptions): RequestHandler {
127
112
  let probedAt = 0;
128
113
  const failedAuth = failedAuthLimiter();
129
- const sessions = dashboardSessions();
130
114
 
131
115
  /**
132
116
  * Re-read the config because a call named a tool we do not serve.
@@ -186,19 +170,6 @@ export function createRequestHandler(options: ServerOptions): RequestHandler {
186
170
  });
187
171
  }
188
172
 
189
- // Above the 404 gate and outside the bearer path below, because a
190
- // top-level browser navigation carries no `Authorization` header — so it
191
- // authenticates itself, against the same authenticator. Unset, this is
192
- // never reached and `/dashboard` is a 404 like any other unknown path.
193
- if (options.dashboard && url.pathname === DASHBOARD_PATH) {
194
- return await handleDashboard(request, {
195
- generations: options.generations,
196
- authenticator: options.authenticator,
197
- primary: options.primary,
198
- sessions,
199
- });
200
- }
201
-
202
173
  if (
203
174
  url.pathname !== MCP_PATH &&
204
175
  url.pathname !== ATTACHMENTS_PATH &&
@@ -369,15 +340,14 @@ export function serve(options: ServeOptions): RunningServer {
369
340
  const allowedHostnames = options.allowedHostnames ?? allowedHostnamesFor(host, loopback);
370
341
 
371
342
  // Cross-origin access, and its absence, are decided here for the same reason
372
- // `allowedHostnames` and `dashboard` are: they are all properties of what this
373
- // is bound to. The two are mutually exclusive and the exclusion is the
374
- // decision — see `./cors.ts`, and ADR-039.
343
+ // `allowedHostnames` is: both are properties of what this is bound to. The
344
+ // two are mutually exclusive and the exclusion is the decision — see
345
+ // `./cors.ts`, and ADR-039.
375
346
  const cors: CorsPolicy | undefined = loopback
376
347
  ? undefined
377
348
  : { allowedOrigins: primary.config.auth.allowed_origins ?? [ANY_ORIGIN] };
378
349
  const handler = createRequestHandler({
379
350
  ...options,
380
- dashboard: servesDashboard(options.dashboard, loopback),
381
351
  ...(allowedHostnames ? { allowedHostnames } : {}),
382
352
  });
383
353
 
@@ -28,7 +28,6 @@ export {
28
28
  visibleCapabilities,
29
29
  visibleToolCount,
30
30
  type BuildServerOptions,
31
- type ConnectionState,
32
31
  type MergedCapability,
33
32
  type ProfileRuntime,
34
33
  } from './visibility.ts';
@@ -20,20 +20,6 @@ import { allowedConnections } from '#policy';
20
20
  * still a leak.
21
21
  */
22
22
 
23
- /**
24
- * A connection's reconciled state, as much of it as a reader needs.
25
- *
26
- * Structural rather than the store's own `ConnectionRecord`: `server` does not
27
- * import `stores` (`src/architecture.test.ts`), and what a surface that reports
28
- * wants from a connection is its key and whether it is working — not the
29
- * timestamps and credential expiry the repository keeps behind it.
30
- */
31
- export interface ConnectionState {
32
- readonly provider: string;
33
- readonly id: string;
34
- readonly status: string;
35
- }
36
-
37
23
  /** Everything one profile contributes to the endpoint. */
38
24
  export interface ProfileRuntime {
39
25
  readonly config: Config;
@@ -49,25 +35,6 @@ export interface ProfileRuntime {
49
35
  * how often to ask (ADR-014).
50
36
  */
51
37
  refreshSkills?(): Promise<void>;
52
- /**
53
- * Which target's adapters this profile was opened against.
54
- *
55
- * Not derivable from `config`: a target is *selected* per run, and the config
56
- * only says which one is the default. Optional for the same reason the one
57
- * below is — a runtime built to answer "what is visible" was never opened
58
- * against anything.
59
- */
60
- readonly target?: string | undefined;
61
- /**
62
- * Reconciled connection state, for a surface that reports rather than
63
- * dispatches.
64
- *
65
- * Optional exactly as `refreshSkills` is: only a served endpoint holds the
66
- * state handle this reads through, and nothing on the dispatch path asks —
67
- * a capability's visibility is decided by policy, not by whether the
68
- * credential behind it currently works.
69
- */
70
- connections?(): Promise<readonly ConnectionState[]>;
71
38
  }
72
39
 
73
40
  export interface BuildServerOptions {
@@ -1,107 +0,0 @@
1
- import { deploymentIdentity, endpointHealth, localUrl } from '../../endpoint-url.ts';
2
- import { defaultOpenBrowser } from '../../oauth.ts';
3
- import { announce, ok, print, style, warn } from '../../output.ts';
4
- import {
5
- ensureProfileToken,
6
- openRuntime,
7
- resolveProfile,
8
- type GlobalFlags,
9
- } from '../../runtime.ts';
10
-
11
- /**
12
- * `lanes link dashboard` — open the page this endpoint serves.
13
- *
14
- * The command exists because the page cannot be reached without it. A browser
15
- * navigating to `/dashboard` carries no `Authorization` header, so the token has
16
- * to arrive some other way, and the only place it already exists is the
17
- * credential store this command can read. It puts it in the URL once; the
18
- * endpoint exchanges it for a session cookie and redirects to a URL without it.
19
- *
20
- * Local only, deliberately. See `#server/dashboard.ts` — ADR-018 leaves a
21
- * deployed instance with no door a person at a browser can come through, and a
22
- * command that opened a loopback URL for a target that is not there would be
23
- * reporting success for a page nobody can load.
24
- */
25
-
26
- export interface DashboardFlags extends GlobalFlags {
27
- /** Print the URL instead of opening it. */
28
- readonly print?: boolean | undefined;
29
- }
30
-
31
- export async function dashboard(flags: DashboardFlags): Promise<void> {
32
- // `resolveProfile` rather than `openRuntime`, and the order is the point.
33
- // Opening a runtime opens that target's adapters, so a deployed target sends
34
- // this command to Secret Manager and a bucket before it can say the one thing
35
- // it needs to — that the dashboard is not there. The refusal below would
36
- // still be correct and nobody would ever read it, because a storage 403
37
- // arrives first. This is the seam `runtime.ts` documents `deploy` and
38
- // `secrets push` stopping at, for the same reason.
39
- const { resolution, config, target, resolved } = await resolveProfile(flags);
40
- announce(resolution);
41
-
42
- const deployed = deploymentIdentity(resolved?.declared.deploy);
43
- if (deployed) {
44
- throw new Error(
45
- `Target "${target}" is deployed to ${deployed.platform}, and the dashboard is served only ` +
46
- 'by a local endpoint — a browser carries no bearer token, and Cloud Run\'s own gate ' +
47
- 'admits only a Google-signed identity token it cannot mint either (ADR-018).\n' +
48
- `What a deployed target can answer: ` +
49
- `lanes link status --profile ${resolution.profile} --target ${target}`,
50
- );
51
- }
52
-
53
- const runtime = await openRuntime(flags);
54
-
55
- try {
56
- const { token } = await ensureProfileToken(runtime.credentials, runtime.config.auth.token_ref);
57
-
58
- const url = new URL(localUrl(runtime.config));
59
- url.pathname = '/dashboard';
60
- url.searchParams.set('k', token);
61
- // Named rather than left to the endpoint's primary, so the page opens on
62
- // the profile this command resolved and printed above.
63
- url.searchParams.set('profile', runtime.resolution.profile);
64
-
65
- // The page is served by a running endpoint or by nothing. Saying so here
66
- // beats opening a browser at a connection error.
67
- const live = await endpointHealth(localUrl(runtime.config), token);
68
- if (!live) {
69
- // Both flags on both lines. They read as correct without them only
70
- // because they were written while a missing one still resolved to the
71
- // workspace default; with nothing to fall back on (ADR-037) each is a
72
- // paste that refuses, and this one is printed at the moment somebody is
73
- // least able to guess what it wanted.
74
- const where = `--profile ${runtime.resolution.profile} --target ${target}`;
75
-
76
- print(warn(`nothing is serving this port — run: lanes link start ${where}`));
77
- print(style.dim(` then: lanes link dashboard ${where}`));
78
- return;
79
- }
80
-
81
- if (!live.profiles.includes(runtime.resolution.profile)) {
82
- // `start --only` serves one profile. The URL would 404 on the rest, which
83
- // is honest but unhelpful without being told why.
84
- print(
85
- warn(
86
- `the endpoint on this port serves ${live.profiles.join(', ')} — ` +
87
- `not "${runtime.resolution.profile}"`,
88
- ),
89
- );
90
- return;
91
- }
92
-
93
- if (flags.print) {
94
- // Printed, not opened: for a terminal on a machine with no browser, and
95
- // for anyone who would rather see what they are about to open. It carries
96
- // the token, which is why it is behind a flag rather than always shown.
97
- print(url.href);
98
- return;
99
- }
100
-
101
- defaultOpenBrowser(url.href);
102
- print(ok(`opened ${style.bold(`${url.origin}/dashboard`)}`));
103
- print(style.dim(' the link carries a one-time key; the page swaps it for a session cookie.'));
104
- } finally {
105
- await runtime.close();
106
- }
107
- }
@@ -1,293 +0,0 @@
1
- import { PROVIDER_MANIFESTS } from '#providers/index.ts';
2
- import { planAll, type ProviderPlan } from '#providers/setup/plan.ts';
3
- import { PROVIDER_MARKS } from './provider-marks.ts';
4
- import { escapeHtml } from './brand.ts';
5
- import { shell } from './dashboard-shell.ts';
6
-
7
- /**
8
- * The dashboard — one page answering what `status`, `setup plan` and
9
- * `target list` answer between them.
10
- *
11
- * It renders and it does not act. Every state-changing thing on it is a command
12
- * to paste, for two reasons that are not the same. Connecting is ADR-005: the
13
- * consent belongs to whoever owns the browser, and the loopback listener that
14
- * receives the code is the CLI's. Everything else is ADR-007: config is written
15
- * from the control plane, and a page served by the endpoint is not it.
16
- *
17
- * So this is a reader, and the honest shape of a reader is a page that tells you
18
- * the line to run. What it saves is not typing — it is knowing which line.
19
- *
20
- * The design is `callback-page.ts`'s, because that is the product's page and
21
- * this is the same product: nothing painted (`color-scheme: light dark` over a
22
- * transparent background, correct in either mode with no theme switch), Lora on
23
- * the heading, and colour reserved for status rather than spent on emphasis.
24
- * What differs is the width — a card centred at 460px is right for one sentence
25
- * and wrong for four sections.
26
- */
27
-
28
- export interface DashboardConnection {
29
- /** `provider.id`, the key everything else addresses this by. */
30
- readonly key: string;
31
- readonly provider: string;
32
- readonly account: string;
33
- /** What the operator calls it, where that is not just the account. */
34
- readonly label?: string | undefined;
35
- /** `active`, `unauthorized`, `disabled`, or `not reconciled`. */
36
- readonly state: string;
37
- }
38
-
39
- export interface DashboardView {
40
- /** The profile being rendered, which `?profile=` may have chosen. */
41
- readonly profile: string;
42
- /** Every profile this endpoint serves, for the switcher. */
43
- readonly profiles: readonly string[];
44
- /** The target whose adapters are open. */
45
- readonly target: string;
46
- /** Every target the profile declares, live or not. */
47
- readonly targets: readonly string[];
48
- readonly connections: readonly DashboardConnection[];
49
- /** `oauth_apps` keys, so a profile with its own client is described as having one. */
50
- readonly ownClients: readonly string[];
51
- }
52
-
53
- /**
54
- * Every command this page renders, built in one place.
55
- *
56
- * `--profile` and `--target` on all of them, unconditionally. The shell a line
57
- * is pasted into resolves both for itself — from `LANES_LINK_PROFILE` and
58
- * `LANES_LINK_TARGET`, or from the workspace default — and a page that shows
59
- * you one profile while handing you a command that silently acts on another is
60
- * worse than one that shows nothing. It is also the rule `resolveSelection`
61
- * already follows by refusing to guess.
62
- */
63
- function command(view: DashboardView, rest: string): string {
64
- return `lanes link ${rest} --profile ${view.profile} --target ${view.target}`;
65
- }
66
-
67
- /**
68
- * A command, shown in full.
69
- *
70
- * Kept for the two places where the command *is* the label — the sign-in page
71
- * and the footer — because there is no provider name to carry it. Everywhere
72
- * else the line is on the button, not on the page; see `copyButton`.
73
- */
74
- function commandLine(line: string): string {
75
- const text = escapeHtml(line);
76
- return (
77
- `<div class="cmd"><code>${text}</code>` +
78
- `<button class="btn copy" type="button" data-copy="${text}" aria-label="Copy command">copy</button></div>`
79
- );
80
- }
81
-
82
- /**
83
- * The command, on the button rather than beside it.
84
- *
85
- * What this trades away is worth naming: the line is no longer selectable by
86
- * hand, so a browser with no clipboard API leaves no way to get it. That is
87
- * narrower than it sounds — `navigator.clipboard` needs a secure context, and
88
- * `http://127.0.0.1` is one by definition, which is the only address this page
89
- * is ever served on. The `title` carries the text for anyone who wants to read
90
- * before pasting, and `lanes link setup plan` prints the same lines.
91
- */
92
- function copyButton(line: string, label: string): string {
93
- const text = escapeHtml(line);
94
- return (
95
- `<button class="btn copy" type="button" data-copy="${text}" title="${text}" ` +
96
- `aria-label="Copy the command that connects ${escapeHtml(label)}">copy</button>`
97
- );
98
- }
99
-
100
- /**
101
- * The provider's mark, or letters standing in for one.
102
- *
103
- * `aria-hidden` on both: every caller puts the name beside it, and a screen
104
- * reader announcing "GitHub, GitHub" is worse than one announcing it once.
105
- *
106
- * The fallback takes the first letter of the family and two of the member,
107
- * because the plain two collapse a catalogue holding four `icloud_*` entries
108
- * and two `*_mcp` ones into a handful of identical marks — `ICA` and `ICO`
109
- * rather than `IC` twice.
110
- */
111
- function mark(id: string): string {
112
- const path = PROVIDER_MARKS[id];
113
- if (path) {
114
- return (
115
- '<svg class="glyph" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">' +
116
- `<path d="${path}"/></svg>`
117
- );
118
- }
119
-
120
- const [family, member] = id.split('_');
121
- const letters = member ? `${family![0]}${member.slice(0, 2)}` : id.slice(0, 2);
122
- return `<span class="glyph letters" aria-hidden="true">${escapeHtml(letters.toUpperCase())}</span>`;
123
- }
124
-
125
- /**
126
- * The state, as one of the design system's badge variants.
127
- *
128
- * "Gold for positive, neutral tokens otherwise" — so `active` is the only gold
129
- * one, and `unauthorized` is *neutral* rather than red. It is not an error: the
130
- * credential is absent from this target's store, which is a thing to do, and
131
- * `--destructive` is reserved for something having gone wrong. `disabled` is
132
- * quieter still, being a row that config no longer declares.
133
- */
134
- function statusPill(state: string): string {
135
- const kind = state === 'active' ? 'positive' : state === 'disabled' ? 'quiet' : 'neutral';
136
- return `<span class="pill ${kind}">${escapeHtml(state)}</span>`;
137
- }
138
-
139
- function connectionsSection(view: DashboardView): string {
140
- if (view.connections.length === 0) {
141
- return '<p class="empty">Nothing connected in this profile yet. Pick one below.</p>';
142
- }
143
-
144
- const rows = view.connections
145
- .map((connection) => {
146
- // A connection that is not active is one the reader can act on, and the
147
- // action is the same `connect` that made it — re-running against an
148
- // existing key repairs it rather than adding a second account. An active
149
- // one has nothing to do, so it carries no button.
150
- const repair =
151
- connection.state === 'active'
152
- ? ''
153
- : copyButton(command(view, `connect ${connection.key}`), connection.key);
154
-
155
- return (
156
- '<div class="row">' +
157
- mark(connection.provider) +
158
- `<code class="key">${escapeHtml(connection.key)}</code>` +
159
- statusPill(connection.state) +
160
- // The label reads, the account hovers: the row is one line wide and the
161
- // address is the longer of the two, so putting both in it wraps every
162
- // row to make one of them legible.
163
- `<span class="account"${
164
- connection.label && connection.label !== connection.account
165
- ? ` title="${escapeHtml(connection.account)}"`
166
- : ''
167
- }>${escapeHtml(connection.label ?? connection.account)}</span>` +
168
- repair +
169
- '</div>'
170
- );
171
- })
172
- .join('\n');
173
-
174
- return `<div class="rows">${rows}</div>`;
175
- }
176
-
177
- /**
178
- * One provider, one line: what it is called and the line that connects it.
179
- *
180
- * Everything else a card used to carry is gone — the description, whether it
181
- * opens a browser, who operates its OAuth client. None of it is lost: it is all
182
- * in `lanes link setup plan` and in the `setup_provider` capability, both of
183
- * which exist to answer "what does connecting this involve" at the length that
184
- * question deserves. This page answers a different one — what is there, and
185
- * what would I paste — and a paragraph per provider buried it.
186
- *
187
- * `plan.command` is built by the same `planFor` those two render, so the line
188
- * copied here and the line an agent suggests cannot drift.
189
- */
190
- function providerCard(plan: ProviderPlan): string {
191
- return (
192
- '<div class="row">' +
193
- mark(plan.id) +
194
- `<span class="name">${escapeHtml(plan.name)}</span>` +
195
- copyButton(plan.command, plan.name) +
196
- '</div>'
197
- );
198
- }
199
-
200
- function catalogue(view: DashboardView): { available: string; another: string } {
201
- const plans = planAll(PROVIDER_MANIFESTS, {
202
- profile: view.profile,
203
- connections: view.connections.map((connection) => connection.key),
204
- ownClients: view.ownClients,
205
- target: view.target,
206
- });
207
-
208
- // The same split `setup_overview` makes: never connected is an invitation,
209
- // and already connected is only an invitation when a second account means
210
- // something (ADR — `multiAccount` is the credential test, not a preference).
211
- const available = plans.filter((plan) => plan.connected.length === 0);
212
- const more = plans.filter((plan) => plan.connected.length > 0 && plan.multiAccount);
213
-
214
- return {
215
- available: available.map(providerCard).join('\n'),
216
- another: more.map(providerCard).join('\n'),
217
- };
218
- }
219
-
220
- function switcher(view: DashboardView): string {
221
- // Links, not a form: choosing a profile here changes what this page shows and
222
- // nothing else. The workspace default is config, and config is written from
223
- // the CLI.
224
- const profiles = view.profiles
225
- .map((name) => {
226
- const current = name === view.profile;
227
- const label = escapeHtml(name);
228
- return current
229
- ? `<span class="chip on">${label}</span>`
230
- : `<a class="chip" href="?profile=${encodeURIComponent(name)}">${label}</a>`;
231
- })
232
- .join('');
233
-
234
- // Every declared target, with the one whose adapters are actually open marked.
235
- // The others are real — they are where `--target` would write — but nothing on
236
- // this page is reading from them.
237
- const targets = view.targets
238
- .map((name) =>
239
- name === view.target
240
- ? `<span class="chip on">${escapeHtml(name)}</span>`
241
- : `<span class="chip off" title="declared, but not the one this endpoint opened">${escapeHtml(name)}</span>`,
242
- )
243
- .join('');
244
-
245
- return (
246
- '<div class="switch">' +
247
- `<div class="group"><span class="eyebrow">profile</span>${profiles}</div>` +
248
- `<div class="group"><span class="eyebrow">target</span>${targets}</div>` +
249
- '</div>'
250
- );
251
- }
252
-
253
- export function dashboardPage(view: DashboardView): Response {
254
- const { available, another } = catalogue(view);
255
-
256
- const body =
257
- `<h1>Lanes Link</h1>${switcher(view)}` +
258
- `<h2 class="eyebrow">Connections</h2>${connectionsSection(view)}` +
259
- (available ? `<h2 class="eyebrow">Available</h2><div class="rows">${available}</div>` : '') +
260
- (another ? `<h2 class="eyebrow">Connect another account</h2><div class="rows">${another}</div>` : '') +
261
- '<h2 class="eyebrow">Elsewhere</h2>' +
262
- '<p class="empty">What is reachable, and what is wrong with it:</p>' +
263
- commandLine(command(view, 'status')) +
264
- commandLine(command(view, 'doctor'));
265
-
266
- return shell(body, 'Lanes Link', 200);
267
- }
268
-
269
- /**
270
- * What an unauthenticated browser gets.
271
- *
272
- * The narrow card rather than the wide page, because it is one sentence — and
273
- * it names the command instead of asking for the token, since the command is
274
- * what puts the token in the URL in the first place. A password field here
275
- * would be a second way in to guard for no benefit: whoever can run the command
276
- * is already whoever the token would prove them to be.
277
- *
278
- * The one command on this page carries neither `--profile` nor `--target`,
279
- * against the rule every other command here follows. Both are names, and this
280
- * page answers before authentication — ADR-018 stopped `/health` naming
281
- * profiles to an anonymous caller for exactly that reason, and a 401 that
282
- * recites the workspace's profile list would put it back.
283
- */
284
- export function dashboardSignInPage(status: number): Response {
285
- return shell(
286
- '<div class="narrow"><h1>Not signed in</h1>' +
287
- '<p class="desc">This page opens from the terminal that is serving it.</p>' +
288
- commandLine('lanes link dashboard') +
289
- '</div>',
290
- 'Lanes Link',
291
- status,
292
- );
293
- }
@@ -1,125 +0,0 @@
1
- import { escapeHtml, FONTS, FOOTER, PAGE_CSP, PAGE_HEADERS, TOKENS } from './brand.ts';
2
-
3
- /**
4
- * The dashboard's chrome: its layout, its one listener, and the document they
5
- * hang in.
6
- *
7
- * Split from `dashboard-page.ts` because the two answer different questions —
8
- * that file decides what a row says, this one decides what a row looks like and
9
- * what the response is allowed to load. Everything shared with the two
10
- * authorization pages, which is every colour and every face, is in `brand.ts`;
11
- * what is left here is the one thing this surface does not share with them, a
12
- * wide list rather than a centred card.
13
- */
14
-
15
- const STYLE = `
16
- body { padding: 40px 24px; }
17
- .wrap { width: 100%; max-width: 620px; margin: 0 auto; }
18
- .narrow { text-align: center; padding: 32px 0; }
19
- h1 { margin: 0 0 20px; }
20
- h2 { margin: 30px 0 8px; }
21
- .desc { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground); }
22
- .empty { margin: 0 0 12px; font-size: 14px; line-height: 1.6; color: var(--muted-foreground);
23
- opacity: 0.8; }
24
-
25
- /* Profile and target selectors. The current one is not gold: gold says a thing
26
- is good, and which profile you are looking at is not a verdict. */
27
- .switch { display: flex; flex-wrap: wrap; gap: 20px; padding-bottom: 4px; }
28
- .group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
29
- .chip { display: inline-block; padding: 3px 10px; font-size: 13px; border-radius: 999px;
30
- border: 1px solid var(--border); color: var(--muted-foreground); text-decoration: none; }
31
- .chip:hover { color: var(--foreground); background: var(--muted); }
32
- .chip.on { color: var(--foreground); background: var(--muted); font-weight: 500; }
33
- .chip.off { border-style: dashed; opacity: 0.5; }
34
-
35
- /* One line per item: mark, name, whatever qualifies it, then the button hard
36
- right. \`min-width: 0\` on the row lets the name ellipsize rather than push
37
- the button off the end. */
38
- .rows { display: flex; flex-direction: column; }
39
- .row { display: flex; align-items: center; gap: 10px; min-width: 0;
40
- padding: 7px 10px 7px 4px; border-radius: 6px; }
41
- .row:hover { background: var(--muted); }
42
- .glyph { flex: none; display: inline-flex; align-items: center; justify-content: center;
43
- width: 17px; height: 17px; margin: 0 3px; opacity: 0.85; }
44
- /* The stand-in wants the box the real marks do not: bare letters read as a mark
45
- that failed to load, where a brand mark reads as itself. */
46
- .glyph.letters { width: 23px; height: 23px; margin: 0; border-radius: 6px; font-size: 8.5px;
47
- font-weight: 500; letter-spacing: 0.02em; color: var(--muted-foreground);
48
- border: 1px solid var(--border); opacity: 1; }
49
- .name, .key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
50
- .name { font-size: 14px; }
51
- .key { font-family: var(--mono); font-size: 13px; }
52
- .account { font-size: 12.5px; color: var(--muted-foreground); opacity: 0.8; min-width: 0;
53
- overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
54
-
55
- /* Always visible, never revealed on hover: the button is the only way to get the
56
- command now that the line itself is not on the page, and an affordance you
57
- have to find by sweeping the mouse is one most readers never find. */
58
- .copy { margin-left: auto; opacity: 0.6; }
59
- .row:hover .copy { opacity: 1; }
60
- .copy:hover, .copy:focus-visible { opacity: 1; }
61
-
62
- /* The two places the command is still the label, so it stays visible. */
63
- .cmd { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px 10px;
64
- background: var(--card); border: 1px dashed var(--border); border-radius: 10px; }
65
- .cmd code { flex: 1; font-size: 12.5px; line-height: 1.5; word-break: break-all; }
66
- .cmd .copy { opacity: 0.8; margin-left: 0; }
67
- `.trim();
68
-
69
- /**
70
- * The copy button, and nothing else.
71
- *
72
- * Inline because a separate file would be the first static asset this
73
- * repository serves, and one listener is not worth that. It reads the command
74
- * from a data attribute rather than from the DOM text so that what is copied is
75
- * exactly what was rendered, whitespace included.
76
- */
77
- const SCRIPT = `
78
- document.addEventListener('click', function (event) {
79
- var button = event.target.closest('.copy');
80
- if (!button || !navigator.clipboard) return;
81
- navigator.clipboard.writeText(button.dataset.copy).then(function () {
82
- button.textContent = 'copied';
83
- setTimeout(function () { button.textContent = 'copy'; }, 1200);
84
- });
85
- });
86
- `.trim();
87
-
88
- export function shell(inner: string, title: string, status: number): Response {
89
- return new Response(
90
- `<!doctype html>
91
- <html lang="en">
92
- <head>
93
- <meta charset="utf-8">
94
- <meta name="viewport" content="width=device-width, initial-scale=1">
95
- ${FONTS}
96
- <title>${escapeHtml(title)}</title>
97
- <style>
98
- ${TOKENS}
99
- ${STYLE}
100
- </style>
101
- </head>
102
- <body>
103
- <div class="wrap">
104
- ${inner}
105
- ${FOOTER}
106
- </div>
107
- <script>
108
- ${SCRIPT}
109
- </script>
110
- </body>
111
- </html>`,
112
- {
113
- status,
114
- headers: {
115
- ...PAGE_HEADERS,
116
- // The shared policy plus the one thing this page has that the others do
117
- // not: an inline listener, for the copy button.
118
- 'content-security-policy': `${PAGE_CSP}; script-src 'unsafe-inline'`,
119
- // Connection keys and account names are not something to leave in a
120
- // shared browser's back-forward cache.
121
- 'cache-control': 'no-store',
122
- },
123
- },
124
- );
125
- }