sealkeep 0.5.2 → 0.6.0

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.
@@ -172,7 +172,7 @@
172
172
 
173
173
  .ph{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .9rem;background:var(--card);
174
174
  border:1px solid var(--rule);border-radius:4px;padding:1.2rem 1.3rem;margin-bottom:1.3rem}
175
- .ph .name{font-size:1.32rem;font-weight:750;letter-spacing:-.03em}
175
+ .ph .name{font-size:1.15rem;font-weight:650;letter-spacing:-.02em}
176
176
  .ph .who{font-family:var(--mono);font-size:.75rem;color:var(--soft);flex:1 1 100%}
177
177
  /* The strip takes focus when the panel opens so a screen reader is told whose
178
178
  account this is. It is not in the tab order, so nobody can arrive here by
@@ -193,11 +193,14 @@
193
193
  .warncard{border-color:var(--warn)}
194
194
  .warncard .eyebrow{color:var(--warn)}
195
195
 
196
- .kv{display:grid;gap:1.2rem;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}
196
+ .kv{display:grid;gap:.75rem;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr))}
197
+ .kv>div{border:1px solid var(--rule);border-radius:4px;padding:.9rem 1rem}
198
+ .kv .cap{display:block;font-size:.78rem;color:var(--soft);margin-top:.25rem}
197
199
  .kv .k{display:block;font-family:var(--mono);font-size:.72rem;color:var(--soft);margin-bottom:.2rem}
198
- .kv b{font-family:var(--mono);font-size:1.24rem;font-weight:600;letter-spacing:-.03em}
199
- .bar{height:3px;background:var(--rule);border-radius:2px;margin:1.05rem 0 .4rem;overflow:hidden}
200
- .bar i{display:block;height:100%;width:0;background:var(--live);transition:width .5s ease}
200
+ .kv b{font-family:var(--mono);font-size:1rem;font-weight:500;color:var(--soft);letter-spacing:-.02em}
201
+ .kv .lead b{font-size:2.2rem;font-weight:700;color:var(--ink);letter-spacing:-.04em;line-height:1.1}
202
+ .bar{height:8px;background:var(--rule);border-radius:4px;margin:1.05rem 0 .4rem;overflow:hidden}
203
+ .bar i{display:block;height:100%;width:0;min-width:6px;background:var(--live);transition:width .5s ease}
201
204
 
202
205
  table{width:100%;border-collapse:collapse;font-size:.9rem;min-width:31rem}
203
206
  th{text-align:left;font-family:var(--mono);font-size:.72rem;color:var(--soft);font-weight:400;
@@ -205,7 +208,29 @@
205
208
  td{padding:.68rem .65rem;border-bottom:1px solid var(--rule)}
206
209
  tr:last-child td{border-bottom:0}
207
210
  td code{font-size:.86em;color:var(--soft)}
208
- .state{font-family:var(--mono);font-size:.74rem}
211
+ .state{font-family:var(--mono);font-size:.72rem;border:1px solid currentColor;border-radius:3px;padding:.18rem .55rem;white-space:nowrap}
212
+ .choice .t,.choice .d{font-family:var(--sans)}
213
+ .cellsub{display:block;font-family:var(--sans);font-size:.75rem;color:var(--soft);margin-top:.15rem}
214
+ .acct-nav{display:flex;gap:.35rem;flex-wrap:wrap;position:sticky;top:0;z-index:5;background:var(--paper);padding:.65rem 0;margin:.4rem 0 .2rem;border-bottom:1px solid var(--rule)}
215
+ .acct-nav a{font-family:var(--mono);font-size:.74rem;color:var(--soft);text-decoration:none;border:1px solid var(--rule);border-radius:999px;padding:.28rem .8rem}
216
+ .acct-nav a:hover{color:var(--ink);border-color:var(--ink)}
217
+ .acct-hero{font-family:var(--mono);font-size:1.55rem;font-weight:700;letter-spacing:-.03em;margin:.9rem 0 0;line-height:1.3}
218
+ .ruleslist{margin:.3rem 0 .9rem;padding-left:1.1rem}
219
+ .ruleslist li{margin:.25rem 0;color:var(--soft)}
220
+ .ruleslist b{color:var(--ink);font-weight:600}
221
+ #tg-body input,#tg-body select{padding:.35rem .5rem;font-size:.85rem;border:1px solid var(--rule);border-radius:4px;background:transparent;color:var(--ink)}
222
+ #tg-body input[type=number]{width:4.2rem}
223
+ #tg-body input:focus,#tg-body select:focus{border-color:var(--live);outline:none}
224
+ #tg-body tr:hover td{background:color-mix(in srgb, var(--rule) 22%, transparent)}
225
+ #tg-save-msg{display:block;margin-top:.6rem}
226
+ .packrow{display:flex;gap:.6rem;flex-wrap:wrap;margin:.4rem 0 .9rem}
227
+ .packchip{display:flex;flex-direction:column;gap:.1rem;border:1px solid var(--rule);border-radius:6px;padding:.6rem .95rem;min-width:6.4rem}
228
+ .packchip b{font-family:var(--mono);font-size:.95rem}
229
+ .packchip span{font-size:.78rem;color:var(--soft)}
230
+ .packchip.now{border-color:var(--live)}
231
+ .packchip.now i{font-style:normal;font-family:var(--mono);font-size:.66rem;color:var(--live);letter-spacing:.03em}
232
+ #p-account .card h3{font-size:1.02rem;margin:.15rem 0 .5rem}
233
+ [id^="anchor-"]{scroll-margin-top:3.4rem}
209
234
  .state.durable{color:var(--live)}
210
235
  .state.pending{color:var(--soft);opacity:.75}
211
236
  .del{background:none;color:var(--soft);border:1px solid var(--rule);padding:.3rem .7rem;
@@ -559,6 +584,16 @@ sealkeep open 08424eb6… ./session.jsonl \
559
584
  <button class="chip" id="a-plan" type="button" title="See your plan">—</button>
560
585
  <span class="who" id="a-who">—</span>
561
586
  </div>
587
+ <p class="acct-hero" id="a-hero"></p>
588
+ <p class="msg" id="a-summary" style="margin:.4rem 0 0"></p>
589
+ <nav class="acct-nav" aria-label="Account sections">
590
+ <a href="#anchor-storage">Storage</a>
591
+ <a href="#anchor-routing">Routing</a>
592
+ <a href="#anchor-machines">Machines</a>
593
+ <a href="#anchor-archives" id="nav-archives">Archives</a>
594
+ <a href="#anchor-plan">Plan</a>
595
+ <a href="#anchor-recovery">Recovery</a>
596
+ </nav>
562
597
 
563
598
  <div class="card next" id="a-firstrun" style="display:none">
564
599
  <span class="eyebrow">Set up</span>
@@ -573,7 +608,7 @@ sealkeep open 08424eb6… ./session.jsonl \
573
608
  <span class="saved" id="a-verify-msg" role="status" aria-live="polite"></span>
574
609
  </div>
575
610
 
576
- <div class="card">
611
+ <div class="card" id="anchor-storage">
577
612
  <span class="eyebrow">Storage</span>
578
613
  <div id="a-store-free" style="display:none">
579
614
  <h3>You run the bucket</h3>
@@ -581,19 +616,83 @@ sealkeep open 08424eb6… ./session.jsonl \
581
616
  <p class="empty">Your machine reports what it has stored. Run <code>sealkeep status</code> to see it.</p>
582
617
  </div>
583
618
  <div id="a-store-paid" style="display:none">
619
+ <h3>What you keep here</h3>
584
620
  <div class="kv">
585
- <div><span class="k">Stored</span><b id="a-used">—</b></div>
586
- <div><span class="k">Included</span><b id="a-quota-b">—</b></div>
587
- <div><span class="k">Archives</span><b id="a-count">—</b></div>
621
+ <div class="lead"><span class="k">Stored</span><b id="a-used">—</b><span class="cap" id="a-used-cap">sealed before it arrived</span></div>
622
+ <div><span class="k">Included</span><b id="a-quota-b">—</b><span class="cap">in your plan</span></div>
623
+ <div><span class="k">Archives</span><b id="a-count">—</b><span class="cap">chunk folders count as one</span></div>
624
+ <div><span class="k">Machines</span><b id="a-machines">—</b><span class="cap">signing uploads</span></div>
625
+ <div><span class="k">Destinations</span><b id="a-dest">—</b><span class="cap">where new seals go</span></div>
588
626
  </div>
589
627
  <div class="bar"><i id="a-bar"></i></div>
590
628
  <p class="msg" id="a-quota"></p>
591
629
  </div>
592
630
  </div>
593
631
 
594
- <div class="card" id="c-archives" style="display:none">
632
+ <div class="card" id="c-targets"><span id="anchor-routing"></span>
633
+ <span class="eyebrow">Routing</span>
634
+ <h3>Where new archives go</h3>
635
+ <p>Rules live on your account — every machine of the vault follows the same ones.</p>
636
+ <ul class="ruleslist">
637
+ <li><b>Lowest priority number wins.</b></li>
638
+ <li><b>A pinned project always goes to its target.</b></li>
639
+ <li><b>Full or capped targets pass to the next</b> — Sealkeep cloud is the fallback.</li>
640
+ </ul>
641
+ <p class="msg" id="tg-msg">Loading…</p>
642
+ <div id="tg-wrap" style="display:none;overflow-x:auto">
643
+ <table>
644
+ <thead><tr><th>Target</th><th>Where</th><th>Priority</th><th>Cap (GB)</th><th>Only these projects</th><th></th></tr></thead>
645
+ <tbody id="tg-body"></tbody>
646
+ </table>
647
+ </div>
648
+ <div class="inline" style="margin-top:.9rem">
649
+ <button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button>
650
+ <button class="ghost" id="tg-add-bucket" type="button">Add my own bucket</button>
651
+ <button class="primary" id="tg-save" type="button">Save routing</button>
652
+ <span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span>
653
+ </div>
654
+ <p class="why" id="tg-note">Drive holds single objects and skips streaming; big under-pressure seals route to cloud or a bucket on their own. Adding Drive here sets the rule — the one-time consent happens on your machine: <code>sealkeep storage connect gdrive</code>.</p>
655
+ </div>
656
+
657
+ <div class="card" id="anchor-machines">
658
+ <span class="eyebrow">Machines</span>
659
+ <h3>Connected machines</h3>
660
+ <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
661
+ <ul class="lines" id="d-list"></ul>
662
+ <p class="empty" id="d-msg">Loading…</p>
663
+
664
+ <!-- One pairing block exists on the page. It leads the panel while no
665
+ machine is connected, and JS moves it back into this slot once one
666
+ is, so the same code is never printed in two places. -->
667
+ <div id="pair-home-more">
668
+ <div class="pair" id="pair">
669
+ <p class="side" id="pair-side" style="display:none">Pairing code</p>
670
+ <!-- Grouped and labelled, so the copy button is announced as
671
+ belonging to the code rather than as a bare "Copy". -->
672
+ <div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
673
+ <code class="pair-code" id="pair-code"></code>
674
+ <button class="primary" id="pair-copy" type="button">Copy</button>
675
+ </div>
676
+ <p class="fine" id="pair-note"></p>
677
+ <div class="inline" id="pair-ask">
678
+ <button class="ghost" id="pair-go" type="button">Show a pairing code</button>
679
+ <span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
680
+ </div>
681
+ <details class="alt" id="pair-alt" style="display:none">
682
+ <summary>Set the machine up from the terminal instead</summary>
683
+ <p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
684
+ <div class="pair-field"><pre id="pair-cli"></pre>
685
+ <button class="ghost" id="pair-cli-copy" type="button">Copy</button>
686
+ </div>
687
+ </details>
688
+ </div>
689
+ </div>
690
+ </div>
691
+
692
+ <div class="card" id="anchor-archives" style="display:none">
595
693
  <span class="eyebrow">Archives</span>
596
- <p id="s-intro">Names live on your machines — the cloud holds sealed archives it cannot read, listed here by reference. Deleting one removes the stored copy; if your machine already reclaimed the source, this is the last copy.</p>
694
+ <h3>What is stored</h3>
695
+ <p id="s-intro">Names live on your machines — your storage holds sealed archives it cannot read, listed here by reference. Deleting one removes the stored copy; if your machine already reclaimed the source, this is the last copy.</p>
597
696
  <p class="msg" id="s-msg">Loading…</p>
598
697
  <div id="s-wrap" style="display:none;overflow-x:auto">
599
698
  <table>
@@ -604,16 +703,16 @@ sealkeep open 08424eb6… ./session.jsonl \
604
703
  </div>
605
704
  </div>
606
705
 
607
- <div class="card" id="c-plan">
706
+ <div class="card" id="c-plan"><span id="anchor-plan"></span>
608
707
  <span class="eyebrow">Plan</span>
609
708
  <div id="a-plan-free" style="display:none">
610
709
  <h3>Free — your bucket, your bill</h3>
611
710
  <p>Unlimited archives with the same encryption. You supply the storage credentials and pay your provider directly.</p>
612
- <p class="empty">Want us to run storage instead? Managed storage is $6 a month for 100 GB, $12 for 500 GB, or $30 for 2 TB. Write to <span id="a-sales">hello@spala.ai</span> from this address and we will switch it on.</p>
711
+ <p class="empty">Want us to run storage instead? Pricing is in Your package, below. Write to <span id="a-sales">hello@spala.ai</span> from this address and we will switch it on.</p>
613
712
  </div>
614
713
  <div id="a-plan-paid" style="display:none">
615
714
  <h3>Managed storage</h3>
616
- <p>We run the bucket and meter what you store. We still hold no key that opens any of it.</p>
715
+ <p>We run the bucket and meter what you store. We still hold no key that opens any of it — the only difference from your own bucket is who pays the storage bill and who can delete it.</p>
617
716
  <div class="kv" style="margin-top:1rem">
618
717
  <div><span class="k">Subscription</span><b id="a-sub-state">—</b></div>
619
718
  <div><span class="k">Renews</span><b id="a-sub-end">—</b></div>
@@ -625,13 +724,16 @@ sealkeep open 08424eb6… ./session.jsonl \
625
724
 
626
725
  <div class="card" id="a-pack-card">
627
726
  <span class="eyebrow">Your package</span>
628
- <p>A package is a number of gigabytes. Which provider holds the ciphertext is our problem — we pick the cheapest that has free egress, and can move between them without you doing anything, because an archive carries everything needed to open it.</p>
629
- <div class="rules" id="a-packs"></div>
727
+ <h3 id="a-pack-current">—</h3>
728
+ <p class="msg" id="a-pack-context" style="margin:0 0 .8rem"></p>
729
+ <div class="packrow" id="a-packs"></div>
730
+ <p class="empty" id="a-pack-alt">Not storing with us? Your own bucket is free with the same encryption, and Pro Lite keeps key escrow and machine sign-in with no metered storage.</p>
630
731
  <p class="why" id="a-pack-note">Changing your package is handled by hand today. Email hello@spala.ai from this address and we will move you within one business day — your archives keep uploading throughout.</p>
631
732
  </div>
632
733
 
633
734
  <div class="card">
634
735
  <span class="eyebrow">What leaves this machine</span>
736
+ <h3>Your upload rules</h3>
635
737
  <p>Everything you do not choose here stays local and is never uploaded.</p>
636
738
  <p class="empty" id="r-note" style="margin-bottom:.9rem">Enforced on your machine, before a session is read or sealed. Anything held back is never uploaded, so this is a rule about what leaves — not a filter we apply after it arrives.</p>
637
739
  <div class="rules">
@@ -658,94 +760,7 @@ sealkeep open 08424eb6… ./session.jsonl \
658
760
  </div>
659
761
  </div>
660
762
 
661
- <div class="card">
662
- <span class="eyebrow">Where your archives live</span>
663
- <p>Archives are encrypted on your machine before anything moves, on both options. This only decides who owns the bucket the ciphertext lands in.</p>
664
- <div class="rules">
665
- <label class="choice" data-on="yes">
666
- <input type="radio" name="stor" value="managed" checked>
667
- <span><span class="t">Let Sealkeep hold it</span>
668
- <span class="d">Pick how many gigabytes and stop thinking about it. No bucket to create, no IAM policy, no access keys, no console. We choose the provider and absorb the storage bill inside your plan.</span></span>
669
- </label>
670
- <label class="choice" data-on="no">
671
- <input type="radio" name="stor" value="byo">
672
- <span><span class="t">Use my own bucket</span>
673
- <span class="d">Your storage account, your bill, your retention rules. Sealkeep records the destination and nothing else — your access keys stay on your machine and are never sent here.</span>
674
- <div class="row2" style="margin-top:.7rem">
675
- <div><label for="st-prov">Provider</label>
676
- <select id="st-prov">
677
- <option value="r2">Cloudflare R2 — no egress fees</option>
678
- <option value="s3">Amazon S3 or any S3-compatible</option>
679
- <option value="gcs">Google Cloud Storage</option>
680
- </select></div>
681
- <div><label for="st-bucket">Bucket</label><input id="st-bucket" placeholder="my-sealkeep-archives"></div>
682
- </div>
683
- <div class="row2">
684
- <div><label for="st-region">Region</label><input id="st-region" placeholder="auto"></div>
685
- <div><label for="st-prefix">Prefix</label><input id="st-prefix" placeholder="sealkeep/"></div>
686
- </div>
687
- </span>
688
- </label>
689
- <button class="primary" id="st-save" type="button" style="margin-top:1.1rem">Save destination</button>
690
- <span class="saved" id="st-msg" role="status" aria-live="polite"></span>
691
- </div>
692
- <p class="why">Either way we hold no key that opens an archive. The difference is only who pays the storage bill and who can delete the bucket.</p>
693
- </div>
694
-
695
- <div class="card" id="c-targets">
696
- <span class="eyebrow">Routing</span>
697
- <h3>Where new archives go</h3>
698
- <p>Rules live on your account, so every machine of the vault follows the same ones. Lowest priority number wins; a project pinned to a target always goes there; a target at its cap passes to the next. Managed cloud is the fallback when nothing else claims an archive.</p>
699
- <p class="msg" id="tg-msg">Loading…</p>
700
- <div id="tg-wrap" style="display:none;overflow-x:auto">
701
- <table>
702
- <thead><tr><th>Target</th><th>Where</th><th>Priority</th><th>Cap (GB)</th><th>Only these projects</th><th></th></tr></thead>
703
- <tbody id="tg-body"></tbody>
704
- </table>
705
- </div>
706
- <div class="inline" style="margin-top:.9rem">
707
- <button class="ghost" id="tg-add-drive" type="button">Add Google Drive</button>
708
- <button class="primary" id="tg-save" type="button">Save routing</button>
709
- <span class="saved" id="tg-save-msg" role="status" aria-live="polite"></span>
710
- </div>
711
- <p class="why" id="tg-note">Drive holds single objects and skips streaming; big under-pressure seals route to cloud or a bucket on their own. Adding Drive here sets the rule — the one-time consent happens on your machine: <code>sealkeep storage connect gdrive</code>.</p>
712
- </div>
713
-
714
- <div class="card">
715
- <span class="eyebrow">Machines</span>
716
- <p>Each connected machine signs its own uploads. Revoking one stops it writing here; the archives it already stored are untouched.</p>
717
- <ul class="lines" id="d-list"></ul>
718
- <p class="empty" id="d-msg">Loading…</p>
719
-
720
- <!-- One pairing block exists on the page. It leads the panel while no
721
- machine is connected, and JS moves it back into this slot once one
722
- is, so the same code is never printed in two places. -->
723
- <div id="pair-home-more">
724
- <div class="pair" id="pair">
725
- <p class="side" id="pair-side" style="display:none">Pairing code</p>
726
- <!-- Grouped and labelled, so the copy button is announced as
727
- belonging to the code rather than as a bare "Copy". -->
728
- <div class="pair-field" id="pair-shown" role="group" aria-labelledby="pair-side" style="display:none">
729
- <code class="pair-code" id="pair-code"></code>
730
- <button class="primary" id="pair-copy" type="button">Copy</button>
731
- </div>
732
- <p class="fine" id="pair-note"></p>
733
- <div class="inline" id="pair-ask">
734
- <button class="ghost" id="pair-go" type="button">Show a pairing code</button>
735
- <span class="saved" id="pair-msg" role="status" aria-live="polite"></span>
736
- </div>
737
- <details class="alt" id="pair-alt" style="display:none">
738
- <summary>Set the machine up from the terminal instead</summary>
739
- <p class="empty">The same code, typed as one command. Needs Node 22 or newer.</p>
740
- <div class="pair-field"><pre id="pair-cli"></pre>
741
- <button class="ghost" id="pair-cli-copy" type="button">Copy</button>
742
- </div>
743
- </details>
744
- </div>
745
- </div>
746
- </div>
747
-
748
- <div class="card" id="c-recovery">
763
+ <div class="card" id="c-recovery"><span id="anchor-recovery"></span>
749
764
  <span class="eyebrow">Recovery key</span>
750
765
  <h3 id="e-head">Loading…</h3>
751
766
  <p id="e-text"></p>
@@ -753,6 +768,7 @@ sealkeep open 08424eb6… ./session.jsonl \
753
768
 
754
769
  <div class="card">
755
770
  <span class="eyebrow">Activity</span>
771
+ <h3>What happened lately</h3>
756
772
  <p>Every change made to this account, newest first.</p>
757
773
  <ul class="lines" id="v-list"></ul>
758
774
  <p class="empty" id="v-msg">Loading…</p>
@@ -760,6 +776,7 @@ sealkeep open 08424eb6… ./session.jsonl \
760
776
 
761
777
  <div class="card">
762
778
  <span class="eyebrow">Account</span>
779
+ <h3>Name and email</h3>
763
780
  <label for="a-name">Account name</label>
764
781
  <div class="inline">
765
782
  <input id="a-name" maxlength="60" autocomplete="off">
@@ -891,7 +908,18 @@ sealkeep open 08424eb6… ./session.jsonl \
891
908
  /* Every error a catch renders into the page goes through this: the first 200
892
909
  characters are a message, anything past that is a payload nobody meant to
893
910
  put on screen. */
