@bkazemi/verily 0.4.0 → 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.
@@ -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, 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,7 +40,7 @@ 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; }
43
+ .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: -1.5px; }
36
44
  .caution svg { display: block; width: 12px; height: 12px; }
37
45
  .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
38
46
  .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
@@ -41,6 +49,9 @@ export const styles = `
41
49
  /* The account's own name carries that weight; a link inside a line of prose does not. */
42
50
  .summary a, .method a, .additional a { font: inherit; }
43
51
  .reference { margin-top: 2px; }
52
+ /* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */
53
+ .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
54
+ .fingerprint span { display: inline-block; margin-right: .5ch; }
44
55
  .method { margin-top: 8px; }
45
56
  /* In the corner the heading leaves empty, apart from everything that was checked. */
46
57
  .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; }
@@ -50,6 +61,15 @@ export const styles = `
50
61
  .account.retired > a, .account.retired > strong { color: #6b786f; }
51
62
  .account.retired .mark { opacity: .45; }
52
63
  .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }
64
+ summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }
65
+ summary::-webkit-details-marker { display: none; }
66
+ .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; }
67
+ .fold svg, summary svg { width: 12px; height: 12px; }
68
+ .fold[aria-expanded="true"] svg, details[open] > summary svg { transform: rotate(90deg); }
69
+ @media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }
70
+ /* The fold and what the holder may do share a line, until a question needs the whole of it. */
71
+ .bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
72
+ .account .bar .row { margin: 0 0 0 auto; }
53
73
  .additional { margin-top: 2px; padding-left: 12px; }
54
74
  .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
