@bkazemi/verily 0.5.0 → 0.7.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.
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalId, externalLink, externalName, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, externalSince, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
2
2
  import { markStyles, verificationMark } from './mark.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { onSignatureResult, signature, standing } from './signed.js';
@@ -19,8 +19,16 @@ export const styles = `
19
19
  h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }
20
20
  button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }
21
21
  button:hover { background: #f2f5f1; }
22
+ .icon { display: grid; place-items: center; padding: 0; }
23
+ .icon svg { width: 16px; height: 16px; }
24
+ .title { display: flex; align-items: center; gap: 6px; min-width: 0; }
25
+ .all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }
26
+ .all:hover { background: #f2f5f1; }
27
+ .all svg { width: 16px; height: 16px; }
22
28
  a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }
23
- a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }
29
+ a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }
30
+ /* An element given its own display would otherwise go on showing while hidden. */
31
+ [hidden] { display: none !important; }
24
32
  .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
25
33
  .mark { width: 36px; height: 36px; flex-shrink: 0; }
26
34
  .provider { width: 14px; height: 14px; }
@@ -32,24 +40,45 @@ export const styles = `
32
40
  .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
33
41
  @keyframes spin { to { transform: rotate(360deg); } }
34
42
  @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
35
- .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }
36
- .caution svg { display: block; width: 12px; height: 12px; }
37
- .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
43
+ /* The "signed" pill after the verifier's name, one size smaller than the line it sits on. */
44
+ .summary .signed { display: inline-block; margin-left: 5px; padding: 0 6px; border: 1px solid #cfdcd3; border-radius: 999px; font-size: 10px; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: 1px; }
45
+ .summary a.signed:hover { background: #f2f5f1; }
46
+ .summary .signed.bad { border-color: #b3261e; color: #b3261e; font-weight: 650; cursor: help; }
47
+ .caution { display: inline-block; margin-left: 3px; color: #a15c00; vertical-align: -1.5px; }
48
+ .caution svg { display: block; width: 10px; height: 10px; }
38
49
  .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
39
50
  .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }
40
51
  .account a, .account strong { font-weight: 650; font-size: 14px; }
41
52
  /* The account's own name carries that weight; a link inside a line of prose does not. */
42
53
  .summary a, .method a, .additional a { font: inherit; }
43
54
  .reference { margin-top: 2px; }
55
+ /* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */
56
+ .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
57
+ .fingerprint span { display: inline-block; margin-right: .5ch; }
44
58
  .method { margin-top: 8px; }
45
59
  /* In the corner the heading leaves empty, apart from everything that was checked. */
46
60
  .listing { position: absolute; top: 12px; right: 14px; padding: 1px 8px; border: 1px solid #e0e6df; border-radius: 999px; color: #6b786f; font-size: 11px; font-weight: 550; }
47
61
  .listing.preferred { border-color: #245f43; color: #245f43; }
48
62
  /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */
49
63
  .account.retired { background: #f6f8f5; }
50
- .account.retired > a, .account.retired > strong { color: #6b786f; }
64
+ .account.retired > a, .account.retired > strong, .account.retired > .named > a, .account.retired > .named > strong { color: #6b786f; }
65
+ /* The name, then a dot and the account's creation date. The date wraps under a long name. */
66
+ /* Clipped 4px left of the text, which hides the dot when the date wraps to a new line. */
67
+ .named { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; margin-left: -4px; padding-left: 4px; overflow-x: clip; }
68
+ .since { position: relative; white-space: nowrap; }
69
+ /* The dot sits in the gap before the date. */
70
+ .since::before { content: "·"; position: absolute; left: -7px; transform: translateX(-50%); }
51
71
  .account.retired .mark { opacity: .45; }
52
72
  .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
73
+ summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }
74
+ summary::-webkit-details-marker { display: none; }
75
+ .fold { display: flex; align-items: center; gap: 4px; width: auto; height: auto; padding: 2px 8px 2px 4px; margin-left: -4px; border: 0; background: none; font: 550 12px/1.6 system-ui, sans-serif; }
76
+ .fold svg, summary svg { width: 12px; height: 12px; }
77
+ .fold[aria-expanded="true"] svg, details[open] > summary svg { transform: rotate(90deg); }
78
+ @media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }
79
+ /* The fold and what the holder may do share a line, until a question needs the whole of it. */
80
+ .bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
81
+ .account .bar .row { margin: 0 0 0 auto; }
53
82
  .additional { margin-top: 2px; padding-left: 12px; }
54
83
  .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
55
84
  dl { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid #e5e9e3; display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; font-size: 11px; }
@@ -64,6 +93,14 @@ export const styles = `
64
93
  .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
65
94
  .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }
66
95
  .account .action { padding: 5px 10px; font-size: 12px; }
96
+ .account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }
97
+ .dots svg { width: 16px; height: 16px; }
98
+ /* Placed under its button each time it opens, above the dialog and clipped by none of it. */
99
+ .menu { position: fixed; inset: auto; margin: 0; min-width: 170px; padding: 6px; border: 1px solid #dce2de; border-radius: 10px; background: #fff; color: #23312b; box-shadow: 0 12px 40px #10201930; }
100
+ .menu:popover-open { display: grid; }
101
+ .account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }
102
+ .menu .danger { color: #b3261e; }
103
+ .menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }
67
104
  footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }
68
105
  footer a { display: flex; color: inherit; }
69
106
  .logo { display: block; height: 13px; }
@@ -86,6 +123,69 @@ export function outward(anchor, url) {
86
123
  anchor.target = '_blank';
87
124
  return anchor;
88
125
  }
126
+ /**
127
+ * Where the verifier lists every public record of a record's subject. A record whose
128
+ * address is not the usual one is left linking to itself.
129
+ */
130
+ export function subjectUrl(e) {
131
+ const url = new URL(e.evidenceUrl);
132
+ const path = url.pathname.replace(/\/connections\/[^/]+$/, '/published');
133
+ if (path === url.pathname)
134
+ return e.evidenceUrl;
135
+ url.pathname = path;
136
+ url.search = `site=${encodeURIComponent(e.siteName)}&reference=${encodeURIComponent(e.local.reference)}`;
137
+ return url.href;
138
+ }
139
+ /** Opens somewhere else, in a tab of its own: a box with an arrow leaving it. */
140
+ function awayMark() {
141
+ const namespace = 'http://www.w3.org/2000/svg';
142
+ const svg = document.createElementNS(namespace, 'svg');
143
+ svg.setAttribute('viewBox', '0 0 24 24');
144
+ svg.setAttribute('fill', 'none');
145
+ svg.setAttribute('aria-hidden', 'true');
146
+ svg.setAttribute('focusable', 'false');
147
+ for (const d of [
148
+ 'M14 5h5v5',
149
+ 'M19 5l-8 8',
150
+ 'M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4',
151
+ ]) {
152
+ const path = document.createElementNS(namespace, 'path');
153
+ path.setAttribute('d', d);
154
+ path.setAttribute('stroke', 'currentColor');
155
+ path.setAttribute('stroke-width', '2');
156
+ path.setAttribute('stroke-linecap', 'round');
157
+ path.setAttribute('stroke-linejoin', 'round');
158
+ svg.append(path);
159
+ }
160
+ return svg;
161
+ }
162
+ /** A small mark of plain strokes, in the surrounding colour. */
163
+ function strokes(box, width, paths) {
164
+ const namespace = 'http://www.w3.org/2000/svg';
165
+ const svg = document.createElementNS(namespace, 'svg');
166
+ svg.setAttribute('viewBox', `0 0 ${box} ${box}`);
167
+ svg.setAttribute('fill', 'none');
168
+ svg.setAttribute('aria-hidden', 'true');
169
+ svg.setAttribute('focusable', 'false');
170
+ for (const d of paths) {
171
+ const path = document.createElementNS(namespace, 'path');
172
+ path.setAttribute('d', d);
173
+ path.setAttribute('stroke', 'currentColor');
174
+ path.setAttribute('stroke-width', String(width));
175
+ path.setAttribute('stroke-linecap', 'round');
176
+ path.setAttribute('stroke-linejoin', 'round');
177
+ svg.append(path);
178
+ }
179
+ return svg;
180
+ }
181
+ /** Points at what is folded away, and turns to point down once it is open. */
182
+ const foldMark = () => strokes(16, 1.8, ['M6 3.5 10.5 8 6 12.5']);
183
+ /** Three dots: there is more to do here than the buttons beside it. */
184
+ const moreMark = () => strokes(16, 2.4, ['M3 8h.01', 'M8 8h.01', 'M13 8h.01']);
185
+ /** A cross drawn in a square, so it sits in the middle of its button in any font. */
186
+ export const closeMark = () => strokes(16, 1.8, ['M4 4l8 8', 'M12 4l-8 8']);
187
+ /** Points back the way the reader came, drawn for the same reason the cross is. */
188
+ export const backMark = () => strokes(16, 1.8, ['M10 3.5 5.5 8 10 12.5']);
89
189
  /** Joins the two cards: the link itself, drawn rather than described. */
90
190
  export function linkMark() {
91
191
  const namespace = 'http://www.w3.org/2000/svg';
@@ -141,15 +241,41 @@ function moment(time) {
141
241
  * expires on its own terms, and a second provider on the same subject would differ.
142
242
  */
143
243
  export function accountCard(heading, name, reference, url, ...extra) {
244
+ return side(heading, name, reference, url, undefined, extra);
245
+ }
246
+ /**
247
+ * The card for an external account: its name, link and id, with the "Joined <date>" text
248
+ * on the name's line when the creation date is known.
249
+ */
250
+ export function namedCard(heading, external, ...extra) {
251
+ return side(heading, externalName(external),
252
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
253
+ externalIdGroups(external) ?? externalId(external), externalLink(external),
254
+ // In UTC, so the day matches the one the verifier's own pages print.
255
+ externalSince(external, (time) => new Date(time).toLocaleDateString(undefined, { dateStyle: 'medium', timeZone: 'UTC' })), extra);
256
+ }
257
+ function side(heading, name, reference, url, since, extra) {
144
258
  const card = node('section', '', 'account');
145
259
  const title = node('h3');
146
260
  const value = node(url ? 'a' : 'strong', name);
147
261
  title.append(...heading);
148
262
  if (value instanceof HTMLAnchorElement)
149
263
  outward(value, url);
150
- card.append(title, value);
151
- if (reference)
264
+ if (since) {
265
+ // The name and the date share a line, and the date wraps under a long name.
266
+ const named = node('div', '', 'named');
267
+ named.append(value, node('span', since, 'muted since'));
268
+ card.append(title, named);
269
+ }
270
+ else
271
+ card.append(title, value);
272
+ if (typeof reference === 'string')
152
273
  card.append(node('div', reference, 'muted reference'));
274
+ else if (reference) {
275
+ const groups = node('div', '', 'muted reference fingerprint');
276
+ groups.append(...reference.map((group) => node('span', group)));
277
+ card.append(groups);
278
+ }
153
279
  card.append(...extra);
154
280
  return card;
155
281
  }
@@ -161,12 +287,77 @@ const retiringNote = 'Retiring keeps this record as history. It will no longer r
161
287
  * from the status on the card.
162
288
  */
163
289
  const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked' ? [] : [evidence.id]);
290
+ /**
291
+ * Everything a holder may do to an account besides renewing it, behind one button. The
292
+ * menu is a popover, so it is drawn above the dialog and the dialog's scrolling cannot
293
+ * clip it, the browser shuts it on Escape or a press elsewhere, and its button opens and
294
+ * shuts it. Nothing where the browser has no popovers, and the choices are then laid out.
295
+ */
296
+ function actionsMenu(more, choices) {
297
+ const menu = node('div', '', 'menu');
298
+ if (typeof menu.showPopover !== 'function')
299
+ return undefined;
300
+ const label = 'More actions';
301
+ more.className = 'action dots';
302
+ more.title = label;
303
+ more.setAttribute('aria-label', label);
304
+ more.setAttribute('aria-haspopup', 'menu');
305
+ more.append(moreMark());
306
+ more.popoverTargetElement = menu;
307
+ menu.setAttribute('popover', 'auto');
308
+ menu.setAttribute('role', 'menu');
309
+ for (const choice of choices)
310
+ choice.setAttribute('role', 'menuitem');
311
+ // Whatever removes the account sits last and apart from the rest.
312
+ menu.append(...choices.slice(0, -1), node('hr'), choices.at(-1));
313
+ const shut = () => {
314
+ try {
315
+ if (menu.matches(':popover-open'))
316
+ menu.hidePopover();
317
+ }
318
+ catch {
319
+ // Gone from the page with its card.
320
+ }
321
+ };
322
+ const gap = 4;
323
+ const edge = 8;
324
+ // Under the button and ending where it ends, before it is first painted.
325
+ menu.addEventListener('beforetoggle', (event) => {
326
+ if (event.newState !== 'open')
327
+ return;
328
+ const at = more.getBoundingClientRect();
329
+ menu.style.top = `${at.bottom + gap}px`;
330
+ menu.style.right = `${document.documentElement.clientWidth - at.right}px`;
331
+ });
332
+ menu.addEventListener('toggle', (event) => {
333
+ if (event.newState !== 'open')
334
+ return;
335
+ // Over the button where the window ends before the menu does.
336
+ const { height, bottom } = menu.getBoundingClientRect();
337
+ if (bottom > innerHeight - edge)
338
+ menu.style.top = `${Math.max(edge, more.getBoundingClientRect().top - gap - height)}px`;
339
+ // It does not follow its button, so it does not stay open once the button moves.
340
+ menu.closest('dialog')?.addEventListener('scroll', shut, { once: true });
341
+ choices[0].focus();
342
+ });
343
+ menu.addEventListener('click', shut);
344
+ menu.addEventListener('keydown', (event) => {
345
+ const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
346
+ if (!step)
347
+ return;
348
+ event.preventDefault();
349
+ const at = choices.indexOf(event.target);
350
+ choices[(at + step + choices.length) % choices.length].focus();
351
+ });
352
+ return menu;
353
+ }
164
354
  /**
165
355
  * Under a holder's own account: renewing it, which is showing the same account again, and
166
356
  * removing it, which asks once more before it does. A removed link has nothing left to do.
167
357
  *
168
358
  * An account still in use can also be listed another way, or retired, which asks first as
169
- * removing does. A retired one is renewed or removed and nothing else.
359
+ * removing does. Those and removing it are then kept in a menu beside Renew, so the row
360
+ * stays one line. A retired one is renewed or removed and nothing else.
170
361
  */
171
362
  function manageRow(evidence, manage) {
172
363
  const row = node('div', '', 'row');
@@ -210,7 +401,14 @@ function manageRow(evidence, manage) {
210
401
  retire.onclick = retiring;
211
402
  listing.push(...(evidence.mark === 'preferred' ? [] : [list('Mark as preferred', 'preferred')]), ...(evidence.mark === 'unused' ? [] : [list('Mark as unused', 'unused')]), ...(evidence.mark === undefined ? [] : [list('Mark as current', 'current')]), retire);
212
403
  }
213
- row.replaceChildren(renew, ...listing, remove, ...(note ? [node('span', note, 'muted')] : []));
404
+ const said = note ? [node('span', note, 'muted')] : [];
405
+ const more = act('');
406
+ // Two buttons need no menu between them.
407
+ const menu = listing.length ? actionsMenu(more, [...listing, remove]) : undefined;
408
+ if (menu)
409
+ row.replaceChildren(renew, more, menu, ...said);
410
+ else
411
+ row.replaceChildren(renew, ...listing, remove, ...said);
214
412
  };
215
413
  const retiring = () => {
216
414
  const cancel = act('Cancel');
@@ -252,36 +450,45 @@ function signatures(evidence) {
252
450
  : signature(source)));
253
451
  }
254
452
  /**
255
- * Says a record is signed, in a word beside its verifier. The word opens the verifier's
256
- * own page for the record at what it says of the signature, where the signed record is to be
257
- * had: nothing is downloaded from the card. The signed record is checked here against the
258
- * verifier's keys: a tick once it has checked, and a warning in the word's place if it
259
- * fails, the one thing on a card that is a warning.
453
+ * The "signed" pill shown after the verifier's name. It links to the signature section of
454
+ * the verifier's evidence page, where the signed record can be downloaded.
455
+ *
456
+ * The signature is checked here against the verifier's keys:
457
+ * - valid: the pill says "signed"
458
+ * - not yet checked: the pill carries a warning sign
459
+ * - invalid: a warning replaces the pill
460
+ *
461
+ * `warned` is called every time the invalid warning is drawn, so a folded card can open.
260
462
  */
261
- function signedMark(evidence, later) {
463
+ function signedMark(evidence, later, warned) {
262
464
  const mark = node('span');
263
- const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
465
+ const link = outward(node('a', 'signed', 'signed'), `${evidence.evidenceUrl}#signed`);
264
466
  const see = 'See the signature (opens in a new tab)';
265
467
  const settle = (results) => {
266
468
  const first = results[0];
267
469
  if (results.some((result) => result.state === 'invalid')) {
268
- const warning = node('span', 'invalid signature', 'bad');
470
+ // Not a link: the page it would open is the one this record failed to match.
471
+ const warning = node('span', 'bad signature', 'signed bad');
269
472
  warning.setAttribute('role', 'alert');
270
473
  // What it means, for a reader who has never met a signature, kept off the card.
271
474
  warning.title = `This record does not match the signature ${evidence.verifierName} put on it, so it may have been altered. Do not rely on it.`;
272
- mark.replaceChildren(document.createTextNode(' · '), warning);
475
+ mark.replaceChildren(warning);
476
+ warned();
273
477
  return;
274
478
  }
275
479
  if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
276
- link.textContent = 'signed ✓';
277
- // Names where the keys came from, which is what the tick answers for.
480
+ // No tick: a signature that checks out is what a reader expects, and the pill is
481
+ // marked only when it did not. Setting the word again clears a warning sign left in it.
482
+ link.textContent = 'signed';
483
+ // Names where the keys came from, which is what the check answers for.
278
484
  link.title = `Signature checked in this browser against the keys ${new URL(evidence.evidenceUrl).host} publishes (key ${first.keyId}). ${see}`;
279
- mark.replaceChildren(document.createTextNode(' · '), link);
485
+ mark.replaceChildren(link);
280
486
  return;
281
487
  }
282
- // No verdict was reached. The record is signed and says so, with a warning beside the
283
- // word in place of a tick, and why is said on hover. A check that only ran out of time
284
- // or could not read what it needed is made again when the dialog next reads.
488
+ // No verdict was reached. The record is signed and says so, with a warning sign in the
489
+ // pill, which is all that tells it from one that checked out, and why is said on hover.
490
+ // A check that only ran out of time or could not read what it needed is made again when
491
+ // the dialog next reads.
285
492
  const why = results.find((result) => result.state === 'unchecked');
286
493
  const reason = why?.state === 'unchecked' && why.why === 'timeout'
287
494
  ? 'Signature check timed out, retrying.'
@@ -292,8 +499,9 @@ function signedMark(evidence, later) {
292
499
  caution.setAttribute('aria-label', reason);
293
500
  caution.append(cautionMark());
294
501
  link.textContent = 'signed';
502
+ link.append(caution);
295
503
  link.title = `${reason} ${see}`;
296
- mark.replaceChildren(document.createTextNode(' · '), link, caution);
504
+ mark.replaceChildren(link);
297
505
  if (why)
298
506
  later(() => void signatures(evidence).then(settle));
299
507
  };
@@ -320,12 +528,39 @@ function cautionMark() {
320
528
  }
321
529
  return svg;
322
530
  }
531
+ /**
532
+ * Opens and shuts what a card folds away: how its account was shown, every method of it
533
+ * together so that none is put ahead of another, and its dates.
534
+ */
535
+ function foldToggle(folded, fold) {
536
+ const toggle = node('button', '', 'fold');
537
+ let shown = fold.open;
538
+ const set = (open) => {
539
+ shown = open;
540
+ for (const part of folded)
541
+ part.hidden = !open;
542
+ toggle.setAttribute('aria-expanded', String(open));
543
+ };
544
+ toggle.type = 'button';
545
+ toggle.append(foldMark(), document.createTextNode('Details'));
546
+ set(fold.open);
547
+ toggle.onclick = () => {
548
+ set(!shown);
549
+ fold.toggle(shown);
550
+ };
551
+ return toggle;
552
+ }
323
553
  /**
324
554
  * One linked account: its state, how it was shown and when, on a card of its own. A
325
555
  * subject with several accounts gets one of these each, since a provider authenticates and
326
556
  * expires on its own terms and none of that may be read across to another's card.
557
+ *
558
+ * Given a `fold`, the card is one of several, read first as a list of accounts: it keeps
559
+ * how it was shown and its dates behind a toggle, which shares a line with what the holder
560
+ * may do. Its state, its verifier and what is said of its signature are never folded.
561
+ * Alone, it shows everything.
327
562
  */
328
- function externalCard(evidence, manage, later = () => { }) {
563
+ function externalCard(evidence, manage, later = () => { }, fold, warned = () => { }) {
329
564
  const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
330
565
  const status = statusLabel(evidence, Date.now());
331
566
  const provider = evidence.providerName;
@@ -345,7 +580,7 @@ function externalCard(evidence, manage, later = () => { }) {
345
580
  else
346
581
  attribution.append(document.createTextNode(evidence.verifierName));
347
582
  if (evidence.signedUrl)
348
- attribution.append(signedMark(evidence, later));
583
+ attribution.append(signedMark(evidence, later, warned));
349
584
  copy.append(node('div', status, 'state'), attribution);
350
585
  const retired = evidence.status === 'retired';
351
586
  // A retired account claims nothing now, which is not the same as a claim that failed.
@@ -379,16 +614,22 @@ function externalCard(evidence, manage, later = () => { }) {
379
614
  dates.append(node('dt', label), node('dd', moment(time)));
380
615
  }
381
616
  const names = { site: evidence.siteName, provider };
617
+ const row = manage && removable(evidence).length ? [manageRow(evidence, manage)] : [];
618
+ const bar = node('div', '', 'bar');
619
+ const methods = [
620
+ ...attestationNote(main, names),
621
+ ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
622
+ ];
623
+ if (fold)
624
+ bar.append(foldToggle([...methods, dates], fold), ...row);
382
625
  const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
383
- const card = accountCard(
626
+ const card = namedCard(
384
627
  // The mark and the name, or just the name. A mark that falls back to writing the name
385
628
  // would print it twice here, since this heading writes it either way.
386
- logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
387
- // A mailbox's address or a domain is its name already, so there is no second identifier.
388
- externalId(evidence.external), externalLink(evidence.external),
629
+ logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], evidence.external,
389
630
  // Status first, then how it was shown, then when. The proof explains the state
390
631
  // above it, so it cannot sit before that state has been given.
391
- summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
632
+ summary, ...(fold ? [] : methods),
392
633
  // How the holder lists the account, in their words and apart from how it was shown:
393
634
  // nothing checked it, so it is never among the methods above it.
394
635
  ...(evidence.status === 'revoked' || retired
@@ -397,7 +638,7 @@ function externalCard(evidence, manage, later = () => { }) {
397
638
  ? [node('div', 'Preferred', 'listing preferred')]
398
639
  : evidence.mark === 'unused'
399
640
  ? [node('div', 'No longer used', 'listing')]
400
- : []), dates, ...(manage && removable(evidence).length ? [manageRow(evidence, manage)] : []));
641
+ : []), ...(fold ? [bar, ...methods, dates] : [dates, ...row]));
401
642
  const surface = retired ? 'account retired' : 'account';
402
643
  card.className = surface;
403
644
  // A signed record is not read, or acted on, before its signature has been checked: until
@@ -432,10 +673,11 @@ function addRow(manage) {
432
673
  }
433
674
  /**
434
675
  * The subject once, then each account linked to it on its own card, in the order given.
435
- * Retired accounts sit in a group of their own at the foot, under what says they are.
436
- * Every record given is of the one subject, which the caller has checked.
676
+ * Retired accounts sit in a group of their own at the foot, under what says they are,
677
+ * shut until it is opened unless no other account is left to read. Every record given is
678
+ * of the one subject, which the caller has checked.
437
679
  */
438
- function render(content, records, manage, later) {
680
+ function render(content, records, manage, later, folds = { open: false, cards: new Set() }) {
439
681
  const [first] = records;
440
682
  // Each card says what it is. Without that the pair is two unlabelled boxes.
441
683
  const local = localSide(first.local, first.siteName);
@@ -448,14 +690,33 @@ function render(content, records, manage, later) {
448
690
  provider: first.providerName,
449
691
  }));
450
692
  const retired = records.filter((record) => record.status === 'retired');
451
- content.replaceChildren(localCard, linkMark(), ...records
452
- .filter((record) => record.status !== 'retired')
453
- .map((record) => externalCard(record, manage, later)), ...(manage ? [addRow(manage)] : []), ...(retired.length
454
- ? [
455
- node('h3', 'Retired accounts', 'group'),
456
- ...retired.map((record) => externalCard(record, manage, later)),
457
- ]
458
- : []),
693
+ const listed = records.filter((record) => record.status !== 'retired');
694
+ // A card alone in its group has room for its dates. Among others it folds them.
695
+ const cards = (among, warned) => among.map((record) => {
696
+ const key = `${record.provider}\n${record.external.id}`;
697
+ return externalCard(record, manage, later, among.length > 1
698
+ ? {
699
+ open: folds.cards.has(key) !== folds.open,
700
+ toggle: (open) => void (open === folds.open ? folds.cards.delete(key) : folds.cards.add(key)),
701
+ }
702
+ : undefined, warned);
703
+ });
704
+ const history = node('details', '', 'history');
705
+ const count = node('summary', '', 'group');
706
+ count.append(foldMark(), document.createTextNode(`Retired accounts (${retired.length})`));
707
+ // A signature that fails is a warning, and a warning is not left behind a fold.
708
+ history.append(count, ...cards(retired, () => {
709
+ history.open = true;
710
+ }));
711
+ // Shut to keep history out of the way of the accounts in use. With none of those there
712
+ // is nothing else to read, so it starts open.
713
+ history.open = folds.retired ?? !listed.length;
714
+ // Pressing the heading is the reader's choice, and is kept. It is heard before the
715
+ // group turns, so what it will be is the opposite of what it is.
716
+ count.addEventListener('click', () => {
717
+ folds.retired = !history.open;
718
+ });
719
+ content.replaceChildren(localCard, linkMark(), ...cards(listed), ...(manage ? [addRow(manage)] : []), ...(retired.length ? [history] : []),
459
720
  // Nothing below the cards may name a provider. Approval, method and dates belong to
460
721
  // the card they came from, and a second provider on this subject gets its own card.
461
722
  node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
@@ -478,7 +739,12 @@ arrange = (records) => records,
478
739
  * Given where whoever is looking holds these accounts: each then has its renewal and
479
740
  * removal under it, and the dialog offers to connect another.
480
741
  */
481
- manage) {
742
+ manage,
743
+ /**
744
+ * Whether the cards of several accounts start with their details open. They start shut
745
+ * unless the page asked otherwise, and a reader may turn each either way from there.
746
+ */
747
+ detailsOpen = false) {
482
748
  const existing = openDialogs.get(opener);
483
749
  if (existing?.open) {
484
750
  existing.focus();
@@ -491,7 +757,11 @@ manage) {
491
757
  root.adoptedStyleSheets = [sheet];
492
758
  const dialog = node('dialog');
493
759
  const heading = node('h2', 'Verification details');
494
- const close = node('button', '×');
760
+ const close = node('button', '', 'icon');
761
+ // Every account on a page of its own, on the verifier. It sits by the heading it widens,
762
+ // and only where there is more than one account to list.
763
+ const all = node('a', '', 'all');
764
+ const title = node('div', '', 'title');
495
765
  const header = node('header');
496
766
  // What drew the record, under it. The record is about the pair, not about us.
497
767
  const stamp = node('footer');
@@ -502,7 +772,12 @@ manage) {
502
772
  close.setAttribute('aria-label', 'Close verification details');
503
773
  content.setAttribute('aria-live', 'polite');
504
774
  stamp.append(stampLink(), node('span', version));
505
- header.append(heading, close);
775
+ all.append(awayMark());
776
+ close.append(closeMark());
777
+ // The close was the first thing in the dialog to take the focus, and still is.
778
+ close.autofocus = true;
779
+ title.append(heading);
780
+ header.append(title, close);
506
781
  dialog.append(header, content, stamp);
507
782
  /** Links removed from this dialog, shown as removed before the page hands over new records. */
508
783
  const removed = new Map();
@@ -519,6 +794,8 @@ manage) {
519
794
  /** How each record given reads now, by its id. */
520
795
  const listings = (given) => new Map([given].flat().map((record) => [record.id, listing(record)]));
521
796
  let held;
797
+ /** What has been opened in this dialog, which outlasts every redraw of its cards. */
798
+ const folds = { open: detailsOpen, cards: new Set() };
522
799
  /** A record as the listings made here leave it, each applied as the backend applies it. */
523
800
  const relisted = (evidence) => {
524
801
  let record = evidence;
@@ -599,13 +876,22 @@ manage) {
599
876
  .map((e) => removed.has(e.id) && e.status !== 'revoked'
600
877
  ? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
601
878
  : relisted(standing(e))));
879
+ const listable = records.filter((e) => e.visibility === 'public' && e.status !== 'revoked');
880
+ // In the header only while it leads somewhere: an anchor with no address is not a link.
881
+ title.replaceChildren(heading, ...(listable.length > 1 ? [all] : []));
882
+ if (listable.length > 1) {
883
+ const label = `View all on ${listable[0].verifierName} (opens in a new tab)`;
884
+ outward(all, subjectUrl(listable[0]));
885
+ all.title = label;
886
+ all.setAttribute('aria-label', label);
887
+ }
602
888
  const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
603
889
  if (key === drawn)
604
890
  return;
605
891
  drawn = key;
606
892
  // The cards are new, and what the old ones were waiting to try again went with them.
607
893
  retries.clear();
608
- render(content, records, actions, (retry) => retries.add(retry));
894
+ render(content, records, actions, (retry) => retries.add(retry), folds);
609
895
  };
610
896
  /**
611
897
  * Redraws at the moment the next record runs out, where that comes before the next check
@@ -1,4 +1,4 @@
1
- import type { Evidence } from '../core/index.js';
1
+ import { type Evidence } from '../core/index.js';
2
2
  import { type Listing } from './evidence-dialog.js';
3
3
  export type { Evidence } from '../core/index.js';
4
4
  export interface Result {
@@ -14,6 +14,12 @@ export interface Arrangement {
14
14
  * the keyboard is on it. On unless this is false. A stacked pill has nothing to open.
15
15
  */
16
16
  peek?: boolean;
17
+ /**
18
+ * Whether the dialog's cards start with their details open: how each account was shown,
19
+ * and its dates. With several accounts they start shut unless this is true. A reader may
20
+ * open or shut each either way.
21
+ */
22
+ detailsOpen?: boolean;
17
23
  }
18
24
  /**
19
25
  * Presents records the page already holds, without asking any backend for them. This is