894
- const errText = (error) => String((error && error.message) || error || "Something went wrong").slice(0, 200);
911
+ /* The control plane names its own internals when a step refuses — "Error at
912
+ step Precondition: that confirmation link is no longer valid". The half
913
+ after the colon is written for a person; the half before it is our
914
+ plumbing, and seeing it makes a clear refusal look like a crash. Strip the
915
+ prefix, keep the sentence, and capitalise what is left. */
916
+ const errText = (error) => {
917
+ let text = String((error && error.message) || error || "Something went wrong");
918
+ text = text.replace(/^\s*Error at step\s+[^:]{0,60}:\s*/i, "");
919
+ text = text.replace(/^\s*(Error|Exception|Failure)\s*:\s*/i, "");
920
+ if (text) text = text.charAt(0).toUpperCase() + text.slice(1);
921
+ return text.slice(0, 200);
922
+ };
895
923
  const bytes = (n) => {
896
924
  n = Number(n) || 0;
897
925
  for (const [u, d] of [["GB", 1024 ** 3], ["MB", 1024 ** 2], ["KB", 1024]])
@@ -910,7 +938,15 @@ sealkeep open 08424eb6… ./session.jsonl \
910
938
  const headers = {};
911
939
  if (body) headers["content-type"] = "application/json";
912
940
  if (token) headers.authorization = "Bearer " + token;
913
- const res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
941
+ // A dropped connection surfaces as TypeError("Failed to fetch") — the
942
+ // browser's words, not ours, and they tell a customer nothing about what
943
+ // to do. Everything else in this panel refuses in a sentence; so does this.
944
+ let res;
945
+ try {
946
+ res = await fetch(API + path, { method, headers, body: body && JSON.stringify(body) });
947
+ } catch {
948
+ throw new Error("No answer from the service. Check your connection and try again.");
949
+ }
914
950
  // A gateway answering for a dead service sends an HTML error page, not
915
951
  // JSON. That markup used to ride error.message straight into spans like
916
952
  // pair-msg — raw <html> on screen. When the body does not parse, the
@@ -944,6 +980,40 @@ sealkeep open 08424eb6… ./session.jsonl \
944
980
  if (which === "account" && !wasPanel && !document.body.classList.contains("booting")) enterPanel();
945
981
  }
