@3sln/trove 0.0.10 → 0.0.12
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/packages/core/src/apiKeys.js +1 -13
- package/packages/core/src/collections/index.js +24 -0
- package/packages/core/src/identity/jwt.js +48 -0
- package/packages/core/src/index.js +2 -1
- package/packages/core/src/signedUrls.js +1 -13
- package/packages/core/src/timingSafe.js +30 -0
- package/packages/server/src/access/externalEvaluation.js +169 -0
- package/packages/server/src/index.js +43 -2
- package/packages/server/src/routes.js +21 -0
- package/packages/web/dist/assets/main-c9dnnnc6.js +356 -0
- package/packages/web/dist/assets/{main-jg5vmp8f.js.map → main-c9dnnnc6.js.map} +10 -10
- package/packages/web/dist/assets/styles-hskanqc0.css +1 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +61 -0
- package/packages/web/src/bl/index.js +3 -1
- package/packages/web/src/bl/queries.js +42 -2
- package/packages/web/src/bl/services.js +38 -0
- package/packages/web/src/bl/state.js +14 -0
- package/packages/web/src/platform/api.js +15 -0
- package/packages/web/src/styles.css +11 -0
- package/packages/web/src/ui/components/adminView.js +144 -2
- package/packages/web/src/ui/compositions/workbench.js +1 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js +0 -356
- package/packages/web/dist/assets/styles-e5gk19rn.css +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;--font-mono:"SF Mono","JetBrains Mono","Fira Code",ui-monospace,Menlo,Consolas,monospace;--radius:8px;--radius-sm:5px;--activity-w:52px;--sidebar-w:280px;--statusbar-h:24px;--ease:cubic-bezier(.2,.7,.2,1);--focus:var(--accent);--focus-halo:color-mix(in srgb,var(--accent)32%,transparent)}:root,:root[data-theme=dark]{--bg:#181a1f;--bg-elevated:#1f2228;--bg-sidebar:#1b1d23;--bg-activity:#16181d;--bg-input:#12141a;--bg-hover:#ffffff0d;--bg-active:#ffffff14;--bg-selected:#2d6fd128;--border:#ffffff14;--border-strong:#ffffff26;--text:#d7dce5;--text-dim:#9aa4b2;--text-faint:#6b7280;--accent:#6ea8fe;--accent-2:#a679f0;--accent-text:#0b1020;--danger:#f2607a;--warn:#e5b567;--success:#6cc48a;--shadow:0 12px 40px -8px #000000a8,0 2px 8px -2px #0008;--scrollbar:#fff2}:root[data-theme=midnight]{--bg:#0c0e14;--bg-elevated:#12151e;--bg-sidebar:#0e111a;--bg-activity:#090b11;--bg-input:#080a10;--bg-hover:#ffffff0a;--bg-active:#ffffff12;--bg-selected:#4b7bff22;--border:#ffffff10;--border-strong:#ffffff1f;--text:#cdd6f4;--text-dim:#8b95b0;--text-faint:#5a6482;--accent:#89b4fa;--accent-2:#cba6f7;--danger:#f38ba8;--warn:#f9e2af;--success:#a6e3a1;--shadow:0 16px 50px -10px #000c,0 2px 10px -2px #000a;--scrollbar:#ffffff1a}:root[data-theme=light]{--bg:#f6f7f9;--bg-elevated:#fff;--bg-sidebar:#eef0f3;--bg-activity:#e6e9ee;--bg-input:#fff;--bg-hover:#0000000a;--bg-active:#00000012;--bg-selected:#2d6fd11f;--border:#00000014;--border-strong:#00000024;--text:#1c2430;--text-dim:#55606f;--text-faint:#8a95a4;--accent:#2d6fd1;--accent-2:#7c50c8;--accent-text:#fff;--danger:#d63b5b;--warn:#b3801f;--success:#2f9e57;--shadow:0 12px 40px -10px #0000002e,0 2px 8px -3px #0000001f;--scrollbar:#00000026}*{box-sizing:border-box}html,body{height:100%;margin:0}*,*:before,*:after{-webkit-tap-highlight-color:transparent}body{font-family:var(--font-ui);color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;overflow:hidden;font-size:13px}button{font:inherit;color:inherit;cursor:pointer;text-align:inherit;background:0 0;border:0}input,select,textarea{font:inherit;color:inherit}::selection{background:var(--accent);color:var(--accent-text)}:focus-visible{outline:2px solid var(--focus);outline-offset:2px}:focus:not(:focus-visible){outline:none}.btn:focus-visible{outline:none;border-color:var(--focus);box-shadow:0 0 0 3px var(--focus-halo)}.btn.primary:focus-visible{box-shadow:0 0 0 3px var(--focus-halo)}.iconbtn:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.act-action:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.launch-clear:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.pb-status:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.pb-brand:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.vn-back:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.c-link:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.react:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.launch-box:focus-within{border-color:var(--focus);box-shadow:var(--shadow),0 0 0 3px var(--focus-halo)}.launch-input:focus-visible{outline:none}.launch-item:focus-visible{outline:none;background:var(--bg-selected);box-shadow:inset 3px 0 0 0 var(--focus),0 0 0 1px var(--focus-halo)}.sheet-row:focus-visible{outline:none;background:var(--bg-selected);box-shadow:inset 3px 0 0 0 var(--focus),0 0 0 1px var(--focus-halo)}.inbox-item:focus-visible{outline:none;background:var(--bg-selected);box-shadow:inset 3px 0 0 0 var(--focus),0 0 0 1px var(--focus-halo)}.activitybar .item:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.activitybar .item:focus-visible:before{content:"";position:absolute;background:linear-gradient(var(--accent),var(--accent-2));border-radius:0 3px 3px 0;width:3px;top:8px;bottom:8px;left:-6px}.activitybar .brand-mark:focus-visible{outline:none;background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}.pb-tab:focus-visible{outline:none;color:var(--accent);box-shadow:inset 0 3px 0 0 var(--focus)}.statusbar .seg:focus-visible{outline:none;background:var(--bg-active);color:var(--text);box-shadow:inset 0 -2px 0 0 var(--focus)}.md a:focus-visible{outline:2px solid var(--focus);outline-offset:3px;border-radius:3px}.link:focus-visible{outline:2px solid var(--focus);outline-offset:3px;border-radius:3px}.launch-up:focus-visible{outline:2px solid var(--focus);outline-offset:3px;border-radius:3px}.lh-example:focus-visible{outline:none;border-color:var(--focus);box-shadow:0 0 0 3px var(--focus-halo)}::-webkit-scrollbar{width:11px;height:11px}::-webkit-scrollbar-thumb{background:var(--scrollbar);background-clip:content-box;border:3px solid #0000;border-radius:6px}::-webkit-scrollbar-thumb:hover{background:var(--border-strong);background-clip:content-box}.workbench{height:100%}.shell{display:grid;grid-template-rows:1fr var(--statusbar-h);height:100vh;height:100dvh}.body{display:grid;grid-template-columns:var(--activity-w)1fr;overflow:hidden;min-height:0}kbd{font-family:var(--font-ui);color:var(--text-dim);background:var(--bg-hover);border:1px solid var(--border);text-align:center;border-radius:4px;min-width:16px;padding:1px 5px;font-size:10.5px}.kbd-edit{display:inline-flex;color:var(--text-dim);font:inherit;cursor:pointer;background:0 0;border:1px solid #0000;border-radius:6px;align-items: center;gap:6px;padding:2px 4px}.kbd-edit:hover{border-color:var(--border);background:var(--bg-hover)}.kbd-edit:focus-visible{outline:none;border-color:var(--focus);box-shadow:0 0 0 3px var(--focus-halo)}.kbd-clash{display:inline-flex;color:var(--warn);align-items: center;margin-right:4px}.kbd-edit.clash kbd{border-color:color-mix(in srgb,var(--warn)50%,transparent);color:var(--warn)}.kbd-edit.listening{border-color:var(--accent);background:var(--bg-active);color:var(--accent);padding:3px 8px;font-size:11px}.activitybar{display:flex;background:var(--bg-activity);border-right:1px solid var(--border);flex-direction:column;align-items: center;gap:4px;padding:8px 0 10px}.activitybar .brand-mark{display:grid;cursor:pointer;transition:background .12s var(--ease),transform .12s var(--ease);background:0 0;border:0;border-radius:10px;place-items: center;width:40px;height:40px;margin-bottom:6px}.activitybar .brand-mark img{width:24px;height:24px}.activitybar .brand-mark:hover{background:var(--bg-hover)}.activitybar .brand-mark:active{transform:translateY(1px)}.activitybar .item{position:relative;color:var(--text-faint);display:grid;transition:color .12s var(--ease),background .12s var(--ease);background:0 0;border:0;border-radius:9px;place-items: center;width:40px;height:40px}.activitybar .item:hover{color:var(--text);background:var(--bg-hover)}.activitybar .item.active{color:var(--text);background:var(--bg-active)}.activitybar .item.active:before{content:"";position:absolute;background:linear-gradient(var(--accent),var(--accent-2));border-radius:0 3px 3px 0;width:3px;top:8px;bottom:8px;left:-6px}.activitybar .item .badge{position:absolute;background:var(--accent);color:var(--accent-text);display:grid;border-radius:8px;place-items: center;min-width:15px;height:15px;padding:0 3px;font-size:9.5px;font-weight:700;top:3px;right:3px}.activitybar .spacer{flex:1}.activitybar .bell-wrap{display:grid;place-items: center}.activitybar .iconbtn{color:var(--text-faint);border-radius:9px;width:40px;height:40px}.activitybar .iconbtn:hover{color:var(--text);background:var(--bg-hover)}.activitybar .principal{cursor:default;margin:4px 0 0}.sidebar{width:var(--sidebar-w);background:var(--bg-sidebar);border-right:1px solid var(--border);display:flex;overflow:hidden;flex-direction:column;min-width:0}.sidebar .head{display:flex;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim);align-items: center;gap:6px;padding:10px 12px 8px;font-size:11px;font-weight:600}.sidebar .head .actions{display:flex;gap:2px;margin-left:auto}.sidebar .scroll{overflow-y:auto;overflow-x:hidden;flex:1}.iconbtn{color:var(--text-dim);display:grid;transition:all .12s var(--ease);background:0 0;border:0;border-radius:6px;place-items: center;width:26px;height:26px}.iconbtn:hover{background:var(--bg-hover);color:var(--text)}.iconbtn:active{transform:translateY(1px)}.iconbtn.danger:hover{color:var(--danger)}.collection-switch{display:flex;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text);transition:border-color .12s var(--ease);border-radius:8px;align-items: center;gap:8px;width:calc(100% - 16px);margin:4px 8px 0;padding:7px 10px}.collection-switch:hover{border-color:var(--accent)}.collection-switch .cs-name{text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-weight:550}.collection-switch svg:last-child{color:var(--text-faint)}.breadcrumb{display:flex;border-bottom:1px solid var(--border);color:var(--text-dim);flex-wrap:wrap;align-items: center;gap:2px;padding:6px 10px;font-size:12px}.breadcrumb .crumb{color:inherit;background:0 0;border:0;border-radius:5px;padding:2px 6px}.breadcrumb .crumb:hover{background:var(--bg-hover);color:var(--text)}.breadcrumb .sep{opacity:.4}.breadcrumb .crumb.current{color:var(--text);font-weight:500}.filelist{padding:4px}.row{display:flex;cursor:default;user-select:none;color:var(--text);transition:background 80ms var(--ease);border-radius:6px;align-items: center;gap:9px;height:30px;padding:0 8px}.compact .row{height:26px}.row:hover{background:var(--bg-hover)}.row.selected{background:var(--bg-selected);box-shadow:inset 0 0 0 1px var(--accent)}.row.drop-target{box-shadow:inset 0 0 0 2px var(--accent);background:var(--bg-selected)}.row .ico{color:var(--text-dim);display:grid;flex:none;place-items: center}.row .ico.folder{color:var(--accent)}.row .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.row .meta{color:var(--text-faint);font-variant-numeric:tabular-nums;font-size:11px}.row .pin-dot{color:var(--success);display:grid;flex:none;place-items: center}.statusbar .offline-badge,.statusbar .offline-badge span{color:var(--warn)}.row .rename-input{background:var(--bg-input);border:1px solid var(--accent);outline:none;color:var(--text);border-radius:4px;flex:1;padding:2px 6px}.empty{display:flex;color:var(--text-faint);text-align:center;flex-direction:column;justify-content:center;align-items: center;gap:10px;height:100%;padding:32px}.empty .big{opacity:.5;font-size:34px}.empty .cta{border:1px dashed var(--border-strong);color:var(--text-dim);background:0 0;border-radius:8px;margin-top:4px;padding:7px 14px}.empty .cta:hover{border-color:var(--accent);color:var(--text)}.editor{display:flex;background:var(--bg);overflow:hidden;flex-direction:column;min-width:0}.tabs{display:flex;background:var(--bg-activity);border-bottom:1px solid var(--border);overflow-x:auto;align-items:stretch;height:36px}.tab{display:flex;border:0;border-right:1px solid var(--border);color:var(--text-dim);position:relative;white-space:nowrap;background:0 0;align-items: center;gap:8px;max-width:220px;padding:0 10px 0 14px}.tab.active{background:var(--bg);color:var(--text)}.tab.active:before{content:"";position:absolute;background:linear-gradient(90deg,var(--accent),var(--accent-2));height:2px;top:0;left:0;right:0}.tab .label{overflow:hidden;text-overflow:ellipsis}.tab .close{display:grid;opacity:.5;border-radius:4px;place-items: center;width:18px;height:18px}.tab .close:hover{background:var(--bg-hover);opacity:1}.editor .stage{overflow:auto;position:relative;flex:1;min-height:0}.welcome{display:grid;place-items: center;height:100%}.welcome .card{text-align:center;max-width:460px;padding:40px}.welcome h1{margin:12px 0 6px;font-size:26px;font-weight:650}.welcome .logo{font-size:46px}.welcome p{color:var(--text-dim);line-height:1.6}.welcome .hints{display:grid;text-align:left;gap:8px;margin-top:22px}.welcome .hint{display:flex;color:var(--text-dim);align-items: center;gap:10px}.statusbar{display:flex;background:linear-gradient(90deg,color-mix(in srgb,var(--accent)20%,var(--bg-activity)),var(--bg-activity));border-top:1px solid var(--border);color:var(--text-dim);align-items: center;gap:2px;padding:0 8px;font-size:11.5px}.statusbar .seg{display:flex;color:inherit;background:0 0;border:0;align-items: center;gap:5px;height:100%;padding:0 8px}.statusbar .seg:hover{background:var(--bg-hover);color:var(--text)}.statusbar .spacer{flex:1}.btn{display:inline-flex;border:1px solid var(--border-strong);background:var(--bg-elevated);color:var(--text);transition:all .12s var(--ease);border-radius:7px;align-items: center;gap:7px;padding:7px 13px}.btn:hover{border-color:var(--accent)}.btn.primary{background:linear-gradient(180deg,var(--accent),color-mix(in srgb,var(--accent)82%,black));color:var(--accent-text);border:0;font-weight:600}.btn.small{border-radius:6px;gap:5px;padding:4px 9px;font-size:12px}.btn.ghost{color:var(--text-dim);background:0 0;border-color:#0000}.btn.ghost:hover{background:var(--bg-hover);border-color:var(--border);color:var(--text)}.btn.on{border-color:var(--accent);color:var(--accent);background:var(--bg-active)}.btn.primary:hover{filter:brightness(1.08)}.btn.danger{color:var(--danger);border-color:color-mix(in srgb,var(--danger)40%,var(--border))}.btn.danger:hover{background:color-mix(in srgb,var(--danger)14%,transparent)}.btn:disabled{opacity:.45;pointer-events:none}.field{display:flex;flex-direction:column;gap:6px}.field label{color:var(--text-dim);font-size:12px}.input,select.input{background:var(--bg-input);color:var(--text);border:1px solid var(--border-strong);outline:none;transition:border-color .12s var(--ease),box-shadow .12s var(--ease);border-radius:7px;width:100%;padding:8px 11px}.input:focus{border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent)22%,transparent)}.switch{position:relative;flex:none;width:38px;height:22px}.switch input{position:absolute;opacity:0;cursor:pointer;z-index:1;width:100%;height:100%;margin:0;inset:0}.switch .track{position:absolute;background:var(--border-strong);transition:background .15s var(--ease);border-radius:22px;inset:0}.switch .track:before{content:"";position:absolute;transition:transform .16s var(--ease);background:#fff;border-radius:50%;width:16px;height:16px;top:3px;left:3px}.switch input:focus-visible+.track{box-shadow:0 0 0 3px var(--focus-halo);outline:1px solid var(--focus)}.switch input:checked+.track{background:var(--accent)}.switch input:checked+.track:before{transform:translate(16px)}.scrim{position:fixed;backdrop-filter:blur(1.5px);z-index:40;background:#0006;inset:0}.palette{position:fixed;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);z-index:50;overflow:hidden;animation:pop .13s var(--ease);border-radius:12px;width:min(640px,92vw);top:12vh;left:50%;translate:-50%}@keyframes pop{0%{opacity:0;transform:translateY(-6px)scale(.98)}to{opacity:1;transform:none}}.palette .search{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:9px;padding:12px 14px}.palette .search input{outline:none;color:var(--text);background:0 0;border:0;flex:1;font-size:15px}.palette .results{overflow-y:auto;max-height:52vh;padding:6px}.palette .opt{display:flex;cursor:default;border-radius:7px;align-items: center;gap:10px;padding:8px 10px}.palette .opt.active{background:var(--bg-active)}.palette .opt .ico{color:var(--text-dim)}.palette .opt .title{flex:1}.palette .opt .cat{color:var(--text-faint);font-size:11px}.palette .opt .kbd{margin-left:auto}.palette .opt .sub{color:var(--text-faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.palette .opt.unavailable{opacity:.5}.palette .opt.unavailable .title{text-decoration:line-through;text-decoration-color:var(--text-faint)}.offline-tag{text-transform:uppercase;letter-spacing:.5px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn)40%,transparent);border-radius:5px;padding:1px 5px;font-size:9.5px}.plugin-features{border-top:1px solid var(--border);margin-top:12px;padding-top:10px}.pf-head{display:flex;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);justify-content:space-between;margin-bottom:8px;font-size:11px}.pf-list{display:flex;flex-direction:column;gap:4px}.pf-row{display:flex;border-radius:6px;align-items: center;gap:8px;padding:4px 6px;font-size:12.5px}.pf-row.off{opacity:.55}.pf-row .pf-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.pf-row .pf-kind{color:var(--text-faint);font-size:10px}.pf-badge{color:var(--text-faint);border:1px solid var(--border);white-space:nowrap;border-radius:5px;margin-left:auto;padding:1px 5px;font-size:9.5px}.pf-badge.offline-ok{color:var(--success);border:1px solid color-mix(in srgb,var(--success)40%,transparent);border-radius:5px;padding:1px 5px;font-size:9.5px}.pf-dot{border-radius:50%;flex:none;width:8px;height:8px}.pf-dot.on{background:var(--success)}.pf-dot.no{background:var(--text-faint)}.palette .none{text-align:center;color:var(--text-faint);display:flex;justify-content:center;align-items: center;gap:8px;padding:22px}.palette .none.error{color:var(--danger,#e5534b)}.palette .none .spinner{width:13px;height:13px}.dialog{position:fixed;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);z-index:50;animation:pop .13s var(--ease);border-radius:12px;width:min(440px,92vw);padding:20px;top:50%;left:50%;translate:-50% -50%}.dialog h3{margin:0 0 12px;font-size:16px}.dialog .body{display:block;color:var(--text-dim);margin-bottom:16px;line-height:1.5}.dialog .row-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.opener-chooser .opener-list{display:flex;flex-direction:column;gap:6px;margin:4px 0 12px}.opener-opt{display:flex;border:1px solid var(--border);cursor:pointer;border-radius:9px;align-items: center;gap:10px;padding:9px 11px}.opener-opt:hover{background:var(--bg-hover)}.opener-opt.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent)10%,transparent)}.opener-opt input{accent-color:var(--accent)}.opener-opt .oo-main{display:flex;flex-direction:column;gap:1px}.opener-opt .oo-title{font-size:13px;font-weight:500}.opener-opt .oo-src{color:var(--text-dim);font-size:11px}.opener-remember{display:flex;color:var(--text-dim);cursor:pointer;align-items: center;gap:8px;font-size:12.5px}.opener-remember input{accent-color:var(--accent)}.menu{overflow-y:auto;overscroll-behavior:contain;position:fixed;z-index:60;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);animation:pop .1s var(--ease);border-radius:9px;min-width:200px;max-height:calc(100vh - 24px);padding:5px}.menu .mi{display:flex;box-sizing:border-box;cursor:default;border-radius:6px;align-items: center;gap:10px;width:100%;padding:7px 9px}.menu .mi:hover{background:var(--bg-active)}.menu .mi.danger:hover{background:color-mix(in srgb,var(--danger)16%,transparent);color:var(--danger)}.menu .mi .kbd{color:var(--text-faint);margin-left:auto}.menu .sep{background:var(--border);height:1px;margin:5px 2px}.toasts{position:fixed;bottom:calc(var(--statusbar-h) + 12px);z-index:70;display:flex;flex-direction:column;gap:8px;width:340px;right:14px}.toast{display:flex;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);animation:slidein .16s var(--ease);border-radius:10px;gap:10px;padding:11px 13px}@keyframes slidein{0%{opacity:0;transform:translate(12px)}}.toast .bar{border-radius:3px;flex:none;width:3px}.toast.info .bar{background:var(--accent)}.toast.success .bar{background:var(--success)}.toast.warn .bar{background:var(--warn)}.toast.error .bar{background:var(--danger)}.toast .msg{flex:1;line-height:1.4}.toast .x{color:var(--text-faint);align-self: flex-start}.tray{position:fixed;bottom:calc(var(--statusbar-h) + 12px);left:calc(var(--activity-w) + 12px);z-index:65;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);overflow:hidden;border-radius:12px;width:320px}.tray .head{display:flex;border-bottom:1px solid var(--border);align-items: center;padding:10px 12px;font-size:12px;font-weight:600}.tray .head .actions{margin-left:auto}.tray .items{overflow-y:auto;max-height:300px}.xfer{border-bottom:1px solid var(--border);padding:9px 12px}.xfer:last-child{border-bottom:0}.xfer-actions{display:flex;gap:6px;margin-top:8px}.xfer .top{display:flex;align-items: center;gap:8px}.xfer .top .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:12px}.xfer .pct{color:var(--text-faint);font-variant-numeric:tabular-nums;font-size:11px}.progress{background:var(--bg-hover);overflow:hidden;border-radius:3px;height:4px;margin-top:7px}.progress>div{background:linear-gradient(90deg,var(--accent),var(--accent-2));transition:width .2s var(--ease);height:100%}.xfer.error .progress>div{background:var(--danger)}.xfer.cancelled{opacity:.55}.searchview{display:flex;flex-direction:column;gap:10px;height:100%;padding:10px}.searchview .box{display:flex;gap:6px}.searchview .modes{display:flex;gap:4px}.chip{border:1px solid var(--border);color:var(--text-dim);background:0 0;border-radius:20px;padding:4px 10px;font-size:11.5px}.chip.active{background:var(--bg-selected);border-color:var(--accent);color:var(--text)}.results{overflow-y:auto;display:flex;flex-direction:column;flex:1;gap:6px}.result{border:1px solid var(--border);background:var(--bg-elevated);cursor:default;border-radius:9px;padding:10px 11px}.result:hover{border-color:var(--border-strong)}.result .top{display:flex;align-items: center;gap:8px}.result .top .name{font-weight:550}.result .top .score{color:var(--text-faint);margin-left:auto;font-size:10.5px}.result .path{color:var(--text-faint);margin:2px 0 0 24px;font-size:11px}.result .snippet{color:var(--text-dim);margin:6px 0 0 24px;font-size:12px;line-height:1.5}.result .snippet mark{background:color-mix(in srgb,var(--accent)32%,transparent);color:var(--text);border-radius:3px;padding:0 2px}.result .badge{background:var(--bg-hover);color:var(--text-dim);border-radius:5px;padding:1px 6px;font-size:10px}.settings{max-width:720px;margin:0 auto;padding:26px 24px 60px}.settings h2{margin:0 0 4px;font-size:20px}.settings .sub{color:var(--text-dim);margin-bottom:22px}.settings .group{margin-bottom:28px}.settings .group h3{text-transform:uppercase;letter-spacing:.6px;color:var(--accent);margin:0 0 10px;font-size:12px}.setting{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:16px;padding:12px 0}.setting .info{flex:1}.setting .info .t{font-weight:550}.setting .info .d{color:var(--text-faint);margin-top:2px;font-size:12px;line-height:1.4}.setting .control{display:flex;flex:none;justify-content:flex-end;width:200px}.plugins{display:flex;flex-direction:column;gap:12px;padding:16px}.plugin-card{border:1px solid var(--border);background:var(--bg-elevated);border-radius:11px;padding:14px}.plugin-card .top{display:flex;align-items: center;gap:10px}.plugin-card .avatar{background:linear-gradient(135deg,var(--accent),var(--accent-2));display:grid;color:var(--accent-text);border-radius:9px;place-items: center;width:38px;height:38px;font-weight:700}.plugin-card .name{font-weight:600}.plugin-card .status{border-radius:12px;margin-left:auto;padding:2px 8px;font-size:11px}.plugin-card .status.active{background:color-mix(in srgb,var(--success)20%,transparent);color:var(--success)}.plugin-card .status.error{background:color-mix(in srgb,var(--danger)20%,transparent);color:var(--danger)}.plugin-card .status.loading{background:var(--bg-hover);color:var(--text-dim)}.plugin-card .caps{display:flex;flex-wrap:wrap;gap:5px;margin:10px 0 0}.plugin-card .cap{background:var(--bg-hover);color:var(--text-dim);border-radius:6px;padding:2px 7px;font-size:10.5px}.plugin-card .desc{color:var(--text-dim);margin-top:8px;font-size:12px;line-height:1.5}.plugin-card .actions{display:flex;gap:8px;margin-top:12px}.plugin-panel{position:fixed;z-index:55;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);overflow:hidden;animation:pop .13s var(--ease);border-radius:12px;top:12px;right:12px}.plugin-panel .head{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:8px;padding:9px 12px;font-size:12px;font-weight:600}.plugin-panel .head .x{margin-left:auto}.plugin-panel .host{display:block}.plugin-install{display:flex;gap:8px;margin-top:10px}.trust{display:inline-flex;border:1px solid var(--border);white-space:nowrap;border-radius:12px;align-items: center;gap:4px;padding:2px 7px;font-size:10.5px}.trust.verified{color:var(--success);border-color:color-mix(in srgb,var(--success)45%,transparent);background:color-mix(in srgb,var(--success)12%,transparent)}.trust.signed{color:var(--text-dim)}.trust.unverified{color:var(--warn);border-color:color-mix(in srgb,var(--warn)40%,transparent)}.trust.invalid{color:var(--danger,#e5484d);border-color:color-mix(in srgb,var(--danger,#e5484d)55%,transparent);background:color-mix(in srgb,var(--danger,#e5484d)14%,transparent);font-weight:600}.dialog.review{overflow:hidden;padding:0}.review-head{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:12px;padding:16px 18px}.review-head .avatar{border-radius:11px;width:44px;height:44px}.review-head .rh-main{flex:1}.review-head .rh-name{display:flex;align-items:baseline;gap:8px;font-size:16px;font-weight:650}.review-head .rh-ver{color:var(--text-faint);font-size:11px;font-weight:400}.review-head .rh-author{color:var(--text-dim);margin-top:2px;font-size:12px}.review-body{overflow-y:auto;max-height:60vh;padding:14px 18px}.review-desc{color:var(--text-dim);margin:0 0 14px;line-height:1.55}.review-section{margin-bottom:16px}.review-section .rs-title{text-transform:uppercase;letter-spacing:.5px;color:var(--accent);margin-bottom:8px;font-size:11px}.cap-list{display:flex;flex-direction:column;gap:8px}.cap-row{display:flex;border:1px solid var(--border);cursor:pointer;border-radius:9px;align-items: flex-start;gap:10px;padding:8px 10px}.cap-row:hover{border-color:var(--border-strong)}.cap-row.blocked,.contrib-row.blocked{opacity:.6;cursor:not-allowed}.contrib-row.blocked .pf-title{text-decoration:line-through}.cap-row input{accent-color:var(--accent);margin-top:2px}.cap-row .cap-name{font-size:13px;font-weight:600}.cap-row .cap-desc{color:var(--text-dim);margin-top:2px;font-size:12px;line-height:1.4}.contrib-list{display:flex;flex-direction:column;gap:3px}.contrib-row{display:flex;align-items: center;gap:8px;padding:3px 4px;font-size:12.5px}.contrib-row .cr-title{color:var(--text)}.contrib-row .cr-detail{color:var(--text-faint);font-size:11px}.contrib-row .pf-kind{color:var(--text-faint);margin-left:auto;font-size:10px}.review-meta{color:var(--text-faint);margin-top:6px;font-size:11px}.dialog.review .row-actions{border-top:1px solid var(--border);margin:0;padding:14px 18px}.setting.stacked{flex-wrap:wrap}.setting.stacked .info{flex:100%}.setting.stacked .control{width:100%;margin-top:12px}.admin .control.admin-actions{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px}.admin .control.admin-actions .btn{white-space:nowrap}.admin .control .mono.muted{color:var(--text-faint)}.acl{grid-column:1/-1;background:color-mix(in srgb,var(--text)4%,transparent);display:flex;border-radius:9px;flex-direction:column;gap:8px;width:100%;margin:10px 0 2px;padding:10px 12px}.acl-row{display:flex;flex-wrap:wrap;align-items: center;gap:6px}.acl-who{color:var(--text-dim);font-size:12.5px;font-family:var(--font-mono);flex:1;min-width:160px}.acl .btn.on{background:var(--accent);color:var(--accent-text)}.acl-add{margin-top:2px}.acl-fixed .acl-who{color:var(--text-faint)}.acl-subject{flex:1;min-width:180px}.rot-bill{background:color-mix(in srgb,var(--text)4%,transparent);border-radius:8px;margin:8px 0 10px;padding:8px 10px}.rot-bill .rb-line{display:flex;color:var(--text-dim);justify-content:space-between;gap:12px;padding:2px 0;font-size:12px}.rot-bill .rb-line.total{border-top:1px solid var(--border);color:var(--text);margin-top:4px;padding-top:5px}.rot-bill .rb-asof{color:var(--text-faint);margin-top:5px;font-size:11px}.enc-section{border-top:1px solid var(--border);margin-top:4px;padding-top:12px}.enc-toggle{display:flex;cursor:pointer;align-items: center;gap:8px;font-size:13px}.enc-hint{color:var(--text-faint);margin-top:4px;font-size:11.5px}.enc-body{margin-top:10px}.enc-body .field{margin-bottom:8px}.enc-problem{display:flex;color:var(--warn);align-items: center;gap:6px;margin:6px 0;font-size:12px}.enc-scope{background:color-mix(in srgb,var(--text)4%,transparent);border-radius:8px;margin-top:10px;padding:10px 12px}.enc-scope .es-title{text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);margin-bottom:3px;font-size:11px;font-weight:650}.enc-scope .es-title+.es-line{margin-bottom:8px}.enc-scope .es-line{color:var(--text-dim);font-size:12px;line-height:1.5}.enc-scope .es-line.note{color:var(--text-faint);margin-top:8px}.review-warning{border-top:1px solid var(--border);background:color-mix(in srgb,var(--warn)10%,transparent);padding:12px 18px}.review-warning .rw-head{display:flex;color:var(--warn);align-items: center;gap:7px;font-size:13px;font-weight:600}.review-warning .rw-detail{color:var(--text-dim);margin-top:5px;font-size:12.5px;line-height:1.5}.review-warning .rw-ack{display:flex;color:var(--text);cursor:pointer;align-items: flex-start;gap:8px;margin-top:10px;font-size:12.5px}.review-warning .rw-ack input{margin-top:2px}.review-warning.unverified{background:color-mix(in srgb,var(--danger)10%,transparent)}.review-warning.unverified .rw-head{color:var(--danger)}.btn.primary.disabled,.btn.primary:disabled{opacity:.45;cursor:not-allowed}.viewer{display:flex;flex-direction:column;height:100%}.viewer.text pre{font-family:var(--font-mono);white-space:pre-wrap;word-break:break-word;color:var(--text);margin:0;padding:18px 20px;font-size:12.5px;line-height:1.65}.viewer.image{display:grid;background:repeating-conic-gradient(var(--bg-elevated)0% 25%,var(--bg)0% 50%)50%/22px 22px;place-items: center}.viewer.image img{object-fit:contain;box-shadow:var(--shadow);max-width:100%;max-height:100%}.viewer .loading,.viewer .fallback{color:var(--text-faint);display:flex;text-align:center;flex-direction:column;align-items: center;gap:12px;margin:auto;padding:40px}.viewer.plugin-viewer{display:block;position:relative}.viewer.plugin-viewer .pv-host{width:100%;height:100%}.viewer.plugin-viewer .pv-status{position:absolute;z-index:2;display:grid;background:var(--bg);place-items: center;inset:0}.viewer.plugin-viewer .pv-status .loading,.viewer.plugin-viewer .pv-status .fallback{color:var(--text-faint);display:flex;text-align:center;flex-direction:column;align-items: center;gap:12px;padding:40px}.viewer-dock{position:fixed;z-index:60;display:none;box-shadow:var(--shadow);background:0 0;border-radius:12px;flex-direction:column;bottom:18px;right:18px}.viewer-dock .vd-bar{display:flex;background:var(--bg-elevated);border:1px solid var(--border-strong);border-bottom:0;border-radius:12px 12px 0 0;align-items: center;gap:6px;height:26px;padding:0 4px 0 10px}.viewer-dock .vd-title{color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:1;min-width:0;font-size:11.5px}.viewer-dock .vd-bar button{color:var(--text-dim);cursor:pointer;display:grid;background:0 0;border:0;border-radius:5px;place-items: center;width:20px;height:20px;padding:0;font-size:12px}.viewer-dock .vd-bar button:hover{background:var(--bg-hover);color:var(--text)}.viewer-dock .vd-body{border:1px solid var(--border-strong);background:0 0;border-top:0;border-radius:0 0 12px 12px;flex:1;min-height:0}.audiobook-host{height:100%;min-height:0}.audiobook{display:grid;grid-template-columns:300px 1fr;height:100%;min-height:0}.audiobook .cover-pane{background:var(--bg-elevated);border-right:1px solid var(--border);display:flex;overflow-y:auto;flex-direction:column;align-items: center;gap:18px;padding:26px}.audiobook .cover{box-shadow:var(--shadow);object-fit:cover;background:linear-gradient(135deg,var(--accent),var(--accent-2));display:grid;color:#fff;border-radius:14px;place-items: center;width:220px;height:220px;font-size:60px}.audiobook .meta{text-align:center}.audiobook .meta .title{font-size:17px;font-weight:650}.audiobook .meta .author{color:var(--text-dim);margin-top:3px}.audiobook .meta .narrator{color:var(--text-faint);margin-top:2px;font-size:12px}.audiobook .transport{display:flex;flex-direction:column;gap:12px;width:100%}.audiobook .scrub{display:flex;align-items: center;gap:10px}.audiobook .scrub input[type=range]{accent-color:var(--accent);flex:1}.audiobook .time{font-variant-numeric:tabular-nums;color:var(--text-faint);min-width:42px;font-size:11px}.audiobook .buttons{display:flex;justify-content:center;align-items: center;gap:14px}.audiobook .buttons .play{background:linear-gradient(180deg,var(--accent),var(--accent-2));color:var(--accent-text);display:grid;box-shadow:var(--shadow);border:0;border-radius:50%;place-items: center;width:54px;height:54px}.audiobook .buttons .play:hover{filter:brightness(1.08)}.audiobook .buttons .skip{background:var(--bg-hover);color:var(--text);display:grid;border:0;border-radius:50%;place-items: center;width:40px;height:40px}.audiobook .buttons .rate{background:var(--bg-hover);color:var(--text-dim);font-variant-numeric:tabular-nums;border:0;border-radius:8px;min-width:46px;padding:6px 8px}.audiobook .chapters{overflow-y:auto;padding:10px}.audiobook .chapters .ch-head{text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim);display:flex;padding:8px 10px;font-size:11px}.audiobook .chapters .ch-head .count{color:var(--text-faint);margin-left:auto}.audiobook .chapter{display:flex;cursor:default;border-radius:8px;align-items: center;gap:10px;padding:9px 11px}.audiobook .chapter:hover{background:var(--bg-hover)}.audiobook .chapter.current{background:var(--bg-selected)}.audiobook .chapter .n{text-align:right;color:var(--text-faint);font-variant-numeric:tabular-nums;width:22px;font-size:12px}.audiobook .chapter .t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}.audiobook .chapter .d{color:var(--text-faint);font-variant-numeric:tabular-nums;font-size:11px}.audiobook .chapter.current .n{color:var(--accent)}.spinner{border:2px solid var(--border-strong);border-top-color:var(--accent);animation:spin .7s linear infinite;border-radius:50%;width:20px;height:20px}@keyframes spin{to{transform:rotate(360deg)}}.dragging-file .editor:after,.dragging-file .sidebar:after{content:"Drop to upload";position:absolute;border:2px dashed var(--accent);display:grid;color:var(--accent);background:color-mix(in srgb,var(--accent)8%,transparent);pointer-events:none;z-index:30;border-radius:12px;place-items: center;font-weight:600;inset:8px}.editor,.sidebar{position:relative}.principal{overflow:hidden;display:grid;background:linear-gradient(135deg,var(--accent),var(--accent-2));color:#fff;border-radius:50%;place-items: center;width:30px;height:30px;font-size:12px;font-weight:700}.principal .avatar-img{object-fit:cover;width:100%;height:100%}.bell-wrap{position:relative}.bell-badge{position:absolute;background:var(--danger);color:#fff;display:grid;border-radius:8px;place-items: center;min-width:15px;height:15px;padding:0 3px;font-size:9.5px;font-weight:700;top:3px;right:3px}.inbox{position:fixed;left:calc(var(--activity-w) + 8px);background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);z-index:55;overflow:hidden;animation:pop .12s var(--ease);border-radius:12px;width:340px;bottom:8px}.inbox-head{display:flex;border-bottom:1px solid var(--border);justify-content:space-between;align-items: center;padding:11px 13px;font-size:12px;font-weight:600}.inbox-head .link,.link{color:var(--accent);background:0 0;border:0;font-size:12px}.inbox-head .muted,.muted{color:var(--text-faint)}.inbox-items{overflow-y:auto;max-height:380px}.inbox-item{border-bottom:1px solid var(--border);cursor:default;padding:10px 13px}.inbox-item:hover{background:var(--bg-hover)}.inbox-item.unread{border-left:2px solid var(--accent)}.inbox-item .ii-title{font-size:12.5px;font-weight:550}.inbox-item .ii-excerpt{color:var(--text-dim);margin-top:2px;font-size:12px}.inbox-item .ii-time{color:var(--text-faint);margin-top:3px;font-size:10.5px}.inbox-empty{display:flex;color:var(--text-faint);flex-direction:column;align-items: center;gap:8px;padding:30px}.editor-split{display:grid;grid-template-columns:1fr 340px;overflow:hidden;min-width:0}.infopanel{border-left:1px solid var(--border);background:var(--bg-sidebar);display:flex;flex-direction:column;min-width:0;min-height:0}.infopanel>*{min-width:0}.ip-head{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:8px;padding:10px 12px;font-size:12px;font-weight:600}.ip-head .iconbtn{margin-left:auto}.ip-body{overflow-y:auto;flex:1}.ip-empty{color:var(--text-faint);text-align:center;padding:30px}.ip-file{border-bottom:1px solid var(--border);padding:12px}.ip-file .ip-name{font-weight:600}.ip-file .ip-path{color:var(--text-faint);margin-top:2px;font-size:11px}.ip-section{border-bottom:1px solid var(--border);padding:12px}.ip-body>:last-child{border-bottom:0}.ip-label{text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:9px;font-size:11px}.tags{display:flex;flex-wrap:wrap;align-items: center;gap:6px}.tag{display:inline-flex;background:var(--bg-selected);border:1px solid var(--border);border-radius:14px;align-items: center;gap:5px;padding:3px 4px 3px 9px;font-size:12px}.tag-x{color:var(--text-faint);display:grid;background:0 0;border:0;border-radius:50%;place-items: center;width:16px;height:16px}.tag-x:hover{background:var(--bg-active);color:var(--danger)}.tag-input{background:var(--bg-input);border:1px solid var(--border);outline:none;color:var(--text);border-radius:14px;width:84px;padding:4px 10px;font-size:12px}.tag-input:focus{border-color:var(--accent);width:120px}.conv .thread{display:flex;flex-direction:column;gap:12px;margin-bottom:12px}.conv-empty{color:var(--text-faint);margin-bottom:12px;font-size:12px;line-height:1.5}.conv-signin{display:flex;color:var(--text-faint);flex-wrap:wrap;align-items:baseline;gap:6px;padding:8px 0;font-size:12px}.comment{font-size:13px}.c-head{display:flex;align-items: center;gap:7px}.c-avatar{background:linear-gradient(135deg,var(--accent-2),var(--accent));color:#fff;display:grid;border-radius:50%;flex:none;place-items: center;width:22px;height:22px;font-size:11px;font-weight:700}.c-author{font-size:12.5px;font-weight:600}.c-time{color:var(--text-faint);font-size:11px}.c-body{white-space:pre-wrap;word-break:break-word;margin:4px 0 4px 29px;line-height:1.5}.c-body.deleted{color:var(--text-faint);font-style:italic}.c-body .mention{color:var(--accent);background:color-mix(in srgb,var(--accent)14%,transparent);border-radius:4px;padding:0 3px;font-weight:600}.c-actions{display:flex;flex-wrap:wrap;align-items: center;gap:4px;margin-left:29px}.react{display:inline-flex;background:0 0;border:0;border-radius:12px;align-items: center;gap:3px;padding:2px 6px;font-size:12px}.react:hover{background:var(--bg-hover)}.react.on{background:var(--bg-selected)}.react .rc{color:var(--text-dim);font-size:10px}.c-link{color:var(--text-faint);background:0 0;border:0;border-radius:5px;padding:2px 5px;font-size:11.5px}.c-link:hover{color:var(--text);background:var(--bg-hover)}.c-link.danger:hover{color:var(--danger)}.replies{border-left:2px solid var(--border);margin-top:10px}.composer{display:flex;flex-direction:column;gap:6px}.composer .replying{color:var(--text-dim);display:flex;align-items: center;gap:8px;font-size:11px}.composer-input{background:var(--bg-input);border:1px solid var(--border-strong);resize:vertical;outline:none;color:var(--text);border-radius:9px;padding:8px 10px;font-family:inherit}.composer-input:focus{border-color:var(--accent)}.composer-actions{display:flex;justify-content:space-between;align-items: center}.composer-actions .btn{padding:5px 12px}@media (max-width:720px){:root{--sidebar-w:220px}.audiobook{grid-template-columns:1fr;grid-template-rows:auto 1fr}.audiobook .cover-pane{border-right:0;border-bottom:1px solid var(--border)}.editor-split{grid-template-columns:1fr}.editor-split .infopanel{display:none}}.launcher{display:flex;flex-direction:column;width:100%;max-width:820px;height:100%;margin:0 auto;padding:0 20px}.launch-box{display:flex;background:var(--bg-input);border:1px solid var(--border-strong);box-shadow:var(--shadow);border-radius:12px;align-items: center;gap:10px;margin:min(12vh,90px) 0 4px;padding:12px 16px}.launch-box>svg{color:var(--text-dim);flex:none}.launch-box button>svg{flex:none}.launch-resolved{display:flex;color:var(--text-dim);flex-wrap:wrap;align-items: center;gap:7px;margin:8px 4px 0;font-size:12px}.launch-resolved .rq-label{text-transform:uppercase;letter-spacing:.05em;color:var(--text-faint);font-size:10px;font-weight:600}.launch-resolved .rq-text{color:var(--text)}.launch-resolved .rq-chip{display:inline-flex;background:var(--bg-hover);border:1px solid var(--border);color:var(--text-dim);border-radius:12px;align-items: center;gap:4px;padding:2px 8px}.launch-input{outline:none;color:var(--text);font:500 17px/1.3 var(--font-ui);background:0 0;border:0;flex:1}.launch-input::placeholder{color:var(--text-faint);font-weight:400}.launch-clear{display:inline-flex;color:var(--text-faint);cursor:pointer;background:0 0;border:0;border-radius:6px;justify-content:center;align-items: center;width:24px;height:24px}.launch-clear:hover{background:var(--bg-hover);color:var(--text)}.launch-mic{display:inline-flex;color:var(--text-faint);cursor:pointer;background:0 0;border:0;border-radius:7px;flex:none;justify-content:center;align-items: center;width:26px;height:26px}.launch-mic:hover{background:var(--bg-hover);color:var(--text)}.launch-mic.on{color:var(--accent-text);background:var(--accent);animation:mic-pulse 1.4s var(--ease)infinite}@keyframes mic-pulse{0%,to{box-shadow:0 0 0 0 var(--focus-halo)}50%{box-shadow:0 0 0 5px #0000}}@media (prefers-reduced-motion:reduce){.launch-mic.on{animation:none}}.launch-body{overflow-y:auto;flex:1;padding:8px 0 40px}.launch-group{margin-top:14px}.launch-h{display:flex;letter-spacing:.04em;text-transform:uppercase;color:var(--text-faint);justify-content:space-between;align-items: center;gap:8px;padding:4px 10px;font-size:11px;font-weight:600}.launch-list{display:flex;flex-direction:column}.launch-item{display:flex;cursor:pointer;color:var(--text);border-radius:8px;align-items: center;gap:10px;padding:8px 10px}.launch-item svg{color:var(--text-dim);flex:none}.launch-item .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:auto;min-width:0}.launch-item:hover{background:var(--bg-hover)}.launch-item.active{background:var(--bg-selected);box-shadow:inset 3px 0 0 0 var(--focus)}.launch-detail{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-faint);flex:0 auto;min-width:0;font-size:12.5px}.launch-more{display:inline-flex;color:var(--text-faint);cursor:pointer;opacity:0;transition:opacity .1s var(--ease),background .1s var(--ease);background:0 0;border:0;border-radius:7px;flex:none;justify-content:center;align-items: center;width:26px;height:26px;margin:-4px -4px -4px 0}.launch-item:hover .launch-more,.launch-item.active .launch-more{opacity:1}.launch-more:focus-visible{opacity:1}.launch-more:hover{background:var(--bg-active);color:var(--text)}.launch-more:focus-visible{outline:none;color:var(--text);background:var(--bg-active);box-shadow:0 0 0 2px var(--focus-halo)}@media (hover:none){.launch-more{opacity:1}}.launch-kind{color:var(--text-faint);border:1px solid var(--border);border-radius:5px;flex:none;padding:1px 6px;font-size:11px}.launch-up{display:inline-flex;color:var(--text-dim);cursor:pointer;background:0 0;border:0;border-radius:5px;align-items: center;gap:3px;padding:2px 4px;font-size:12px}.launch-up:hover{background:var(--bg-hover);color:var(--text)}.launch-empty{color:var(--text-faint);padding:8px 12px;font-size:13px}.view-switch{display:inline-flex;border-left:1px solid var(--border);flex:none;align-items: center;gap:2px;margin-left:2px;padding-left:4px}.vs-btn{display:inline-flex;color:var(--text-faint);cursor:pointer;background:0 0;border:0;border-radius:7px;justify-content:center;align-items: center;width:26px;height:26px}.vs-btn:hover{background:var(--bg-hover);color:var(--text)}.vs-btn.on{background:var(--bg-active);color:var(--text)}.vs-btn:focus-visible{outline:none;color:var(--text);box-shadow:0 0 0 2px var(--focus-halo)}.view-error{display:flex;border:1px solid var(--border);background:var(--bg-hover);color:var(--text-dim);border-radius:8px;align-items: center;gap:6px;margin:8px 10px 0;padding:7px 10px;font-size:12.5px}.view-error svg{color:var(--warn);flex:none}.grid-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(132px,1fr));gap:10px;padding:4px 10px}.grid-tile{display:flex;cursor:pointer;color:var(--text);border-radius:10px;flex-direction:column;gap:6px;padding:6px}.grid-tile:hover{background:var(--bg-hover)}.grid-tile.active{background:var(--bg-selected);box-shadow:inset 0 0 0 2px var(--focus)}.gt-media{position:relative;display:grid;aspect-ratio:1;background:var(--bg-hover);overflow:hidden;border-radius:8px;place-items: center}.gt-media>svg{color:var(--text-faint)}.gt-img{position:absolute;object-fit:cover;width:100%;height:100%;inset:0}.gt-badge{position:absolute;color:#fff;background:#0009;border-radius:5px;padding:1px 6px;font-size:10.5px;bottom:5px;left:5px}.gt-media .launch-more{position:absolute;background:var(--bg-elevated);margin:0;top:3px;right:3px}.grid-tile:hover .launch-more,.grid-tile.active .launch-more{opacity:1}.gt-name{overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;word-break:break-word;-webkit-box-orient:vertical;font-size:12.5px;line-height:1.35}.viewer-nav{display:flex;background:var(--bg-activity);border-bottom:1px solid var(--border);align-items: center;gap:6px;height:40px;padding:0 8px}.vn-back{display:inline-flex;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text-dim);border-radius:7px;justify-content:center;align-items: center;width:30px;height:28px}.vn-back:hover{color:var(--text);background:var(--bg-hover)}.vn-trail{display:flex;overflow-x:auto;flex:1;align-items: center;gap:2px;min-width:0}.vn-crumb{display:inline-flex;color:var(--text-dim);white-space:nowrap;background:0 0;border:0;border-radius:6px;align-items: center;gap:5px;max-width:240px;padding:4px 8px}.vn-crumb .label{overflow:hidden;text-overflow:ellipsis}.vn-crumb:hover{background:var(--bg-hover);color:var(--text)}.vn-crumb.active{color:var(--text);font-weight:550}.vn-crumb:not(:last-child):after{content:"›";color:var(--text-faint);margin-left:6px}.vn-actions{display:flex;gap:2px}.search-modal{position:fixed;z-index:60;inset:0}.search-modal .scrim{position:absolute;z-index:0;background:#00000073;inset:0}.search-modal-panel{z-index:1;position:absolute;background:var(--bg-elevated);border:1px solid var(--border-strong);box-shadow:var(--shadow);overflow:hidden;display:flex;border-radius:12px;width:min(680px,94vw);max-height:72vh;top:12vh;left:50%;transform:translate(-50%)}.search-modal-panel .launcher{max-width:none;height:100%;padding:0 12px}.search-modal-panel .launch-box{margin:12px 0 4px}.search-modal-panel .launch-body{max-height:56vh}.ip-uri{display:flex;border:1px solid var(--border);background:var(--bg-elevated);color:var(--text-dim);font:11px/1.4 var(--font-mono,ui-monospace,monospace);text-align:left;cursor:pointer;overflow:hidden;border-radius:6px;align-items: center;gap:6px;width:100%;min-width:0;padding:6px 8px}.ip-uri>span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.ip-uri:hover{color:var(--text);border-color:var(--accent)}.ip-backlinks{display:flex;flex-direction:column;gap:4px}.ip-backlink{display:flex;color:var(--text-dim);text-align:left;cursor:pointer;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:0 0;border:1px solid #0000;border-radius:6px;align-items: center;gap:6px;width:100%;padding:5px 8px;font-size:12px}.ip-backlink:hover{background:var(--bg-elevated);color:var(--text)}.ip-muted{color:var(--text-faint);padding:2px 0;font-size:12px}.ip-muted.error{color:var(--danger,#e5534b)}.viewer.markdown{overflow:auto;padding:32px 40px}.md{color:var(--text);max-width:760px;margin:0 auto;font-size:14px;line-height:1.65}.md-h{margin:1.6em 0 .6em;font-weight:650;line-height:1.3}.md-h:first-child{margin-top:0}.md-h1{font-size:1.7em}.md-h2{font-size:1.35em}.md-h3{font-size:1.12em}.md-h4{font-size:1em}.md-h5{color:var(--text-dim);font-size:.92em}.md-h6{color:var(--text-dim);text-transform:uppercase;letter-spacing:.04em;font-size:.88em}.md-p{margin:0 0 1em}.md-list{margin:0 0 1em;padding-left:1.4em}.md-li{margin:.25em 0}.md-quote{border-left:3px solid var(--border);color:var(--text-dim);margin:0 0 1em;padding:.1em 0 .1em 1em}.md-hr{border:none;border-top:1px solid var(--border);margin:1.8em 0}.md-code{overflow-x:auto;background:var(--bg-elevated);border:1px solid var(--border);font:12px/1.55 var(--font-mono,ui-monospace,monospace);border-radius:8px;margin:0 0 1em;padding:12px 14px}.md-inline-code{background:var(--bg-elevated);font:.9em var(--font-mono,ui-monospace,monospace);border-radius:4px;padding:.12em .35em}.md-link{color:var(--accent);text-decoration:none;border-bottom:1px solid #0000}.md-link:hover{border-bottom-color:currentColor}.md-trove{border-bottom:1px dashed color-mix(in srgb,var(--accent)55%,transparent)}.md-deadlink{color:var(--text-faint);text-decoration:line-through;cursor:not-allowed}.md-truncated{border:1px dashed var(--border);color:var(--text-faint);border-radius:8px;margin:1.5em 0 0;padding:10px 12px;font-size:12.5px}.activity-panel{position:fixed;right:12px;bottom:calc(var(--statusbar-h) + 10px);z-index:60;overflow-y:auto;background:var(--bg-elevated);border:1px solid var(--border-strong);border-radius:var(--radius);box-shadow:var(--shadow);display:flex;flex-direction:column;width:380px;max-height:62vh}.act-head{display:flex;border-bottom:1px solid var(--border);justify-content:space-between;align-items: center;padding:10px 12px}.act-head h3{margin:0;font-size:13px;font-weight:600}.act-section{border-bottom:1px solid var(--border);padding:10px 12px}.act-section:last-of-type{border-bottom:none}.act-section-title{text-transform:uppercase;letter-spacing:.06em;color:var(--text-faint);margin-bottom:8px;font-size:10.5px}.act-empty{color:var(--text-faint);margin:0;font-size:12.5px}.act-row{display:flex;align-items: flex-start;gap:8px}.act-body{flex:1;min-width:0}.act-title{overflow-wrap:anywhere;font-size:12.5px}.act-detail,.act-meta{color:var(--text-dim);overflow-wrap:anywhere;margin-top:2px;font-size:11.5px}.act-meta{color:var(--text-faint)}.act-error{color:var(--danger);overflow-wrap:anywhere;margin-top:2px;font-size:11.5px}.act-remedy{font-family:var(--font-mono,ui-monospace,monospace);color:var(--text-dim);background:var(--bg-inset,var(--bg-hover));border:1px solid var(--border);overflow:auto;white-space:pre;tab-size:2;border-radius:6px;max-height:220px;margin:6px 0 2px;padding:8px 10px;font-size:10.5px;line-height:1.5}.act-amount{color:var(--text-faint);margin-top:3px;font-size:11px}.act-action{color:var(--text-faint);cursor:pointer;background:0 0;border:none;border-radius:4px;padding:2px}.act-action:hover{background:var(--bg-hover);color:var(--text)}.act-task,.act-issue{padding:7px 0}.act-task+.act-task,.act-issue+.act-issue{border-top:1px solid var(--border)}.act-actions{display:flex;gap:6px;margin:6px 0 0 21px}.act-done .act-title,.act-cancelled .act-title{color:var(--text-dim)}.act-failed .act-title{color:var(--danger)}.act-sev-warning .act-title{color:var(--warn)}.act-offline{display:flex;color:var(--warn);background:var(--bg-hover);align-items: center;gap:6px;padding:8px 12px;font-size:11.5px}.act-foot{padding:10px 12px}.act-bar{background:var(--bg-input);overflow:hidden;border-radius:2px;height:3px;margin:6px 0 0 21px}.act-fill{background:var(--accent);transition:width .25s var(--ease);border-radius:2px;height:100%}.act-bar.indeterminate .act-fill{animation:act-slide 1.1s var(--ease)infinite;width:35%}@keyframes act-slide{0%{margin-left:-35%}to{margin-left:100%}}.sb-attention,.sb-attention:hover{color:var(--warn)}.sb-usage{gap:6px}.usage-bar{background:var(--bg-input);overflow:hidden;border-radius:2px;width:42px;height:4px}.usage-fill{background:var(--text-dim);transition:width .3s var(--ease);border-radius:2px;height:100%}.sb-usage.low .usage-fill{background:var(--warn)}.sb-usage.low{color:var(--warn)}.sb-usage.critical .usage-fill{background:var(--danger)}.sb-usage.critical{color:var(--danger)}:root{--phonebar-h:46px;--phonetabs-h:56px}.shell.phone{grid-template-rows:calc(var(--phonebar-h) + env(safe-area-inset-top))1fr calc(var(--phonetabs-h) + env(safe-area-inset-bottom))}.shell.phone .body{grid-template-columns:1fr}.phonebar{display:flex;background:var(--bg-activity);color:var(--text-dim);align-items: center}.phonebar.top{padding:env(safe-area-inset-top)6px 0;border-bottom:1px solid var(--border);gap:8px}.pb-brand{display:grid;background:0 0;border:0;border-radius:9px;flex:none;place-items: center;width:34px;height:34px}.pb-brand img{width:21px;height:21px}.pb-title{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text);flex:1;min-width:0;font-size:15px;font-weight:600}.pb-status{position:relative;display:grid;color:var(--text-dim);background:0 0;border:0;border-radius:10px;flex:none;place-items: center;width:38px;height:38px}.pb-status:active{background:var(--bg-hover)}.pb-status.warn{color:var(--warn)}.pb-status.danger{color:var(--danger)}.pb-badge{position:absolute;background:var(--danger);color:#fff;display:grid;border-radius:8px;place-items: center;min-width:15px;height:15px;padding:0 3px;font-size:9.5px;font-weight:700;top:2px;right:2px}.phonebar.bottom{padding:0 4px env(safe-area-inset-bottom);border-top:1px solid var(--border);background:color-mix(in srgb,var(--accent)6%,var(--bg-activity));justify-content:space-around}.pb-tab{position:relative;height:var(--phonetabs-h);color:var(--text-faint);display:flex;background:0 0;border:0;border-radius:10px;flex-direction:column;flex:1;justify-content:center;align-items: center;gap:3px}.pb-tab .pb-label{font-size:10.5px;font-weight:550}.pb-tab.active{color:var(--accent)}.pb-tab:active{background:var(--bg-hover)}.pb-tab .pb-badge{background:var(--accent);color:var(--accent-text);top:6px;right:22%}.sheet-wrap{position:fixed;z-index:58;inset:0}.sheet-wrap .scrim{position:absolute;z-index:0;background:#00000080;inset:0}.sheet{position:absolute;z-index:41;overflow-y:auto;background:var(--bg-elevated);border-top:1px solid var(--border-strong);box-shadow:var(--shadow);padding-bottom:calc(12px + env(safe-area-inset-bottom));animation:sheet-up .18s var(--ease);border-radius:16px 16px 0 0;max-height:82vh;bottom:0;left:0;right:0}@keyframes sheet-up{0%{transform:translateY(18px);opacity:.4}to{transform:none;opacity:1}}.sheet-grip{display:grid;background:0 0;border:0;place-items: center;width:100%;height:22px}.sheet-grip:before{content:"";background:var(--border-strong);border-radius:2px;width:38px;height:4px}.sheet-body{display:flex;flex-direction:column;padding:2px 10px 6px}.sheet-title{letter-spacing:.04em;text-transform:uppercase;color:var(--text-faint);padding:4px 8px 10px;font-size:12px;font-weight:600}.sheet-row{display:flex;color:var(--text);text-align:left;background:0 0;border:0;border-radius:10px;align-items: center;gap:12px;width:100%;min-height:46px;padding:0 8px;font-size:14.5px}.sheet-row:active{background:var(--bg-hover)}.sheet-row.static{color:var(--text-dim)}.sheet-row.danger{color:var(--danger)}.sheet-row svg{color:var(--text-dim);flex:none}.sheet-row.danger svg{color:var(--danger)}.sheet-row .sr-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.sheet-row .sr-value{color:var(--text-dim);font-size:13px}.sheet-row .sr-go{color:var(--text-faint)}.sheet-row .sb-usage{padding:0}.sheet-actions{display:flex;border-top:1px solid var(--border);gap:8px;margin-top:8px;padding:12px 8px 4px}.sheet-actions .btn{flex:1;justify-content:center}.sheet-me{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:10px;margin-bottom:6px;padding:4px 8px 12px}.sheet-me .avatar-img,.sheet-me .avatar-txt{display:grid;background:linear-gradient(135deg,var(--accent-2),var(--accent));color:#fff;border-radius:50%;flex:none;place-items: center;width:36px;height:36px;font-weight:700}.sheet-me .sm-name{color:var(--text);font-size:14.5px;font-weight:600}.sheet-me .sm-sub{color:var(--text-faint);font-size:12px}.shell.phone .editor-split{grid-template-columns:1fr}.shell.phone .infopanel{border-left:0}.shell.phone .launcher{padding:0 12px}.shell.phone .launch-box{margin-top:14px;padding:10px 12px}.shell.phone .launch-input{font-size:16px}.shell.phone .search-modal-panel{border-radius:0;width:100vw;height:100dvh;max-height:100dvh;top:0}.shell.phone .activity-panel{left:8px;right:8px;bottom:calc(var(--phonetabs-h) + env(safe-area-inset-bottom) + 8px);width:auto;max-height:68vh}.shell.phone .inbox,.shell.phone .toasts,.shell.phone .tray{left:8px;right:8px;bottom:calc(var(--phonetabs-h) + env(safe-area-inset-bottom) + 8px);width:auto}.shell.phone .viewer-dock{left:8px;right:8px;bottom:calc(var(--phonetabs-h) + env(safe-area-inset-bottom) + 8px);width:auto!important}.shell.tv{--activity-w:84px;--statusbar-h:34px;padding:2.2% 2.6%;font-size:17px}.shell.tv .activitybar{gap:10px;padding:16px 0 18px}.shell.tv .activitybar .item,.shell.tv .activitybar .iconbtn,.shell.tv .activitybar .brand-mark{border-radius:14px;width:64px;height:64px}.shell.tv .statusbar{font-size:15px}.shell.tv .launcher{max-width:1100px}.shell.tv .launch-input{font-size:24px}.shell.tv .launch-box{border-radius:16px;padding:16px 22px}.shell.tv .launch-mic{border-radius:11px;width:40px;height:40px}.shell.tv .launch-item{border-radius:12px;padding:14px 16px;font-size:18px}.shell.tv .launch-item .name{max-width:none}.shell.tv .launch-h{font-size:14px}.shell.tv .btn{padding:11px 20px;font-size:16px}.shell.tv .viewer.text pre{padding:28px 34px;font-size:19px;line-height:1.7}.shell.tv .md{max-width:1000px;font-size:21px}.shell.tv .viewer.markdown{padding:40px 52px}.shell.tv .viewer-nav{height:54px;padding:0 14px}.shell.tv .vn-crumb{max-width:420px;padding:8px 12px;font-size:17px}.shell.tv .vn-back{width:44px;height:40px}.shell.tv .settings{max-width:1000px;font-size:17px}.shell.tv .settings .setting .info .d{font-size:15px}.shell.tv .infopanel{font-size:16px}.shell.tv .toasts,.shell.tv .activity-panel{right:2.6%;bottom:calc(var(--statusbar-h) + 2.2%)}.shell.tv .plugin-panel{top:2.2%;right:2.6%}.shell.tv .tray{left:calc(var(--activity-w) + 2.6%);bottom:calc(var(--statusbar-h) + 2.2%)}.shell.tv .inbox{left:calc(var(--activity-w) + 2.6%);bottom:2.2%}.shell.tv .viewer-dock{right:2.6%;bottom:calc(var(--statusbar-h) + 2.2%)}.shell.tv :focus{outline:3px solid var(--accent);outline-offset:3px}.shell.tv :focus-visible{outline:3px solid var(--accent);outline-offset:3px}.shell.tv .launch-item:focus-visible{box-shadow:none}.shell.tv .sheet-row:focus-visible{box-shadow:none}.shell.tv .inbox-item:focus-visible{box-shadow:none}.shell.tv .activitybar .item:focus-visible{box-shadow:none}.shell.tv .activitybar .brand-mark:focus-visible{box-shadow:none}.shell.tv .statusbar .seg:focus-visible{box-shadow:none}.shell.tv .pb-tab:focus-visible{box-shadow:none}.shell.tv .btn:focus-visible{box-shadow:none}.shell.tv .iconbtn:focus-visible{box-shadow:none}.shell.tv .act-action:focus-visible{box-shadow:none}.shell.tv .launch-clear:focus-visible{box-shadow:none}.shell.tv .vn-back:focus-visible{box-shadow:none}.shell.tv .c-link:focus-visible{box-shadow:none}.shell.tv .react:focus-visible{box-shadow:none}.shell.tv .lh-example:focus-visible{box-shadow:none}.shell.tv .kbd-edit:focus-visible{box-shadow:none}.shell.tv .activitybar .item:focus-visible:before{content:none}.shell.tv .launch-input:focus{outline:none}.shell.tv .launch-input:focus-visible{outline:none}.shell.tv .launch-box:focus-within{outline:3px solid var(--accent);outline-offset:3px;border-color:var(--border-strong);box-shadow:var(--shadow)}.shell.tv .launch-item:focus,.shell.tv .sheet-row:focus{background:var(--bg-selected)}.shell.tv .launch-item.active{outline:3px solid var(--accent);outline-offset:3px;background:var(--bg-selected);box-shadow:none}.shell.tv .launch-body:focus-within .launch-item.active:not(:focus){outline:none;background:var(--bg-hover)}.shell.tv .launch-body{padding-left:8px;padding-right:8px}.launch-help{border-top:1px solid var(--border);margin:6px 10px 0;padding:12px 0 0}.lh-hint{display:flex;color:var(--text-dim);align-items: flex-start;gap:8px;font-size:12.5px;line-height:1.55}.lh-hint svg{color:var(--text-faint);flex:none;margin-top:2px}.lh-examples{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}.lh-example{display:inline-flex;border:1px solid var(--border);background:var(--bg-hover);color:var(--text);border-radius:8px;align-items:baseline;gap:7px;padding:5px 10px;font-size:12.5px}.lh-example:hover{border-color:var(--accent)}.lh-example .lh-q{font-family:var(--font-mono);font-size:12px}.lh-example .lh-label{color:var(--text-faint);font-size:11.5px}.mcp-url{display:flex;align-items: center;gap:8px;min-width:0}.mcp-url .mono{font-family:var(--font-mono);color:var(--text);background:var(--bg-input);border:1px solid var(--border);overflow-x:auto;white-space:nowrap;border-radius:6px;max-width:100%;padding:5px 9px;font-size:12.5px}.mcp-url .mono.small{color:var(--text-dim);font-size:11.5px}.settings .control .mono{font-family:var(--font-mono);color:var(--text-dim);font-size:12.5px}.mcp-warn{display:flex;color:var(--warn);background:color-mix(in srgb,var(--warn)10%,transparent);border:1px solid color-mix(in srgb,var(--warn)34%,transparent);border-radius:9px;align-items: flex-start;gap:9px;margin:4px 0 10px;padding:10px 12px;font-size:12.5px;line-height:1.55}.mcp-warn svg{flex:none;margin-top:1px}.shell.phone .settings{padding:18px 14px 40px}.shell.phone .setting{flex-direction:column;align-items:stretch;gap:8px}.shell.phone .setting .control{justify-content:stretch;width:100%}.shell.phone .setting .control>*{flex:1}.shell.phone .setting .control .switch,.shell.phone .setting .control .iconbtn,.shell.phone .mcp-url .btn{flex:none}.shell.phone .setting .control .switch{align-self: flex-start}.shell.phone .mcp-url .mono{flex:1;min-width:0}.shell.phone .setting:has(.control>.iconbtn:only-child){flex-direction:row;align-items: center}.shell.phone .setting:has(.control>.iconbtn:only-child) .control{justify-content:flex-end;width:auto}.launch-h .lh-title{display:flex;align-items:baseline;gap:8px;min-width:0}.launch-h .lh-actions{display:flex;flex:none;align-items: center;gap:2px}.launch-h .lh-verbatim{text-transform:none;color:var(--text-dim);letter-spacing:0;font-weight:500}.keys-actions{display:flex;align-items: center;gap:8px;padding:12px 0 2px}.keys-empty{color:var(--text-faint);padding:10px 0;font-size:12.5px}.keys-list{display:flex;flex-direction:column;gap:8px;margin-top:12px}.key-row{display:flex;border:1px solid var(--border);background:var(--bg-elevated);border-radius:10px;align-items: flex-start;gap:12px;padding:12px 13px}.key-row-main{flex:1;min-width:0}.key-row-name{display:flex;align-items: center;gap:8px}.key-row-name .t{font-weight:550}.key-row.dead{opacity:.55}.key-row.dead .key-row-name .t{text-decoration:line-through}.key-tag{text-transform:uppercase;letter-spacing:.5px;background:var(--bg-hover);color:var(--text-dim);border-radius:6px;padding:2px 7px;font-size:10.5px}.key-row-scopes{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px}.key-chip{display:inline-flex;border:1px solid var(--border);overflow:hidden;border-radius:6px;align-items: center;gap:6px;font-size:11px}.key-chip .where{background:var(--bg-hover);color:var(--text-dim);padding:2px 7px}.key-chip .what{color:var(--accent);font-family:var(--font-mono);padding:2px 7px;font-size:10.5px}.key-row-meta{color:var(--text-faint);margin-top:7px;font-size:11.5px}.key-form{border:1px solid var(--border);background:var(--bg-elevated);border-radius:11px;margin-top:12px;padding:4px 14px 14px}.key-form .setting:last-of-type{border-bottom:0}.key-scopes{padding:12px 0 4px}.key-scopes-head .t{font-weight:550}.key-scopes-head .d{color:var(--text-faint);margin-top:2px;font-size:12px;line-height:1.4}.key-scope{display:flex;border-bottom:1px solid var(--border);align-items: center;gap:12px;padding:9px 0}.key-scope:last-child{border-bottom:0}.key-scope.wildcard{background:color-mix(in srgb,var(--warn)8%,transparent);border-bottom:0;border:1px solid color-mix(in srgb,var(--warn)26%,transparent);border-radius:9px;margin-bottom:4px;padding:10px 11px}.key-scope-name{display:flex;flex:1;align-items: center;gap:8px;min-width:0;font-size:13px}.key-scope-name span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.key-scope-name svg{color:var(--text-dim);flex:none}.key-scope.wildcard .key-scope-name svg{color:var(--warn)}.key-scope-caps{display:flex;flex:none;gap:6px}.key-scope-caps .cap{display:inline-flex;cursor:pointer;user-select:none;border:1px solid var(--border);color:var(--text-dim);background:0 0;border-radius:7px;align-items: center;gap:5px;padding:4px 9px;font-size:11.5px}.key-scope-caps .cap:hover{background:var(--bg-hover)}.key-scope-caps .cap.on{color:var(--accent);border-color:color-mix(in srgb,var(--accent)45%,var(--border));background:color-mix(in srgb,var(--accent)12%,transparent)}.key-scope-caps .cap input{accent-color:var(--accent);margin:0}.key-minted{background:color-mix(in srgb,var(--warn)9%,transparent);border:1px solid color-mix(in srgb,var(--warn)32%,transparent);border-radius:11px;margin:12px 0 4px;padding:13px 14px}.key-minted-head{display:flex;color:var(--warn);align-items: center;gap:9px;font-weight:550}.key-minted-head svg{flex:none}.key-minted-note{color:var(--text-dim);margin:8px 0 11px;font-size:12.5px;line-height:1.55}.key-secret{display:flex;align-items: center;gap:8px;min-width:0}.key-secret code{font-family:var(--font-mono);color:var(--text);background:var(--bg-input);border:1px solid var(--border);overflow-x:auto;white-space:nowrap;border-radius:6px;flex:1;min-width:0;padding:7px 10px;font-size:12.5px}@media (max-width:560px){.key-scope{flex-direction:column;align-items:stretch;gap:8px}.key-scope-caps{flex-wrap:wrap}.key-row{flex-direction:column}}.gate{display:grid;place-items: center;min-height:100%;padding:40px 20px}.gate-card{text-align:center;width:min(520px,100%)}.gate-icon{display:grid;color:var(--accent);background:color-mix(in srgb,var(--accent)13%,transparent);border:1px solid color-mix(in srgb,var(--accent)28%,transparent);border-radius:15px;place-items: center;width:56px;height:56px;margin:0 auto 16px}.gate-card h2{margin:0 0 8px;font-size:19px}.gate-sub{color:var(--text-dim);margin:0 0 20px;font-size:13px;line-height:1.6}.gate-actions{display:flex;justify-content:center;gap:8px;margin-top:18px}.gate-list{display:flex;text-align:left;flex-direction:column;gap:8px}.gate-choice{display:flex;cursor:pointer;font:inherit;color:inherit;border:1px solid var(--border);background:var(--bg-elevated);border-radius:10px;align-items: center;gap:12px;width:100%;padding:12px 14px}.gate-choice:hover{border-color:color-mix(in srgb,var(--accent)45%,var(--border));background:var(--bg-hover)}.gate-choice:focus-visible{outline:none;box-shadow:0 0 0 3px var(--focus-halo)}.gate-choice-main{display:flex;flex-direction:column;flex:1;gap:2px;min-width:0}.gate-choice-main .n{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:550}.gate-choice-main .d{color:var(--text-faint);font-size:11.5px}.gate-caps{font-family:var(--font-mono);color:var(--text-dim);background:var(--bg-hover);border-radius:6px;flex:none;padding:2px 8px;font-size:10.5px}
|
|
@@ -13,10 +13,10 @@
|
|
|
13
13
|
name, no icons, no theme colour, and "Install app" offers a nameless page.
|
|
14
14
|
Silently, because a failed manifest fetch is not an error anyone is shown. -->
|
|
15
15
|
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
|
16
|
-
<link rel="stylesheet" href="/assets/styles-
|
|
16
|
+
<link rel="stylesheet" href="/assets/styles-hskanqc0.css" />
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
<div class="workbench"></div>
|
|
20
|
-
<script type="module" src="/assets/main-
|
|
20
|
+
<script type="module" src="/assets/main-c9dnnnc6.js"></script>
|
|
21
21
|
</body>
|
|
22
22
|
</html>
|
package/packages/web/dist/sw.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
// API and FILES deliberately do NOT rotate. API is data, not code, and FILES holds the
|
|
19
19
|
// bytes of files the user pinned for offline use — naming it per build would throw away
|
|
20
20
|
// someone's offline library every time the app was redeployed.
|
|
21
|
-
const SHELL = 'trove-shell-
|
|
21
|
+
const SHELL = 'trove-shell-10c4c8fa57';
|
|
22
22
|
const API = 'trove-api-v1';
|
|
23
23
|
const FILES = 'trove-files-v1';
|
|
24
24
|
const KEEP = new Set([SHELL, API, FILES]);
|
|
@@ -890,6 +890,12 @@ export class CloseSearchModalAction extends ShellAction {
|
|
|
890
890
|
apply(wb) { wb.set({ searchModal: false }); }
|
|
891
891
|
}
|
|
892
892
|
|
|
893
|
+
/** Open (or close) one collection's access list on the administration screen. */
|
|
894
|
+
export class ShowCollectionAccessAction extends ShellAction {
|
|
895
|
+
constructor(collectionId) { super(); this.collectionId = collectionId; }
|
|
896
|
+
apply(wb) { wb.set({ aclFor: wb.get().aclFor === this.collectionId ? null : this.collectionId }); }
|
|
897
|
+
}
|
|
898
|
+
|
|
893
899
|
export class SetLaunchQueryAction extends ShellAction {
|
|
894
900
|
constructor(query) { super(); this.query = query; }
|
|
895
901
|
// Back to the top, for the same reason the palette does: the old highlight belonged to
|
|
@@ -1770,6 +1776,61 @@ export class ClearSidecarAction extends Action {
|
|
|
1770
1776
|
* The estimate comes with the state rather than only before starting: on a metered store
|
|
1771
1777
|
* this is a real bill, and the number is worth having in front of the button.
|
|
1772
1778
|
*/
|
|
1779
|
+
/**
|
|
1780
|
+
* Read one collection's access list.
|
|
1781
|
+
*
|
|
1782
|
+
* Takes the collection rather than reading the open one, because this is asked from a
|
|
1783
|
+
* screen that lists every collection — the rotation equivalent next door can assume the
|
|
1784
|
+
* open one and this cannot.
|
|
1785
|
+
*/
|
|
1786
|
+
export class LoadGrantsAction extends Action {
|
|
1787
|
+
static deps = ['acl', 'api', 'notifications'];
|
|
1788
|
+
constructor(collectionId) { super(); this.collectionId = collectionId; }
|
|
1789
|
+
async execute({ acl, api, notifications }) {
|
|
1790
|
+
const collectionId = this.collectionId;
|
|
1791
|
+
if (!collectionId) return;
|
|
1792
|
+
acl.set({ collectionId, loading: true, error: null });
|
|
1793
|
+
try {
|
|
1794
|
+
const res = await api.collectionGrants(collectionId);
|
|
1795
|
+
acl.set({ grants: res?.grants || [], admins: res?.admins || [], loading: false });
|
|
1796
|
+
} catch (err) {
|
|
1797
|
+
// A non-admin gets a 403, and that is the correct answer rather than an error worth
|
|
1798
|
+
// a toast — the section simply does not offer itself. Same reasoning as the rotation
|
|
1799
|
+
// loader immediately below.
|
|
1800
|
+
acl.set({ loading: false, error: err?.message || 'Could not read the access list' });
|
|
1801
|
+
if (err?.status !== 403) notifications.error?.(err?.message || 'Could not read the access list');
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
/**
|
|
1807
|
+
* Grant or revoke, which are one operation.
|
|
1808
|
+
*
|
|
1809
|
+
* An empty capability list removes the grant — that is `setGrant`'s existing shape, and
|
|
1810
|
+
* keeping it means revoking cannot drift from granting. Reloads afterwards rather than
|
|
1811
|
+
* patching the slice, because the server expands capabilities (admin implies the rest) and
|
|
1812
|
+
* a client that guessed the expansion would eventually disagree with it.
|
|
1813
|
+
*/
|
|
1814
|
+
export class SetGrantAction extends Action {
|
|
1815
|
+
static deps = ['acl', 'api', 'engine', 'notifications'];
|
|
1816
|
+
constructor(collectionId, grant) { super(); this.collectionId = collectionId; this.grant = grant; }
|
|
1817
|
+
async execute({ acl, api, engine, notifications }) {
|
|
1818
|
+
acl.set({ busy: true });
|
|
1819
|
+
try {
|
|
1820
|
+
await api.setCollectionGrant(this.collectionId, this.grant);
|
|
1821
|
+
const who = this.grant.type === 'anyone' ? 'everyone' : this.grant.subject;
|
|
1822
|
+
notifications.success?.(this.grant.capabilities?.length
|
|
1823
|
+
? `Access for ${who} updated`
|
|
1824
|
+
: `Access for ${who} removed`);
|
|
1825
|
+
} catch (err) {
|
|
1826
|
+
notifications.error?.(err?.message || 'Could not change access');
|
|
1827
|
+
} finally {
|
|
1828
|
+
acl.set({ busy: false });
|
|
1829
|
+
await engine.dispatch(new LoadGrantsAction(this.collectionId)).next(['complete', 'error', 'abort']);
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1773
1834
|
export class LoadRotationAction extends Action {
|
|
1774
1835
|
static deps = ['api', 'explorer', 'notifications', 'rotation'];
|
|
1775
1836
|
async execute({ api, explorer, notifications, rotation }) {
|
|
@@ -8,7 +8,7 @@ import { Engine, Provider } from '@3sln/ngin';
|
|
|
8
8
|
import { cell } from '../runtime.js';
|
|
9
9
|
import { registerCoreContext, registerViewportContext } from './context.js';
|
|
10
10
|
import { TransfersService } from './services.js';
|
|
11
|
-
import { explorerState, searchState, apiKeysState, viewState as viewStateSlice, overlayState, workbenchState, rotationState } from './state.js';
|
|
11
|
+
import { explorerState, searchState, apiKeysState, viewState as viewStateSlice, overlayState, workbenchState, aclState, rotationState } from './state.js';
|
|
12
12
|
import { NavigationService } from '../platform/navigation.js';
|
|
13
13
|
import { SocialService } from './social.js';
|
|
14
14
|
import { OfflineService } from './offline.js';
|
|
@@ -23,6 +23,7 @@ export function createApp(platform) {
|
|
|
23
23
|
const search = searchState();
|
|
24
24
|
const apiKeys = apiKeysState();
|
|
25
25
|
const rotation = rotationState();
|
|
26
|
+
const acl = aclState();
|
|
26
27
|
// One place for "what's running" and "what's stuck", covering both sides of the wire.
|
|
27
28
|
const activity = new ActivityService(platform);
|
|
28
29
|
// What the UI is in the middle of doing — see bl/state.js. A resource, because it
|
|
@@ -72,6 +73,7 @@ export function createApp(platform) {
|
|
|
72
73
|
activity: Provider.fromSingleton(activity),
|
|
73
74
|
apiKeys: Provider.fromSingleton(apiKeys),
|
|
74
75
|
rotation: Provider.fromSingleton(rotation),
|
|
76
|
+
acl: Provider.fromSingleton(acl),
|
|
75
77
|
viewState: Provider.fromSingleton(viewState),
|
|
76
78
|
|
|
77
79
|
// The engine itself, for actions that choreograph — dispatching a follow-up rather
|
|
@@ -31,8 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
import { Query } from '@3sln/ngin';
|
|
33
33
|
import { queryOf } from './intern.js';
|
|
34
|
-
import { selectedNodesOf, draftScopesOf, collectionMenuOf } from './services.js';
|
|
35
|
-
import { ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction } from './actions.js';
|
|
34
|
+
import { selectedNodesOf, draftScopesOf, collectionMenuOf, collectionAdminOf } from './services.js';
|
|
35
|
+
import { ExecCommandAction, LoadSidecarAction, ClearSidecarAction, LoadRotationAction, LoadGrantsAction, ShowCollectionAccessAction } from './actions.js';
|
|
36
36
|
import { ASSOC_KEY, describeOpener } from './openers.js';
|
|
37
37
|
import { rankCommands } from './match.js';
|
|
38
38
|
import { statusFactsOf, driveConditionOf } from './status.js';
|
|
@@ -149,6 +149,15 @@ export const explorer = new ServiceQuery('explorer', (r) => r.observe(), (v) =>
|
|
|
149
149
|
collectionMenu: collectionMenuOf(v,
|
|
150
150
|
(id) => new ExecCommandAction('collections.switch', id),
|
|
151
151
|
() => new ExecCommandAction('collections.create')),
|
|
152
|
+
// The administration screen's view of the same collections: what each is on, whether it
|
|
153
|
+
// is sealed, and what may be done to it.
|
|
154
|
+
collectionAdmin: collectionAdminOf(v, {
|
|
155
|
+
open: (id) => new ExecCommandAction('collections.switch', id),
|
|
156
|
+
scan: () => new ExecCommandAction('workbench.scanCollection'),
|
|
157
|
+
rotate: () => new ExecCommandAction('workbench.openSettings'),
|
|
158
|
+
create: () => new ExecCommandAction('collections.create'),
|
|
159
|
+
access: (id) => new ShowCollectionAccessAction(id),
|
|
160
|
+
}),
|
|
152
161
|
}));
|
|
153
162
|
/** Query text, results, and the palette's file list. */
|
|
154
163
|
export const search = new ServiceQuery('search', (r) => r.observe());
|
|
@@ -206,6 +215,37 @@ class RotationView extends Query {
|
|
|
206
215
|
release(r, this);
|
|
207
216
|
}
|
|
208
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Who may do what on one collection.
|
|
220
|
+
*
|
|
221
|
+
* Keyed by collection and loaded by `bootAction`, the same shape as `rotationFor` above —
|
|
222
|
+
* it loads when an administrator opens the row and stops mattering when they close it.
|
|
223
|
+
* No polling: an ACL only changes when somebody changes it, and the action that does so
|
|
224
|
+
* reloads.
|
|
225
|
+
*/
|
|
226
|
+
export const grantsFor = (collectionId) => GrantsView.of(collectionId);
|
|
227
|
+
|
|
228
|
+
class GrantsView extends Query {
|
|
229
|
+
static deps = ['acl', 'appState'];
|
|
230
|
+
static of = queryOf(GrantsView);
|
|
231
|
+
|
|
232
|
+
constructor(collectionId) {
|
|
233
|
+
super();
|
|
234
|
+
this.collectionId = collectionId;
|
|
235
|
+
this.bootAction = new LoadGrantsAction(collectionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
boot(r, { notify }) {
|
|
239
|
+
const cell = r.acl.observe();
|
|
240
|
+
notify(cell.getValue());
|
|
241
|
+
hold(r, this, cell.onDirty(() => notify(cell.getValue())));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
kill(r) {
|
|
245
|
+
release(r, this);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
209
249
|
/** The admin API-key list, with the unsubmitted draft resolved into what it would grant. */
|
|
210
250
|
export const apiKeys = new ServiceQuery('apiKeys', (r) => r.observe(),
|
|
211
251
|
(v) => ({ ...v, draftScopes: draftScopesOf(v) }));
|
|
@@ -198,6 +198,44 @@ export function draftScopesOf(state) {
|
|
|
198
198
|
* @param {(id?: string) => object} switchTo builds the action for picking a collection
|
|
199
199
|
* @param {() => object} create builds the action for making a new one
|
|
200
200
|
*/
|
|
201
|
+
/**
|
|
202
|
+
* The administration screen's row per collection.
|
|
203
|
+
*
|
|
204
|
+
* Built here rather than in the component for the same reason `collectionMenuOf` is: these
|
|
205
|
+
* rows carry ACTIONS, and deciding which actions a collection offers is a question about
|
|
206
|
+
* the collection, not about rendering. A component that assembled them would have to know
|
|
207
|
+
* that rotation lives behind Settings and that scanning applies to the OPEN collection.
|
|
208
|
+
*
|
|
209
|
+
* `scan` and `rotate` are sequenced pairs — switch first, then do the thing — because both
|
|
210
|
+
* operate on whatever is open. That is what makes them work from a screen that lists every
|
|
211
|
+
* collection rather than only the current one. Rotation routes to Settings rather than
|
|
212
|
+
* starting one: the estimate and the confirmation live there, and a rotation begun without
|
|
213
|
+
* seeing its cost is exactly the button nobody should have.
|
|
214
|
+
*/
|
|
215
|
+
export function collectionAdminOf(state, { open, scan, rotate, create, access }) {
|
|
216
|
+
const current = state?.collectionId;
|
|
217
|
+
const rows = (state?.collections || []).map((c) => ({
|
|
218
|
+
id: c.id,
|
|
219
|
+
name: c.name || c.id,
|
|
220
|
+
driver: c.driver || 'unknown',
|
|
221
|
+
system: !!c.system,
|
|
222
|
+
current: c.id === current,
|
|
223
|
+
// `describeEncryption` gives the fingerprint and nothing secret, so this is safe to show
|
|
224
|
+
// and is the only way to tell two keys apart across a rotation.
|
|
225
|
+
fingerprint: c.encryption?.fingerprint || null,
|
|
226
|
+
encrypted: !!c.encryption,
|
|
227
|
+
capabilities: c.capabilities || [],
|
|
228
|
+
actions: {
|
|
229
|
+
open: [open(c.id)],
|
|
230
|
+
scan: [open(c.id), scan()],
|
|
231
|
+
rotate: c.encryption ? [open(c.id), rotate()] : null,
|
|
232
|
+
// Access needs no switch: the ACL is read by collection id, not by what is open.
|
|
233
|
+
access: c.capabilities?.includes('admin') ? [access(c.id)] : null,
|
|
234
|
+
},
|
|
235
|
+
}));
|
|
236
|
+
return { rows, canCreate: !!state?.canCreateCollection, create: [create()] };
|
|
237
|
+
}
|
|
238
|
+
|
|
201
239
|
export function collectionMenuOf(state, switchTo, create) {
|
|
202
240
|
// No fallback: this only decides which row gets a tick, and with nothing open the answer
|
|
203
241
|
// is that none of them do. `|| 'default'` ticked a collection the user had not chosen,
|
|
@@ -119,6 +119,9 @@ export const workbenchState = () => slice({
|
|
|
119
119
|
activity: 'home', // home (stack) | plugins | settings
|
|
120
120
|
sidebarVisible: true,
|
|
121
121
|
launch: { query: '', index: 0 },
|
|
122
|
+
// Which collection's access list an administrator has opened, if any. Shell state
|
|
123
|
+
// rather than a component field: it decides what is on screen.
|
|
124
|
+
aclFor: null,
|
|
122
125
|
searchModal: false, // the double-shift modal search overlay
|
|
123
126
|
// Phone chrome: which bottom sheet is up, if any ('status' | 'more'). A phone has no room
|
|
124
127
|
// for a permanent status bar or a left rail, so both fold into a sheet pulled up on demand.
|
|
@@ -139,6 +142,17 @@ export function wrapIndex(index, delta, count) {
|
|
|
139
142
|
* rather than a fetch-per-render because two things read it (the progress line and whether
|
|
140
143
|
* Start is offered) and they must not disagree.
|
|
141
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Who may do what on one collection, while an administrator is looking at it.
|
|
147
|
+
*
|
|
148
|
+
* Keyed by collection like the rotation slice next door, and for the same reason: the
|
|
149
|
+
* answer belongs to a collection rather than to the drive, so switching is a different
|
|
150
|
+
* question rather than a change to notice.
|
|
151
|
+
*/
|
|
152
|
+
export const aclState = () => slice({
|
|
153
|
+
collectionId: null, grants: [], admins: [], loading: false, error: null, busy: false,
|
|
154
|
+
});
|
|
155
|
+
|
|
142
156
|
export const rotationState = () => slice({
|
|
143
157
|
collectionId: null, rotation: null, estimate: null, loading: false, error: null, busy: false,
|
|
144
158
|
});
|
|
@@ -250,6 +250,21 @@ export class TroveApiClient {
|
|
|
250
250
|
rotationEstimate(id) {
|
|
251
251
|
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate/estimate`);
|
|
252
252
|
}
|
|
253
|
+
/** Who has access to a collection. Admin-only, server-side. */
|
|
254
|
+
collectionGrants(id) {
|
|
255
|
+
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/grants`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Add, change or remove one grant.
|
|
260
|
+
*
|
|
261
|
+
* Upsert by (type, subject); an empty capability list REMOVES the grant, which is the
|
|
262
|
+
* shape `setGrant` already had — so revoking is the same call as granting, with nothing.
|
|
263
|
+
*/
|
|
264
|
+
setCollectionGrant(id, grant) {
|
|
265
|
+
return this.request('POST', `/api/collections/${encodeURIComponent(id)}/grants`, { body: grant });
|
|
266
|
+
}
|
|
267
|
+
|
|
253
268
|
rotationState(id) {
|
|
254
269
|
return this.request('GET', `/api/collections/${encodeURIComponent(id)}/rotate`);
|
|
255
270
|
}
|
|
@@ -693,6 +693,17 @@ kbd {
|
|
|
693
693
|
.admin .control.admin-actions .btn { white-space: nowrap; }
|
|
694
694
|
.admin .control .mono.muted { color: var(--text-faint); }
|
|
695
695
|
|
|
696
|
+
.acl { grid-column: 1 / -1; width: 100%; margin: 10px 0 2px; padding: 10px 12px; border-radius: 9px;
|
|
697
|
+
background: color-mix(in srgb, var(--text) 4%, transparent); display: flex; flex-direction: column; gap: 8px; }
|
|
698
|
+
.acl-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
|
699
|
+
.acl-who { flex: 1; min-width: 160px; font-size: 12.5px; color: var(--text-dim); font-family: var(--font-mono); }
|
|
700
|
+
.acl .btn.on { background: var(--accent); color: var(--accent-text); }
|
|
701
|
+
.acl-add { margin-top: 2px; }
|
|
702
|
+
/* A grant this screen cannot change — see adminView.js. Dimmed rather than hidden: the
|
|
703
|
+
list has to be true about who holds admin. */
|
|
704
|
+
.acl-fixed .acl-who { color: var(--text-faint); }
|
|
705
|
+
.acl-subject { flex: 1; min-width: 180px; }
|
|
706
|
+
|
|
696
707
|
/* The rotation bill, itemised. Only where a provider actually charges for the traffic. */
|
|
697
708
|
.rot-bill { margin: 8px 0 10px; padding: 8px 10px; border-radius: 8px;
|
|
698
709
|
background: color-mix(in srgb, var(--text) 4%, transparent); }
|
|
@@ -17,11 +17,13 @@
|
|
|
17
17
|
// exists. What was missing was somewhere to look.
|
|
18
18
|
|
|
19
19
|
import { dd } from '../../runtime.js';
|
|
20
|
+
import { watchQuery } from '../../bl/watchQuery.js';
|
|
21
|
+
import { grantsFor } from '../../bl/queries.js';
|
|
20
22
|
import { icon } from '../icon.js';
|
|
21
23
|
import { bytes } from '../format.js';
|
|
22
|
-
import { ExecCommandAction } from '../../bl/actions.js';
|
|
24
|
+
import { ExecCommandAction, SetGrantAction } from '../../bl/actions.js';
|
|
23
25
|
|
|
24
|
-
const { div, h2, h3, p, span, button } = dd;
|
|
26
|
+
const { div, h2, h3, p, span, button, select, option, input } = dd;
|
|
25
27
|
|
|
26
28
|
export default function adminView(state, ui) {
|
|
27
29
|
const caps = state.caps;
|
|
@@ -31,6 +33,7 @@ export default function adminView(state, ui) {
|
|
|
31
33
|
h2('Administration'),
|
|
32
34
|
p({ className: 'sub' }, 'How this drive is configured, and what it can do. Most of it is decided by the deployment rather than here.'),
|
|
33
35
|
accessSection(caps, state, ui),
|
|
36
|
+
collectionsSection(state, ui),
|
|
34
37
|
storageSection(caps, state, ui),
|
|
35
38
|
extensionsSection(caps, state),
|
|
36
39
|
workSection(state, ui),
|
|
@@ -79,6 +82,145 @@ function accessSection(caps, state, ui) {
|
|
|
79
82
|
);
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Every collection, what it sits on, and whether it is sealed.
|
|
87
|
+
*
|
|
88
|
+
* The list an administrator actually wants: not "which one am I looking at" — the switcher
|
|
89
|
+
* in the status bar answers that — but what exists, on what, under which key. Rows and their
|
|
90
|
+
* actions come from the engine (`collectionAdmin`), so this only draws.
|
|
91
|
+
*/
|
|
92
|
+
function collectionsSection(state, ui) {
|
|
93
|
+
const admin = state.ex?.collectionAdmin;
|
|
94
|
+
const rows = admin?.rows || [];
|
|
95
|
+
// SEQUENCED, not fired together. `dispatch()` returns a feed rather than a promise, so
|
|
96
|
+
// `forEach(dispatch)` starts everything at once — and these pairs are switch-then-do,
|
|
97
|
+
// where the second only means anything after the first has landed. Fired concurrently,
|
|
98
|
+
// "Rotate key…" opened Settings and was then thrown back to home by the switch that had
|
|
99
|
+
// not finished yet. Same shape as platform/commands.js, which got this right first.
|
|
100
|
+
const run = (actions) => async () => {
|
|
101
|
+
for (const action of actions) {
|
|
102
|
+
const settled = await ui.engine.dispatch(action).next(['complete', 'error', 'abort']);
|
|
103
|
+
if (settled?.type !== 'complete') break;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return div({ className: 'group' },
|
|
107
|
+
h3('Collections'),
|
|
108
|
+
p({ className: 'sub' }, 'What exists on this drive, where its bytes live, and which key seals it.'),
|
|
109
|
+
...(rows.length ? rows.map((c) => div({ className: 'setting stacked' },
|
|
110
|
+
div({ className: 'info' },
|
|
111
|
+
div({ className: 't' }, c.name, c.current ? span({ className: 'mono muted' }, ' · open') : null),
|
|
112
|
+
div({ className: 'd' },
|
|
113
|
+
c.encrypted
|
|
114
|
+
// The fingerprint is the only way to tell two keys apart across a rotation, and
|
|
115
|
+
// it is safe to show — it is not the key.
|
|
116
|
+
? `${c.driver} · sealed, key ${String(c.fingerprint).slice(0, 12)}…`
|
|
117
|
+
: `${c.driver} · not encrypted`),
|
|
118
|
+
div({ className: 'd' }, `You may: ${c.capabilities.join(', ') || 'nothing'}`),
|
|
119
|
+
),
|
|
120
|
+
div({ className: 'control admin-actions' },
|
|
121
|
+
c.current ? null : button({ className: 'btn small' }, 'Open').on({ click: run(c.actions.open) }),
|
|
122
|
+
button({ className: 'btn small' }, 'Scan').on({ click: run(c.actions.scan) }),
|
|
123
|
+
// Routed to Settings rather than started here: the estimate and the confirmation
|
|
124
|
+
// live there, and a rotation begun without seeing its cost is the button nobody
|
|
125
|
+
// should have.
|
|
126
|
+
c.actions.rotate
|
|
127
|
+
? button({ className: 'btn small' }, 'Rotate key…').on({ click: run(c.actions.rotate) })
|
|
128
|
+
: null,
|
|
129
|
+
// Only offered where you could actually change it — `setGrant` asserts admin, so a
|
|
130
|
+
// button that always showed would be one that sometimes only produces a 403.
|
|
131
|
+
c.actions.access
|
|
132
|
+
? button({ className: `btn small ${state.wb?.aclFor === c.id ? 'on' : ''}` }, 'Access…')
|
|
133
|
+
.on({ click: run(c.actions.access) })
|
|
134
|
+
: null,
|
|
135
|
+
),
|
|
136
|
+
// Leased only while open, by a query keyed on this collection — the same shape
|
|
137
|
+
// settingsView uses for `rotationFor`.
|
|
138
|
+
state.wb?.aclFor === c.id
|
|
139
|
+
? ui.watch(watchQuery(ui.engine, grantsFor(c.id)), (g) => accessEditor(g, ui, c.id))
|
|
140
|
+
: null,
|
|
141
|
+
)) : [row('Collections', 'Nothing has been created yet.', 'none')]),
|
|
142
|
+
admin?.canCreate
|
|
143
|
+
? div({ className: 'setting' },
|
|
144
|
+
div({ className: 'info' },
|
|
145
|
+
div({ className: 't' }, 'New collection'),
|
|
146
|
+
div({ className: 'd' }, 'A backing store you own — a bucket, a directory, a mount. Encryption is chosen here, at creation.'),
|
|
147
|
+
),
|
|
148
|
+
div({ className: 'control' },
|
|
149
|
+
button({ className: 'btn small' }, 'Create…').on({ click: run(admin.create) })),
|
|
150
|
+
)
|
|
151
|
+
: row('New collection', 'You do not have permission to create one on this drive.', 'not allowed'),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The capabilities a grant can carry. `admin` expands to the rest, server-side. */
|
|
156
|
+
const CAPS = ['read', 'write', 'delete', 'admin'];
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Who may do what on one collection.
|
|
160
|
+
*
|
|
161
|
+
* Rendered under the row an administrator opened, and reading `grantsFor(id)` — a query
|
|
162
|
+
* keyed by collection with a `bootAction`, so it loads when this is shown and stops
|
|
163
|
+
* mattering when it is not.
|
|
164
|
+
*
|
|
165
|
+
* Revoking is granting with nothing: one call, so the two cannot drift.
|
|
166
|
+
*/
|
|
167
|
+
function accessEditor(g, ui, collectionId) {
|
|
168
|
+
if (!g) return div({ className: 'acl' }, span({ className: 'mono muted' }, 'Loading access…'));
|
|
169
|
+
if (g.error) return div({ className: 'acl' }, span({ className: 'mono muted' }, g.error));
|
|
170
|
+
const grants = g.grants || [];
|
|
171
|
+
const set = (grant) => ui.engine.dispatch(new SetGrantAction(collectionId, grant));
|
|
172
|
+
|
|
173
|
+
const line = (grant) => {
|
|
174
|
+
const held = new Set(grant.capabilities || []);
|
|
175
|
+
const who = grant.type === 'anyone' ? 'Everyone' : `${grant.type}: ${grant.subject}`;
|
|
176
|
+
return div({ className: 'acl-row' },
|
|
177
|
+
span({ className: 'acl-who' }, who),
|
|
178
|
+
...CAPS.map((cap) => button({
|
|
179
|
+
className: `btn small ${held.has(cap) ? 'on' : ''}`,
|
|
180
|
+
title: cap === 'admin' ? 'admin implies every other capability' : `toggle ${cap}`,
|
|
181
|
+
}, cap).on({
|
|
182
|
+
click: () => set({
|
|
183
|
+
type: grant.type,
|
|
184
|
+
subject: grant.subject,
|
|
185
|
+
capabilities: held.has(cap)
|
|
186
|
+
? (grant.capabilities || []).filter((c) => c !== cap)
|
|
187
|
+
: [...(grant.capabilities || []), cap],
|
|
188
|
+
}),
|
|
189
|
+
})),
|
|
190
|
+
// Revoke is the same call with an empty list.
|
|
191
|
+
button({ className: 'btn small', title: 'remove this grant entirely' }, '\u00d7')
|
|
192
|
+
.on({ click: () => set({ type: grant.type, subject: grant.subject, capabilities: [] }) }),
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
// Drive administrators, from the deployment's own configuration. Shown because leaving
|
|
197
|
+
// them out makes the list a lie — they hold admin on every collection — and shown
|
|
198
|
+
// WITHOUT controls because `setGrant` cannot touch them: a revoke button here would be
|
|
199
|
+
// one that silently does nothing.
|
|
200
|
+
const admins = (g.admins || []).map((who) => div({ className: 'acl-row acl-fixed' },
|
|
201
|
+
span({ className: 'acl-who' }, who),
|
|
202
|
+
span({ className: 'mono muted' }, 'admin · set by this deployment'),
|
|
203
|
+
));
|
|
204
|
+
|
|
205
|
+
return div({ className: 'acl' },
|
|
206
|
+
...admins,
|
|
207
|
+
...(grants.length ? grants.map(line) : (admins.length ? [] : [span({ className: 'mono muted' }, 'Nobody yet.')])),
|
|
208
|
+
div({ className: 'acl-row acl-add' },
|
|
209
|
+
select({ className: 'acl-type' }, option({ value: 'user' }, 'user'), option({ value: 'role' }, 'role'), option({ value: 'anyone' }, 'anyone')),
|
|
210
|
+
input({ className: 'acl-subject', placeholder: 'email, or a role name' }),
|
|
211
|
+
button({ className: 'btn small' }, 'Grant read').on({
|
|
212
|
+
click: (e) => {
|
|
213
|
+
const rowEl = e.target.closest('.acl-add');
|
|
214
|
+
const type = rowEl.querySelector('.acl-type').value;
|
|
215
|
+
const subject = rowEl.querySelector('.acl-subject').value.trim();
|
|
216
|
+
if (type !== 'anyone' && !subject) return;
|
|
217
|
+
set({ type, subject: type === 'anyone' ? '' : subject, capabilities: ['read'] });
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
82
224
|
/**
|
|
83
225
|
* What this deployment can actually do with bytes.
|
|
84
226
|
*
|
|
@@ -155,6 +155,7 @@ export default function workbench({ engine, platform }) {
|
|
|
155
155
|
// Gathers what already exists rather than fetching anything new — see adminView.js.
|
|
156
156
|
adminView: region(engine, {
|
|
157
157
|
caps: q.capabilities, ex: q.explorer, plugins: q.plugins, act: q.activity, so: q.social,
|
|
158
|
+
wb: q.workbench,
|
|
158
159
|
}, (s) => adminView(s, ui)),
|
|
159
160
|
infoPanel: region(engine, { so: q.social, off: q.offline, nav: q.navigation },
|
|
160
161
|
(s) => infoPanel(s, ui)),
|