55
75
  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 +84,14 @@ export const styles = `
64
84
  .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
65
85
  .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }
66
86
  .account .action { padding: 5px 10px; font-size: 12px; }
87
+ .account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }
88
+ .dots svg { width: 16px; height: 16px; }
89
+ /* Placed under its button each time it opens, above the dialog and clipped by none of it. */
90
+ .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; }
91
+ .menu:popover-open { display: grid; }
92
+ .account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }
93
+ .menu .danger { color: #b3261e; }
94
+ .menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }
67
95
  footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }
68
96
  footer a { display: flex; color: inherit; }
69
97
  .logo { display: block; height: 13px; }
@@ -86,6 +114,69 @@ export function outward(anchor, url) {
86
114
  anchor.target = '_blank';
87
115
  return anchor;
88
116
  }
117
+ /**
118
+ * Where the verifier lists every public record of a record's subject. A record whose
119
+ * address is not the usual one is left linking to itself.
120
+ */
121
+ export function subjectUrl(e) {
122
+ const url = new URL(e.evidenceUrl);
123
+ const path = url.pathname.replace(/\/connections\/[^/]+$/, '/published');
124
+ if (path === url.pathname)
125
+ return e.evidenceUrl;
126
+ url.pathname = path;
127
+ url.search = `site=${encodeURIComponent(e.siteName)}&reference=${encodeURIComponent(e.local.reference)}`;
128
+ return url.href;
129
+ }
130
+ /** Opens somewhere else, in a tab of its own: a box with an arrow leaving it. */
131
+ function awayMark() {
132
+ const namespace = 'http://www.w3.org/2000/svg';
133
+ const svg = document.createElementNS(namespace, 'svg');
134
+ svg.setAttribute('viewBox', '0 0 24 24');
135
+ svg.setAttribute('fill', 'none');
136
+ svg.setAttribute('aria-hidden', 'true');
137
+ svg.setAttribute('focusable', 'false');
138
+ for (const d of [
139
+ 'M14 5h5v5',
140
+ 'M19 5l-8 8',
141
+ 'M18 14v4a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h4',
142
+ ]) {
143
+ const path = document.createElementNS(namespace, 'path');
144
+ path.setAttribute('d', d);
145
+ path.setAttribute('stroke', 'currentColor');
146
+ path.setAttribute('stroke-width', '2');
147
+ path.setAttribute('stroke-linecap', 'round');
148
+ path.setAttribute('stroke-linejoin', 'round');
149
+ svg.append(path);
150
+ }
151
+ return svg;
152
+ }
153
+ /** A small mark of plain strokes, in the surrounding colour. */
154
+ function strokes(box, width, paths) {
155
+ const namespace = 'http://www.w3.org/2000/svg';
156
+ const svg = document.createElementNS(namespace, 'svg');
157
+ svg.setAttribute('viewBox', `0 0 ${box} ${box}`);
158
+ svg.setAttribute('fill', 'none');
159
+ svg.setAttribute('aria-hidden', 'true');
160
+ svg.setAttribute('focusable', 'false');
161
+ for (const d of paths) {
162
+ const path = document.createElementNS(namespace, 'path');
163
+ path.setAttribute('d', d);
164
+ path.setAttribute('stroke', 'currentColor');
165
+ path.setAttribute('stroke-width', String(width));
166
+ path.setAttribute('stroke-linecap', 'round');
167
+ path.setAttribute('stroke-linejoin', 'round');
168
+ svg.append(path);
169
+ }
170
+ return svg;
171
+ }
172
+ /** Points at what is folded away, and turns to point down once it is open. */
173
+ const foldMark = () => strokes(16, 1.8, ['M6 3.5 10.5 8 6 12.5']);
174
+ /** Three dots: there is more to do here than the buttons beside it. */
175
+ const moreMark = () => strokes(16, 2.4, ['M3 8h.01', 'M8 8h.01', 'M13 8h.01']);
176
+ /** A cross drawn in a square, so it sits in the middle of its button in any font. */
177
+ export const closeMark = () => strokes(16, 1.8, ['M4 4l8 8', 'M12 4l-8 8']);
178
+ /** Points back the way the reader came, drawn for the same reason the cross is. */
179
+ export const backMark = () => strokes(16, 1.8, ['M10 3.5 5.5 8 10 12.5']);
89
180
  /** Joins the two cards: the link itself, drawn rather than described. */
90
181
  export function linkMark() {
91
182
  const namespace = 'http://www.w3.org/2000/svg';
@@ -117,7 +208,7 @@ export function linkMark() {
117
208
  * Additional methods sit beneath the main one, indented under it.
118
209
  */
119
210
  function attestationNote(attestation, names, additional = false) {
120
- const label = attestationLabel(attestation.method, names);
211
+ const label = attestationLabel(attestation.method, names, attestation);
121
212
  if (!label)
122
213
  return [];
123
214
  const note = node('div', additional ? '+ ' : '', `muted ${additional ? 'additional' : 'method'}`);
@@ -148,8 +239,13 @@ export function accountCard(heading, name, reference, url, ...extra) {
148
239
  if (value instanceof HTMLAnchorElement)
149
240
  outward(value, url);
150
241
  card.append(title, value);
151
- if (reference)
242
+ if (typeof reference === 'string')
152
243
  card.append(node('div', reference, 'muted reference'));
244
+ else if (reference) {
245
+ const groups = node('div', '', 'muted reference fingerprint');
246
+ groups.append(...reference.map((group) => node('span', group)));
247
+ card.append(groups);
248
+ }
153
249
  card.append(...extra);
154
250
  return card;
155
251
  }
@@ -161,12 +257,77 @@ const retiringNote = 'Retiring keeps this record as history. It will no longer r
161
257
  * from the status on the card.
162
258
  */
163
259
  const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked' ? [] : [evidence.id]);
260
+ /**
261
+ * Everything a holder may do to an account besides renewing it, behind one button. The
262
+ * menu is a popover, so it is drawn above the dialog and the dialog's scrolling cannot
263
+ * clip it, the browser shuts it on Escape or a press elsewhere, and its button opens and
264
+ * shuts it. Nothing where the browser has no popovers, and the choices are then laid out.
265
+ */
266
+ function actionsMenu(more, choices) {
267
+ const menu = node('div', '', 'menu');
268
+ if (typeof menu.showPopover !== 'function')
269
+ return undefined;
270
+ const label = 'More actions';
271
+ more.className = 'action dots';
272
+ more.title = label;
273
+ more.setAttribute('aria-label', label);
274
+ more.setAttribute('aria-haspopup', 'menu');
275
+ more.append(moreMark());
276
+ more.popoverTargetElement = menu;
277
+ menu.setAttribute('popover', 'auto');
278
+ menu.setAttribute('role', 'menu');
279
+ for (const choice of choices)
280
+ choice.setAttribute('role', 'menuitem');
281
+ // Whatever removes the account sits last and apart from the rest.
282
+ menu.append(...choices.slice(0, -1), node('hr'), choices.at(-1));
283
+ const shut = () => {
284
+ try {
285
+ if (menu.matches(':popover-open'))
286
+ menu.hidePopover();
287
+ }
288
+ catch {
289
+ // Gone from the page with its card.
290
+ }
291
+ };
292
+ const gap = 4;
293
+ const edge = 8;
294
+ // Under the button and ending where it ends, before it is first painted.
295
+ menu.addEventListener('beforetoggle', (event) => {
296
+ if (event.newState !== 'open')
297
+ return;
298
+ const at = more.getBoundingClientRect();
299
+ menu.style.top = `${at.bottom + gap}px`;
300
+ menu.style.right = `${document.documentElement.clientWidth - at.right}px`;
301
+ });
302
+ menu.addEventListener('toggle', (event) => {
303
+ if (event.newState !== 'open')
304
+ return;
305
+ // Over the button where the window ends before the menu does.
306
+ const { height, bottom } = menu.getBoundingClientRect();
307
+ if (bottom > innerHeight - edge)
308
+ menu.style.top = `${Math.max(edge, more.getBoundingClientRect().top - gap - height)}px`;
309
+ // It does not follow its button, so it does not stay open once the button moves.
310
+ menu.closest('dialog')?.addEventListener('scroll', shut, { once: true });
311
+ choices[0].focus();
312
+ });
313
+ menu.addEventListener('click', shut);
314
+ menu.addEventListener('keydown', (event) => {
315
+ const step = { ArrowDown: 1, ArrowUp: -1 }[event.key];
316
+ if (!step)
317
+ return;
318
+ event.preventDefault();
319
+ const at = choices.indexOf(event.target);
320
+ choices[(at + step + choices.length) % choices.length].focus();
321
+ });
322
+ return menu;
323
+ }
164
324
  /**
165
325
  * Under a holder's own account: renewing it, which is showing the same account again, and
166
326
  * removing it, which asks once more before it does. A removed link has nothing left to do.
167
327
  *
168
328
  * 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.
329
+ * removing does. Those and removing it are then kept in a menu beside Renew, so the row
330
+ * stays one line. A retired one is renewed or removed and nothing else.
170
331
  */
171
332
  function manageRow(evidence, manage) {
172
333
  const row = node('div', '', 'row');
@@ -210,7 +371,14 @@ function manageRow(evidence, manage) {
210
371
  retire.onclick = retiring;
211
372
  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
373
  }
213
- row.replaceChildren(renew, ...listing, remove, ...(note ? [node('span', note, 'muted')] : []));
374
+ const said = note ? [node('span', note, 'muted')] : [];
375
+ const more = act('');
376
+ // Two buttons need no menu between them.
377
+ const menu = listing.length ? actionsMenu(more, [...listing, remove]) : undefined;
378
+ if (menu)
379
+ row.replaceChildren(renew, more, menu, ...said);
380
+ else
381
+ row.replaceChildren(renew, ...listing, remove, ...said);
214
382
  };
215
383
  const retiring = () => {
216
384
  const cancel = act('Cancel');
@@ -256,9 +424,10 @@ function signatures(evidence) {
256
424
  * own page for the record at what it says of the signature, where the signed record is to be
257
425
  * had: nothing is downloaded from the card. The signed record is checked here against the
258
426
  * 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.
427
+ * fails, the one thing on a card that is a warning. `warned` is told each time that warning
428
+ * is drawn, on the first check or on a later one, so whatever folds the card away opens.
260
429
  */
261
- function signedMark(evidence, later) {
430
+ function signedMark(evidence, later, warned) {
262
431
  const mark = node('span');
263
432
  const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
264
433
  const see = 'See the signature (opens in a new tab)';
@@ -270,6 +439,7 @@ function signedMark(evidence, later) {
270
439
  // What it means, for a reader who has never met a signature, kept off the card.
271
440
  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
441
  mark.replaceChildren(document.createTextNode(' · '), warning);
442
+ warned();
273
443
  return;
274
444
  }
275
445
  if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
@@ -320,12 +490,39 @@ function cautionMark() {
320
490
  }
321
491
  return svg;
322
492
  }
493
+ /**
494
+ * Opens and shuts what a card folds away: how its account was shown, every method of it
495
+ * together so that none is put ahead of another, and its dates.
496
+ */
497
+ function foldToggle(folded, fold) {
498
+ const toggle = node('button', '', 'fold');
499
+ let shown = fold.open;
500
+ const set = (open) => {
501
+ shown = open;
502
+ for (const part of folded)
503
+ part.hidden = !open;
504
+ toggle.setAttribute('aria-expanded', String(open));
505
+ };
506
+ toggle.type = 'button';
507
+ toggle.append(foldMark(), document.createTextNode('Details'));
508
+ set(fold.open);
509
+ toggle.onclick = () => {
510
+ set(!shown);
511
+ fold.toggle(shown);
512
+ };
513
+ return toggle;
514
+ }
323
515
  /**
324
516
  * One linked account: its state, how it was shown and when, on a card of its own. A
325
517
  * subject with several accounts gets one of these each, since a provider authenticates and
326
518
  * expires on its own terms and none of that may be read across to another's card.
519
+ *
520
+ * Given a `fold`, the card is one of several, read first as a list of accounts: it keeps
521
+ * how it was shown and its dates behind a toggle, which shares a line with what the holder
522
+ * may do. Its state, its verifier and what is said of its signature are never folded.
523
+ * Alone, it shows everything.
327
524
  */
328
- function externalCard(evidence, manage, later = () => { }) {
525
+ function externalCard(evidence, manage, later = () => { }, fold, warned = () => { }) {
329
526
  const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
330
527
  const status = statusLabel(evidence, Date.now());
331
528
  const provider = evidence.providerName;
@@ -345,7 +542,7 @@ function externalCard(evidence, manage, later = () => { }) {
345
542
  else
346
543
  attribution.append(document.createTextNode(evidence.verifierName));
347
544
  if (evidence.signedUrl)
348
- attribution.append(signedMark(evidence, later));
545
+ attribution.append(signedMark(evidence, later, warned));
349
546
  copy.append(node('div', status, 'state'), attribution);
350
547
  const retired = evidence.status === 'retired';
351
548
  // A retired account claims nothing now, which is not the same as a claim that failed.
@@ -379,16 +576,24 @@ function externalCard(evidence, manage, later = () => { }) {
379
576
  dates.append(node('dt', label), node('dd', moment(time)));
380
577
  }
381
578
  const names = { site: evidence.siteName, provider };
579
+ const row = manage && removable(evidence).length ? [manageRow(evidence, manage)] : [];
580
+ const bar = node('div', '', 'bar');
581
+ const methods = [
582
+ ...attestationNote(main, names),
583
+ ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)),
584
+ ];
585
+ if (fold)
586
+ bar.append(foldToggle([...methods, dates], fold), ...row);
382
587
  const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
383
588
  const card = accountCard(
384
589
  // The mark and the name, or just the name. A mark that falls back to writing the name
385
590
  // would print it twice here, since this heading writes it either way.
386
591
  logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
387
592
  // A mailbox's address or a domain is its name already, so there is no second identifier.
388
- externalId(evidence.external), externalLink(evidence.external),
593
+ externalIdGroups(evidence.external) ?? externalId(evidence.external), externalLink(evidence.external),
389
594
  // Status first, then how it was shown, then when. The proof explains the state
390
595
  // 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)),
596
+ summary, ...(fold ? [] : methods),
392
597
  // How the holder lists the account, in their words and apart from how it was shown:
393
598
  // nothing checked it, so it is never among the methods above it.
394
599
  ...(evidence.status === 'revoked' || retired
@@ -397,7 +602,7 @@ function externalCard(evidence, manage, later = () => { }) {
397
602
  ? [node('div', 'Preferred', 'listing preferred')]
398
603
  : evidence.mark === 'unused'
399
604
  ? [node('div', 'No longer used', 'listing')]
400
- : []), dates, ...(manage && removable(evidence).length ? [manageRow(evidence, manage)] : []));
605
+ : []), ...(fold ? [bar, ...methods, dates] : [dates, ...row]));
401
606
  const surface = retired ? 'account retired' : 'account';
402
607
  card.className = surface;
403
608
  // A signed record is not read, or acted on, before its signature has been checked: until
@@ -432,10 +637,11 @@ function addRow(manage) {
432
637
  }
433
638
  /**
434
639
  * 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.
640
+ * Retired accounts sit in a group of their own at the foot, under what says they are,
641
+ * shut until it is opened unless no other account is left to read. Every record given is
642
+ * of the one subject, which the caller has checked.
437
643
  */
438
- function render(content, records, manage, later) {
644
+ function render(content, records, manage, later, folds = { open: false, cards: new Set() }) {
439
645
  const [first] = records;
440
646
  // Each card says what it is. Without that the pair is two unlabelled boxes.
441
647
  const local = localSide(first.local, first.siteName);
@@ -448,14 +654,33 @@ function render(content, records, manage, later) {
448
654
  provider: first.providerName,
449
655
  }));
450
656
  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
- : []),
657
+ const listed = records.filter((record) => record.status !== 'retired');
658
+ // A card alone in its group has room for its dates. Among others it folds them.
659
+ const cards = (among, warned) => among.map((record) => {
660
+ const key = `${record.provider}\n${record.external.id}`;
661
+ return externalCard(record, manage, later, among.length > 1
662
+ ? {
663
+ open: folds.cards.has(key) !== folds.open,
664
+ toggle: (open) => void (open === folds.open ? folds.cards.delete(key) : folds.cards.add(key)),
665
+ }
666
+ : undefined, warned);
667
+ });
668
+ const history = node('details', '', 'history');
669
+ const count = node('summary', '', 'group');
670
+ count.append(foldMark(), document.createTextNode(`Retired accounts (${retired.length})`));
671
+ // A signature that fails is a warning, and a warning is not left behind a fold.
672
+ history.append(count, ...cards(retired, () => {
673
+ history.open = true;
674
+ }));
675
+ // Shut to keep history out of the way of the accounts in use. With none of those there
676
+ // is nothing else to read, so it starts open.
677
+ history.open = folds.retired ?? !listed.length;
678
+ // Pressing the heading is the reader's choice, and is kept. It is heard before the
679
+ // group turns, so what it will be is the opposite of what it is.
680
+ count.addEventListener('click', () => {
681
+ folds.retired = !history.open;
682
+ });
683
+ content.replaceChildren(localCard, linkMark(), ...cards(listed), ...(manage ? [addRow(manage)] : []), ...(retired.length ? [history] : []),
459
684
  // Nothing below the cards may name a provider. Approval, method and dates belong to
460
685
  // the card they came from, and a second provider on this subject gets its own card.
461
686
  node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
@@ -478,7 +703,12 @@ arrange = (records) => records,
478
703
  * Given where whoever is looking holds these accounts: each then has its renewal and
479
704
  * removal under it, and the dialog offers to connect another.
480
705
  */
481
- manage) {
706
+ manage,
707
+ /**
708
+ * Whether the cards of several accounts start with their details open. They start shut
709
+ * unless the page asked otherwise, and a reader may turn each either way from there.
710
+ */
711
+ detailsOpen = false) {
482
712
  const existing = openDialogs.get(opener);
483
713
  if (existing?.open) {
484
714
  existing.focus();
@@ -491,7 +721,11 @@ manage) {
491
721
  root.adoptedStyleSheets = [sheet];
492
722
  const dialog = node('dialog');
493
723
  const heading = node('h2', 'Verification details');
494
- const close = node('button', '×');
724
+ const close = node('button', '', 'icon');
725
+ // Every account on a page of its own, on the verifier. It sits by the heading it widens,
726
+ // and only where there is more than one account to list.
727
+ const all = node('a', '', 'all');
728
+ const title = node('div', '', 'title');
495
729
  const header = node('header');
496
730
  // What drew the record, under it. The record is about the pair, not about us.
497
731
  const stamp = node('footer');
@@ -502,7 +736,12 @@ manage) {
502
736
  close.setAttribute('aria-label', 'Close verification details');
503
737
  content.setAttribute('aria-live', 'polite');
504
738
  stamp.append(stampLink(), node('span', version));
505
- header.append(heading, close);
739
+ all.append(awayMark());
740
+ close.append(closeMark());
741
+ // The close was the first thing in the dialog to take the focus, and still is.
742
+ close.autofocus = true;
743
+ title.append(heading);
744
+ header.append(title, close);
506
745
  dialog.append(header, content, stamp);
507
746
  /** Links removed from this dialog, shown as removed before the page hands over new records. */
508
747
  const removed = new Map();
@@ -519,6 +758,8 @@ manage) {
519
758
  /** How each record given reads now, by its id. */
520
759
  const listings = (given) => new Map([given].flat().map((record) => [record.id, listing(record)]));
521
760
  let held;
761
+ /** What has been opened in this dialog, which outlasts every redraw of its cards. */
762
+ const folds = { open: detailsOpen, cards: new Set() };
522
763
  /** A record as the listings made here leave it, each applied as the backend applies it. */
523
764
  const relisted = (evidence) => {
524
765
  let record = evidence;
@@ -599,13 +840,22 @@ manage) {
599
840
  .map((e) => removed.has(e.id) && e.status !== 'revoked'
600
841
  ? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
601
842
  : relisted(standing(e))));
843
+ const listable = records.filter((e) => e.visibility === 'public' && e.status !== 'revoked');
844
+ // In the header only while it leads somewhere: an anchor with no address is not a link.
845
+ title.replaceChildren(heading, ...(listable.length > 1 ? [all] : []));
846
+ if (listable.length > 1) {
847
+ const label = `View all on ${listable[0].verifierName} (opens in a new tab)`;
848
+ outward(all, subjectUrl(listable[0]));
849
+ all.title = label;
850
+ all.setAttribute('aria-label', label);
851
+ }
602
852
  const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
603
853
  if (key === drawn)
604
854
  return;
605
855
  drawn = key;
606
856
  // The cards are new, and what the old ones were waiting to try again went with them.
607
857
  retries.clear();
608
- render(content, records, actions, (retry) => retries.add(retry));
858
+ render(content, records, actions, (retry) => retries.add(retry), folds);
609
859
  };
610
860
  /**
611
861
  * 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