946
982
 
983
+ /* In-panel navigation. The pills and the plan chip both land here rather
984
+ than leaving it to the native anchor jump: a programmatic smooth scroll is
985
+ dropped on the floor in some browsers — observed live, where the hash
986
+ changed and the page never moved — so this checks whether anything
987
+ actually happened and takes the plain jump if not. A nav that does nothing
988
+ is worse than one that does not animate. The offset clears the sticky nav. */
989
+ function goTo(el) {
990
+ if (!el) return;
991
+ const start = window.scrollY;
992
+ // The nav wraps to two rows on a phone, so a fixed offset would park the
993
+ // card underneath it. Measure what is actually sticking.
994
+ const nav = document.querySelector(".acct-nav");
995
+ const clearance = (nav ? nav.getBoundingClientRect().height : 0) + 12;
996
+ const target = Math.max(0, el.getBoundingClientRect().top + start - clearance);
997
+ const smooth = motionOK();
998
+ window.scrollTo({ top: target, behavior: smooth ? "smooth" : "instant" });
999
+ if (!smooth) return;
1000
+ setTimeout(() => {
1001
+ if (Math.abs(window.scrollY - start) < 4 && Math.abs(target - start) > 24) {
1002
+ window.scrollTo({ top: target, behavior: "instant" });
1003
+ }
1004
+ }, 320);
1005
+ }
1006
+ for (const link of document.querySelectorAll(".acct-nav a")) {
1007
+ link.addEventListener("click", (event) => {
1008
+ const id = link.getAttribute("href").slice(1);
1009
+ const target = document.getElementById(id);
1010
+ if (!target) return;
1011
+ event.preventDefault();
1012
+ history.replaceState(null, "", location.pathname + location.search + "#" + id);
1013
+ goTo(target);
1014
+ });
1015
+ }
1016
+
947
1017
  /* "Account" is a promise the page has to keep on the first click, not the
948
1018
  second. Routing it here rather than leaving it to the native anchor means
949
1019
  exactly one thing is scrolling, and it runs after the state that decides
@@ -977,7 +1047,7 @@ sealkeep open 08424eb6… ./session.jsonl \
977
1047
  $("a-name").value = user.label || "";
978
1048
  $("a-plan").textContent = paid ? "Managed storage" : "Free";
979
1049
  $("a-plan").className = "chip" + (paid ? " pro" : "");
980
- $("a-plan").onclick = () => $("c-plan").scrollIntoView({ behavior: "smooth", block: "start" });
1050
+ $("a-plan").onclick = () => goTo($("c-plan"));
981
1051
  $("a-who").textContent = [
982
1052
  user.email,
983
1053
  "joined " + day(user.created_at),
@@ -994,7 +1064,7 @@ sealkeep open 08424eb6… ./session.jsonl \
994
1064
  // destination arrives; the plan is only the opening guess.
995
1065
  lastPaid = paid;
996
1066
  applyStorageView();
997
- on("c-archives", paid);
1067
+ on("anchor-archives", paid); on("nav-archives", paid);
998
1068
  on("a-plan-free", !paid);
999
1069
  on("a-plan-paid", paid);
1000
1070
 
@@ -1181,22 +1251,55 @@ sealkeep open 08424eb6… ./session.jsonl \
1181
1251
  // projects, priority}]. Editing here lands on every machine of the vault,
1182
1252
  // because resolveTargets is cloud-first.
1183
1253
  let targetRows = [];
1254
+ let planIsPaid = false;
1184
1255
  const providerLabel = (p) => ({ vaultline: "Sealkeep cloud", gdrive: "Google Drive", r2: "Cloudflare R2", s3: "S3", gcs: "Google Cloud" }[p] || p);
1185
1256
  function renderTargets() {
1186
1257
  const body = $("tg-body");
1187
1258
  body.replaceChildren();
1188
1259
  if (!targetRows.length) {
1189
1260
  $("tg-msg").className = "empty";
1190
- $("tg-msg").textContent = "No rules yet — everything goes to Sealkeep cloud. Add your Drive or set rules from a machine: sealkeep storage targets add …";
1261
+ $("tg-msg").textContent = planIsPaid
1262
+ ? "No rules yet — everything goes to Sealkeep cloud. Add a destination here, or set rules from a machine: sealkeep storage targets add …"
1263
+ : "No rules yet — each machine seals to the bucket configured on it. Add a destination here to route by project or cap it.";
1191
1264
  $("tg-wrap").style.display = "none";
1192
1265
  return;
1193
1266
  }
1194
1267
  $("tg-msg").textContent = "";
1195
1268
  for (const t of targetRows) {
1196
1269
  const tr = document.createElement("tr");
1197
- const name = document.createElement("td"); name.textContent = providerLabel(t.provider);
1270
+ const name = document.createElement("td");
1271
+ if (t.__editable) {
1272
+ const sel = document.createElement("select");
1273
+ for (const [v, l] of [["r2", "Cloudflare R2"], ["s3", "S3-compatible"], ["gcs", "Google Cloud"]]) {
1274
+ const o = document.createElement("option"); o.value = v; o.textContent = l; sel.append(o);
1275
+ }
1276
+ sel.value = t.provider;
1277
+ sel.setAttribute("aria-label", "Provider");
1278
+ sel.addEventListener("change", () => { t.provider = sel.value; });
1279
+ name.append(sel);
1280
+ } else name.textContent = providerLabel(t.provider);
1198
1281
  const where = document.createElement("td");
1199
- const wc = document.createElement("code"); wc.textContent = t.provider === "vaultline" ? "managed" : [t.bucket, t.prefix].filter(Boolean).join("/") || "—"; where.append(wc);
1282
+ if (t.__editable) {
1283
+ const b = document.createElement("input"); b.placeholder = "bucket"; b.style.width = "8rem"; b.value = t.bucket || "";
1284
+ b.setAttribute("aria-label", "Bucket name");
1285
+ b.addEventListener("input", () => { t.bucket = b.value.trim(); });
1286
+ const p = document.createElement("input"); p.placeholder = "prefix/"; p.style.width = "6rem"; p.value = t.prefix || "";
1287
+ p.setAttribute("aria-label", "Prefix");
1288
+ p.addEventListener("input", () => { t.prefix = p.value.trim() || undefined; });
1289
+ where.append(b, document.createTextNode(" "), p);
1290
+ } else {
1291
+ const wc = document.createElement("code");
1292
+ wc.textContent = t.provider === "vaultline" ? "managed"
1293
+ : t.provider === "gdrive" ? "folder " + (t.bucket || "Sealkeep")
1294
+ : [t.bucket, t.prefix].filter(Boolean).join("/") || "—";
1295
+ where.append(wc);
1296
+ if (t.usedBytes) {
1297
+ const held = document.createElement("span");
1298
+ held.className = "cellsub";
1299
+ held.textContent = "holds " + bytes(t.usedBytes);
1300
+ where.append(held);
1301
+ }
1302
+ }
1200
1303
  const pr = document.createElement("td");
1201
1304
  const prIn = document.createElement("input"); prIn.type = "number"; prIn.className = "n"; prIn.min = "0"; prIn.max = "99";
1202
1305
  prIn.value = t.priority ?? (t.provider === "vaultline" ? 0 : 10);
@@ -1235,6 +1338,11 @@ sealkeep open 08424eb6… ./session.jsonl \
1235
1338
  renderTargets();
1236
1339
  } catch (error) { say("tg-msg", errText(error), "err"); }
1237
1340
  }
1341
+ $("tg-add-bucket").addEventListener("click", () => {
1342
+ targetRows.push({ id: "byo-" + Math.random().toString(36).slice(2, 8), provider: "r2", priority: 5, __editable: true });
1343
+ renderTargets();
1344
+ say("tg-save-msg", "Fill bucket and prefix, save — then put the access keys on your machine: sealkeep storage credentials set");
1345
+ });
1238
1346
  $("tg-add-drive").addEventListener("click", () => {
1239
1347
  if (targetRows.some((t) => t.provider === "gdrive")) { say("tg-save-msg", "Drive is already in the rules."); return; }
1240
1348
  targetRows.push({ id: "gdrive-" + Math.random().toString(36).slice(2, 8), provider: "gdrive", bucket: "Sealkeep", maxGb: 50, priority: 10 });
@@ -1246,7 +1354,8 @@ sealkeep open 08424eb6… ./session.jsonl \
1246
1354
  if (!token) { say("tg-save-msg", "Sign in again to save.", "err"); return; }
1247
1355
  $("tg-save").disabled = true;
1248
1356
  try {
1249
- await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(targetRows) } });
1357
+ const clean = targetRows.map(({ __editable, ...t }) => t);
1358
+ await api("/v1/cloud/storage-targets", { method: "PUT", token, body: { targets_json: JSON.stringify(clean) } });
1250
1359
  say("tg-save-msg", "Saved. Every machine follows these on its next seal.");
1251
1360
  } catch (error) { say("tg-save-msg", errText(error), "err"); }
1252
1361
  $("tg-save").disabled = false;
@@ -1262,6 +1371,7 @@ sealkeep open 08424eb6… ./session.jsonl \
1262
1371
  // an empty state is prose, so it is set in prose. .msg is mono because
1263
1372
  // it reports what a machine did.
1264
1373
  on("s-intro", false);
1374
+ $("a-count").textContent = "0";
1265
1375
  $("s-msg").className = "empty";
1266
1376
  $("s-msg").textContent = "Nothing stored yet. Archives appear here on their own once a machine is connected.";
1267
1377
  $("s-wrap").style.display = "none";
@@ -1275,13 +1385,21 @@ sealkeep open 08424eb6… ./session.jsonl \
1275
1385
  const ref = document.createElement("td");
1276
1386
  const code = document.createElement("code"); code.textContent = String(r.vault_ref).slice(0, 8); ref.append(code);
1277
1387
  const size = document.createElement("td");
1278
- size.textContent = r.chunked ? bytes(r.bytes) + " in " + r.chunkCount + " sealed chunks" : bytes(r.bytes);
1388
+ const sc = document.createElement("code");
1389
+ sc.textContent = bytes(r.bytes);
1390
+ size.append(sc);
1391
+ if (r.chunked) {
1392
+ const sub = document.createElement("span");
1393
+ sub.className = "cellsub";
1394
+ sub.textContent = r.chunkCount + " sealed chunks";
1395
+ size.append(sub);
1396
+ }
1279
1397
  const st = document.createElement("td");
1280
1398
  const s = document.createElement("span");
1281
1399
  s.className = "state " + (r.state === "durable" ? "durable" : "pending");
1282
1400
  s.textContent = r.state === "durable" ? "stored" : "incomplete";
1283
1401
  st.append(s);
1284
- const when = document.createElement("td"); when.textContent = ago(r.durable_at || r.created_at);
1402
+ const when = document.createElement("td"); when.className = "mono"; when.style.fontSize = ".78rem"; when.style.color = "var(--soft)"; when.textContent = ago(r.durable_at || r.created_at);
1285
1403
  const act = document.createElement("td");
1286
1404
  const btn = document.createElement("button");
1287
1405
  btn.className = "del"; btn.type = "button"; btn.textContent = "Delete";
@@ -1311,11 +1429,13 @@ sealkeep open 08424eb6… ./session.jsonl \
1311
1429
  body.append(tr);
1312
1430
  }
1313
1431
 
1314
- const stored = rows.filter((r) => r.state === "durable").length;
1432
+ // Counts describe archives, not stored objects — a chunk family is one.
1433
+ const folded = foldFamilies(rows);
1434
+ const stored = folded.filter((r) => r.state === "durable").length;
1315
1435
  say("s-msg", "");
1316
- $("a-count").textContent = String(rows.length);
1317
- $("s-foot").textContent = `${rows.length} archive${rows.length === 1 ? "" : "s"} · ${stored} fully stored`
1318
- + (stored < rows.length ? ` · ${rows.length - stored} incomplete, not billed` : "");
1436
+ $("a-count").textContent = String(folded.length);
1437
+ $("s-foot").textContent = `${folded.length} archive${folded.length === 1 ? "" : "s"} · ${stored} fully stored`
1438
+ + (stored < folded.length ? ` · ${folded.length - stored} incomplete, not billed` : "");
1319
1439
  $("s-wrap").style.display = "";
1320
1440
  } catch (error) { say("s-msg", errText(error), "err"); }
1321
1441
  }
@@ -1325,17 +1445,44 @@ sealkeep open 08424eb6… ./session.jsonl \
1325
1445
  try { user = await api("/me", { token }); }
1326
1446
  catch { sessionStorage.removeItem(TOKEN); return false; }
1327
1447
 
1448
+ // Set before any card renders: the routing card's empty state asks whether
1449
+ // this account stores with us, and it draws during loadTargets below.
1450
+ planIsPaid = user.plan === "pro";
1328
1451
  renderAccount(user);
1329
1452
  // Each section fails on its own. One dead panel must not blank the rest.
1330
1453
  if (user.plan === "pro") { $("a-count").textContent = "—"; loadStorage(token); }
1331
- loadTargets(token);
1454
+ await loadTargets(token);
1332
1455
  loadAudit(token);
1333
- loadStorage2(token);
1334
1456
  const devices = await loadDevices(token);
1335
1457
 
1336
1458
  // A brand-new account has exactly one useful next step. It leads the panel,
1337
1459
  // and it leads with the code rather than with a terminal — a paying
1338
1460
  // customer should not have to be a terminal user to finish setting up.
1461
+ // The glance line: what is connected, where seals go — from data already fetched.
1462
+ $("a-machines").textContent = String(devices);
1463
+ $("a-dest").textContent = String(Math.max(targetRows.length, 1));
1464
+ const destNames = targetRows.slice()
1465
+ .sort((a, b) => (a.priority ?? 10) - (b.priority ?? 10))
1466
+ .map((t) => providerLabel(t.provider) + (t.maxGb ? " ≤" + t.maxGb + " GB" : ""));
1467
+ const driveUsed = targetRows.filter((t) => t.provider === "gdrive" && t.usedBytes)
1468
+ .reduce((n, t) => n + t.usedBytes, 0);
1469
+ const byoUsed = targetRows.filter((t) => t.provider !== "gdrive" && t.provider !== "vaultline" && t.usedBytes)
1470
+ .reduce((n, t) => n + t.usedBytes, 0);
1471
+ // Only a paid account stores with us. Saying "0 B in Sealkeep cloud" to
1472
+ // someone who runs their own bucket states a number about a place their
1473
+ // archives never go — the free line names their storage instead.
1474
+ const paidPlan = planIsPaid;
1475
+ const parts = [];
1476
+ if (paidPlan) parts.push(bytes(Number(user.used_bytes) || 0) + " in Sealkeep cloud");
1477
+ if (driveUsed) parts.push(bytes(driveUsed) + " in Google Drive");
1478
+ if (byoUsed) parts.push(bytes(byoUsed) + " in your bucket");
1479
+ $("a-hero").textContent = parts.length ? parts.join(" · ")
1480
+ : "Your archives live in storage you run";
1481
+ $("a-summary").textContent =
1482
+ (devices ? devices + " machine" + (devices === 1 ? "" : "s") + " connected" : "No machine connected yet")
1483
+ + " · new archives go to "
1484
+ + (destNames.length ? destNames.join(", then ")
1485
+ : paidPlan ? "Sealkeep cloud" : "the bucket each machine is configured with");
1339
1486
  const nothingYet = devices === 0 && Number(user.used_bytes) === 0;
1340
1487
  on("a-firstrun", nothingYet);
1341
1488
  $(nothingYet ? "pair-home-first" : "pair-home-more").append($("pair"));
@@ -1377,53 +1524,14 @@ sealkeep open 08424eb6… ./session.jsonl \
1377
1524
  // half the bytes, and still no innerHTML anywhere on the page.
1378
1525
  $("g-in").prepend($("g-up").querySelector("svg").cloneNode(true));
1379
1526
 
1380
- const storChoices = [...document.querySelectorAll("input[name=stor]")];
1527
+ // The destination choice lives in Routing; this card only switches between
1528
+ // "we meter it" (paid) and "your machine reports it" (free).
1381
1529
  let lastPaid = false;
1382
- let lastStorageMode = null;
1383
1530
  function applyStorageView() {
1384
- const managed = lastStorageMode ? lastStorageMode !== "byo" : lastPaid;
1385
- on("a-store-free", !managed);
1386
- on("a-store-paid", managed);
1531
+ on("a-store-free", !lastPaid);
1532
+ on("a-store-paid", lastPaid);
1387
1533
  }
1388
1534
 
1389
- const paintStorage = () => {
1390
- for (const input of storChoices) input.closest(".choice").dataset.on = input.checked ? "yes" : "no";
1391
- };
1392
- for (const input of storChoices) input.addEventListener("change", paintStorage);
1393
-
1394
- async function loadStorage2(token) {
1395
- try {
1396
- const where = await api("/v1/cloud/storage", { token });
1397
- const pick = storChoices.find((input) => input.value === where.mode) || storChoices[0];
1398
- pick.checked = true;
1399
- if (where.provider && where.provider !== "vaultline") $("st-prov").value = where.provider;
1400
- $("st-bucket").value = where.bucket && where.mode === "byo" ? where.bucket : "";
1401
- $("st-region").value = where.region || "";
1402
- $("st-prefix").value = where.mode === "byo" ? (where.prefix || "") : "";
1403
- lastStorageMode = where.mode;
1404
- applyStorageView();
1405
- paintStorage();
1406
- } catch { /* the card keeps its defaults */ }
1407
- }
1408
-
1409
- $("st-save").addEventListener("click", async () => {
1410
- const token = sessionStorage.getItem(TOKEN);
1411
- const mode = (storChoices.find((input) => input.checked) || {}).value || "managed";
1412
- $("st-save").disabled = true;
1413
- $("st-msg").textContent = "Saving";
1414
- try {
1415
- const saved = await api("/v1/cloud/storage", { method: "PUT", token, body: mode === "byo"
1416
- ? { mode, provider: $("st-prov").value, bucket: $("st-bucket").value.trim(), region: $("st-region").value.trim(), prefix: $("st-prefix").value.trim() }
1417
- : { mode } });
1418
- lastStorageMode = saved.mode;
1419
- applyStorageView();
1420
- $("st-msg").textContent = saved.mode === "byo"
1421
- ? "Saved. Run sealkeep storage credentials set on the machine that uploads — the keys stay there."
1422
- : "Saved. Nothing else to configure.";
1423
- } catch (error) { $("st-msg").textContent = errText(error); }
1424
- finally { $("st-save").disabled = false; }
1425
- });
1426
-
1427
1535
  /* -------------------------------------------------------------- pairing
1428
1536
  An account created through Google has no password anyone knows, so a
1429
1537
  one-time code is the only way a machine can ever sign in. That makes this
@@ -1622,22 +1730,23 @@ sealkeep open 08424eb6… ./session.jsonl \
1622
1730
 
1623
1731
  const packsFrom = (user) => {
1624
1732
  const current = packageIdFor(user);
1733
+ const cur = PACKAGES.find((p) => p.id === current);
1734
+ $("a-pack-current").textContent = cur
1735
+ ? (cur.managed ? `${cur.name} — $${cur.monthly} a month` : `${cur.name} — free`)
1736
+ : `Custom allocation`;
1737
+ $("a-pack-context").textContent = cur ? cur.blurb : "";
1625
1738
  const host = $("a-packs");
1626
1739
  while (host.firstChild) host.removeChild(host.firstChild);
1627
- for (const pack of PACKAGES) {
1628
- const row = document.createElement("div");
1629
- row.className = "choice";
1630
- row.dataset.on = pack.id === current ? "yes" : "no";
1631
- const wrap = document.createElement("span");
1632
- const title = document.createElement("span");
1633
- title.className = "t";
1634
- title.textContent = pack.managed ? `${pack.name} — $${pack.monthly}/mo` : `${pack.name} — free`;
1635
- const desc = document.createElement("span");
1636
- desc.className = "d";
1637
- desc.textContent = pack.id === current ? `${pack.blurb} This is your package.` : pack.blurb;
1638
- wrap.append(title, desc);
1639
- row.append(wrap);
1640
- host.append(row);
1740
+ for (const pack of PACKAGES.filter((p) => p.managed)) {
1741
+ const chip = document.createElement("span");
1742
+ chip.className = "packchip" + (pack.id === current ? " now" : "");
1743
+ const name = document.createElement("b"); name.textContent = pack.name;
1744
+ const price = document.createElement("span"); price.textContent = `$${pack.monthly}/mo`;
1745
+ chip.append(name, price);
1746
+ if (pack.id === current) {
1747
+ const tag = document.createElement("i"); tag.textContent = "your package"; chip.append(tag);
1748
+ }
1749
+ host.append(chip);
1641
1750
  }
1642
1751
  if (current === "custom") {
1643
1752
  const gb = Math.round((Number(user.quota_bytes) || 0) / 1073741824);