selfstore 1.8.10 → 1.8.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/dist/widgets.js CHANGED
@@ -130,7 +130,23 @@ img[part~='icon'] {
130
130
  background: color-mix(in srgb, var(--_accent) 10%, transparent);
131
131
  }
132
132
  [part~='status-ok'] { background: color-mix(in srgb, var(--_ok) 12%, transparent); }
133
+ [part~='status-warn'] { background: color-mix(in srgb, var(--_warn) 12%, transparent); }
133
134
  [part~='status-error'] { background: color-mix(in srgb, var(--_danger) 12%, transparent); }
135
+ /* The state, said in one glyph before the sentence is read: a saved backup is a
136
+ tick, anything asking for a gesture is a mark. It carries the severity color
137
+ the dot would have carried, and reads at a glance from across the page. */
138
+ span[part~='status-glyph'] {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ width: 1.5em;
143
+ height: 1.5em;
144
+ border-radius: 50%;
145
+ flex-shrink: 0;
146
+ font-size: 0.85em;
147
+ font-weight: 700;
148
+ background: color-mix(in srgb, currentColor 15%, transparent);
149
+ }
134
150
  [part='spinner'] {
135
151
  width: 0.7em; height: 0.7em; flex-shrink: 0;
136
152
  border-radius: 50%;
@@ -354,6 +370,10 @@ button[part~='menu-item-danger'] { color: var(--_danger); }
354
370
  }
355
371
  }
