@nordbyte/nordrelay 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.env.example +35 -0
  2. package/README.md +118 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +90 -2
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-server.js +20 -4
  23. package/dist/peer-store.js +17 -2
  24. package/dist/relay-runtime-helpers.js +3 -1
  25. package/dist/relay-runtime.js +7 -0
  26. package/dist/settings-wizard-test.js +216 -0
  27. package/dist/slack-artifacts.js +165 -0
  28. package/dist/slack-bot.js +1461 -0
  29. package/dist/slack-channel-runtime.js +147 -0
  30. package/dist/slack-command-surface.js +46 -0
  31. package/dist/slack-diagnostics.js +116 -0
  32. package/dist/slack-rate-limit.js +139 -0
  33. package/dist/user-management-crypto.js +38 -0
  34. package/dist/user-management-normalize.js +188 -0
  35. package/dist/user-management-types.js +1 -0
  36. package/dist/user-management.js +193 -196
  37. package/dist/web-api-contract.js +8 -0
  38. package/dist/web-dashboard-access-routes.js +62 -0
  39. package/dist/web-dashboard-assets.js +1 -0
  40. package/dist/web-dashboard-pages.js +14 -4
  41. package/dist/web-dashboard-peer-routes.js +32 -11
  42. package/dist/web-dashboard.js +34 -0
  43. package/dist/web-state.js +2 -2
  44. package/dist/webui-assets/dashboard.css +193 -0
  45. package/dist/webui-assets/dashboard.js +546 -145
  46. package/package.json +3 -1
  47. package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
