@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.
@@ -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 primary "what's next?" surface
659
- // they're the highest-friction next-step for most operators (operator
660
- // just provisioned a vault, the obvious next action is installing the
661
- // PWA / transcription module on top of it). Reachable tile leads
662
- // everything because it answers "where's my hub?" before anything
663
- // else the question every operator hits before MCP / module
664
- // installs even matter.
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
- actions = `<p><a class="btn btn-secondary" href="/admin/modules">Manage modules</a></p>`;
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><a class="btn btn-secondary" href="/admin/modules">Manage in admin</a></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 { margin: 0; padding-left: 1.25rem; color: ${PALETTE.fgMuted}; }
2143
- .log-lines li { margin: 0.15rem 0; }
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}