356
372
  `;
373
+ function siblingTag(own, self, name) {
374
+ const suffix = `-${self}`;
375
+ return own.endsWith(suffix) ? `${own.slice(0, -suffix.length)}-${name}` : `selfstore-${name}`;
376
+ }
357
377
  function put(el, ...children) {
358
378
  for (const c of children) {
359
379
  if (c == null) continue;
@@ -381,12 +401,15 @@ function h(tag, attrs = {}, ...children) {
381
401
  function upgradeOwnProperties(el) {
382
402
  const proto = Object.getPrototypeOf(el);
383
403
  const bag = el;
404
+ const claimed = /* @__PURE__ */ new Set();
384
405
  for (const name of Object.getOwnPropertyNames(el)) {
385
406
  if (!(name in proto)) continue;
386
407
  const value = bag[name];
387
408
  delete bag[name];
388
409
  bag[name] = value;
410
+ claimed.add(name);
389
411
  }
412
+ return claimed;
390
413
  }
391
414
  var FlowWidget = class extends HTMLElement {
392
415
  root;
@@ -399,6 +422,42 @@ var FlowWidget = class extends HTMLElement {
399
422
  packs() {
400
423
  return {};
401
424
  }
425
+ #store = null;
426
+ #icons = {};
427
+ /**
428
+ * The store this widget reads: the simple facade (anything exposing
429
+ * `flowHost`) or a hand-built FlowHost. Idempotent, so a host may assign it
430
+ * from a reactive effect that runs more than once.
431
+ *
432
+ * Every widget over a store had written this same pair, plus the host lookup
433
+ * and the glyph map beside it - the same fifteen lines in six files, each one
434
+ * a place where a fix could land in five.
435
+ */
436
+ get store() {
437
+ return this.#store;
438
+ }
439
+ set store(v) {
440
+ if (v === this.#store) return;
441
+ this.#store = v;
442
+ this.storeChanged();
443
+ this.follow(this.hostOf());
444
+ }
445
+ /** Called before the widget follows a newly assigned store, for one that has
446
+ * its own view state to drop (a journey in progress belongs to the old one). */
447
+ storeChanged() {
448
+ }
449
+ /** The ports behind the current store, or null while it is unwired. */
450
+ hostOf() {
451
+ return hostOf(this.#store);
452
+ }
453
+ /** Optional glyph per destination kind, same contract in every widget. */
454
+ get icons() {
455
+ return this.#icons;
456
+ }
457
+ set icons(v) {
458
+ this.#icons = v ?? {};
459
+ this.rerender();
460
+ }
402
461
  /** Follow an engine: rerender on every notification, replacing any previous
403
462
  * subscription. A no-op before connection and on a null host, so a host may
404
463
  * assign the store from a reactive effect that runs more than once. */
@@ -426,13 +485,17 @@ var FlowWidget = class extends HTMLElement {
426
485
  this.overrides = map ?? {};
427
486
  this.rerender();
428
487
  }
429
- /** The shipped translation for the page's language, if the widget has one.
430
- * An explicit `lang` on the element (or on any ancestor) wins over the
431
- * document's, which wins over the browser's, so a host that already sets
432
- * `<html lang>` gets translated copy without writing a single label. */
488
+ /** The language this widget speaks. An explicit `lang` on the element (or on
489
+ * any ancestor) wins over the document's, which wins over the browser's, so
490
+ * a host that already sets `<html lang>` gets translated copy without
491
+ * writing a single label. Also what to hand `Intl` when a widget formats a
492
+ * date or a duration itself. */
493
+ langTag() {
494
+ return this.closest("[lang]")?.getAttribute("lang") || document.documentElement.lang || navigator.language || "en";
495
+ }
496
+ /** The shipped translation for the page's language, if the widget has one. */
433
497
  localized() {
434
- const tag = this.closest("[lang]")?.getAttribute("lang") || document.documentElement.lang || navigator.language || "";
435
- return this.packs()[tag.slice(0, 2).toLowerCase()] ?? {};
498
+ return this.packs()[this.langTag().slice(0, 2).toLowerCase()] ?? {};
436
499
  }
437
500
  /**
438
501
  * The copy for a key, with `{placeholders}` filled in.
@@ -449,6 +512,21 @@ var FlowWidget = class extends HTMLElement {
449
512
  if (!vars) return copy;
450
513
  return copy.replace(/\{(\w+)\}/g, (whole, name) => vars[name] ?? whole);
451
514
  }
515
+ /**
516
+ * "just now", "3 minutes ago", "yesterday" - in the widget's language, from
517
+ * Intl rather than from five label keys per language. The word for the first
518
+ * minute is passed in: it is the one part Intl says badly ("this minute"),
519
+ * and each widget words it in its own register.
520
+ */
521
+ since(ts, justNow) {
522
+ const mins = Math.floor((Date.now() - ts) / 6e4);
523
+ if (mins < 1) return justNow;
524
+ const rtf = new Intl.RelativeTimeFormat(this.langTag(), { numeric: "auto" });
525
+ if (mins < 60) return rtf.format(-mins, "minute");
526
+ const hours = Math.floor(mins / 60);
527
+ if (hours < 24) return rtf.format(-hours, "hour");
528
+ return new Date(ts).toLocaleDateString(this.langTag());
529
+ }
452
530
  /** A heading an empty-string label removes: labels = { 'share.title': '' }
453
531
  * hides that heading (the host page already says it) without a fork.
454
532
  * Append through put(), which skips the null. */
@@ -468,8 +546,40 @@ var FlowWidget = class extends HTMLElement {
468
546
  /** Hands back whatever the host set before this class existed, then the
469
547
  * subclass reads its attributes and wires itself. Subclasses override this
470
548
  * and call `super.connectedCallback()` FIRST, for that reason. */
549
+ /**
550
+ * What the HOST decided before this element could hear it.
551
+ *
552
+ * Assigning a property to an element that has not upgraded yet is what every
553
+ * framework does - the widget bundle is loaded lazily, the assignment is
554
+ * not - and `upgradeOwnProperties` replays those assignments through the
555
+ * real setters. Then reading the same knob back from the markup would undo
556
+ * them: an attribute is a DEFAULT written by the page, a property is a
557
+ * decision taken by the app, and the decision has to win. A widget reads its
558
+ * attributes through this set.
559
+ */
560
+ assigned = /* @__PURE__ */ new Set();
471
561
  connectedCallback() {
472
- upgradeOwnProperties(this);
562
+ for (const name of upgradeOwnProperties(this)) this.assigned.add(name);
563
+ }
564
+ /**
565
+ * An attribute the host has NOT already decided in code, or null.
566
+ *
567
+ * Two ways it can have decided, and both have to be caught. It replayed
568
+ * through `assigned` on connect - or it has not been replayed yet, and its
569
+ * value is sitting on the element as an own property shadowing this very
570
+ * accessor. That second case is the one that bit: on upgrade the browser
571
+ * fires attributeChangedCallback BEFORE connectedCallback, so a widget
572
+ * writing the attribute through its own setter wrote into the shadowing own
573
+ * property instead - overwriting the decision it was about to replay.
574
+ */
575
+ attr(name) {
576
+ if (this.assigned.has(name) || Object.hasOwn(this, name)) return null;
577
+ return this.getAttribute(name);
578
+ }
579
+ /** The same, as a boolean: absent is null, anything but "false" is true. */
580
+ flag(name) {
581
+ const value = this.attr(name);
582
+ return value === null ? null : value !== "false";
473
583
  }
474
584
  emit(name, detail) {
475
585
  this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
@@ -1950,8 +2060,25 @@ var SelfstoreJoinElement = class extends FlowWidget {
1950
2060
  }
1951
2061
  };
1952
2062
 
1953
- // src/widgets/status.ts
2063
+ // src/widgets/kinds.ts
1954
2064
  var EN4 = {
2065
+ "destination.kind.drive": "Google Drive",
2066
+ "destination.kind.file": "A file on this device",
2067
+ "destination.kind.webdav": "Your own server",
2068
+ "destination.kind.s3": "Your own bucket",
2069
+ "destination.kind.device": "This device only"
2070
+ };
2071
+ var FR4 = {
2072
+ "destination.kind.file": "Un fichier sur cet appareil",
2073
+ "destination.kind.webdav": "Votre serveur",
2074
+ "destination.kind.s3": "Votre bucket",
2075
+ "destination.kind.device": "Cet appareil seulement"
2076
+ };
2077
+
2078
+ // src/widgets/status.ts
2079
+ var EN5 = {
2080
+ ...EN4,
2081
+ "status.justNow": "just now",
1955
2082
  "status.ephemeral": "Nothing is saved",
1956
2083
  "status.cacheOnly": "Never saved anywhere yet",
1957
2084
  "status.saving": "Saving...",
@@ -1965,7 +2092,9 @@ var EN4 = {
1965
2092
  "status.action.reconnect": "Reconnect",
1966
2093
  "status.action.unlock": "Unlock"
1967
2094
  };
1968
- var FR4 = {
2095
+ var FR5 = {
2096
+ ...FR4,
2097
+ "status.justNow": "a l'instant",
1969
2098
  "status.ephemeral": "Rien n'est enregistr\xE9",
1970
2099
  "status.cacheOnly": "Vos donn\xE9es n'ont jamais \xE9t\xE9 enregistr\xE9es ailleurs",
1971
2100
  "status.saving": "Enregistrement...",
@@ -1983,23 +2112,12 @@ var SelfstoreStatusElement = class extends FlowWidget {
1983
2112
  static get observedAttributes() {
1984
2113
  return ["variant"];
1985
2114
  }
1986
- #store = null;
1987
2115
  #variant = "row";
1988
- #icons = {};
1989
2116
  defaults() {
1990
- return EN4;
2117
+ return EN5;
1991
2118
  }
1992
2119
  packs() {
1993
- return { fr: FR4 };
1994
- }
1995
- /** The simple store (anything exposing `flowHost`), or a hand-built FlowHost. */
1996
- get store() {
1997
- return this.#store;
1998
- }
1999
- set store(v) {
2000
- if (v === this.#store) return;
2001
- this.#store = v;
2002
- this.wire();
2120
+ return { fr: FR5 };
2003
2121
  }
2004
2122
  /** 'row' (dot + text + action) or 'dot' (the dot alone). Also the attribute. */
2005
2123
  get variant() {
@@ -2009,31 +2127,16 @@ var SelfstoreStatusElement = class extends FlowWidget {
2009
2127
  this.#variant = v === "dot" ? "dot" : "row";
2010
2128
  this.rerender();
2011
2129
  }
2012
- /** Optional icon per target kind (e.g. { drive: url, file: url }), shown
2013
- * before the text in the row variant. Same contract as the connect cards. */
2014
- get icons() {
2015
- return this.#icons;
2016
- }
2017
- set icons(v) {
2018
- this.#icons = v ?? {};
2019
- this.rerender();
2020
- }
2021
2130
  attributeChangedCallback(name, _old, value) {
2022
2131
  if (name === "variant") this.variant = value;
2023
2132
  }
2024
2133
  connectedCallback() {
2025
2134
  super.connectedCallback();
2026
2135
  if (this.hasAttribute("variant")) this.variant = this.getAttribute("variant");
2027
- this.wire();
2028
- }
2029
- host() {
2030
- return hostOf(this.#store);
2031
- }
2032
- wire() {
2033
- this.follow(this.host());
2136
+ this.follow(this.hostOf());
2034
2137
  }
2035
2138
  view(into) {
2036
- const host = this.host();
2139
+ const host = this.hostOf();
2037
2140
  if (!host) return;
2038
2141
  const { status, targetKind, label } = host.engine.state;
2039
2142
  const sansLieu = (cle) => {
@@ -2041,6 +2144,7 @@ var SelfstoreStatusElement = class extends FlowWidget {
2041
2144
  const copie = this.t(jumelle);
2042
2145
  return copie === jumelle ? this.t(cle) : copie;
2043
2146
  };
2147
+ const { lastSavedAt } = host.engine.state;
2044
2148
  const text = label ? this.t(status.labelKey, { label }) : sansLieu(status.labelKey);
2045
2149
  const dot = h("span", {
2046
2150
  part: `status-dot sev-${status.severity}`,
@@ -2061,20 +2165,36 @@ var SelfstoreStatusElement = class extends FlowWidget {
2061
2165
  );
2062
2166
  return;
2063
2167
  }
2064
- const icon = this.#icons[targetKind];
2168
+ const TINT = {
2169
+ ok: "status-ok",
2170
+ warn: "status-warn",
2171
+ danger: "status-error",
2172
+ info: ""
2173
+ };
2174
+ const GLYPH = { ok: "\u2713", warn: "!", danger: "!", info: "" };
2175
+ const glyph = GLYPH[status.severity];
2176
+ const icon = this.icons[targetKind];
2065
2177
  const where = this.t(`status.where.${targetKind}`);
2066
- const sub = where !== `status.where.${targetKind}` ? where : label ?? "";
2178
+ const kindName = this.t(`destination.kind.${targetKind}`);
2179
+ const named = kindName === `destination.kind.${targetKind}` ? null : kindName;
2180
+ const own = targetKind === "drive" ? null : label;
2181
+ const place = where !== `status.where.${targetKind}` ? where : own ?? named ?? label ?? "";
2182
+ const when = lastSavedAt ? this.since(lastSavedAt, this.t("status.justNow")) : "";
2183
+ const sub = [place, status.state === "saved" ? when : ""].filter(Boolean).join(", ");
2184
+ const title = sub ? sansLieu(status.labelKey) : text;
2067
2185
  put(
2068
2186
  into,
2069
2187
  h(
2070
2188
  "div",
2071
- { part: "row status-row" },
2072
- dot,
2189
+ { part: `status row status-row ${TINT[status.severity] ?? ""}` },
2190
+ // The severity colors the glyph, not the box: tinting the whole line
2191
+ // turns a title into a warning about itself.
2192
+ glyph ? h("span", { part: `status-glyph sev-${status.severity}`, "aria-hidden": "true" }, glyph) : dot,
2073
2193
  icon ? h("img", { part: "icon", src: icon, alt: "" }) : null,
2074
2194
  h(
2075
2195
  "div",
2076
2196
  {},
2077
- h("div", { part: "title" }, text),
2197
+ h("div", { part: "title" }, title),
2078
2198
  sub ? h("div", { part: "sub" }, sub) : null
2079
2199
  ),
2080
2200
  status.actionable && status.action ? h(
@@ -2091,7 +2211,7 @@ var SelfstoreStatusElement = class extends FlowWidget {
2091
2211
  };
2092
2212
 
2093
2213
  // src/widgets/backups.ts
2094
- var EN5 = {
2214
+ var EN6 = {
2095
2215
  "backups.mine.heading": "My backups",
2096
2216
  "backups.mine.personal": "Main backup",
2097
2217
  "backups.active": "Active",
@@ -2148,7 +2268,7 @@ var EN5 = {
2148
2268
  "backups.replica.s3.secret": "Secret access key",
2149
2269
  "error.generic": "That did not work. Check the connection and try again."
2150
2270
  };
2151
- var FR5 = {
2271
+ var FR6 = {
2152
2272
  "backups.mine.heading": "Sauvegardes",
2153
2273
  "backups.mine.personal": "Sauvegarde principale",
2154
2274
  "backups.active": "Active",
@@ -2257,10 +2377,10 @@ var SelfstoreBackupsElement = class extends FlowWidget {
2257
2377
  #withShare = true;
2258
2378
  #withShared = true;
2259
2379
  defaults() {
2260
- return EN5;
2380
+ return EN6;
2261
2381
  }
2262
2382
  packs() {
2263
- return { fr: FR5 };
2383
+ return { fr: FR6 };
2264
2384
  }
2265
2385
  /** The headless manager this panel drives (see 'selfstore/backups'). */
2266
2386
  get manager() {
@@ -3275,14 +3395,14 @@ var SelfstoreBackupsElement = class extends FlowWidget {
3275
3395
  var pillStyle = "display:inline-block;font-size:0.72em;font-weight:600;line-height:1.6;padding:0 0.55em;border-radius:999px;border:1px solid var(--_border)";
3276
3396
 
3277
3397
  // src/widgets/gate.ts
3278
- var EN6 = {
3398
+ var EN7 = {
3279
3399
  "gate.title": "Where should your data live?",
3280
3400
  "gate.hint": "It is encrypted on this device. Choose where to keep it and it gets saved there for you from now on.",
3281
3401
  "gate.fine": "",
3282
3402
  "gate.defer": "Later - keep it in this browser only",
3283
3403
  "gate.defer.note": "Data kept only in this browser is lost if its storage is cleared."
3284
3404
  };
3285
- var FR6 = {
3405
+ var FR7 = {
3286
3406
  "gate.title": "O\xF9 enregistrer vos donn\xE9es ?",
3287
3407
  "gate.hint": "Elles sont chiffr\xE9es sur cet appareil. Choisissez une sauvegarde : tout y sera enregistr\xE9 automatiquement.",
3288
3408
  "gate.fine": "",
@@ -3346,10 +3466,10 @@ var SelfstoreGateElement = class extends FlowWidget {
3346
3466
  #open = false;
3347
3467
  #connect = null;
3348
3468
  defaults() {
3349
- return EN6;
3469
+ return EN7;
3350
3470
  }
3351
3471
  packs() {
3352
- return { fr: FR6 };
3472
+ return { fr: FR7 };
3353
3473
  }
3354
3474
  /** The simple store (anything exposing `flowHost`), or a hand-built FlowHost. */
3355
3475
  get store() {
@@ -3448,16 +3568,18 @@ var SelfstoreGateElement = class extends FlowWidget {
3448
3568
  super();
3449
3569
  this.root.append(h("style", {}, GATE_STYLES));
3450
3570
  }
3451
- attributeChangedCallback(name, _old, value) {
3452
- if (name === "armed") this.armed = value !== "false";
3453
- if (name === "deferrable") this.deferrable = value !== "false";
3571
+ attributeChangedCallback(name) {
3572
+ const value = this.flag(name);
3573
+ if (value === null) return;
3574
+ if (name === "armed") this.armed = value;
3575
+ if (name === "deferrable") this.deferrable = value;
3454
3576
  }
3455
3577
  connectedCallback() {
3456
3578
  super.connectedCallback();
3457
- if (this.hasAttribute("armed")) this.armed = this.getAttribute("armed") !== "false";
3458
- if (this.hasAttribute("deferrable")) {
3459
- this.deferrable = this.getAttribute("deferrable") !== "false";
3460
- }
3579
+ const armed = this.flag("armed");
3580
+ if (armed !== null) this.armed = armed;
3581
+ const deferrable = this.flag("deferrable");
3582
+ if (deferrable !== null) this.deferrable = deferrable;
3461
3583
  this.wire();
3462
3584
  }
3463
3585
  host() {
@@ -3584,14 +3706,13 @@ var SelfstoreGateElement = class extends FlowWidget {
3584
3706
  };
3585
3707
 
3586
3708
  // src/widgets/destination.ts
3587
- var EN7 = {
3709
+ var EN8 = {
3710
+ ...EN4,
3588
3711
  "destination.heading": "Where your data is kept",
3589
- "destination.kind.drive": "Google Drive",
3590
- "destination.kind.file": "A file on this device",
3591
- "destination.kind.webdav": "Your own server",
3592
- "destination.kind.s3": "Your own bucket",
3593
- "destination.kind.device": "This device only",
3594
3712
  "destination.encrypted": "Password-protected",
3713
+ "destination.active": "Active",
3714
+ "destination.reach.drive": "On all your devices",
3715
+ "destination.reach.file": "",
3595
3716
  "destination.export": "Export a copy",
3596
3717
  "destination.exported": "The copy is written.",
3597
3718
  "destination.exportCancelled": "Nothing was written.",
@@ -3602,13 +3723,13 @@ var EN7 = {
3602
3723
  "destination.back": "Back",
3603
3724
  "error.generic": "Something went wrong."
3604
3725
  };
3605
- var FR7 = {
3726
+ var FR8 = {
3727
+ ...FR4,
3606
3728
  "destination.heading": "O\xF9 vos donn\xE9es sont enregistr\xE9es",
3607
- "destination.kind.file": "Un fichier sur cet appareil",
3608
- "destination.kind.webdav": "Votre serveur",
3609
- "destination.kind.s3": "Votre bucket",
3610
- "destination.kind.device": "Cet appareil seulement",
3611
3729
  "destination.encrypted": "Prot\xE9g\xE9e par un mot de passe",
3730
+ "destination.active": "Active",
3731
+ "destination.reach.drive": "Sur tous vos appareils",
3732
+ "destination.reach.file": "",
3612
3733
  "destination.export": "Exporter une copie",
3613
3734
  "destination.exported": "La copie est \xE9crite.",
3614
3735
  "destination.exportCancelled": "Rien n'a \xE9t\xE9 \xE9crit.",
@@ -3635,38 +3756,23 @@ var DESTINATION_STYLES = `
3635
3756
  font-weight: 600;
