@bkazemi/verily 0.9.5 → 0.10.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
@@ -118,7 +118,7 @@ const verily = createVerily({
118
118
 
119
119
  Sign-in methods need an app registered with the provider. The callback URL is always `<baseUrl>/callback`, and every place you run Verily, development included, needs its own registered.
120
120
 
121
- - **GitHub:** [register an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) and set its callback URL. An OAuth app has only one, so development and production each need their own app.
121
+ - **GitHub:** run `npx verily github-app <callback>...`, open the address it prints in a browser signed in to GitHub, and click **Create GitHub App**. It prints the client id and secret. One app can list up to 10 callback URLs, so development and production can share it. Add `--org <name>` for an organization to own the app, or `--name <name>` if the default name is taken. You can instead [register an OAuth app](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by hand, which has only one callback URL.
122
122
  - **Discord:** create an application in the [Discord developer portal](https://discord.com/developers/applications) and add the callback URL under **OAuth2 → Redirects**. One application can list several. Discord shows the client secret only when you reset it.
123
123
  - **YouTube:** in the [Google Cloud console](https://console.cloud.google.com/), enable the YouTube Data API v3, create an OAuth client of type **Web application**, and add the callback URL as an authorized redirect URI. One client can list several. The `youtube.readonly` scope is sensitive: until Google verifies your app, users see a warning and only 100 accounts can ever use it, so apply for verification before offering it publicly.
124
124
 
@@ -168,7 +168,7 @@ import { init } from '@bkazemi/verily';
168
168
  const client = init({ backendUrl: '/api/verily' });
169
169
  ```
170
170
 
171
- 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.9.5/dist/verily.js`. The script defines a global `Verily`.
171
+ 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.10.0/dist/verily.js`. The script defines a global `Verily`.
172
172
 
173
173
  Then place the badge wherever the account appears:
174
174
 
@@ -217,6 +217,8 @@ It lists up to four verified accounts, then counts the rest, such as `+2 more`.
217
217
 
218
218
  With several accounts, each card shows the name, status and verifier, with the method and dates behind a Details toggle. Add `details-open`, or pass `{ detailsOpen: true }`, to start every card open.
219
219
 
220
+ A sign-in counts until its record expires, a year by default. To ask for more recent proof, add `max-age`, such as `max-age="30d"`, or pass `{ maxAgeMs }`. A record whose account was last proved before then reads as Unconfirmed until its holder renews it. A bare number is seconds, and `s`, `m`, `h` and `d` set the unit. A published proof is reread on a schedule, so `max-age` mostly affects sign-ins and mailed codes. Each card shows when its account was last proved. For a published proof that is Last checked. For a sign-in or code it is Approved, or Authenticated where a renewal by another method moved the approval later.
221
+
220
222
  The badge refreshes every 30 seconds and reads only public evidence. The client can also start and end connections, and draw a badge into your own element:
221
223
 
222
224
  ```js
@@ -370,7 +372,7 @@ A static site only needs the script and the badge markup; the backend runs somew
370
372
 
371
373
  ```html
372
374
  <script
373
- src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.9.5/dist/verily.js"
375
+ src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.10.0/dist/verily.js"
374
376
  defer
375
377
  ></script>
376
378
  <verily-badge
@@ -460,7 +462,7 @@ const { [user.id]: connections } = await verily.connections([user.id]);
460
462
  const verified = connections.filter((c) => c.status === 'verified');
461
463
  ```
462
464
 
463
- Show only `verified` records as verified. A `retired` record carries `retiredAt` and can be shown as history, never as a current account. `mark` is `preferred` or `unused` where the user set one; it is their ordering, not something checked.
465
+ Show only `verified` records as verified. Pass `{ maxAgeMs }` to read a record whose account was last proved longer ago than that as `unconfirmed`. A `retired` record carries `retiredAt` and can be shown as history, never as a current account. `mark` is `preferred` or `unused` where the user set one; it is their ordering, not something checked.
464
466
 
465
467
  To show them, hand the records to the badge. It draws them as given and fetches nothing, so it works for unlisted links, which a browser cannot read for itself:
466
468
 
@@ -598,7 +600,7 @@ An instance sets `dns: true`.
598
600
 
599
601
  ## Operations
600
602
 
601
- Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
603
+ Verification lasts a year, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached. A published proof that `recheck()` still finds extends its record once half the term is gone, so only a sign-in or a mailed code needs renewing. Raising `validityMs` lengthens records still running on the next `recheck()`; lowering it shortens nothing.
602
604
 
603
605
  A user's DNS setup is saved for seven days. If propagation outlasts a flow's ten minutes, they choose DNS again and get the same TXT value and the last domain entered. Each return reads the proof afresh. The TXT record has to stay after verification, for the ongoing checks.
604
606
 
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, externalSince, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, externalSince, authenticatedEarlier, lastProved, localSide, proofTitle, statusLabel, } from '../core/index.js';
2
2
  import { markStyles, verificationMark } from './mark.js';
3
3
  import dialogCss from './evidence-dialog.css.js';
4
4
  import { providerMark } from './provider-mark.js';
@@ -561,11 +561,15 @@ function externalCard(evidence, manage, later = () => { }, fold, warned = () =>
561
561
  evidence.expiresAt,
562
562
  ]);
563
563
  }
564
- // When an artifact was last read belongs with the other times, not inside a sentence.
565
- // Only artifact methods drift: a sign-in is established once and does not go stale.
564
+ // When the account was last proved: a published proof when it was last read, and a
565
+ // sign-in or mailed code when it happened, where that is not already the approval date.
566
566
  const main = evidence.attestations.external[0];
567
- if (main.artifactUrl && !retired)
568
- dateRows.push(['Last checked', main.confirmedAt]);
567
+ if (!retired) {
568
+ if (main.artifactUrl)
569
+ dateRows.push(['Last checked', main.confirmedAt]);
570
+ else if (authenticatedEarlier(evidence))
571
+ dateRows.push(['Authenticated', evidence.authenticatedAt]);
572
+ }
569
573
  for (const [label, time] of dateRows) {
570
574
  dates.append(node('dt', label), node('dd', moment(time)));
571
575
  }
@@ -20,6 +20,11 @@ export interface Arrangement {
20
20
  * open or shut each either way.
21
21
  */
22
22
  detailsOpen?: boolean;
23
+ /**
24
+ * How recently an account must have been proved to count as verified, in milliseconds.
25
+ * An older record reads as unconfirmed. Unset, a record counts until it expires.
26
+ */
27
+ maxAgeMs?: number;
23
28
  }
24
29
  /**
25
30
  * Presents records the page already holds, without asking any backend for them. This is
@@ -51,9 +56,10 @@ export declare function init({ backendUrl, handoffUrl }: {
51
56
  /** The site's current public connections, so an embed need not hardcode ids. */
52
57
  listPublished(): Promise<Evidence[]>;
53
58
  getConnection(id: string): Promise<Evidence>;
54
- mountBadge(element: HTMLElement, { connectionId, evidence }: {
59
+ mountBadge(element: HTMLElement, { connectionId, evidence, maxAgeMs, }: {
55
60
  connectionId: string;
56
61
  evidence?: Evidence;
62
+ maxAgeMs?: number;
57
63
  }): Promise<void>;
58
64
  /**
59
65
  * Several of one subject's public connections as a single pill. One that cannot be
@@ -1,4 +1,4 @@
1
- import { accountsOf } from '../core/index.js';
1
+ import { accountsOf, parseMaxAge, withMaxAge } from '../core/index.js';
2
2
  import { routePath } from '../core/routes.js';
3
3
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
4
4
  import { openConnectDialog, } from './connect-dialog.js';
@@ -92,7 +92,7 @@ function details(element) {
92
92
  throw error;
93
93
  }
94
94
  }
95
- }, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
95
+ }, latestGroup.get(element), (records) => accounts(aged(element, records)), managers.get(element), openHosts.has(element));
96
96
  }
97
97
  }
98
98
  /**
@@ -103,6 +103,24 @@ function details(element) {
103
103
  const stackedHosts = new WeakSet();
104
104
  const quietHosts = new WeakSet();
105
105
  const openHosts = new WeakSet();
106
+ /** The max age each host was given, where the page set one. */
107
+ const maxAges = new WeakMap();
108
+ /** Sets or clears a host's max age. */
109
+ function bound(element, maxAgeMs) {
110
+ if (maxAgeMs === undefined)
111
+ maxAges.delete(element);
112
+ else
113
+ maxAges.set(element, maxAgeMs);
114
+ }
115
+ /**
116
+ * The records as this host's max age has them read. Call it on records already resolved
117
+ * by `standing()`, since a signed record can carry different proof dates.
118
+ */
119
+ function aged(element, records) {
120
+ const maxAgeMs = maxAges.get(element);
121
+ const now = Date.now();
122
+ return records.map((e) => withMaxAge(e, maxAgeMs, now));
123
+ }
106
124
  /**
107
125
  * How many accounts a pill names before it counts the rest. A stacked pill pays for each
108
126
  * row in the page's height. A panel floats over the page and pays nothing, but it cannot
@@ -111,7 +129,8 @@ const openHosts = new WeakSet();
111
129
  const stackedAccounts = 4;
112
130
  const peekedAccounts = 8;
113
131
  /** Records how a host's pill is arranged, from what the page last said. */
114
- function arrange(element, { stacked, peek, detailsOpen }) {
132
+ function arrange(element, { stacked, peek, detailsOpen, maxAgeMs }) {
133
+ bound(element, maxAgeMs);
115
134
  if (detailsOpen)
116
135
  openHosts.add(element);
117
136
  else
@@ -164,7 +183,7 @@ function accounts(records) {
164
183
  * `load` gives the records afresh when the dialog opens and as it stays open.
165
184
  */
166
185
  function presentGroup(element, records, load) {
167
- const ordered = accounts(records.map(standing));
186
+ const ordered = accounts(aged(element, records.map(standing)));
168
187
  const verified = ordered.filter(live);
169
188
  const lead = verified[0] ?? ordered[0];
170
189
  latestGroup.set(element, records);
@@ -448,7 +467,8 @@ export function init({ backendUrl, handoffUrl }) {
448
467
  throw new Error('Invalid evidence response');
449
468
  return data;
450
469
  },
451
- async mountBadge(element, { connectionId, evidence }) {
470
+ async mountBadge(element, { connectionId, evidence, maxAgeMs, }) {
471
+ bound(element, maxAgeMs);
452
472
  // Evidence already in hand renders at once. Otherwise a host with nothing to show
453
473
  // gets the waiting pill, whose frame and mark the finished badge keeps; a host that
454
474
  // already shows a badge keeps it up until the check answers, so a periodic refresh
@@ -477,7 +497,7 @@ export function init({ backendUrl, handoffUrl }) {
477
497
  return [fresh];
478
498
  });
479
499
  const draw = () => {
480
- const badge = renderBadge(element, standing(e));
500
+ const badge = renderBadge(element, aged(element, [standing(e)])[0]);
481
501
  // Null means the pill on screen is unchanged. Its handler stands, and reads the above.
482
502
  if (badge)
483
503
  opens(element, badge);
@@ -503,7 +523,10 @@ export function init({ backendUrl, handoffUrl }) {
503
523
  const ids = [...new Set(connectionIds)];
504
524
  arrange(element, arrangement);
505
525
  if (ids.length === 1)
506
- return client.mountBadge(element, { connectionId: ids[0] });
526
+ return client.mountBadge(element, {
527
+ connectionId: ids[0],
528
+ maxAgeMs: arrangement.maxAgeMs,
529
+ });
507
530
  if (!badgeShown(element))
508
531
  renderBadgePending(element);
509
532
  const read = async () => {
@@ -713,6 +736,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
713
736
  'stacked',
714
737
  'peek',
715
738
  'details-open',
739
+ 'max-age',
716
740
  ];
717
741
  handed;
718
742
  /**
@@ -773,6 +797,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
773
797
  stacked: this.getAttribute('stacked') !== null,
774
798
  peek: this.getAttribute('peek') !== 'off',
775
799
  detailsOpen: this.getAttribute('details-open') !== null,
800
+ maxAgeMs: parseMaxAge(this.getAttribute('max-age')),
776
801
  };
777
802
  // The holder's own badge, where the page says so: its dialog connects another
778
803
  // account, renews one and removes one, through the backend named. The host hears
@@ -838,7 +863,11 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
838
863
  return;
839
864
  }
840
865
  if (backendUrl)
841
- void init({ backendUrl }).mountBadge(this, { connectionId, evidence });
866
+ void init({ backendUrl }).mountBadge(this, {
867
+ connectionId,
868
+ evidence,
869
+ maxAgeMs: arrangement.maxAgeMs,
870
+ });
842
871
  }
843
872
  });
844
873
  }
@@ -615,6 +615,30 @@ export declare function fresh(attestation: Attestation, now: number, freshness?:
615
615
  * proved when it happened, and a published proof when it was last read.
616
616
  */
617
617
  export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>): number;
618
+ /**
619
+ * Whether a sign-in or mailed code happened more than a day before the record was last
620
+ * approved. That only happens after a renewal by another method. Otherwise the two dates
621
+ * match and a card shows only the approval.
622
+ */
623
+ export declare function authenticatedEarlier(evidence: Pick<Evidence, 'authenticatedAt' | 'approvedAt'>): boolean;
624
+ /**
625
+ * Whether a record's account was last proved within `maxAgeMs` of `now`. A reader who
626
+ * wants recent proof sets its own bound: a sign-in counts once, a year ago or this morning,
627
+ * and only the reader knows how old is too old.
628
+ */
629
+ export declare function recent(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>, maxAgeMs: number, now: number): boolean;
630
+ /**
631
+ * The record as a reader with a `maxAgeMs` bound sees it. A verified record last proved
632
+ * before the bound reads as unconfirmed: not disproved, only not proved recently enough.
633
+ * With no bound the record is returned as is.
634
+ */
635
+ export declare function withMaxAge<T extends Evidence>(evidence: T, maxAgeMs: number | undefined, now: number): T;
636
+ /**
637
+ * A max age written as a whole number with an optional unit: `s`, `m`, `h` or `d`. A bare
638
+ * number is seconds, as in HTTP and OpenID Connect. Returns milliseconds, or undefined
639
+ * for anything else.
640
+ */
641
+ export declare function parseMaxAge(text: string | null | undefined): number | undefined;
618
642
  /**
619
643
  * The word for a record's state. Evidence can be read after it was issued, so an approval
620
644
  * that has run out since reads as expired whatever status it was issued with.
@@ -80,6 +80,46 @@ export function lastProved(evidence) {
80
80
  const main = evidence.attestations.external[0];
81
81
  return main.artifactUrl ? main.confirmedAt : evidence.authenticatedAt;
82
82
  }
83
+ /**
84
+ * Whether a sign-in or mailed code happened more than a day before the record was last
85
+ * approved. That only happens after a renewal by another method. Otherwise the two dates
86
+ * match and a card shows only the approval.
87
+ */
88
+ export function authenticatedEarlier(evidence) {
89
+ return evidence.approvedAt - evidence.authenticatedAt > 86400000;
90
+ }
91
+ /**
92
+ * Whether a record's account was last proved within `maxAgeMs` of `now`. A reader who
93
+ * wants recent proof sets its own bound: a sign-in counts once, a year ago or this morning,
94
+ * and only the reader knows how old is too old.
95
+ */
96
+ export function recent(evidence, maxAgeMs, now) {
97
+ return now - lastProved(evidence) <= maxAgeMs;
98
+ }
99
+ /**
100
+ * The record as a reader with a `maxAgeMs` bound sees it. A verified record last proved
101
+ * before the bound reads as unconfirmed: not disproved, only not proved recently enough.
102
+ * With no bound the record is returned as is.
103
+ */
104
+ export function withMaxAge(evidence, maxAgeMs, now) {
105
+ return maxAgeMs !== undefined &&
106
+ evidence.status === 'verified' &&
107
+ !recent(evidence, maxAgeMs, now)
108
+ ? { ...evidence, status: 'unconfirmed' }
109
+ : evidence;
110
+ }
111
+ /**
112
+ * A max age written as a whole number with an optional unit: `s`, `m`, `h` or `d`. A bare
113
+ * number is seconds, as in HTTP and OpenID Connect. Returns milliseconds, or undefined
114
+ * for anything else.
115
+ */
116
+ export function parseMaxAge(text) {
117
+ const match = /^\s*(\d+)\s*([smhd]?)\s*$/.exec(text ?? '');
118
+ if (!match)
119
+ return undefined;
120
+ const unit = { '': 1000, s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]];
121
+ return Number(match[1]) * unit;
122
+ }
83
123
  /**
84
124
  * The word for a record's state. Evidence can be read after it was issued, so an approval
85
125
  * that has run out since reads as expired whatever status it was issued with.
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
2
3
  import { serve } from './serve.js';
3
4
  import { init } from './init.js';
5
+ import { githubApp } from './github-app.js';
4
6
  const usage = `Usage:
5
7
  verily serve Run an instance on Node, from the environment.
6
- verily init cloudflare [dir] Write a Cloudflare project into dir.`;
8
+ verily init cloudflare [dir] Write a Cloudflare project into dir.
9
+ verily github-app <callback>... [--org <name>] [--name <name>]
10
+ Create a GitHub App for sign-in, up to 10 callbacks.`;
7
11
  /** Writes the starter, then says what to do with it. */
8
12
  async function start(directory) {
9
13
  await init(directory);
@@ -15,6 +19,29 @@ Next, in that directory:
15
19
  3. npx wrangler secret put OWNER_KEY
16
20
  4. npm run deploy`);
17
21
  }
22
+ /** Runs the manifest flow, then prints the app's credentials. */
23
+ async function createGithubApp(args) {
24
+ const { values, positionals } = parseArgs({
25
+ args,
26
+ allowPositionals: true,
27
+ options: { org: { type: 'string' }, name: { type: 'string' } },
28
+ });
29
+ const app = await githubApp({
30
+ callbacks: positionals,
31
+ org: values.org,
32
+ name: values.name,
33
+ onReady: (url) => console.log(`Open ${url} in a browser signed in to GitHub.`),
34
+ });
35
+ console.log(`Created ${app.htmlUrl}
36
+
37
+ GITHUB_CLIENT_ID=${app.clientId}
38
+ GITHUB_CLIENT_SECRET=${app.clientSecret}
39
+
40
+ Keep the secret on the server. GitHub shows it only this once.
41
+
42
+ To show a logo when people sign in, upload one under Display information at
43
+ ${app.htmlUrl}`);
44
+ }
18
45
  const [command, target, directory] = process.argv.slice(2);
19
46
  try {
20
47
  if (command === 'serve') {
@@ -25,6 +52,9 @@ try {
25
52
  else if (command === 'init' && target === 'cloudflare') {
26
53
  await start(directory ?? '.');
27
54
  }
55
+ else if (command === 'github-app') {
56
+ await createGithubApp(process.argv.slice(3));
57
+ }
28
58
  else {
29
59
  console.error(usage);
30
60
  process.exit(command === undefined || command === '--help' || command === '-h' ? 0 : 2);
@@ -0,0 +1,26 @@
1
+ /** What `githubApp()` gets back from GitHub once the app is created. */
2
+ export interface GithubAppCredentials {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ /** The app's settings page on GitHub. */
6
+ htmlUrl: string;
7
+ }
8
+ /**
9
+ * Registers a GitHub App for sign-in through GitHub's manifest flow. A local server serves a
10
+ * form that posts the manifest to GitHub, the holder clicks Create there, and GitHub
11
+ * redirects back with a code that is exchanged for the app's client id and secret.
12
+ *
13
+ * The app is public, so any GitHub account can sign in with it, and asks for no
14
+ * permissions and no webhook.
15
+ */
16
+ export declare function githubApp(options: {
17
+ /** The callback URLs to register, at most 10. */
18
+ callbacks: string[];
19
+ /** The organization to own the app. Without one, the signed-in GitHub account owns it. */
20
+ org?: string;
21
+ /** The app's name, unique across GitHub. Defaults to one made from the first callback. */
22
+ name?: string;
23
+ fetch?: typeof fetch;
24
+ /** Called with the local address to open in a browser. */
25
+ onReady: (url: string) => void;
26
+ }): Promise<GithubAppCredentials>;
@@ -0,0 +1,94 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { escape } from '../server/escape.js';
4
+ /**
5
+ * Registers a GitHub App for sign-in through GitHub's manifest flow. A local server serves a
6
+ * form that posts the manifest to GitHub, the holder clicks Create there, and GitHub
7
+ * redirects back with a code that is exchanged for the app's client id and secret.
8
+ *
9
+ * The app is public, so any GitHub account can sign in with it, and asks for no
10
+ * permissions and no webhook.
11
+ */
12
+ export async function githubApp(options) {
13
+ const { callbacks, org } = options;
14
+ const request = options.fetch ?? fetch;
15
+ if (callbacks.length === 0)
16
+ throw new Error('Name at least one callback URL');
17
+ if (callbacks.length > 10)
18
+ throw new Error('GitHub allows at most 10 callback URLs');
19
+ for (const callback of callbacks)
20
+ if (!URL.canParse(callback) || !/^https?:$/.test(new URL(callback).protocol))
21
+ throw new Error(`${callback} is not an http or https URL`);
22
+ if (org !== undefined && !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(org))
23
+ throw new Error(`${org} is not a GitHub organization name`);
24
+ // GitHub caps app names at 34 characters.
25
+ const name = options.name ?? `Verily ${new URL(callbacks[0]).hostname}`.slice(0, 34);
26
+ const state = randomBytes(16).toString('base64url');
27
+ const server = createServer();
28
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
29
+ const local = `http://127.0.0.1:${server.address().port}`;
30
+ const manifest = {
31
+ name,
32
+ description: `Sign-in for Verily on ${new URL(callbacks[0]).hostname}.`,
33
+ url: new URL(callbacks[0]).origin,
34
+ redirect_url: `${local}/done`,
35
+ callback_urls: callbacks,
36
+ public: true,
37
+ hook_attributes: { url: new URL(callbacks[0]).origin, active: false },
38
+ default_permissions: {},
39
+ };
40
+ const target = new URL(org
41
+ ? `https://github.com/organizations/${org}/settings/apps/new`
42
+ : 'https://github.com/settings/apps/new');
43
+ target.searchParams.set('state', state);
44
+ const credentials = new Promise((resolve, reject) => {
45
+ server.on('request', (req, res) => {
46
+ const url = new URL(req.url ?? '/', local);
47
+ const reply = (status, title, text, body = '') => {
48
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
49
+ res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><p>${escape(text)}</p>${body}`);
50
+ };
51
+ if (url.pathname === '/') {
52
+ // The form submits itself, with a button for when scripts are off.
53
+ return reply(200, 'Create the GitHub App', 'Sending you to GitHub to create the app.', `<form method="post" action="${escape(target.href)}">` +
54
+ `<input type="hidden" name="manifest" value="${escape(JSON.stringify(manifest))}">` +
55
+ '<button>Continue to GitHub</button></form>' +
56
+ '<script>document.forms[0].submit()</script>');
57
+ }
58
+ if (url.pathname !== '/done')
59
+ return reply(404, 'Not found', 'Not found.');
60
+ const code = url.searchParams.get('code');
61
+ // A request without the state this run made did not come from GitHub's redirect.
62
+ if (url.searchParams.get('state') !== state || !code)
63
+ return reply(400, 'Wrong link', 'This link is not from this run. Nothing was created.');
64
+ void convert(code).then((result) => {
65
+ reply(200, 'GitHub App created', 'The app is created. Go back to the terminal.');
66
+ resolve(result);
67
+ }, (error) => {
68
+ reply(502, 'Failed', 'GitHub did not return the app. See the terminal.');
69
+ reject(error);
70
+ });
71
+ });
72
+ });
73
+ async function convert(code) {
74
+ const response = await request(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
75
+ method: 'POST',
76
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Verily-V0' },
77
+ signal: AbortSignal.timeout(15000),
78
+ });
79
+ const app = await response.json().catch(() => undefined);
80
+ if (!response.ok ||
81
+ !isRecord(app) ||
82
+ typeof app.client_id !== 'string' ||
83
+ typeof app.client_secret !== 'string' ||
84
+ typeof app.html_url !== 'string')
85
+ throw new Error(`GitHub did not return the app (HTTP ${response.status})`);
86
+ return { clientId: app.client_id, clientSecret: app.client_secret, htmlUrl: app.html_url };
87
+ }
88
+ options.onReady(`${local}/`);
89
+ // Closing waits for the last reply to be sent, then drops idle connections.
90
+ return await credentials.finally(() => server.close());
91
+ }
92
+ function isRecord(value) {
93
+ return typeof value === 'object' && value !== null;
94
+ }
@@ -907,8 +907,10 @@ class Server {
907
907
  html `<p class="evidence"><a href="${e.evidenceUrl}">Inspect evidence</a></p>`, html `<dl>${rows.map(([label, value]) => html `<dt>${label}</dt><dd>${value}</dd>`)}</dl>`, shown && html `<p class="fine">${shown}</p>`, !site &&
908
908
  e.visibility === 'public' &&
909
909
  html `
910
- <p class="fine">Embed on your site</p>
911
- <pre><code>${highlightHtml(embed)}</code></pre>
910
+ <details class="embed">
911
+ <summary class="fine">Embed on your site</summary>
912
+ <pre><code>${highlightHtml(embed)}</code></pre>
913
+ </details>
912
914
  `, html `
913
915
  ${(goes.length > 0 || listing) && html `<div class="actions">${goes}${listing}</div>`}
914
916
  ${ending}
@@ -20,7 +20,7 @@ const wrangler = `// Your instance's Cloudflare settings. Fill in account_id and
20
20
  "account_id": "YOUR_CLOUDFLARE_ACCOUNT_ID",
21
21
  "main": "worker.ts",
22
22
  "compatibility_date": "2026-07-01",
23
- "compatibility_flags": ["nodejs_compat"],
23
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
24
24
  // The library loads undici only on Node, so the Worker build gets a stand-in for it.
25
25
  "alias": { "undici": "@bkazemi/verily/instance/undici" },
26
26
 
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, authenticatedEarlier, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
2
2
  import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
3
3
  import { copyScript, waitScript } from './copy.js';
4
4
  import { html } from './html.js';
@@ -400,7 +400,9 @@ function accountCard(e, external, now) {
400
400
  : [
401
401
  ['Approved', e.approvedAt],
402
402
  [e.expiresAt > now ? 'Valid until' : 'Expired on', e.expiresAt],
403
- ['Last checked', external[0].artifactUrl ? external[0].confirmedAt : undefined],
403
+ external[0].artifactUrl
404
+ ? ['Last checked', external[0].confirmedAt]
405
+ : ['Authenticated', authenticatedEarlier(e) ? e.authenticatedAt : undefined],
404
406
  ])}
405
407
  </div>
406
408
  `,
@@ -142,6 +142,8 @@ export declare class VerilyService {
142
142
  /** What a signed record says, if this instance signed it. */
143
143
  checked(signed: unknown): Promise<SignedDocument | undefined>;
144
144
  private get sendLimits();
145
+ /** How long an approval stands. A published proof that is still up extends it. */
146
+ private get validity();
145
147
  /** A proof is only as fresh as the schedule that reads it, so both live together. */
146
148
  private get freshness();
147
149
  validateLocal(local: LocalAccount): LocalAccount;
@@ -173,6 +173,10 @@ export class VerilyService {
173
173
  address: this.options.sendLimits?.address ?? 5,
174
174
  };
175
175
  }
176
+ /** How long an approval stands. A published proof that is still up extends it. */
177
+ get validity() {
178
+ return this.options.validityMs ?? 365 * 86400000;
179
+ }
176
180
  /** A proof is only as fresh as the schedule that reads it, so both live together. */
177
181
  get freshness() {
178
182
  return this.options.freshnessMs ?? freshnessMs;
@@ -925,7 +929,7 @@ export class VerilyService {
925
929
  authenticatedAt: flow.authenticatedAt,
926
930
  approvedAt: this.now(),
927
931
  connectedAt: this.now(),
928
- expiresAt: this.now() + (this.options.validityMs ?? 30 * 86400000),
932
+ expiresAt: this.now() + this.validity,
929
933
  attestations: {
930
934
  local: this.declared(flow),
931
935
  external: [this.attestation(flow, provider, connectionId)],
@@ -1229,7 +1233,7 @@ export class VerilyService {
1229
1233
  const created = createdAt === undefined ? {} : { createdAt };
1230
1234
  connection.local = flow.local;
1231
1235
  connection.approvedAt = this.now();
1232
- connection.expiresAt = this.now() + (this.options.validityMs ?? 30 * 86400000);
1236
+ connection.expiresAt = this.now() + this.validity;
1233
1237
  connection.revocationReason = undefined;
1234
1238
  if (attestation.method === main.method) {
1235
1239
  connection.authenticatedAt = flow.authenticatedAt;
@@ -1537,6 +1541,19 @@ export class VerilyService {
1537
1541
  const interval = this.options.recheckMs ?? 86400000;
1538
1542
  const now = this.now();
1539
1543
  const spent = new Map();
1544
+ // Validity counts from approval, so a longer term set since applies to running records.
1545
+ await this.transaction(async (tx) => {
1546
+ for (const connection of await tx.list('connections')) {
1547
+ const term = connection.approvedAt + this.validity;
1548
+ if (connection.revokedAt === undefined &&
1549
+ connection.retiredAt === undefined &&
1550
+ connection.expiresAt > now &&
1551
+ connection.expiresAt < term) {
1552
+ connection.expiresAt = term;
1553
+ await tx.put('connections', connection.id, connection);
1554
+ }
1555
+ }
1556
+ });
1540
1557
  // When a proof was last read, or last tried. One whose last read did not confirm it,
1541
1558
  // because the read failed or the proof was missing, is tried again four times as soon.
1542
1559
  const tried = (a) => a.readAt ?? a.confirmedAt;
@@ -1718,6 +1735,10 @@ export class VerilyService {
1718
1735
  }
1719
1736
  held.confirmedAt = started;
1720
1737
  delete held.absentAt;
1738
+ // A proof still up keeps its record running. It is moved only once half the term is
1739
+ // gone, so a daily read does not report a new expiry every time.
1740
+ if (current.expiresAt > started && current.expiresAt - started < this.validity / 2)
1741
+ current.expiresAt = started + this.validity;
1721
1742
  // A zone can start or stop being signed, so the flag is as the last read found it.
1722
1743
  if (dnssec)
1723
1744
  held.dnssec = true;
@@ -1,2 +1,2 @@
1
- declare const _default: ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '\u00B7';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
1
+ declare const _default: ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '\u00B7';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n\n/* The embed snippet is copied once, so it stays closed until asked for. */\n.embed summary {\n width: fit-content;\n cursor: pointer;\n}\n.embed summary:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.embed pre {\n margin-top: 0;\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
2
2
  export default _default;
@@ -1,2 +1,2 @@
1
1
  // Generated from style.css by scripts/css.mjs. Do not edit.
2
- export default ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '·';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
2
+ export default ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '·';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n\n/* The embed snippet is copied once, so it stays closed until asked for. */\n.embed summary {\n width: fit-content;\n cursor: pointer;\n}\n.embed summary:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.embed pre {\n margin-top: 0;\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
@@ -8,7 +8,7 @@
8
8
  * leaves. So the styling does the two things that helps with, a measured column and a
9
9
  * legible hierarchy, and nothing that would make a record look like a product.
10
10
  */
11
- export declare const stylesheet = ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '\u00B7';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
11
+ export declare const stylesheet = ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '\u00B7';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n\n/* The embed snippet is copied once, so it stays closed until asked for. */\n.embed summary {\n width: fit-content;\n cursor: pointer;\n}\n.embed summary:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.embed pre {\n margin-top: 0;\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
12
12
  /**
13
13
  * A short digest of the sheet above, put in the link's query so a release that changes the
14
14
  * styling changes its address. Without that the only safe cache is no cache: a reader who
@@ -1,4 +1,4 @@
1
- import type { Evidence, LocalKind, Visibility } from '../core/index.js';
1
+ import { type Evidence, type LocalKind, type Visibility } from '../core/index.js';
2
2
  /**
3
3
  * For a site that uses a hosted Verily instance instead of running its own. The site's
4
4
  * backend does three things, and this is all of them: it vouches to the instance for its
@@ -120,10 +120,13 @@ export declare function createSiteClient(options: SiteClientOptions): {
120
120
  * them with. Only this site can read them. Every id asked for is in the answer, with an
121
121
  * empty list for a subject who has none. Revoked and expired records are included, so
122
122
  * show one only when its `status` is `verified`. With `cacheMs` set, an answer read
123
- * within it is reused unless `fresh` asks for a new one.
123
+ * within it is reused unless `fresh` asks for a new one. With `maxAgeMs` set, a
124
+ * verified record whose account was last proved longer ago than that reads as
125
+ * `unconfirmed`.
124
126
  */
125
- connections(ids: string[], { fresh }?: {
127
+ connections(ids: string[], { fresh, maxAgeMs }?: {
126
128
  fresh?: boolean;
129
+ maxAgeMs?: number;
127
130
  }): Promise<Record<string, Evidence[]>>;
128
131
  /**
129
132
  * For the return endpoint: the result a user came back with, if the instance signed it
@@ -1,3 +1,4 @@
1
+ import { withMaxAge } from '../core/index.js';
1
2
  /** How many subjects the instance reads in one request. */
2
3
  const maxReadIds = 50;
3
4
  const encoder = new TextEncoder();
@@ -127,9 +128,11 @@ export function createSiteClient(options) {
127
128
  * them with. Only this site can read them. Every id asked for is in the answer, with an
128
129
  * empty list for a subject who has none. Revoked and expired records are included, so
129
130
  * show one only when its `status` is `verified`. With `cacheMs` set, an answer read
130
- * within it is reused unless `fresh` asks for a new one.
131
+ * within it is reused unless `fresh` asks for a new one. With `maxAgeMs` set, a
132
+ * verified record whose account was last proved longer ago than that reads as
133
+ * `unconfirmed`.
131
134
  */
132
- async connections(ids, { fresh = false } = {}) {
135
+ async connections(ids, { fresh = false, maxAgeMs } = {}) {
133
136
  // No prototype, so an id such as `__proto__` is an entry like any other.
134
137
  const answer = Object.create(null);
135
138
  const found = Object.create(null);
@@ -169,7 +172,7 @@ export function createSiteClient(options) {
169
172
  }
170
173
  }
171
174
  for (const [id, name] of names)
172
- answer[id] = found[name] ?? [];
175
+ answer[id] = (found[name] ?? []).map((e) => withMaxAge(e, maxAgeMs, now));
173
176
  return answer;
174
177
  },
175
178
  /**
package/dist/verily.js CHANGED
@@ -9577,6 +9577,7 @@ var Verily = (() => {
9577
9577
  Refused: () => Refused,
9578
9578
  accountsOf: () => accountsOf,
9579
9579
  attestationLabel: () => attestationLabel,
9580
+ authenticatedEarlier: () => authenticatedEarlier,
9580
9581
  dialogPath: () => dialogPath,
9581
9582
  dialogRoute: () => dialogRoute,
9582
9583
  expired: () => expired,
@@ -9593,17 +9594,20 @@ var Verily = (() => {
9593
9594
  isRedirectProvider: () => isRedirectProvider,
9594
9595
  lastProved: () => lastProved,
9595
9596
  localSide: () => localSide,
9597
+ parseMaxAge: () => parseMaxAge,
9596
9598
  place: () => place,
9597
9599
  presentConnections: () => presentConnections,
9598
9600
  proofTitle: () => proofTitle,
9599
9601
  providerMethod: () => providerMethod,
9602
+ recent: () => recent,
9600
9603
  routeOf: () => routeOf,
9601
9604
  routePath: () => routePath,
9602
9605
  routes: () => routes,
9603
9606
  signedBy: () => signedBy,
9604
9607
  status: () => status,
9605
9608
  statusLabel: () => statusLabel,
9606
- verifySigned: () => verifySigned
9609
+ verifySigned: () => verifySigned,
9610
+ withMaxAge: () => withMaxAge
9607
9611
  });
9608
9612
 
9609
9613
  // src/core/signed.ts
@@ -9753,6 +9757,21 @@ var Verily = (() => {
9753
9757
  const main = evidence.attestations.external[0];
9754
9758
  return main.artifactUrl ? main.confirmedAt : evidence.authenticatedAt;
9755
9759
  }
9760
+ function authenticatedEarlier(evidence) {
9761
+ return evidence.approvedAt - evidence.authenticatedAt > 864e5;
9762
+ }
9763
+ function recent(evidence, maxAgeMs, now) {
9764
+ return now - lastProved(evidence) <= maxAgeMs;
9765
+ }
9766
+ function withMaxAge(evidence, maxAgeMs, now) {
9767
+ return maxAgeMs !== void 0 && evidence.status === "verified" && !recent(evidence, maxAgeMs, now) ? { ...evidence, status: "unconfirmed" } : evidence;
9768
+ }
9769
+ function parseMaxAge(text) {
9770
+ const match = /^\s*(\d+)\s*([smhd]?)\s*$/.exec(text ?? "");
9771
+ if (!match) return void 0;
9772
+ const unit = { "": 1e3, s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[match[2]];
9773
+ return Number(match[1]) * unit;
9774
+ }
9756
9775
  function statusLabel(evidence, now) {
9757
9776
  if (evidence.status === "revoked") return "Revoked";
9758
9777
  if (evidence.status === "retired") return "Retired";
@@ -10987,7 +11006,7 @@ ${claims(evidence)}`;
10987
11006
  }
10988
11007
 
10989
11008
  // src/version.ts
10990
- var version = "v0.9.5";
11009
+ var version = "v0.10.0";
10991
11010
 
10992
11011
  // src/browser/evidence-dialog.ts
10993
11012
  function stampLink() {
@@ -11412,7 +11431,11 @@ ${claims(evidence)}`;
11412
11431
  ]);
11413
11432
  }
11414
11433
  const main = evidence.attestations.external[0];
11415
- if (main.artifactUrl && !retired) dateRows.push(["Last checked", main.confirmedAt]);
11434
+ if (!retired) {
11435
+ if (main.artifactUrl) dateRows.push(["Last checked", main.confirmedAt]);
11436
+ else if (authenticatedEarlier(evidence))
11437
+ dateRows.push(["Authenticated", evidence.authenticatedAt]);
11438
+ }
11416
11439
  for (const [label, time] of dateRows) {
11417
11440
  dates.append(node("dt", label), node("dd", moment(time)));
11418
11441
  }
@@ -12228,7 +12251,7 @@ ${record2.external.id}`;
12228
12251
  }
12229
12252
  },
12230
12253
  latestGroup.get(element),
12231
- accounts,
12254
+ (records) => accounts(aged(element, records)),
12232
12255
  managers.get(element),
12233
12256
  openHosts.has(element)
12234
12257
  );
@@ -12237,9 +12260,20 @@ ${record2.external.id}`;
12237
12260
  var stackedHosts = /* @__PURE__ */ new WeakSet();
12238
12261
  var quietHosts = /* @__PURE__ */ new WeakSet();
12239
12262
  var openHosts = /* @__PURE__ */ new WeakSet();
12263
+ var maxAges = /* @__PURE__ */ new WeakMap();
12264
+ function bound(element, maxAgeMs) {
12265
+ if (maxAgeMs === void 0) maxAges.delete(element);
12266
+ else maxAges.set(element, maxAgeMs);
12267
+ }
12268
+ function aged(element, records) {
12269
+ const maxAgeMs = maxAges.get(element);
12270
+ const now = Date.now();
12271
+ return records.map((e2) => withMaxAge(e2, maxAgeMs, now));
12272
+ }
12240
12273
  var stackedAccounts = 4;
12241
12274
  var peekedAccounts = 8;
12242
- function arrange(element, { stacked, peek, detailsOpen }) {
12275
+ function arrange(element, { stacked, peek, detailsOpen, maxAgeMs }) {
12276
+ bound(element, maxAgeMs);
12243
12277
  if (detailsOpen) openHosts.add(element);
12244
12278
  else openHosts.delete(element);
12245
12279
  if (stacked) stackedHosts.add(element);
@@ -12259,7 +12293,7 @@ ${record2.external.id}`;
12259
12293
  }));
12260
12294
  }
12261
12295
  function presentGroup(element, records, load2) {
12262
- const ordered = accounts(records.map(standing));
12296
+ const ordered = accounts(aged(element, records.map(standing)));
12263
12297
  const verified = ordered.filter(live);
12264
12298
  const lead = verified[0] ?? ordered[0];
12265
12299
  latestGroup.set(element, records);
@@ -12341,8 +12375,8 @@ ${record2.external.id}`;
12341
12375
  });
12342
12376
  if (!response.ok) throw new Error("Verily request unavailable");
12343
12377
  const answer = await response.json();
12344
- const bound = response.headers.get("X-Verily-Flow");
12345
- if (bound && record(answer) && typeof answer.id === "string") bindings.set(answer.id, bound);
12378
+ const bound2 = response.headers.get("X-Verily-Flow");
12379
+ if (bound2 && record(answer) && typeof answer.id === "string") bindings.set(answer.id, bound2);
12346
12380
  return answer;
12347
12381
  }
12348
12382
  async function request(path, data) {
@@ -12491,7 +12525,12 @@ ${record2.external.id}`;
12491
12525
  if (!validEvidence(data)) throw new Error("Invalid evidence response");
12492
12526
  return data;
12493
12527
  },
12494
- async mountBadge(element, { connectionId, evidence }) {
12528
+ async mountBadge(element, {
12529
+ connectionId,
12530
+ evidence,
12531
+ maxAgeMs
12532
+ }) {
12533
+ bound(element, maxAgeMs);
12495
12534
  if (!evidence && !badgeShown(element)) renderBadgePending(element);
12496
12535
  const current = begin(element);
12497
12536
  try {
@@ -12508,7 +12547,7 @@ ${record2.external.id}`;
12508
12547
  return [fresh2];
12509
12548
  });
12510
12549
  const draw = () => {
12511
- const badge = renderBadge(element, standing(e2));
12550
+ const badge = renderBadge(element, aged(element, [standing(e2)])[0]);
12512
12551
  if (badge) opens(element, badge);
12513
12552
  };
12514
12553
  draw();
@@ -12527,7 +12566,11 @@ ${record2.external.id}`;
12527
12566
  async mountBadges(element, { connectionIds, ...arrangement }) {
12528
12567
  const ids = [...new Set(connectionIds)];
12529
12568
  arrange(element, arrangement);
12530
- if (ids.length === 1) return client.mountBadge(element, { connectionId: ids[0] });
12569
+ if (ids.length === 1)
12570
+ return client.mountBadge(element, {
12571
+ connectionId: ids[0],
12572
+ maxAgeMs: arrangement.maxAgeMs
12573
+ });
12531
12574
  if (!badgeShown(element)) renderBadgePending(element);
12532
12575
  const read2 = async () => {
12533
12576
  const answers = await Promise.allSettled(ids.map((id) => client.getConnection(id)));
@@ -12672,7 +12715,8 @@ ${record2.external.id}`;
12672
12715
  "connect",
12673
12716
  "stacked",
12674
12717
  "peek",
12675
- "details-open"
12718
+ "details-open",
12719
+ "max-age"
12676
12720
  ];
12677
12721
  handed;
12678
12722
  /**
@@ -12727,7 +12771,8 @@ ${record2.external.id}`;
12727
12771
  const arrangement = {
12728
12772
  stacked: this.getAttribute("stacked") !== null,
12729
12773
  peek: this.getAttribute("peek") !== "off",
12730
- detailsOpen: this.getAttribute("details-open") !== null
12774
+ detailsOpen: this.getAttribute("details-open") !== null,
12775
+ maxAgeMs: parseMaxAge(this.getAttribute("max-age"))
12731
12776
  };
12732
12777
  if (backendUrl && (handoffUrl !== null || this.getAttribute("connect") !== null)) {
12733
12778
  const client = init({ backendUrl, handoffUrl: handoffUrl ?? void 0 });
@@ -12781,7 +12826,12 @@ ${record2.external.id}`;
12781
12826
  renderBadgePending(this);
12782
12827
  return;
12783
12828
  }
12784
- if (backendUrl) void init({ backendUrl }).mountBadge(this, { connectionId, evidence });
12829
+ if (backendUrl)
12830
+ void init({ backendUrl }).mountBadge(this, {
12831
+ connectionId,
12832
+ evidence,
12833
+ maxAgeMs: arrangement.maxAgeMs
12834
+ });
12785
12835
  }
12786
12836
  }
12787
12837
  );
package/dist/version.d.ts CHANGED
@@ -7,4 +7,4 @@
7
7
  * output stays what tsc emitted and nothing has to be substituted into it. A version bump
8
8
  * changes this line with package.json.
9
9
  */
10
- export declare const version = "v0.9.5";
10
+ export declare const version = "v0.10.0";
package/dist/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  * output stays what tsc emitted and nothing has to be substituted into it. A version bump
8
8
  * changes this line with package.json.
9
9
  */
10
- export const version = 'v0.9.5';
10
+ export const version = 'v0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bkazemi/verily",
3
- "version": "0.9.5",
3
+ "version": "0.10.0",
4
4
  "description": "Self-hosted account-to-account verification for Node.js",
5
5
  "type": "module",
6
6
  "license": "MIT",