@openparachute/hub 0.5.13-rc.13 → 0.5.13-rc.21
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.
- package/package.json +1 -1
- package/src/__tests__/api-modules-ops.test.ts +257 -4
- package/src/__tests__/api-modules.test.ts +90 -0
- package/src/__tests__/cli.test.ts +13 -0
- package/src/__tests__/hub-server.test.ts +10 -13
- package/src/__tests__/install.test.ts +259 -24
- package/src/__tests__/lifecycle.test.ts +90 -13
- package/src/__tests__/module-manifest.test.ts +19 -3
- package/src/__tests__/post-install.test.ts +0 -2
- package/src/__tests__/scope-registry.test.ts +9 -9
- package/src/__tests__/services-manifest.test.ts +456 -43
- package/src/__tests__/setup-wizard.test.ts +228 -0
- package/src/__tests__/status.test.ts +4 -4
- package/src/__tests__/upgrade.test.ts +362 -3
- package/src/api-modules-ops.ts +79 -7
- package/src/api-modules.ts +97 -1
- package/src/cli.ts +50 -4
- package/src/commands/install.ts +108 -6
- package/src/commands/lifecycle.ts +20 -0
- package/src/commands/upgrade.ts +213 -27
- package/src/help.ts +54 -17
- package/src/hub-server.ts +5 -0
- package/src/hub.ts +71 -0
- package/src/module-manifest.ts +22 -17
- package/src/service-spec.ts +44 -60
- package/src/services-manifest.ts +163 -3
- package/src/setup-wizard.ts +205 -12
- package/web/ui/dist/assets/index-5Mj6FqPg.css +1 -0
- package/web/ui/dist/assets/{index-D63mUkVX.js → index-BqjySZ_7.js} +12 -12
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DliViliP.css +0 -1
package/src/setup-wizard.ts
CHANGED
|
@@ -646,22 +646,41 @@ export interface RenderDoneStepProps {
|
|
|
646
646
|
* shape.
|
|
647
647
|
*/
|
|
648
648
|
installTiles?: readonly ModuleInstallTileState[];
|
|
649
|
+
/**
|
|
650
|
+
* Whether parachute-app is installed alongside the vault. Drives the
|
|
651
|
+
* "Start using your vault" lead tile (hub#342): when true, the tile
|
|
652
|
+
* links to `/app/notes/` (the canonical user-facing surface — App
|
|
653
|
+
* auto-bootstraps Notes-as-UI per the 2026-05-21 migration). When
|
|
654
|
+
* false, it falls back to the vault's own admin UI at
|
|
655
|
+
* `/vault/<name>/admin/` so the operator still has a single obvious
|
|
656
|
+
* "start using parachute" target. Omitted = back-compat with tests
|
|
657
|
+
* that render the done step without dependency-checking; defaults to
|
|
658
|
+
* false (vault-admin fallback).
|
|
659
|
+
*/
|
|
660
|
+
appInstalled?: boolean;
|
|
649
661
|
}
|
|
650
662
|
|
|
651
663
|
export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
652
|
-
const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles } = props;
|
|
664
|
+
const { vaultName, hubOrigin, exposeMode, mintedToken, installTiles, appInstalled } = props;
|
|
653
665
|
const reachable = exposeMode ? renderReachableTile(exposeMode, hubOrigin) : "";
|
|
654
666
|
const mcpTile = renderMcpTile(vaultName, hubOrigin, mintedToken);
|
|
655
667
|
const tiles = installTiles && installTiles.length > 0 ? installTiles : [];
|
|
656
668
|
const installSection = tiles.length > 0 ? renderInstallTiles(tiles) : "";
|
|
669
|
+
const startTile = renderStartUsingTile(vaultName, appInstalled === true);
|
|
657
670
|
// The done-grid hosts the MCP-connect tile + the admin-UI fallback.
|
|
658
|
-
// The install tiles sit above it as a
|
|
659
|
-
//
|
|
660
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
//
|
|
664
|
-
//
|
|
671
|
+
// The install tiles sit above it as a "what's next?" surface (curated
|
|
672
|
+
// catalog of modules an operator might want next). The "Start using
|
|
673
|
+
// your vault" tile leads everything user-facing because it answers
|
|
674
|
+
// Aaron's hub#342 friction directly: there was no clear way from the
|
|
675
|
+
// wizard's done screen to actually USE Parachute — the wizard
|
|
676
|
+
// surfaced "install more" + "go to admin" + an MCP command, none of
|
|
677
|
+
// which is "open the canonical user-facing UI" (Notes via App). With
|
|
678
|
+
// this tile in pole position, the operator's first click goes to a
|
|
679
|
+
// surface that says "hello, here's your vault" rather than a hub
|
|
680
|
+
// admin page. The reachable tile sits above even the start tile
|
|
681
|
+
// because "where's my hub?" answers the URL question every operator
|
|
682
|
+
// hits before they can click anything else (especially on tailnet /
|
|
683
|
+
// public expose where the loopback URL isn't the answer).
|
|
665
684
|
const body = `
|
|
666
685
|
<div class="card">
|
|
667
686
|
<div class="card-header">
|
|
@@ -670,6 +689,7 @@ export function renderDoneStep(props: RenderDoneStepProps): string {
|
|
|
670
689
|
<p class="subtitle">Your hub is ready. Here's what to do next.</p>
|
|
671
690
|
</div>
|
|
672
691
|
${reachable}
|
|
692
|
+
${startTile}
|
|
673
693
|
${installSection}
|
|
674
694
|
<section class="done-grid">
|
|
675
695
|
${mcpTile}
|
|
@@ -839,6 +859,51 @@ function renderMcpTile(
|
|
|
839
859
|
</div>`;
|
|
840
860
|
}
|
|
841
861
|
|
|
862
|
+
/**
|
|
863
|
+
* The "Start using your vault" lead tile on the done step (hub#342).
|
|
864
|
+
*
|
|
865
|
+
* Closes Aaron's "no clear way to go from setting up parachute to
|
|
866
|
+
* actually using parachute" friction. Sits above the MCP / install
|
|
867
|
+
* tiles because it's the canonical user-facing entry point —
|
|
868
|
+
* everything else on the done screen is operator-flavored (MCP
|
|
869
|
+
* command, admin UI, additional module installs).
|
|
870
|
+
*
|
|
871
|
+
* Two shapes:
|
|
872
|
+
* - **App installed** → primary tile targets `/app/notes/` (the
|
|
873
|
+
* Notes app reading the just-created vault). This is the
|
|
874
|
+
* canonical surface post-Notes-as-app migration (parachute-app §17).
|
|
875
|
+
* - **App NOT installed** → primary tile targets the vault's own
|
|
876
|
+
* admin UI at `/vault/<name>/admin/`. The copy explains that
|
|
877
|
+
* installing App + Notes is the recommended next step for a
|
|
878
|
+
* content-browsing surface, and points at the install tile below.
|
|
879
|
+
*
|
|
880
|
+
* Either way, the operator has ONE obvious click target that says
|
|
881
|
+
* "start using parachute" — not three competing tiles where the
|
|
882
|
+
* "real" entry point is buried under the MCP command pre-hub#342.
|
|
883
|
+
*/
|
|
884
|
+
function renderStartUsingTile(vaultName: string, appInstalled: boolean): string {
|
|
885
|
+
const safeVault = escapeHtml(vaultName);
|
|
886
|
+
// Vault names pass `/^[a-z0-9][a-z0-9-]*$/i` so URL-encoding is mostly
|
|
887
|
+
// a no-op today, but use encodeURIComponent defensively to match hub.ts:505.
|
|
888
|
+
const urlVault = encodeURIComponent(vaultName);
|
|
889
|
+
if (appInstalled) {
|
|
890
|
+
return `<section class="start-using" data-testid="start-using-tile">
|
|
891
|
+
<h2>Start using your vault</h2>
|
|
892
|
+
<p>Notes is installed and ready. Capture your first note in the
|
|
893
|
+
Notes app — it reads from <code>${safeVault}</code> directly.</p>
|
|
894
|
+
<p><a class="btn btn-primary" href="/app/notes/">Open Notes</a></p>
|
|
895
|
+
</section>`;
|
|
896
|
+
}
|
|
897
|
+
return `<section class="start-using" data-testid="start-using-tile">
|
|
898
|
+
<h2>Start using your vault</h2>
|
|
899
|
+
<p>Your vault <code>${safeVault}</code> is provisioned. Install
|
|
900
|
+
<strong>App</strong> below (it bundles the Notes UI) to start
|
|
901
|
+
capturing — or open the vault's admin UI now to see what's
|
|
902
|
+
inside.</p>
|
|
903
|
+
<p><a class="btn btn-primary" href="/vault/${urlVault}/admin/">Open vault admin</a></p>
|
|
904
|
+
</section>`;
|
|
905
|
+
}
|
|
906
|
+
|
|
842
907
|
/**
|
|
843
908
|
* The "What's next?" install-tiles row (hub#272 Item B). One tile per
|
|
844
909
|
* curated module the operator might want next (Notes, Scribe). Each
|
|
@@ -859,6 +924,15 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
|
859
924
|
const safeShort = escapeHtml(tile.short);
|
|
860
925
|
const safeName = escapeHtml(tile.displayName);
|
|
861
926
|
const safeTagline = escapeHtml(tile.tagline);
|
|
927
|
+
const useItNowUrl = USE_IT_NOW_URLS[tile.short];
|
|
928
|
+
// "Use it now" → the canonical user-facing URL per
|
|
929
|
+
// module-ui-declaration.md, rendered as the PRIMARY action on a
|
|
930
|
+
// succeeded / already-installed install tile (hub#342). "Manage
|
|
931
|
+
// modules" stays as the secondary affordance so the admin SPA is
|
|
932
|
+
// one click away too. The URL table is keyed by the wizard's
|
|
933
|
+
// curated shorts (app, scribe today; vault excluded since the
|
|
934
|
+
// wizard owns its step); modules with no known surface fall
|
|
935
|
+
// through to a single "Manage modules" link, same as pre-#342.
|
|
862
936
|
if (tile.operation) {
|
|
863
937
|
const op = tile.operation;
|
|
864
938
|
const logLines = op.log.map((l) => `<li>${escapeHtml(l)}</li>`).join("");
|
|
@@ -870,7 +944,13 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
|
870
944
|
// catches every in-flight op at once).
|
|
871
945
|
let actions = "";
|
|
872
946
|
if (op.status === "succeeded") {
|
|
873
|
-
|
|
947
|
+
const useItNowLink = useItNowUrl
|
|
948
|
+
? `<a class="btn btn-primary" href="${escapeAttr(useItNowUrl)}">Use it now</a>`
|
|
949
|
+
: "";
|
|
950
|
+
actions = `<p class="install-tile-actions">
|
|
951
|
+
${useItNowLink}
|
|
952
|
+
<a class="btn btn-secondary" href="/admin/modules">Manage modules</a>
|
|
953
|
+
</p>`;
|
|
874
954
|
} else if (op.status === "failed") {
|
|
875
955
|
actions = `<form method="POST" action="/admin/setup/install/${safeShort}" class="install-retry">
|
|
876
956
|
${renderInstallTileCsrfPlaceholder()}
|
|
@@ -889,11 +969,17 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
|
889
969
|
</div>`;
|
|
890
970
|
}
|
|
891
971
|
if (tile.alreadyInstalled) {
|
|
972
|
+
const useItNowLink = useItNowUrl
|
|
973
|
+
? `<a class="btn btn-primary" href="${escapeAttr(useItNowUrl)}">Use it now</a>`
|
|
974
|
+
: "";
|
|
892
975
|
return `<div class="install-tile install-tile-installed">
|
|
893
976
|
<h3>${safeName}</h3>
|
|
894
977
|
<p class="install-tile-tagline">${safeTagline}</p>
|
|
895
978
|
<p class="install-tile-status">Already installed.</p>
|
|
896
|
-
<p
|
|
979
|
+
<p class="install-tile-actions">
|
|
980
|
+
${useItNowLink}
|
|
981
|
+
<a class="btn btn-secondary" href="/admin/modules">Manage in admin</a>
|
|
982
|
+
</p>
|
|
897
983
|
</div>`;
|
|
898
984
|
}
|
|
899
985
|
return `<div class="install-tile">
|
|
@@ -906,6 +992,30 @@ function renderInstallTile(tile: ModuleInstallTileState): string {
|
|
|
906
992
|
</div>`;
|
|
907
993
|
}
|
|
908
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Canonical "Use it now" target per curated module short (hub#342).
|
|
997
|
+
* Each value is the canonical user-facing URL the module ships its UI
|
|
998
|
+
* at — per `module-ui-declaration.md` (`uiUrl` / `managementUrl` rules).
|
|
999
|
+
* App's surface is the bundled Notes-as-UI auto-bootstrap mount;
|
|
1000
|
+
* Scribe is the operator-facing admin UI (per `module-surfaces.md`,
|
|
1001
|
+
* scribe's admin surface is at `/scribe/admin` once an admin SPA ships
|
|
1002
|
+
* — scribe#53 tracks). Missing entries here fall through to "Manage
|
|
1003
|
+
* modules" only — i.e. modules without a declared first-party UI
|
|
1004
|
+
* surface. Vault is intentionally omitted: the wizard's own vault
|
|
1005
|
+
* step owns the post-vault-install flow and the lead "Start using
|
|
1006
|
+
* your vault" tile (above the install row) handles the vault-side
|
|
1007
|
+
* surface decision.
|
|
1008
|
+
*/
|
|
1009
|
+
const USE_IT_NOW_URLS: Partial<Record<CuratedModuleShort, string>> = {
|
|
1010
|
+
app: "/app/notes/",
|
|
1011
|
+
notes: "/notes/",
|
|
1012
|
+
// Omitted: scribe + runner. They don't ship an admin SPA yet
|
|
1013
|
+
// (scribe#53, runner#8 track). Pointing "Use it now" at /scribe/admin
|
|
1014
|
+
// or /runner/admin today would 404 — better to fall through to the
|
|
1015
|
+
// "Manage modules" link than to send the operator into a dead end.
|
|
1016
|
+
// Add the entry here once those modules ship their admin UI.
|
|
1017
|
+
};
|
|
1018
|
+
|
|
909
1019
|
/**
|
|
910
1020
|
* CSRF token placeholder for install-tile forms. The token comes from
|
|
911
1021
|
* the wizard's per-request CSRF cookie; rendered by the parent step's
|
|
@@ -1045,10 +1155,16 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
|
|
|
1045
1155
|
// Module install tiles (hub#272 Item B). One per curated module
|
|
1046
1156
|
// other than vault (which the wizard already provisioned).
|
|
1047
1157
|
const installTiles = buildInstallTiles(url, deps);
|
|
1158
|
+
// hub#342: drive the lead "Start using your vault" tile's target.
|
|
1159
|
+
// When parachute-app is installed alongside vault, the tile links
|
|
1160
|
+
// to `/app/notes/` (auto-bootstrapped Notes-as-UI per parachute-app
|
|
1161
|
+
// §17). Otherwise it falls back to the vault's own admin UI.
|
|
1162
|
+
const appInstalled = isModuleInstalled("app", deps.manifestPath);
|
|
1048
1163
|
const doneProps: RenderDoneStepProps = {
|
|
1049
1164
|
vaultName,
|
|
1050
1165
|
hubOrigin: deps.issuer,
|
|
1051
1166
|
installTiles,
|
|
1167
|
+
appInstalled,
|
|
1052
1168
|
};
|
|
1053
1169
|
if (exposeMode !== undefined) doneProps.exposeMode = exposeMode;
|
|
1054
1170
|
if (mintedToken) doneProps.mintedToken = mintedToken;
|
|
@@ -1690,6 +1806,20 @@ function validateAccountFields(input: {
|
|
|
1690
1806
|
return undefined;
|
|
1691
1807
|
}
|
|
1692
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* Whether a given curated module is currently installed (has a row in
|
|
1811
|
+
* services.json keyed by its canonical `manifestName`). Used by the
|
|
1812
|
+
* done-step renderer (hub#342) to decide whether to point the "Start
|
|
1813
|
+
* using your vault" tile at `/app/notes/` (App installed → Notes UI
|
|
1814
|
+
* auto-bootstrapped) vs the vault's own admin UI. Cheap manifest read
|
|
1815
|
+
* shared with `buildInstallTiles`.
|
|
1816
|
+
*/
|
|
1817
|
+
function isModuleInstalled(short: CuratedModuleShort, manifestPath: string): boolean {
|
|
1818
|
+
const manifest = readManifest(manifestPath);
|
|
1819
|
+
const spec = specFor(short);
|
|
1820
|
+
return manifest.services.some((s) => s.name === spec.manifestName);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1693
1823
|
/**
|
|
1694
1824
|
* Read the first vault's display name from services.json for the
|
|
1695
1825
|
* step-4 success page. Falls back to "default" if for any reason the
|
|
@@ -2046,6 +2176,18 @@ const STYLES = `
|
|
|
2046
2176
|
display: flex;
|
|
2047
2177
|
flex-direction: column;
|
|
2048
2178
|
gap: 0.4rem;
|
|
2179
|
+
/* min-width: 0 lets the grid track shrink below the tile's intrinsic
|
|
2180
|
+
content width — without it a long log line in .install-tile-log
|
|
2181
|
+
forces the tile (and its parent grid track) wider than the card,
|
|
2182
|
+
which is what stretched the wizard when Aaron clicked Install App. */
|
|
2183
|
+
min-width: 0;
|
|
2184
|
+
}
|
|
2185
|
+
/* "Use it now" action row on a successful install-tile (hub#342 item 3). */
|
|
2186
|
+
.install-tile-actions {
|
|
2187
|
+
margin: 0;
|
|
2188
|
+
display: flex;
|
|
2189
|
+
flex-wrap: wrap;
|
|
2190
|
+
gap: 0.4rem;
|
|
2049
2191
|
}
|
|
2050
2192
|
.install-tile h3 {
|
|
2051
2193
|
margin: 0;
|
|
@@ -2131,6 +2273,23 @@ const STYLES = `
|
|
|
2131
2273
|
margin: 1rem 0;
|
|
2132
2274
|
font-family: ${FONT_MONO};
|
|
2133
2275
|
font-size: 0.85rem;
|
|
2276
|
+
/* Install logs spit long lines (npm package names with paths, JSON
|
|
2277
|
+
dumps, stack traces). Without these constraints the <li> contents
|
|
2278
|
+
overflow the card horizontally — caught Aaron mid-install (hub#342):
|
|
2279
|
+
clicking Install App / Install Scribe blew up the entire wizard
|
|
2280
|
+
layout, font size jumped, the page stretched off-screen. The
|
|
2281
|
+
triple of overflow-x:auto + white-space:pre-wrap + min-width:0
|
|
2282
|
+
keeps the log inside its container regardless of line length:
|
|
2283
|
+
overflow-x:auto on .op-log gives a horizontal scrollbar as a
|
|
2284
|
+
last-resort affordance; pre-wrap on .log-lines li wraps cleanly
|
|
2285
|
+
at whitespace so the common case never even needs to scroll;
|
|
2286
|
+
min-width:0 on the outer log-lines list is the magic-flex bit
|
|
2287
|
+
that lets the list itself shrink below its content's intrinsic
|
|
2288
|
+
width inside the card's flex/grid layout. break-word (rather
|
|
2289
|
+
than break-all) keeps URLs / paths legible when they DO have to
|
|
2290
|
+
break. */
|
|
2291
|
+
overflow-x: auto;
|
|
2292
|
+
max-width: 100%;
|
|
2134
2293
|
}
|
|
2135
2294
|
.op-status {
|
|
2136
2295
|
margin: 0 0 0.5rem;
|
|
@@ -2139,8 +2298,18 @@ const STYLES = `
|
|
|
2139
2298
|
}
|
|
2140
2299
|
.op-succeeded { color: ${PALETTE.success}; }
|
|
2141
2300
|
.op-failed { color: ${PALETTE.danger}; }
|
|
2142
|
-
.log-lines {
|
|
2143
|
-
|
|
2301
|
+
.log-lines {
|
|
2302
|
+
margin: 0;
|
|
2303
|
+
padding-left: 1.25rem;
|
|
2304
|
+
color: ${PALETTE.fgMuted};
|
|
2305
|
+
min-width: 0;
|
|
2306
|
+
}
|
|
2307
|
+
.log-lines li {
|
|
2308
|
+
margin: 0.15rem 0;
|
|
2309
|
+
white-space: pre-wrap;
|
|
2310
|
+
word-break: break-word;
|
|
2311
|
+
overflow-wrap: anywhere;
|
|
2312
|
+
}
|
|
2144
2313
|
|
|
2145
2314
|
.done-grid {
|
|
2146
2315
|
display: grid;
|
|
@@ -2258,6 +2427,30 @@ const STYLES = `
|
|
|
2258
2427
|
}
|
|
2259
2428
|
.reachable .fine { font-size: 0.85rem; color: ${PALETTE.fgMuted}; margin: 0.4rem 0 0; }
|
|
2260
2429
|
|
|
2430
|
+
/* "Start using your vault" lead tile on the done step (hub#342).
|
|
2431
|
+
Same visual weight as .reachable so the operator's eye lands here
|
|
2432
|
+
as the primary user-facing entry — slightly more prominent
|
|
2433
|
+
padding + a stronger heading to telegraph "this is the click
|
|
2434
|
+
you're looking for." */
|
|
2435
|
+
.start-using {
|
|
2436
|
+
background: ${PALETTE.cardBg};
|
|
2437
|
+
border: 1px solid ${PALETTE.accent};
|
|
2438
|
+
border-radius: 8px;
|
|
2439
|
+
padding: 1rem 1.1rem;
|
|
2440
|
+
margin: 0 0 1rem;
|
|
2441
|
+
}
|
|
2442
|
+
.start-using h2 {
|
|
2443
|
+
margin: 0 0 0.5rem;
|
|
2444
|
+
text-transform: none;
|
|
2445
|
+
letter-spacing: 0;
|
|
2446
|
+
font-size: 1.1rem;
|
|
2447
|
+
color: ${PALETTE.fg};
|
|
2448
|
+
font-family: ${FONT_SERIF};
|
|
2449
|
+
font-weight: 400;
|
|
2450
|
+
}
|
|
2451
|
+
.start-using p { margin: 0.4rem 0; }
|
|
2452
|
+
.start-using p:last-child { margin-bottom: 0; }
|
|
2453
|
+
|
|
2261
2454
|
code {
|
|
2262
2455
|
background: ${PALETTE.borderLight};
|
|
2263
2456
|
padding: 0.05rem 0.3rem;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg: #faf8f4;--bg-soft: #f3f0ea;--fg: #2c2a26;--fg-muted: #6b6860;--fg-dim: #9a9690;--accent: #4a7c59;--accent-soft: rgba(74, 124, 89, .08);--accent-hover: #3d6849;--border: #e4e0d8;--border-light: #ece9e2;--card-bg: #ffffff;--error: #a3392b;--error-soft: rgba(163, 57, 43, .08);--warn: #b08023;--warn-soft: rgba(176, 128, 35, .08);--success: #3d6849;--success-soft: rgba(61, 104, 73, .08);--font-serif: Georgia, "Times New Roman", serif;--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;--font-mono: ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace;font-family:var(--font-sans)}*{box-sizing:border-box}html,body{margin:0;padding:0;background:var(--bg);color:var(--fg)}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button{font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease}button:hover{background:var(--accent-hover)}button:disabled{opacity:.5;cursor:not-allowed}button.secondary{background:#fff;color:var(--fg);border:1px solid var(--border)}button.secondary:hover{background:var(--bg-soft)}input,select,textarea{font:inherit;background:#fff;border:1px solid var(--border);border-radius:6px;padding:.55rem .75rem;color:var(--fg)}input:focus,select:focus,textarea:focus{outline:none;border-color:var(--accent)}code{font-family:var(--font-mono);font-size:.85em;background:var(--bg-soft);padding:.1em .3em;border-radius:3px}.page{max-width:880px;margin:0 auto;padding:1.5rem 1.5rem 6rem}.nav{display:flex;gap:1rem;align-items:center;padding-bottom:1rem;border-bottom:1px solid var(--border);margin-bottom:2rem}.nav .brand{font-weight:600;font-family:var(--font-serif);font-size:1.15rem;margin-right:auto}.nav .brand .sub{color:var(--fg-dim);font-size:.78rem;font-weight:400;margin-left:.4rem;font-family:var(--font-sans)}.nav a{color:var(--fg-muted);font-size:.95rem}.nav a:hover{text-decoration:none;color:var(--fg)}.nav .nav-divider{display:inline-block;width:1px;height:1.1em;background:var(--border);align-self:center}.nav .nav-dropdown{position:relative}.nav .nav-dropdown-summary{list-style:none;cursor:pointer;color:var(--fg-muted);font-size:.95rem;-webkit-user-select:none;user-select:none}.nav .nav-dropdown-summary::-webkit-details-marker{display:none}.nav .nav-dropdown-summary:hover{color:var(--fg)}.nav .nav-dropdown[open]>.nav-dropdown-summary{color:var(--fg)}.nav .nav-dropdown-summary:after{content:" ▾";font-size:.7em;color:var(--fg-dim)}.nav .nav-dropdown-panel{position:absolute;top:calc(100% + .4rem);left:0;z-index:10;min-width:12rem;background:var(--card-bg);border:1px solid var(--border);border-radius:8px;box-shadow:0 4px 12px #00000014;padding:.4rem 0;display:flex;flex-direction:column}.nav .nav-dropdown-item{padding:.4rem .85rem;color:var(--fg);font-size:.9rem;text-decoration:none}.nav .nav-dropdown-item:hover{background:var(--bg-soft);color:var(--fg);text-decoration:none}.nav .nav-dropdown-item-disabled{color:var(--fg-dim);cursor:not-allowed}.nav .nav-dropdown-item-disabled:hover{background:transparent;color:var(--fg-dim)}.nav .auth-spa{font-size:.85rem;color:var(--fg-muted)}.nav .auth-spa strong{font-weight:600;color:var(--fg)}.nav .auth-spa-signout{background:none;border:none;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:2px}.nav .auth-spa-signout:hover:not(:disabled){color:var(--accent-hover)}.nav .auth-spa-signout:disabled{color:var(--fg-dim);cursor:not-allowed}h2{margin:0 0 1rem;font-size:1.4rem;font-weight:500}.muted{color:var(--fg-muted);font-size:.92rem}.dim{color:var(--fg-dim);font-size:.85rem}.error-banner{background:var(--error-soft);border:1px solid var(--error);color:var(--error);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.warn-banner{background:var(--warn-soft);border:1px solid var(--warn);color:var(--warn);padding:.75rem 1rem;border-radius:8px;margin-bottom:1rem;font-size:.9rem}.empty{padding:3rem 1.5rem;text-align:center;color:var(--fg-muted);background:var(--bg-soft);border-radius:10px}.empty-rich{text-align:left;padding:2rem 1.75rem;background:#fff;border:1px solid var(--border)}.empty-rich .empty-headline{font-size:1.05rem;color:var(--fg);margin:0 0 .5rem;font-weight:500}.list-header{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;margin-bottom:1rem}.list-header h2{margin:0}.tag{display:inline-block;padding:.1em .55em;background:var(--accent-soft);color:var(--accent);border-radius:4px;font-size:.78rem;font-weight:500}.tag.muted{background:var(--bg-soft);color:var(--fg-muted)}.tag.source-oauth{background:#4a7cc61f;color:#3b6aa6}.tag.source-operator{background:#c6984a24;color:#8a5e1f}.tag.source-cli{background:#4a7c5924;color:#2f5a3f}.tag.source-unknown{background:var(--bg-soft);color:var(--fg-muted)}@media(prefers-color-scheme:dark){.tag.source-oauth{background:#7a9cdc24;color:#9bb6d8}.tag.source-operator{background:#dcb46e24;color:#d4b27a}.tag.source-cli{background:#7ab08a24;color:#8fc49e}.tag.source-unknown{background:#e8e4dc0f;color:#a8a49a}}.vault-row{display:flex;align-items:center;gap:1rem;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;margin-bottom:.5rem;text-decoration:none;color:inherit;transition:border-color .15s ease}.vault-row:hover{border-color:var(--accent);text-decoration:none}.vault-row .body{flex:1;min-width:0}.vault-row .name{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.vault-row .name code{font-size:.95em}.vault-row .url{margin-top:.25rem;word-break:break-all}.vault-row .chev{color:var(--fg-dim);font-size:1.2rem}form .row{margin-bottom:1rem}form label{display:block;font-size:.9rem;color:var(--fg-muted);margin-bottom:.3rem;font-weight:500}form input[type=text]{width:100%}form .actions{display:flex;gap:.6rem;align-items:center;margin-top:1rem}form .field-hint{margin-top:.35rem;font-size:.82rem;color:var(--fg-dim)}form .field-error{margin-top:.35rem;font-size:.85rem;color:var(--error)}.section{background:#fff;border:1px solid var(--border);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner{background:var(--success-soft);border:1px solid var(--success);border-radius:10px;padding:1.25rem 1.5rem;margin-bottom:1.5rem}.mint-banner h3{margin:0 0 .5rem;font-size:1rem;color:var(--success)}.mint-banner .token-box{display:flex;align-items:center;gap:.5rem;margin:.85rem 0 .5rem}.mint-banner code{flex:1;font-size:.9rem;padding:.6rem .75rem;background:#fff;border:1px solid var(--border);word-break:break-all;-webkit-user-select:all;user-select:all}.mint-banner .warn{margin:.75rem 0 0;font-size:.85rem;color:var(--warn)}.mint-banner .actions{margin-top:1rem;display:flex;gap:.5rem}.kv{display:grid;grid-template-columns:8.5rem 1fr;gap:.5rem 1rem;font-size:.92rem}.kv>div:nth-child(odd){color:var(--fg-muted)}.kv code{word-break:break-all}.channel-toggle{margin:1.25rem 0 1.5rem;padding:.75rem 1rem;border:1px solid var(--border, #ddd);border-radius:6px;background:var(--bg-soft, #fafafa)}.channel-toggle legend{padding:0 .25rem;font-weight:600;font-size:.95rem}.channel-toggle label{display:inline-flex;align-items:center;gap:.4rem;margin-right:1.5rem;cursor:pointer;font-size:.95rem}.channel-toggle label input[type=radio]:disabled+*{opacity:.5}.channel-toggle code{font-size:.85em}.channel-toggle p.muted{margin:.4rem 0 0;font-size:.85rem}.module-config{display:flex;flex-direction:column;gap:1.25rem}.module-config-header h1{margin-bottom:.35rem}.module-config-form fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:1rem}.module-config-form .field{display:flex;flex-direction:column;gap:.25rem}.module-config-form .field input,.module-config-form .field select,.module-config-form .field textarea{width:100%}.module-config-form .field-inline{flex-direction:row;align-items:center;flex-wrap:wrap;gap:.5rem}.module-config-form .field-inline label{display:inline-flex;align-items:center;gap:.5rem}.module-config-form .field-inline .field-hint{flex-basis:100%;margin-left:1.6rem}.module-config-form .field-invalid input,.module-config-form .field-invalid select,.module-config-form .field-invalid textarea{border-color:var(--error)}.module-config-form .actions{display:flex;gap:.6rem;align-items:center;margin-top:.5rem}.module-config-form .actions button.destructive{background:#fff;color:var(--fg);border:1px solid var(--border)}.module-config-form .actions button.destructive:hover{background:var(--bg-soft)}.module-config-form .banner{margin:0;padding:.75rem 1rem;border-radius:6px;border:1px solid transparent;font-size:.9rem}.module-config-form .banner-success{background:var(--success-soft);border-color:var(--success);color:var(--success)}.module-config-form .banner-success p,.module-config-form .banner-success ul{margin:.4rem 0 0}.module-config-form .banner-error{background:var(--error-soft, rgba(163, 57, 43, .08));border-color:var(--error);color:var(--error)}.modules-installed,.modules-installable{margin-top:1.75rem}.modules-installed>h2,.modules-installable>h2{font-size:1.15rem;font-weight:600;margin:0 0 .75rem;color:var(--fg)}.modules-installed>p.muted,.modules-installable>p.muted{margin:0 0 .5rem}.install-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:.6rem}.install-card{display:flex;flex-direction:row;align-items:center;gap:1rem;flex-wrap:wrap;padding:.85rem 1rem;background:#fff;border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease}.install-card:hover{border-color:var(--accent)}.install-card-body{flex:1 1 0;min-width:0}.install-card-body h3{margin:0 0 .2rem;font-size:1rem;font-weight:600;color:var(--fg)}.install-card-body .tagline{margin:0 0 .35rem;color:var(--fg-muted);font-size:.92rem}.install-card-meta{margin:0;font-size:.82rem}.install-card-actions{flex:0 0 auto}.install-card .error{flex-basis:100%;margin-top:.5rem;color:var(--error);font-size:.85rem}.module-row .actions .btn,a.btn{display:inline-block;font:inherit;background:var(--accent);color:#fff;border:0;border-radius:6px;padding:.55rem 1.1rem;cursor:pointer;transition:background .15s ease;text-decoration:none}.module-row .actions .btn:hover,a.btn:hover{background:var(--accent-hover);text-decoration:none}.module-uis{margin:.5rem 0 0;padding:.5rem 0 0;border-top:1px solid var(--border-light)}.module-uis>summary{cursor:pointer;font-size:.88rem;color:var(--fg-muted);font-weight:500;padding:.15rem 0;list-style:revert}.module-uis>summary:hover{color:var(--fg)}.ui-sub-units{list-style:none;padding:0;margin:.5rem 0 0 1.1rem;display:flex;flex-direction:column;gap:.35rem}.ui-sub-unit{display:flex;flex-direction:row;align-items:center;gap:.65rem;padding:.5rem .75rem;background:var(--bg-soft);border:1px solid var(--border-light);border-radius:6px;transition:border-color .15s ease,background .15s ease}.ui-sub-unit:hover{border-color:var(--accent);background:#fff}.ui-icon{flex:0 0 auto;width:20px;height:20px;border-radius:4px;object-fit:contain}.ui-sub-unit-body{flex:1 1 0;min-width:0}.ui-sub-unit-link{color:var(--fg);font-size:.95rem;text-decoration:none}.ui-sub-unit-link:hover{color:var(--accent);text-decoration:underline}.ui-sub-unit-link strong{font-weight:600}.ui-sub-unit .tagline{margin:.2rem 0 0;font-size:.82rem;color:var(--fg-muted)}.status{flex:0 0 auto;display:inline-block;padding:.1em .55em;background:var(--bg-soft);color:var(--fg-muted);border-radius:4px;font-size:.78rem;font-weight:500;white-space:nowrap}.status-active{background:var(--success-soft);color:var(--success)}.status-pending-oauth{background:var(--warn-soft);color:var(--warn)}.status-disabled{background:var(--bg-soft);color:var(--fg-dim)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
|