3636
3757
  }
3637
3758
  `;
3638
- function siblingTag(own, name) {
3639
- const suffix = "-destination";
3640
- return own.endsWith(suffix) ? `${own.slice(0, -suffix.length)}-${name}` : `selfstore-${name}`;
3641
- }
3642
3759
  var SelfstoreDestinationElement = class extends FlowWidget {
3643
- #store = null;
3644
3760
  #targets = null;
3645
3761
  #account = null;
3646
- #icons = {};
3647
3762
  #confirm = null;
3648
3763
  #changing = false;
3649
3764
  #busy = false;
3650
3765
  #message = null;
3651
3766
  defaults() {
3652
- return EN7;
3767
+ return EN8;
3653
3768
  }
3654
3769
  packs() {
3655
- return { fr: FR7 };
3770
+ return { fr: FR8 };
3656
3771
  }
3657
3772
  constructor() {
3658
3773
  super();
3659
3774
  this.root.append(h("style", {}, DESTINATION_STYLES));
3660
3775
  }
3661
- /** The simple store (anything exposing `flowHost`), or a hand-built FlowHost. */
3662
- get store() {
3663
- return this.#store;
3664
- }
3665
- set store(v) {
3666
- if (v === this.#store) return;
3667
- this.#store = v;
3668
- this.wire();
3669
- }
3670
3776
  /** The destinations offered when the user changes their mind. Given them, the
3671
3777
  * panel runs the connect journey in place; without them the button is absent
3672
3778
  * rather than dead - a host that cannot offer a choice should not show one. */
@@ -3687,14 +3793,6 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3687
3793
  this.#account = v || null;
3688
3794
  this.rerender();
3689
3795
  }
3690
- /** Optional glyph per destination kind, same contract as the connect cards. */
3691
- get icons() {
3692
- return this.#icons;
3693
- }
3694
- set icons(v) {
3695
- this.#icons = v ?? {};
3696
- this.rerender();
3697
- }
3698
3796
  /** Veto on what is destructive. Returns false (or throws) to stop; absent
3699
3797
  * means proceed. The panel never invents its own confirm dialog: an app has
3700
3798
  * its own words and its own modal. */
@@ -3706,13 +3804,7 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3706
3804
  }
3707
3805
  connectedCallback() {
3708
3806
  super.connectedCallback();
3709
- this.wire();
3710
- }
3711
- host() {
3712
- return hostOf(this.#store);
3713
- }
3714
- wire() {
3715
- this.follow(this.host());
3807
+ this.follow(this.hostOf());
3716
3808
  }
3717
3809
  /** Run a gesture, keeping the panel honest while it is in flight. */
3718
3810
  async run(job) {
@@ -3732,7 +3824,7 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3732
3824
  * nothing, so it must not clear the pending-export nudge nor claim success -
3733
3825
  * that is the download that lies about itself. */
3734
3826
  exportCopy() {
3735
- const host = this.host();
3827
+ const host = this.hostOf();
3736
3828
  if (!host) return Promise.resolve();
3737
3829
  return this.run(async () => {
3738
3830
  const blob = await host.engine.exportBlob();
@@ -3745,7 +3837,7 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3745
3837
  /** Stop saving to this destination. The local data stays, and so does the
3746
3838
  * backup already written: this detaches, it never deletes. */
3747
3839
  detach(label) {
3748
- const host = this.host();
3840
+ const host = this.hostOf();
3749
3841
  if (!host) return Promise.resolve();
3750
3842
  return this.run(async () => {
3751
3843
  try {
@@ -3763,11 +3855,11 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3763
3855
  * tested ones rather than a second implementation. */
3764
3856
  connectView(into) {
3765
3857
  const el = document.createElement(
3766
- siblingTag(this.localName, "connect")
3858
+ siblingTag(this.localName, "destination", "connect")
3767
3859
  );
3768
- el.store = this.#store;
3860
+ el.store = this.store;
3769
3861
  el.targets = this.#targets;
3770
- el.icons = this.#icons;
3862
+ el.icons = this.icons;
3771
3863
  el.labels = this.labels;
3772
3864
  const done = () => {
3773
3865
  this.#changing = false;
@@ -3786,19 +3878,21 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3786
3878
  );
3787
3879
  }
3788
3880
  view(into) {
3789
- const host = this.host();
3881
+ const host = this.hostOf();
3790
3882
  if (!host) return;
3791
3883
  if (this.#changing && this.#targets) return this.connectView(into);
3792
3884
  const { targetKind, label, encrypted } = host.engine.state;
3793
3885
  const kind = this.t(`destination.kind.${targetKind}`);
3794
3886
  const title = kind === `destination.kind.${targetKind}` ? label ?? targetKind : kind;
3795
- const sub = [this.#account, label && label !== title ? label : null].filter(Boolean).join(" \xB7 ");
3796
- const icon = this.#icons[targetKind];
3797
- const status = document.createElement(siblingTag(this.localName, "status"));
3887
+ const sub = this.#account || (label && label !== title ? label : null);
3888
+ const icon = this.icons[targetKind];
3889
+ const status = document.createElement(
3890
+ siblingTag(this.localName, "destination", "status")
3891
+ );
3798
3892
  status.setAttribute("variant", "row");
3799
3893
  status.labels = this.labels;
3800
- status.icons = this.#icons;
3801
- status.store = this.#store;
3894
+ status.icons = this.icons;
3895
+ status.store = this.store;
3802
3896
  const act = (key, part, onclick) => {
3803
3897
  const label2 = this.t(key);
3804
3898
  if (!label2) return null;
@@ -3808,8 +3902,14 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3808
3902
  label2
3809
3903
  );
3810
3904
  };
3905
+ const durable = targetKind !== "device" && targetKind !== "ephemeral";
3906
+ const reach = this.t(`destination.reach.${targetKind}`);
3907
+ const line = [sub, reach === `destination.reach.${targetKind}` ? null : reach].filter(Boolean).join(" \xB7 ");
3811
3908
  put(
3812
3909
  into,
3910
+ // The situation first, then what holds it: a panel that opens on a card
3911
+ // makes the reader work out from the name whether anything is wrong.
3912
+ status,
3813
3913
  this.heading("destination-heading", "destination.heading"),
3814
3914
  h(
3815
3915
  "div",
@@ -3818,12 +3918,18 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3818
3918
  h(
3819
3919
  "div",
3820
3920
  {},
3821
- h("div", { part: "title" }, title),
3822
- sub ? h("div", { part: "sub" }, sub) : null,
3823
- encrypted ? h("div", { part: "sub" }, this.t("destination.encrypted")) : null
3921
+ h(
3922
+ "div",
3923
+ { part: "title" },
3924
+ title,
3925
+ // Only where there IS a backup: "Active" over the device-only
3926
+ // state would badge the absence of one as a live destination.
3927
+ durable ? h("span", { part: "tag destination-active" }, this.t("destination.active")) : null,
3928
+ encrypted ? h("span", { part: "tag destination-encrypted" }, this.t("destination.encrypted")) : null
3929
+ ),
3930
+ line ? h("div", { part: "sub" }, line) : null
3824
3931
  )
3825
3932
  ),
3826
- status,
3827
3933
  h(
3828
3934
  "div",
3829
3935
  { part: "destination-actions" },
@@ -3847,10 +3953,6 @@ var SelfstoreDestinationElement = class extends FlowWidget {
3847
3953
  };
3848
3954
 
3849
3955
  // src/widgets/storage.ts
3850
- function siblingTag2(own, name) {
3851
- const suffix = "-storage";
3852
- return own.endsWith(suffix) ? `${own.slice(0, -suffix.length)}-${name}` : `selfstore-${name}`;
3853
- }
3854
3956
  var SelfstoreStorageElement = class extends FlowWidget {
3855
3957
  static get observedAttributes() {
3856
3958
  return ["deferrable", "recommended"];
@@ -3928,16 +4030,22 @@ var SelfstoreStorageElement = class extends FlowWidget {
3928
4030
  this.#recommended = v;
3929
4031
  this.rerender();
3930
4032
  }
3931
- attributeChangedCallback(name, _old, value) {
3932
- if (name === "deferrable") this.deferrable = value !== "false";
3933
- if (name === "recommended") this.recommended = value;
4033
+ attributeChangedCallback(name) {
4034
+ if (name === "deferrable") {
4035
+ const value = this.flag(name);
4036
+ if (value !== null) this.deferrable = value;
4037
+ }
4038
+ if (name === "recommended") {
4039
+ const value = this.attr(name);
4040
+ if (value !== null) this.recommended = value;
4041
+ }
3934
4042
  }
3935
4043
  connectedCallback() {
3936
4044
  super.connectedCallback();
3937
- if (this.hasAttribute("deferrable"))
3938
- this.deferrable = this.getAttribute("deferrable") !== "false";
3939
- if (this.hasAttribute("recommended"))
3940
- this.recommended = this.getAttribute("recommended");
4045
+ const deferrable = this.flag("deferrable");
4046
+ if (deferrable !== null) this.deferrable = deferrable;
4047
+ const recommended = this.attr("recommended");
4048
+ if (recommended !== null) this.recommended = recommended;
3941
4049
  this.follow(hostOf(this.#store));
3942
4050
  }
3943
4051
  host() {
@@ -3955,7 +4063,7 @@ var SelfstoreStorageElement = class extends FlowWidget {
3955
4063
  const { ready, status } = host.engine.state;
3956
4064
  const wanted = !ready || status.action === "choose-destination" ? "gate" : "destination";
3957
4065
  if (this.#childKind !== wanted || !this.#child) {
3958
- this.#child = document.createElement(siblingTag2(this.localName, wanted));
4066
+ this.#child = document.createElement(siblingTag(this.localName, "storage", wanted));
3959
4067
  this.#childKind = wanted;
3960
4068
  }
3961
4069
  const el = this.#child;
@@ -3973,7 +4081,7 @@ var SelfstoreStorageElement = class extends FlowWidget {
3973
4081
  el.confirmAction = this.#confirm;
3974
4082
  }
3975
4083
  el.store = this.#store;
3976
- const list = wanted === "destination" && this.#manager ? Object.assign(document.createElement(siblingTag2(this.localName, "backups")), {
4084
+ const list = wanted === "destination" && this.#manager ? Object.assign(document.createElement(siblingTag(this.localName, "storage", "backups")), {
3977
4085
  labels: this.labels,
3978
4086
  manager: this.#manager
3979
4087
  }) : null;
@@ -3981,6 +4089,341 @@ var SelfstoreStorageElement = class extends FlowWidget {
3981
4089
  }
3982
4090
  };
3983
4091
 
4092
+ // src/widgets/account.ts
4093
+ var EN9 = {
4094
+ ...EN4,
4095
+ "account.open": "Storage",
4096
+ "account.settings": "Settings",
4097
+ "account.change": "Change backup",
4098
+ "account.saved": "Saved {when}",
4099
+ "account.justNow": "just now"
4100
+ };
4101
+ var FR9 = {
4102
+ ...FR4,
4103
+ "account.open": "Sauvegarde",
4104
+ "account.settings": "Param\xE8tres",
4105
+ "account.change": "Changer de sauvegarde",
4106
+ "account.saved": "Enregistr\xE9 {when}",
4107
+ "account.justNow": "\xE0 l'instant"
4108
+ };
4109
+ var ACCOUNT_STYLES = `
4110
+ :host { position: relative; display: inline-block; }
4111
+ /* Every other widget fills the width it is given, so the shared stack declares
4112
+ itself a size container (that is what lets a card lay out against ITS width
4113
+ and not the page's). This one is a control in a header: it must be as wide as
4114
+ its own label. Inline-size containment makes a box report an intrinsic width
4115
+ of ZERO - the pill collapsed to its dot in a flex header, with the label
4116
+ overflowing off screen. Nothing here queries its own width. */
4117
+ [part~='stack'] { container-type: normal; display: block; }
4118
+ [part~='account-trigger'] {
4119
+ display: inline-flex;
4120
+ align-items: center;
4121
+ gap: 0.4rem;
4122
+ max-width: 22rem;
4123
+ overflow: hidden;
4124
+ white-space: nowrap;
4125
+ text-overflow: ellipsis;
4126
+ font: inherit;
4127
+ color: inherit;
4128
+ background: transparent;
4129
+ border: 1px solid var(--_border);
4130
+ border-radius: 999px;
4131
+ padding: 0.3rem 0.7rem;
4132
+ cursor: pointer;
4133
+ }
4134
+ [part~='account-dot'] {
4135
+ width: 0.5rem;
4136
+ height: 0.5rem;
4137
+ border-radius: 50%;
4138
+ background: currentColor;
4139
+ flex: none;
4140
+ }
4141
+ [part~='account-menu'] {
4142
+ position: absolute;
4143
+ right: 0;
4144
+ top: calc(100% + 0.4rem);
4145
+ z-index: 20;
4146
+ min-width: min(17rem, calc(100vw - 2rem));
4147
+ max-width: min(24rem, calc(100vw - 2rem));
4148
+ display: flex;
4149
+ flex-direction: column;
4150
+ align-items: stretch;
4151
+ gap: 0.25rem;
4152
+ text-align: start;
4153
+ padding: 0.4rem;
4154
+ background: var(--selfstore-surface, Canvas);
4155
+ box-shadow: var(--selfstore-shadow, 0 10px 30px rgb(0 0 0 / 0.14));
4156
+ }
4157
+ [part~='account-card'] {
4158
+ display: flex;
4159
+ align-items: flex-start;
4160
+ gap: 0.6rem;
4161
+ width: 100%;
4162
+ font: inherit;
4163
+ color: inherit;
4164
+ text-align: start;
4165
+ background: transparent;
4166
+ border: none;
4167
+ border-radius: calc(var(--_radius) * 0.75);
4168
+ padding: 0.5rem 0.6rem;
4169
+ cursor: pointer;
4170
+ }
4171
+ [part~='account-card']:hover { background: color-mix(in srgb, currentColor 7%, transparent); }
4172
+ [part~='account-logo'] { width: 1.4rem; height: 1.4rem; object-fit: contain; flex: none; }
4173
+ [part~='account-text'] {
4174
+ display: flex;
4175
+ flex-direction: column;
4176
+ gap: 0.15rem;
4177
+ min-width: 0;
4178
+ line-height: 1.35;
4179
+ }
4180
+ [part~='account-title'] {
4181
+ font-weight: 600;
4182
+ overflow: hidden;
4183
+ text-overflow: ellipsis;
4184
+ white-space: nowrap;
4185
+ }
4186
+ /* An address is long and its END is the part that identifies it, so it gets a
4187
+ line of its own and an ellipsis rather than a wrap that pushes the status
4188
+ line out of the card. */
4189
+ [part~='account-mail'], [part~='account-line'] {
4190
+ font-size: 0.8125rem;
4191
+ color: var(--_ink-dim);
4192
+ overflow: hidden;
4193
+ text-overflow: ellipsis;
4194
+ white-space: nowrap;
4195
+ }
4196
+ [part~='account-item'] {
4197
+ text-align: start;
4198
+ padding: 0.4rem 0.6rem;
4199
+ border-radius: calc(var(--_radius) * 0.75);
4200
+ text-decoration: none;
4201
+ color: inherit;
4202
+ }
4203
+ [part~='account-item']:hover { background: color-mix(in srgb, currentColor 7%, transparent); }
4204
+ [part~='account-sep'] { height: 1px; background: var(--_border); margin: 0.2rem 0.3rem; }
4205
+ `;
4206
+ var TICK_MS = 3e4;
4207
+ var SelfstoreAccountElement = class extends FlowWidget {
4208
+ #confirm = null;
4209
+ #open = false;
4210
+ #timer = null;
4211
+ #away = null;
4212
+ defaults() {
4213
+ return EN9;
4214
+ }
4215
+ packs() {
4216
+ return { fr: FR9 };
4217
+ }
4218
+ constructor() {
4219
+ super();
4220
+ this.root.append(h("style", {}, ACCOUNT_STYLES));
4221
+ }
4222
+ /** Who holds the backup ("someone@example.com"). Absent: whatever the store
4223
+ * knows. A brand name is not an address, and several accounts look alike. */
4224
+ #account = null;
4225
+ get account() {
4226
+ return this.#account ?? this.store?.account ?? null;
4227
+ }
4228
+ set account(v) {
4229
+ this.#account = v || null;
4230
+ this.rerender();
4231
+ }
4232
+ /** Veto on changing backup, which leaves the app with no destination until
4233
+ * the user picks one. Returns false (or throws) to stop. */
4234
+ get confirmAction() {
4235
+ return this.#confirm;
4236
+ }
4237
+ set confirmAction(fn) {
4238
+ this.#confirm = fn;
4239
+ }
4240
+ /** Whether the menu is on screen. Settable, so a host can close it from its
4241
+ * own router after the settings item navigated away. */
4242
+ get open() {
4243
+ return this.#open;
4244
+ }
4245
+ set open(v) {
4246
+ if (v === this.#open) return;
4247
+ this.#open = v;
4248
+ this.watch();
4249
+ this.rerender();
4250
+ }
4251
+ connectedCallback() {
4252
+ super.connectedCallback();
4253
+ this.follow(this.hostOf());
4254
+ }
4255
+ disconnectedCallback() {
4256
+ this.unsub?.();
4257
+ this.unsub = null;
4258
+ this.#open = false;
4259
+ this.watch();
4260
+ }
4261
+ /** While the menu is open: a clock for the relative time, and the two ways
4262
+ * out that make it a menu rather than a page - a click anywhere else, and
4263
+ * Escape. `pointerdown` and not `click`, so the gesture that closes it does
4264
+ * not also land on whatever was under the menu. Shadow DOM retargets the
4265
+ * event, so what is asked is the composed path, never `e.target`. */
4266
+ watch() {
4267
+ if (this.#timer) clearInterval(this.#timer);
4268
+ this.#timer = null;
4269
+ if (this.#away) {
4270
+ document.removeEventListener("pointerdown", this.#away, true);
4271
+ document.removeEventListener("keydown", this.#away, true);
4272
+ this.#away = null;
4273
+ }
4274
+ if (!this.#open || !this.isConnected) return;
4275
+ this.#timer = setInterval(() => this.rerender(), TICK_MS);
4276
+ this.#away = (e) => {
4277
+ if (e instanceof KeyboardEvent) {
4278
+ if (e.key === "Escape") this.open = false;
4279
+ return;
4280
+ }
4281
+ if (!e.composedPath().includes(this)) this.open = false;
4282
+ };
4283
+ document.addEventListener("pointerdown", this.#away, true);
4284
+ document.addEventListener("keydown", this.#away, true);
4285
+ }
4286
+ /** The destination's name. An unknown kind (a host's own target) has no
4287
+ * shipped name: its own label is a better answer than the raw key. */
4288
+ named(host) {
4289
+ const { targetKind, label } = host.engine.state;
4290
+ const kind = this.t(`destination.kind.${targetKind}`);
4291
+ return kind === `destination.kind.${targetKind}` ? label ?? targetKind : kind;
4292
+ }
4293
+ /**
4294
+ * The one line under the account. A plain "saved" is the only state this
4295
+ * element words itself, because it is the only one with nothing to do about
4296
+ * it - and the only one where WHEN is the whole information. Everything else
4297
+ * (reconnect, unlock, changes to download) belongs to <selfstore-status>,
4298
+ * which owns both the wording and the button that resolves it.
4299
+ */
4300
+ line(host) {
4301
+ const { status, lastSavedAt } = host.engine.state;
4302
+ if (status.state === "saved" && lastSavedAt && !("status.saved" in this.labels))
4303
+ return h(
4304
+ "span",
4305
+ { part: "account-line" },
4306
+ this.t("account.saved", { when: this.since(lastSavedAt, this.t("account.justNow")) })
4307
+ );
4308
+ const el = document.createElement(
4309
+ siblingTag(this.localName, "account", "status")
4310
+ );
4311
+ el.setAttribute("variant", "row");
4312
+ el.labels = this.labels;
4313
+ el.icons = this.icons;
4314
+ el.store = this.store;
4315
+ return el;
4316
+ }
4317
+ /** Ask for the settings. The host owns the route, so this only says that it
4318
+ * was asked - and closes, because a menu that stays open behind a page
4319
+ * transition reads as a failed click. */
4320
+ settings() {
4321
+ this.open = false;
4322
+ this.emit("selfstore-account-settings");
4323
+ }
4324
+ /** Change backup: detach, and let the first-run screen take it from there.
4325
+ * The data stays; only the destination is let go. */
4326
+ async change() {
4327
+ const host = this.hostOf();
4328
+ if (!host) return;
4329
+ this.open = false;
4330
+ const label = host.engine.state.label;
4331
+ try {
4332
+ if (this.#confirm && !await this.#confirm({ type: "detach", label })) return;
4333
+ } catch {
4334
+ return;
4335
+ }
4336
+ await host.engine.detachTarget();
4337
+ }
4338
+ card(host) {
4339
+ const { targetKind } = host.engine.state;
4340
+ const icon = this.icons[targetKind];
4341
+ const mail = this.account;
4342
+ return h(
4343
+ "button",
4344
+ {
4345
+ part: "account-card",
4346
+ type: "button",
4347
+ role: "menuitem",
4348
+ title: this.t("account.settings"),
4349
+ onclick: () => this.settings()
4350
+ },
4351
+ icon ? h("img", { part: "account-logo", src: icon, alt: "" }) : null,
4352
+ h(
4353
+ "span",
4354
+ { part: "account-text" },
4355
+ h("span", { part: "account-title" }, this.named(host)),
4356
+ mail ? h("span", { part: "account-mail" }, mail) : null,
4357
+ this.line(host)
4358
+ )
4359
+ );
4360
+ }
4361
+ view(into) {
4362
+ const host = this.hostOf();
4363
+ if (!host) return;
4364
+ const { status } = host.engine.state;
4365
+ const trigger = h(
4366
+ "button",
4367
+ {
4368
+ part: `account-trigger ${status.severity}`,
4369
+ type: "button",
4370
+ "aria-haspopup": "menu",
4371
+ "aria-expanded": String(this.#open),
4372
+ "aria-label": this.t("account.open"),
4373
+ onclick: () => this.open = !this.#open
4374
+ },
4375
+ // The severity colors the DOT, never the label: a whole line turning
4376
+ // amber reads as a warning about the destination's name.
4377
+ h("span", { part: `account-dot sev-${status.severity}` }),
4378
+ h("span", {}, this.named(host))
4379
+ );
4380
+ const menu = this.#open ? h(
4381
+ "div",
4382
+ { part: "card account-menu", role: "menu" },
4383
+ this.card(host),
4384
+ h("div", { part: "account-sep" }),
4385
+ h(
4386
+ "button",
4387
+ {
4388
+ part: "link account-item",
4389
+ type: "button",
4390
+ role: "menuitem",
4391
+ onclick: () => this.settings()
4392
+ },
4393
+ this.t("account.settings")
4394
+ ),
4395
+ h(
4396
+ "button",
4397
+ {
4398
+ part: "link account-item account-change",
4399
+ type: "button",
4400
+ role: "menuitem",
4401
+ onclick: () => void this.change()
4402
+ },
4403
+ this.t("account.change")
4404
+ )
4405
+ ) : null;
4406
+ put(into, trigger, menu);
4407
+ if (menu) this.anchor(menu);
4408
+ }
4409
+ /**
4410
+ * Which side the menu hangs from.
4411
+ *
4412
+ * A header control is usually at the right edge, so the menu hangs right -
4413
+ * and the moment the header wraps on a narrow window, that same control sits
4414
+ * at the LEFT edge and the menu hangs off the screen. Measured rather than
4415
+ * assumed: it hangs left when there is room to the right, and right
4416
+ * otherwise, which lands correctly at both edges without the host declaring
4417
+ * where it put the control.
4418
+ */
4419
+ anchor(menu) {
4420
+ const here = this.getBoundingClientRect();
4421
+ const fits = here.left + menu.getBoundingClientRect().width <= window.innerWidth - 8;
4422
+ menu.style.left = fits ? "0" : "auto";
4423
+ menu.style.right = fits ? "auto" : "0";
4424
+ }
4425
+ };
4426
+
3984
4427
  // src/entries/widgets.ts
3985
4428
  function defineSelfstoreWidgets(prefix = "selfstore") {
3986
4429
  const define = (name, ctor) => {
@@ -3994,8 +4437,9 @@ function defineSelfstoreWidgets(prefix = "selfstore") {
3994
4437
  define(`${prefix}-gate`, SelfstoreGateElement);
3995
4438
  define(`${prefix}-destination`, SelfstoreDestinationElement);
3996
4439
  define(`${prefix}-storage`, SelfstoreStorageElement);
4440
+ define(`${prefix}-account`, SelfstoreAccountElement);
3997
4441
  }
3998
4442
 
3999
- export { FlowWidget, SelfstoreBackupsElement, SelfstoreConnectElement, SelfstoreDestinationElement, SelfstoreGateElement, SelfstoreJoinElement, SelfstoreShareElement, SelfstoreStatusElement, SelfstoreStorageElement, defineSelfstoreWidgets };
4443
+ export { FlowWidget, SelfstoreAccountElement, SelfstoreBackupsElement, SelfstoreConnectElement, SelfstoreDestinationElement, SelfstoreGateElement, SelfstoreJoinElement, SelfstoreShareElement, SelfstoreStatusElement, SelfstoreStorageElement, defineSelfstoreWidgets };
4000
4444
  //# sourceMappingURL=widgets.js.map
4001
4445
  //# sourceMappingURL=widgets.js.map