@@ -209,7 +209,7 @@ export function renderDashboardApp() {
209
209
 
210
210
  <section class="page" id="page-activity">
211
211
  <div class="panel">
212
- <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
212
+ <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
213
213
  <div id="activityList" class="list"></div>
214
214
  </div>
215
215
  </section>
@@ -226,6 +226,8 @@ export function renderDashboardApp() {
226
226
  <div class="panel">
227
227
  <div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
228
228
  <div id="adapterHealth" class="list"></div>
229
+ <h2 class="task-section-title">Adapter Conformance</h2>
230
+ <div id="adapterConformance" class="list"></div>
229
231
  </div>
230
232
  </section>
231
233
 
@@ -242,12 +244,13 @@ export function renderDashboardApp() {
242
244
 
243
245
  <section class="page" id="page-access">
244
246
  <div class="panel">
245
- <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
247
+ <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="createSlackChannelBtn" class="secondary">Add Slack channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
246
248
  <div id="accessTabs" class="tabs access-tabs">
247
249
  <button type="button" data-access-tab="users" class="active">Users</button>
248
250
  <button type="button" data-access-tab="groups">Groups</button>
249
251
  <button type="button" data-access-tab="telegram">Telegram</button>
250
252
  <button type="button" data-access-tab="discord">Discord</button>
253
+ <button type="button" data-access-tab="slack">Slack</button>
251
254
  <button type="button" data-access-tab="locks">Locks</button>
252
255
  <button type="button" data-access-tab="audit">Audit</button>
253
256
  </div>
@@ -269,13 +272,20 @@ export function renderDashboardApp() {
269
272
  </div>
270
273
  <div id="discordChannelsList" class="list"></div>
271
274
  </div>
275
+ <div class="access-tab" data-access-tab-panel="slack">
276
+ <div class="access-tab-heading">
277
+ <h2>Slack channels</h2>
278
+ <input id="slackChannelSearch" placeholder="Search Slack channels">
279
+ </div>
280
+ <div id="slackChannelsList" class="list"></div>
281
+ </div>
272
282
  <div class="access-tab" data-access-tab-panel="locks">
273
283
  <h2>Locks</h2>
274
284
  <div id="locksList" class="list"></div>
275
285
  </div>
276
286
  <div class="access-tab" data-access-tab-panel="audit">
277
287
  <h2>Audit</h2>
278
- <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
288
+ <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
279
289
  <div id="auditList" class="list"></div>
280
290
  </div>
281
291
  </div>
@@ -292,7 +302,7 @@ export function renderDashboardApp() {
292
302
 
293
303
  <section class="page" id="page-settings">
294
304
  <div class="panel">
295
- <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
305
+ <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
296
306
  <div id="settingsTabs" class="tabs"></div>
297
307
  <div id="settingsForm" class="settings-grid"></div>
298
308
  </div>
@@ -1,7 +1,8 @@
1
1
  import { isPermission } from "./access-control.js";
2
2
  import { AGENT_IDS, isAgentId } from "./agent.js";
3
3
  import { ensurePeerTlsFiles, loadOrCreatePeerIdentity, } from "./peer-identity.js";
4
- import { pairPeer, RemoteRelayClient } from "./peer-client.js";
4
+ import { checkPeerEndpoint, pairPeer, RemoteRelayClient } from "./peer-client.js";
5
+ import { buildPeerReadiness, peerListenUrl } from "./peer-readiness.js";
5
6
  import { PeerStore } from "./peer-store.js";
6
7
  import { publicPeer } from "./peer-types.js";
7
8
  import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
@@ -10,15 +11,18 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
10
11
  const identity = loadOrCreatePeerIdentity(options.home, options.config.peerName);
11
12
  const tls = options.config.peerTlsEnabled ? ensurePeerTlsFiles(options.home, identity.public) : null;
12
13
  if (req.method === "GET" && url.pathname === "/api/peers") {
14
+ const readiness = await buildPeerReadiness(options.config);
13
15
  sendJson(res, 200, store.snapshot(identity.public, {
14
16
  enabled: options.config.peerEnabled,
15
- listenUrl: peerListenUrl(options.config),
17
+ listenUrl: readiness.listenUrl,
16
18
  requireTls: options.config.peerRequireTls,
19
+ readiness,
17
20
  }));
18
21
  return true;
19
22
  }
20
23
  if (req.method === "POST" && url.pathname === "/api/peers/invite") {
21
24
  const body = await readJsonBody(req);
25
+ const readiness = await buildPeerReadiness(options.config);
22
26
  const created = store.createInvitation({
23
27
  name: optionalStringField(body, "name"),
24
28
  expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
@@ -27,7 +31,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
27
31
  allowedWorkspaceRoots: arrayStringField(body, "allowedWorkspaceRoots"),
28
32
  workspaceAliases: parseWorkspaceAliases(body.workspaceAliases),
29
33
  });
30
- const listenUrl = peerListenUrl(options.config);
34
+ const listenUrl = readiness.listenUrl;
31
35
  const command = `nordrelay peer add ${listenUrl} --code ${created.code}`;
32
36
  sendJson(res, 201, {
33
37
  invitation: created.invitation,
@@ -36,10 +40,35 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
36
40
  fingerprint: identity.public.fingerprint,
37
41
  tlsFingerprint: tls?.fingerprint,
38
42
  command,
43
+ readiness,
44
+ warnings: readiness.warnings,
39
45
  });
40
46
  options.auditPeerAction?.("peer_invite_created", created.invitation.name);
41
47
  return true;
42
48
  }
49
+ if (req.method === "POST" && url.pathname === "/api/peers/probe") {
50
+ const body = await readJsonBody(req);
51
+ const readiness = await buildPeerReadiness(options.config);
52
+ const peerId = optionalStringField(body, "peerId");
53
+ if (peerId) {
54
+ const probe = await new RemoteRelayClient(store).rpc(peerId, "peer.probe", {}, options.activityActor);
55
+ sendJson(res, 200, { type: "remote", peerId, readiness, probe });
56
+ return true;
57
+ }
58
+ const expectedTlsFingerprint = options.config.peerPublicUrl ? undefined : tls?.fingerprint;
59
+ const probe = await checkPeerEndpoint(readiness.listenUrl, { expectedTlsFingerprint });
60
+ sendJson(res, 200, { type: "local", readiness, probe });
61
+ return true;
62
+ }
63
+ const invitationMatch = url.pathname.match(/^\/api\/peers\/invitations\/([^/]+)$/);
64
+ if (invitationMatch?.[1] && req.method === "DELETE") {
65
+ const invitation = store.deleteInvitation(decodeURIComponent(invitationMatch[1]));
66
+ sendJson(res, 200, { removed: Boolean(invitation), invitation });
67
+ if (invitation) {
68
+ options.auditPeerAction?.("peer_invite_deleted", `${invitation.name} (${invitation.id})`);
69
+ }
70
+ return true;
71
+ }
43
72
  if (req.method === "POST" && (url.pathname === "/api/peers" || url.pathname === "/api/peers/pair")) {
44
73
  const body = await readJsonBody(req);
45
74
  const result = await pairPeer({
@@ -157,14 +186,6 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
157
186
  }
158
187
  return false;
159
188
  }
160
- function peerListenUrl(config) {
161
- if (config.peerPublicUrl)
162
- return config.peerPublicUrl;
163
- const scheme = config.peerTlsEnabled ? "https" : "http";
164
- const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
165
- const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
166
- return `${scheme}://${displayHost}:${config.peerPort}`;
167
- }
168
189
  function parseScopes(values) {
169
190
  return values.filter(isPermission);
170
191
  }
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { URL } from "node:url";
6
6
  import { enabledAgents } from "./agent-factory.js";
7
+ import { buildAdapterConformanceMatrix } from "./adapter-conformance.js";
7
8
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
9
  import { isAgentId } from "./agent.js";
9
10
  import { AuditLogStore } from "./audit-log.js";
@@ -13,6 +14,7 @@ import { loadConfig } from "./config.js";
13
14
  import { friendlyErrorText } from "./error-messages.js";
14
15
  import { RelayRuntime } from "./relay-runtime.js";
15
16
  import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
17
+ import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "./settings-wizard-test.js";
16
18
  import { UserStore, publicUser } from "./user-management.js";
17
19
  import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
18
20
  import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
@@ -161,6 +163,7 @@ async function handleApi(req, res, url, authUser) {
161
163
  auth: currentUserDto(authUser),
162
164
  channels: listChannelDescriptors(),
163
165
  agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
166
+ adapterConformance: scopedAdapterConformance(authUser),
164
167
  enabledAgents: enabledAgents(config).filter((agentId) => users.canUseAgent(authUser, agentId)),
165
168
  controls: scopedControlOptions(authUser, await runtime.controlOptions()),
166
169
  status: await runtime.bootstrapStatus(),
@@ -173,6 +176,10 @@ async function handleApi(req, res, url, authUser) {
173
176
  sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
174
177
  return;
175
178
  }
179
+ if (req.method === "GET" && url.pathname === "/api/adapters/conformance") {
180
+ sendJson(res, 200, scopedAdapterConformance(authUser));
181
+ return;
182
+ }
176
183
  if (await handleDashboardAccessRoute(req, res, url, {
177
184
  users,
178
185
  runtime,
@@ -199,6 +206,11 @@ async function handleApi(req, res, url, authUser) {
199
206
  sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
200
207
  return;
201
208
  }
209
+ if (req.method === "POST" && url.pathname === "/api/settings/wizard/test") {
210
+ const body = await readJsonBody(req);
211
+ sendJson(res, 200, await runSettingsWizardTest(optionalStringField(body, "channel") ?? "", mergeSettingsWizardTestSettings(activeSettingsValues(config), objectRecord(body?.settings))));
212
+ return;
213
+ }
202
214
  if (await handleDashboardSessionRoute(req, res, url, {
203
215
  runtime,
204
216
  authUser,
@@ -474,6 +486,13 @@ function scopedControlOptions(authUser, options) {
474
486
  workspaces: options.workspaces.filter((workspace) => users.canUseWorkspace(authUser, workspace)),
475
487
  };
476
488
  }
489
+ function scopedAdapterConformance(authUser) {
490
+ const matrix = buildAdapterConformanceMatrix();
491
+ return {
492
+ ...matrix,
493
+ agents: matrix.agents.filter((adapter) => users.canUseAgent(authUser, adapter.id)),
494
+ };
495
+ }
477
496
  function scopedSessionPage(authUser, page) {
478
497
  return {
479
498
  ...page,
@@ -652,6 +671,21 @@ function activeSettingsValues(current) {
652
671
  DISCORD_NOTIFY_MODE: current.discordNotifyMode === current.notifyMode ? "" : current.discordNotifyMode,
653
672
  DISCORD_QUIET_HOURS: quietOverrideValue(current.discordQuietHours, current.quietHours),
654
673
  DISCORD_AUTO_SEND_ARTIFACTS: current.discordAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.discordAutoSendArtifacts),
674
+ SLACK_ENABLED: boolValue(current.slackEnabled),
675
+ SLACK_BOT_TOKEN: current.slackBotToken,
676
+ SLACK_APP_TOKEN: current.slackAppToken,
677
+ SLACK_SIGNING_SECRET: current.slackSigningSecret,
678
+ SLACK_SOCKET_MODE: boolValue(current.slackSocketMode),
679
+ SLACK_PORT: String(current.slackPort),
680
+ SLACK_ALLOWED_TEAM_IDS: current.slackAllowedTeamIds.join(","),
681
+ SLACK_ALLOWED_CHANNEL_IDS: current.slackAllowedChannelIds.join(","),
682
+ SLACK_MESSAGE_CONTENT_ENABLED: boolValue(current.slackMessageContentEnabled),
683
+ SLACK_COMMAND: current.slackCommand,
684
+ SLACK_CLI_MIRROR_MODE: current.slackMirrorMode === current.mirrorMode ? "" : current.slackMirrorMode,
685
+ SLACK_CLI_MIRROR_MIN_UPDATE_MS: current.slackMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.slackMirrorMinUpdateMs),
686
+ SLACK_NOTIFY_MODE: current.slackNotifyMode === current.notifyMode ? "" : current.slackNotifyMode,
687
+ SLACK_QUIET_HOURS: quietOverrideValue(current.slackQuietHours, current.quietHours),
688
+ SLACK_AUTO_SEND_ARTIFACTS: current.slackAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.slackAutoSendArtifacts),
655
689
  NORDRELAY_CODEX_ENABLED: boolValue(current.codexEnabled),
656
690
  NORDRELAY_PI_ENABLED: boolValue(current.piEnabled),
657
691
  NORDRELAY_HERMES_ENABLED: boolValue(current.hermesEnabled),
package/dist/web-state.js CHANGED
@@ -123,7 +123,7 @@ function isWebChatMessage(value) {
123
123
  typeof candidate.text === "string" &&
124
124
  typeof candidate.timestamp === "string" &&
125
125
  ["user", "agent", "system", "tool"].includes(candidate.role) &&
126
- ["web", "telegram", "discord", "cli"].includes(candidate.source);
126
+ ["web", "telegram", "discord", "slack", "cli"].includes(candidate.source);
127
127
  }
128
128
  function isWebActivityEvent(value) {
129
129
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -133,7 +133,7 @@ function isWebActivityEvent(value) {
133
133
  return typeof candidate.id === "string" &&
134
134
  typeof candidate.timestamp === "string" &&
135
135
  typeof candidate.type === "string" &&
136
- ["web", "telegram", "discord", "cli"].includes(candidate.source) &&
136
+ ["web", "telegram", "discord", "slack", "cli"].includes(candidate.source) &&
137
137
  ["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
138
138
  }
139
139
  function normalizeSince(value) {
@@ -182,6 +182,15 @@
182
182
  grid-template-columns: repeat(2, minmax(0, 1fr));
183
183
  gap: 16px;
184
184
  }
185
+ .conformance-grid {
186
+ display: grid;
187
+ grid-template-columns: repeat(2, minmax(0, 1fr));
188
+ gap: 12px;
189
+ }
190
+ .conformance-grid h3 {
191
+ margin: 0 0 8px;
192
+ font-size: 15px;
193
+ }
185
194
  .session-detail-section {
186
195
  margin-top: 20px;
187
196
  }
@@ -239,6 +248,115 @@
239
248
  padding: 10px;
240
249
  margin: 0 0 12px;
241
250
  }
251
+ .settings-wizard {
252
+ display: grid;
253
+ gap: 14px;
254
+ margin-top: 14px;
255
+ }
256
+ .wizard-header {
257
+ display: flex;
258
+ align-items: flex-start;
259
+ justify-content: space-between;
260
+ gap: 12px;
261
+ }
262
+ .wizard-header h2 {
263
+ margin: 0 0 4px;
264
+ }
265
+ .wizard-header p,
266
+ .wizard-step p,
267
+ .wizard-card p {
268
+ margin: 0;
269
+ color: var(--muted);
270
+ line-height: 1.45;
271
+ }
272
+ .wizard-choice-grid {
273
+ display: grid;
274
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
275
+ gap: 12px;
276
+ }
277
+ .wizard-card {
278
+ display: grid;
279
+ gap: 8px;
280
+ border: 1px solid var(--border-soft);
281
+ border-radius: 8px;
282
+ background: var(--surface-soft);
283
+ padding: 12px;
284
+ min-width: 0;
285
+ }
286
+ .wizard-links {
287
+ display: flex;
288
+ gap: 8px;
289
+ align-items: center;
290
+ flex-wrap: wrap;
291
+ }
292
+ .wizard-links a {
293
+ font-size: 12px;
294
+ border: 1px solid var(--border);
295
+ border-radius: 999px;
296
+ padding: 4px 8px;
297
+ color: var(--accent);
298
+ text-decoration: none;
299
+ background: var(--surface);
300
+ }
301
+ .wizard-links a:hover {
302
+ text-decoration: underline;
303
+ }
304
+ .wizard-progress {
305
+ display: flex;
306
+ gap: 8px;
307
+ flex-wrap: wrap;
308
+ }
309
+ .wizard-progress button {
310
+ background: var(--surface);
311
+ color: var(--text);
312
+ border-color: var(--border);
313
+ min-height: 32px;
314
+ height: 32px;
315
+ line-height: 1;
316
+ }
317
+ .wizard-progress button:hover {
318
+ background: var(--accent-strong);
319
+ color: white;
320
+ border-color: var(--accent-strong);
321
+ }
322
+ .wizard-progress button.active {
323
+ background: var(--accent);
324
+ color: white;
325
+ border-color: var(--accent);
326
+ }
327
+ .wizard-step {
328
+ display: grid;
329
+ gap: 12px;
330
+ }
331
+ .wizard-step h3 {
332
+ margin: 0;
333
+ }
334
+ .wizard-actions {
335
+ display: flex;
336
+ gap: 8px;
337
+ align-items: center;
338
+ flex-wrap: wrap;
339
+ }
340
+ .wizard-errors {
341
+ display: grid;
342
+ gap: 6px;
343
+ }
344
+ .wizard-error,
345
+ .wizard-warning {
346
+ border-radius: 8px;
347
+ padding: 8px 10px;
348
+ font-size: 13px;
349
+ }
350
+ .wizard-error {
351
+ border: 1px solid var(--danger);
352
+ color: var(--danger);
353
+ background: color-mix(in srgb, var(--danger) 8%, transparent);
354
+ }
355
+ .wizard-warning {
356
+ border: 1px solid #d9c27a;
357
+ color: #8a6a12;
358
+ background: var(--warn);
359
+ }
242
360
  .task-grid,
243
361
  .metrics-grid {
244
362
  display: grid;
@@ -340,6 +458,69 @@
340
458
  gap: 8px;
341
459
  margin-top: 8px;
342
460
  }
461
+ .peer-invite-details {
462
+ display: grid;
463
+ gap: 6px;
464
+ margin: 10px 0;
465
+ padding: 10px;
466
+ border: 1px solid var(--border-soft);
467
+ border-radius: 8px;
468
+ background: var(--surface);
469
+ }
470
+ .peer-invite-details small {
471
+ margin-top: 0;
472
+ font-weight: 700;
473
+ }
474
+ .peer-invite-copy,
475
+ .peer-invite-command {
476
+ display: block;
477
+ width: 100%;
478
+ text-align: left;
479
+ overflow-wrap: anywhere;
480
+ white-space: pre-wrap;
481
+ }
482
+ .copy-id.peer-invite-command {
483
+ padding: 8px;
484
+ border: 1px solid var(--border-soft);
485
+ border-radius: 6px;
486
+ background: var(--surface);
487
+ color: var(--text);
488
+ text-decoration: none;
489
+ }
490
+ .copy-id.peer-invite-command:hover {
491
+ background: var(--accent-soft);
492
+ border-color: var(--accent);
493
+ color: var(--link);
494
+ text-decoration: none;
495
+ }
496
+ .peer-warning {
497
+ display: grid;
498
+ gap: 4px;
499
+ margin: 10px 0;
500
+ padding: 10px;
501
+ border: 1px solid #d9c27a;
502
+ border-radius: 8px;
503
+ background: var(--warn);
504
+ color: #8a6a12;
505
+ }
506
+ .peer-warning small {
507
+ color: #8a6a12;
508
+ }
509
+ .peer-probe-result {
510
+ margin-top: 10px;
511
+ padding: 10px;
512
+ border: 1px solid var(--border-soft);
513
+ border-radius: 8px;
514
+ background: var(--surface);
515
+ }
516
+ .peer-probe-result.ok {
517
+ border-color: #8ed0aa;
518
+ background: color-mix(in srgb, var(--accent-soft) 45%, var(--surface));
519
+ }
520
+ .peer-probe-result.warn {
521
+ border-color: #d9c27a;
522
+ background: var(--warn);
523
+ }
343
524
  * {
344
525
  box-sizing: border-box;
345
526
  }
@@ -991,6 +1172,9 @@ dialog::backdrop {
991
1172
  .overview-adapter-grid {
992
1173
  grid-template-columns: 1fr;
993
1174
  }
1175
+ .conformance-grid {
1176
+ grid-template-columns: 1fr;
1177
+ }
994
1178
  .chat-layout {
995
1179
  grid-template-columns: 1fr;
996
1180
  }
@@ -1060,4 +1244,13 @@ dialog::backdrop {
1060
1244
  margin-left: 0;
1061
1245
  justify-content: stretch;
1062
1246
  }
1247
+ .wizard-header,
1248
+ .wizard-actions {
1249
+ align-items: stretch;
1250
+ flex-direction: column;
1251
+ }
1252
+ .wizard-header button,
1253
+ .wizard-actions button {
1254
+ width: 100%;
1255
+ }
1063
1256
  }