@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.
package/README.md CHANGED
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
147
 
148
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js`. The script defines a global `Verily`.
148
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.7.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -184,6 +184,8 @@ Add `stacked` to name the accounts in the badge itself, one to a row:
184
184
 
185
185
  It lists up to four verified accounts in the order they were connected, then counts the rest as `+2 more`. It is still one badge that opens the one set of details. It is as tall as its rows, so give it a line of its own. With one verified account it is the ordinary badge. `mountBadges` and `presentConnections` take the same choices as `{ stacked: true }` and `{ peek: false }`.
186
186
 
187
+ In the details of several accounts, each account is a short card: its name, its status and who verified it. How it was shown and its dates sit behind a Details toggle on the card, and retired accounts are folded into a group at the foot. Add `details-open` to the badge, or pass `{ detailsOpen: true }`, to start every card open where your readers come for the proof. A reader can still open or shut each one. A single account always shows everything.
188
+
187
189
  The badge refreshes every 30 seconds, and only ever reads public evidence. The client can also start and end connections, and draw a badge into an element of your own:
188
190
 
189
191
  ```js
@@ -229,6 +231,8 @@ import {
229
231
 
230
232
  All methods except the sign-ins and the mailed code let the user publish the proof in their own time and come back. A proof that does not check out can be put right and handed back on the same step, up to five times, so a mistyped address never means publishing or signing again. Gists, link-backs, DNS records and files can be taken down later, so Verily re-reads them on a schedule. A PGP signature is kept by Verily and published at `<baseUrl>/connections/<id>/proof`.
231
233
 
234
+ **How old an account is.** A link shows who controls an account, not how long it has existed. Signing in with GitHub, Discord or YouTube also reads when the account was made: GitHub's `created_at`, the time inside a Discord id, and a YouTube channel's `publishedAt`. It is kept on the record as `external.createdAt`, in milliseconds, shown as "Joined" beside the account's name on evidence and in the badge's dialog, and covered by a signed record. The other methods read an address or a proof and learn no date, so a record first shown by one of them has none until its holder also signs in. A provider of your own sets it by returning `createdAt` from `authenticate()`.
235
+
232
236
  **Several at once.** List more than one, and `/verify` offers each method as its own button:
233
237
 
234
238
  ```ts
@@ -299,7 +303,7 @@ A static site only needs the script and the badge markup; the backend runs somew
299
303
  4. Add the script and the badge to your site:
300
304
 
301
305
  ```html
302
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js" defer></script>
306
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.7.0/dist/verily.js" defer></script>
303
307
  <verily-badge
304
308
  backend-url="https://your-backend.example"
305
309
  connection-id="CONNECTION_ID"
@@ -391,7 +395,7 @@ To show them, hand the records to the badge. It draws them as given and fetches
391
395
 
392
396
  From script, set the property instead: `badge.connections = records`. Send a page only the records its reader may see: whatever is in the page, the reader has. Several records become one badge, as under [Show the badge](#show-the-badge), and an unlisted one opens its details without linking anywhere.
393
397
 
394
- Each record has the provider, the external account's handle and profile address, the visibility and the expiry. Unlisted records are included, and only your site can read them, so you can show a link to your own users without making it public. `connections()` takes any number of ids, so read a whole page of users in one call. Pass `cacheMs` to the client to reuse an answer for that long, and `{ fresh: true }` to a call that must not.
398
+ Each record has the provider, the external account's handle and profile address, the visibility and the expiry. Unlisted records are included, and only your site can read them, so you can show a link to your own users without making it public. `connections()` takes any number of ids, so read a whole page of users in one call. Every call reads the instance, so a removed or expired link stops showing on the next page load. Passing `cacheMs` to the client reuses an answer for that long, and a connection revoked in that time still shows as verified until it runs out, so leave it unset or keep it to a few seconds. `{ fresh: true }` makes one call read anyway.
395
399
 
396
400
  If you would rather your user ids never left your site, give the client a secret:
397
401
 
@@ -443,7 +447,7 @@ With a key set:
443
447
  - **`GET <baseUrl>/connections/<id>?format=signed`** returns the signed file for a public record that is currently verified: the record as JSON under an OpenPGP cleartext signature. It is signed when asked for, so it says the record stood at that moment. An unlisted, revoked or expired record has no signed form. A public retired record has one, signed as `version: 2` with `status: "retired"` and `retiredAt`, so a reader that knows only version 1 refuses it and never takes it for a record that stood.
444
448
  - **`GET <baseUrl>/keys.asc`** is the public key as `gpg --import` reads it. **`GET <baseUrl>/keys`** lists the same keys as JSON, each with its fingerprint. The Cloudflare Worker also serves that list at `/.well-known/verily-keys.json`.
445
449
  - **`<baseUrl>/check`** is a page where a saved file can be pasted and read back.
446
- - The evidence page says the record is signed and links to the signed record. The badge's dialog checks the signature in the browser with OpenPGP.js, which it starts only when a record is signed, marks the record `signed ✓`, and links to that page. A record whose signature fails is shown as unconfirmed.
450
+ - The evidence page says the record is signed and links to the signed record. The badge's dialog checks the signature in the browser with OpenPGP.js, which it starts only when a record is signed, marks the record with a `signed` pill after its verifier, and links to that page. A record whose signature fails is shown as unconfirmed, with `bad signature` in the pill's place.
447
451
  - The approval step tells the holder that a saved signed record outlives removal.
448
452
 
449
453
  To check a file yourself:
@@ -507,6 +511,8 @@ The Cloudflare Worker sets `dns: true`.
507
511
 
508
512
  ## Operations
509
513
 
514
+ DNS setup is saved for seven days from its first attempt. If propagation takes longer than a flow’s ten minutes, sign in and choose DNS again to restore the same TXT value and the last domain entered (or restart the same renewal). Each return creates a fresh flow and reads the proof again; the saved setup grants no verification on its own. Only the most recent domain entered is remembered for that operation. Keep the TXT record after verification for ongoing checks. A proof covers the exact domain entered; verify parent domains and subdomains separately.
515
+
510
516
  Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
511
517
 
512
518
  Run the upkeep on a schedule, for example hourly:
@@ -525,8 +531,21 @@ setInterval(
525
531
  );
526
532
  ```
527
533
 
528
- - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
529
- - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
534
+ - **`prune()`** deletes expired flows and DNS drafts, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
535
+ - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per provider per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`). A read that finds the proof gone, such as a deleted gist, a page without the link or a DNS answer without the record, makes the connection unconfirmed at once. A read that fails, such as a timeout or a server error, changes nothing, and the proof stays current for 7 days after its last successful read (`freshnessMs`). Either way the proof is read again after a quarter of `recheckMs`, and the connection is verified again as soon as a read finds the proof. A provider of your own says a proof is gone by throwing `Absent` from `verify`; any other error is a failed read. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
536
+
537
+ - **`onChange`** is an option, not a call. Set it to hear when a record's status, visibility, expiry or mark changes, so your site can update its own copy without polling:
538
+
539
+ ```ts
540
+ createVerily({
541
+ // ...
542
+ onChange: async ({ evidence, local, revision }) => {
543
+ await db.saveConnectionIfNewer(local.id, evidence, revision);
544
+ },
545
+ });
546
+ ```
547
+
548
+ `evidence` is the record as it stands now, `local` is your subject with its private id, `previous` is the status, visibility, expiry and mark last confirmed as delivered, and `revision` counts the deliveries of that record. Keep the `revision` you last applied for each record and ignore a change that is not higher: a call that timed out is not cancelled and can finish after a newer one. A change made by a request is delivered before that request returns, and the call is awaited, so keep it quick. A record that expires or goes unconfirmed changes with time alone, so those are delivered by `recheck()`, or by `service.announce()` if you do not recheck. Delivery is at least once: a call that throws is made again on the next run, after the records that have not failed, and turning the option on reports every existing record once, 50 per run. A record removed by `prune()` is not reported.
530
549
 
531
550
  **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
532
551
 
@@ -15,17 +15,20 @@ export declare function renderBadgeMessage(element: HTMLElement, message: string
15
15
  * `more` is how many other accounts of the same subject stand behind the one shown, said
16
16
  * as a count beside it. `linked` is whether the record has a page of its own a reader can
17
17
  * open: an unlisted one has none, so its pill is a button and never a link to nowhere.
18
+ * `href` is that page, where it is not the record's own: a pill standing for several
19
+ * accounts links to all of them.
18
20
  *
19
21
  * `rows` are those of the other accounts to name, in order. Stacked, each has a row of
20
22
  * its own in the pill, which is then as tall as its rows. Otherwise the pill stays short
21
23
  * and, with `peek`, names them in a panel that opens beside it while a pointer rests on it
22
24
  * or the keyboard is on it. Either way the accounts past the rows are a count.
23
25
  */
24
- export declare function renderBadge(element: HTMLElement, evidence: Evidence, { more, linked, rows, peek, }?: {
26
+ export declare function renderBadge(element: HTMLElement, evidence: Evidence, { more, linked, rows, peek, href, }?: {
25
27
  more?: number;
26
28
  linked?: boolean;
27
29
  rows?: Evidence[];
28
30
  peek?: boolean;
31
+ href?: string;
29
32
  }): HTMLElement | null;
30
33
  /**
31
34
  * Shuts a host's panel and lets go of what it was listening for. For a host leaving the
@@ -155,11 +155,12 @@ export function badgeShown(element) {
155
155
  return intact(element) && shown.has(element);
156
156
  }
157
157
  /** Everything the pill draws: equal keys mean an identical pill. */
158
- function renderKey(evidence, current, label, more, linked, rows, peek) {
158
+ function renderKey(evidence, current, label, more, linked, rows, peek, href) {
159
159
  return JSON.stringify([
160
160
  more,
161
161
  linked,
162
162
  peek,
163
+ href,
163
164
  rows.map((row) => [row.provider, row.providerName, externalName(row.external)]),
164
165
  evidence.provider,
165
166
  evidence.providerName,
@@ -220,13 +221,15 @@ export function renderBadgeMessage(element, message) {
220
221
  * `more` is how many other accounts of the same subject stand behind the one shown, said
221
222
  * as a count beside it. `linked` is whether the record has a page of its own a reader can
222
223
  * open: an unlisted one has none, so its pill is a button and never a link to nowhere.
224
+ * `href` is that page, where it is not the record's own: a pill standing for several
225
+ * accounts links to all of them.
223
226
  *
224
227
  * `rows` are those of the other accounts to name, in order. Stacked, each has a row of
225
228
  * its own in the pill, which is then as tall as its rows. Otherwise the pill stays short
226
229
  * and, with `peek`, names them in a panel that opens beside it while a pointer rests on it
227
230
  * or the keyboard is on it. Either way the accounts past the rows are a count.
228
231
  */
229
- export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, } = {}) {
232
+ export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, href = evidence.evidenceUrl, } = {}) {
230
233
  const provider = evidence.providerName;
231
234
  const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
232
235
  const retired = evidence.status === 'retired';
@@ -238,7 +241,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
238
241
  ? 'revoked'
239
242
  : 'expired';
240
243
  const label = statusLabel(evidence, Date.now());
241
- const key = renderKey(evidence, current, label, more, linked, rows, peek);
244
+ const key = renderKey(evidence, current, label, more, linked, rows, peek, href);
242
245
  if (intact(element) && shown.get(element) === key)
243
246
  return null;
244
247
  const handle = externalName(evidence.external);
@@ -250,7 +253,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
250
253
  // is: in the page's text colour, and not as a verification that failed.
251
254
  paintMark(mark, current ? 'current' : retired ? 'pending' : 'inactive');
252
255
  if (linked) {
253
- badge.href = evidence.evidenceUrl;
256
+ badge.href = href;
254
257
  badge.rel = 'noreferrer';
255
258
  }
256
259
  else
@@ -1,5 +1,4 @@
1
- import { externalId, externalLink, externalName } from '../core/index.js';
2
- import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
1
+ import { accountCard, backMark, closeMark, linkMark, node, namedCard, outward, stampLink, styles, } from './evidence-dialog.js';
3
2
  import { providerMark } from './provider-mark.js';
4
3
  import { version } from '../version.js';
5
4
  const connectStyles = `
@@ -14,13 +13,15 @@ const connectStyles = `
14
13
  /* The block scrolls and its frame does not, so the copy button stays in its corner. */
15
14
  .code { position: relative; margin: 10px 0; }
16
15
  pre { margin: 0; padding: 10px 64px 10px 12px; border: 1px solid #e0e6df; border-radius: 8px; background: #f6f8f5; font: 12px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto; }
17
- .code .action { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 11px; }
16
+ .code .action { position: absolute; top: 9px; right: 9px; padding: 3px 8px; font-size: 11px; }
18
17
  label { display: grid; gap: 4px; margin-top: 12px; font-weight: 600; }
19
18
  input[type=url], input[type=email], input[type=tel], input[type=text], textarea { width: 100%; padding: 8px 10px; border: 1px solid #cfd8d2; border-radius: 8px; font: 13px/1.4 system-ui, sans-serif; color: inherit; background: #fff; }
20
19
  textarea { font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
21
20
  fieldset { margin: 16px 0 0; padding: 10px 14px; border: 1px solid #e0e6df; border-radius: 10px; }
22
21
  legend { padding: 0 4px; color: #6b786f; font-size: 11px; font-weight: 550; }
23
22
  fieldset label { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; font-weight: 500; }
23
+ /* A radio has no baseline to speak of, so it is set in the middle of the label's first line. */
24
+ fieldset input { flex-shrink: 0; align-self: flex-start; width: 13px; height: 13px; font: inherit; margin-top: 4px; margin-top: calc((1lh - 13px) / 2); margin-bottom: 0; }
24
25
  .error { color: #9b2c33; }
25
26
  `;
26
27
  /** Puts a copy control on a block the holder must reproduce exactly, as the flow page does. */
@@ -110,7 +111,7 @@ renew) {
110
111
  root.adoptedStyleSheets = [sheet];
111
112
  const dialog = node('dialog');
112
113
  const heading = node('h2', 'Verify an account');
113
- const close = node('button', '×');
114
+ const close = node('button', '', 'icon');
114
115
  const header = node('header');
115
116
  const stamp = node('footer');
116
117
  const content = node('div', 'Loading…', 'steps');
@@ -118,6 +119,7 @@ renew) {
118
119
  dialog.setAttribute('aria-labelledby', heading.id);
119
120
  close.type = 'button';
120
121
  close.setAttribute('aria-label', 'Close');
122
+ close.append(closeMark());
121
123
  content.setAttribute('aria-live', 'polite');
122
124
  stamp.append(stampLink(), node('span', version));
123
125
  header.append(heading, close);
@@ -127,9 +129,10 @@ renew) {
127
129
  openDialogs.set(opener, dialog);
128
130
  let returning = false;
129
131
  if (back) {
130
- const before = node('button', '‹');
132
+ const before = node('button', '', 'icon');
131
133
  before.type = 'button';
132
134
  before.setAttribute('aria-label', 'Back');
135
+ before.append(backMark());
133
136
  header.className = 'backed';
134
137
  header.replaceChildren(before, heading, close);
135
138
  before.onclick = () => {
@@ -431,9 +434,9 @@ renew) {
431
434
  }
432
435
  function approve(flow) {
433
436
  const logo = providerMark(flow.provider.id, flow.provider.method);
434
- const external = accountCard(logo
437
+ const external = namedCard(logo
435
438
  ? [logo, document.createTextNode(flow.provider.name)]
436
- : [document.createTextNode(flow.provider.name)], externalName(flow.external), externalId(flow.external), externalLink(flow.external));
439
+ : [document.createTextNode(flow.provider.name)], flow.external);
437
440
  const parts = [subjectCard(flow.local), linkMark(), external];
438
441
  const choice = node('fieldset');
439
442
  const visibilityText = {
@@ -1,7 +1,7 @@
1
- import { type Evidence } from '../core/index.js';
1
+ import { type Evidence, type ExternalAccount } from '../core/index.js';
2
2
  /** The logotype as a link to the project, as both dialogs carry it in their footer. */
3
3
  export declare function stampLink(): HTMLAnchorElement;
4
- export declare const styles = "\n * { box-sizing: border-box; }\n dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }\n .caution svg { display: block; width: 12px; height: 12px; }\n .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n .method { margin-top: 8px; }\n /* In the corner the heading leaves empty, apart from everything that was checked. */\n .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; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong { color: #6b786f; }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n 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; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
4
+ export declare const styles = "\n * { box-sizing: border-box; }\n dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n .icon { display: grid; place-items: center; padding: 0; }\n .icon svg { width: 16px; height: 16px; }\n .title { display: flex; align-items: center; gap: 6px; min-width: 0; }\n .all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }\n .all:hover { background: #f2f5f1; }\n .all svg { width: 16px; height: 16px; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n /* An element given its own display would otherwise go on showing while hidden. */\n [hidden] { display: none !important; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n /* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n .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; }\n .summary a.signed:hover { background: #f2f5f1; }\n .summary .signed.bad { border-color: #b3261e; color: #b3261e; font-weight: 650; cursor: help; }\n .caution { display: inline-block; margin-left: 3px; color: #a15c00; vertical-align: -1.5px; }\n .caution svg { display: block; width: 10px; height: 10px; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n /* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */\n .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }\n .fingerprint span { display: inline-block; margin-right: .5ch; }\n .method { margin-top: 8px; }\n /* In the corner the heading leaves empty, apart from everything that was checked. */\n .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; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong, .account.retired > .named > a, .account.retired > .named > strong { color: #6b786f; }\n /* The name, then a dot and the account's creation date. The date wraps under a long name. */\n /* Clipped 4px left of the text, which hides the dot when the date wraps to a new line. */\n .named { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 14px; margin-left: -4px; padding-left: 4px; overflow-x: clip; }\n .since { position: relative; white-space: nowrap; }\n /* The dot sits in the gap before the date. */\n .since::before { content: \"\u00B7\"; position: absolute; left: -7px; transform: translateX(-50%); }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }\n summary::-webkit-details-marker { display: none; }\n .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; }\n .fold svg, summary svg { width: 12px; height: 12px; }\n .fold[aria-expanded=\"true\"] svg, details[open] > summary svg { transform: rotate(90deg); }\n @media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }\n /* The fold and what the holder may do share a line, until a question needs the whole of it. */\n .bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }\n .account .bar .row { margin: 0 0 0 auto; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n 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; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; flex-wrap: wrap; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n .account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }\n .dots svg { width: 16px; height: 16px; }\n /* Placed under its button each time it opens, above the dialog and clipped by none of it. */\n .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; }\n .menu:popover-open { display: grid; }\n .account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }\n .menu .danger { color: #b3261e; }\n .menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
5
5
  export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
6
6
  /**
7
7
  * Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
@@ -9,6 +9,15 @@ export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text
9
9
  * take the reader off the page the pill was on. `noreferrer` keeps the opener unreachable.
10
10
  */
11
11
  export declare function outward(anchor: HTMLAnchorElement, url: string): HTMLAnchorElement;
12
+ /**
13
+ * Where the verifier lists every public record of a record's subject. A record whose
14
+ * address is not the usual one is left linking to itself.
15
+ */
16
+ export declare function subjectUrl(e: Evidence): string;
17
+ /** A cross drawn in a square, so it sits in the middle of its button in any font. */
18
+ export declare const closeMark: () => SVGSVGElement;
19
+ /** Points back the way the reader came, drawn for the same reason the cross is. */
20
+ export declare const backMark: () => SVGSVGElement;
12
21
  /** Joins the two cards: the link itself, drawn rather than described. */
13
22
  export declare function linkMark(): SVGSVGElement;
14
23
  /**
@@ -16,7 +25,12 @@ export declare function linkMark(): SVGSVGElement;
16
25
  * `extra` carries evidence belonging to that side alone: a provider authenticates and
17
26
  * expires on its own terms, and a second provider on the same subject would differ.
18
27
  */
19
- export declare function accountCard(heading: Node[], name: string, reference: string | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
28
+ export declare function accountCard(heading: Node[], name: string, reference: string | string[] | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
29
+ /**
30
+ * The card for an external account: its name, link and id, with the "Joined <date>" text
31
+ * on the name's line when the creation date is known.
32
+ */
33
+ export declare function namedCard(heading: Node[], external: ExternalAccount, ...extra: HTMLElement[]): HTMLElement;
20
34
  /**
21
35
  * A record as a card shows it. Where the card speaks for several records of one account,
22
36
  * `links` names every one not yet revoked, whether or not it is shown, so removing the
@@ -74,4 +88,9 @@ arrange?: (records: Evidence[]) => Account[],
74
88
  * Given where whoever is looking holds these accounts: each then has its renewal and
75
89
  * removal under it, and the dialog offers to connect another.
76
90
  */
77
- manage?: Manage): void;
91
+ manage?: Manage,
92
+ /**
93
+ * Whether the cards of several accounts start with their details open. They start shut
94
+ * unless the page asked otherwise, and a reader may turn each either way from there.
95
+ */
96
+ detailsOpen?: boolean): void;