You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys-App/src/lib/components/OpenPanel.svelte

647 lines
23 KiB

Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
<script lang="ts">
// The "abrir" action of the inspector (plan of phase 2, section 9): steps 9
// to 18 of spec §63 on a capsule that passed steps 1 to 8, with the release
// the person supplies directly, pasted from drand or taken from the record
// of an official fixture (decision 4: the page never fetches it), and the
// credentials that time_and_key asks for. The opening code, with noble and
// age-encryption, is imported on demand, so the page's first load does not
// carry it.
//
// The plaintext of a fixture is opened in memory and shown. The plaintext
// of the person's own file goes to a private temporary file of the browser
// (OPFS, tempfile.ts), committed only after step 18 (spec §56), offered for
// download and deleted on request, when another capsule is opened or
// loaded, and when the page is left; in memory, up to MEMORY_LIMIT, when
// the browser has no such file or refuses it. An opening in progress stops
// when the panel is destroyed, and its file is removed.
import { onDestroy, tick } from 'svelte';
import { toHex } from '$lib/dkc/index.ts';
import type { Fixture } from '$lib/inspector/fixtures.ts';
import { errorGloss, escapeInvisible, formatByteCount, formatInteger, printableText } from '$lib/inspector/format.ts';
import { buildOpenReport, type OpenReport, plaintextFileName } from '$lib/inspector/opening.ts';
import { drandReleaseURL, parseReleaseText, releaseText } from '$lib/inspector/release-input.ts';
import type { Report } from '$lib/inspector/report.ts';
import { browserPlatform, cancellable, createTempFile, freeSpace, type TempFile } from '$lib/inspector/tempfile.ts';
import ExtensionList from './ExtensionList.svelte';
import StepList from './StepList.svelte';
let {
report,
capsule,
fixture,
nowMs,
}: {
/** The inspection of the capsule: valid, with its round and profile. */
report: Report;
/** What was inspected: the bytes of a fixture or the person's file. */
capsule: Uint8Array | Blob;
/** The official fixture, when it is one. */
fixture: Fixture | undefined;
/** When the report was made. */
nowMs: number;
} = $props();
/** The largest plaintext opened in memory when the browser has no OPFS. */
const MEMORY_LIMIT = 64 << 20;
/** Printable plaintext up to this many characters is shown whole. */
const SHOWN_TEXT = 100_000;
interface Result {
readonly report: OpenReport;
readonly ms: number;
/** The plaintext offered for download: a temporary file, or memory. */
readonly download?: { readonly url: string; readonly name: string; readonly size: number; readonly temporary: boolean };
}
// Read once: the panel is made again for each inspected capsule.
const initial = (): { round: number; release: string } => ({
round: report.capsule!.round,
release: fixture?.release === undefined ? '' : releaseText(fixture.release.round, toHex(fixture.release.signature)),
});
const { round, release: initialRelease } = initial();
/** The input a problem is about. */
type ProblemField = 'release' | 'identities' | 'accessKey';
const FIELD_IDS: Readonly<Record<ProblemField, string>> = {
release: 'release-input',
identities: 'ids-input',
accessKey: 'dkk-input',
};
let releaseInput = $state(initialRelease);
let identities = $state('');
let accessKey: File | undefined = $state();
let problem: string | undefined = $state();
let problemField: ProblemField | undefined = $state();
let busy = $state(false);
let result: Result | undefined = $state();
let deleted = $state(false);
let announcement = $state('');
const timeAndKey = $derived(report.capsule?.accessPolicy === 'time_and_key');
const due = $derived(report.unlock?.epochMs !== undefined && report.unlock.epochMs <= nowMs);
const drandURL = $derived(drandReleaseURL(report.profile!.chainHash, round));
const payloadLength = $derived(report.prelude?.payloadLength ?? 0);
// The temporary file and the object URL of the last opening.
let temp: TempFile | undefined;
let objectURL: string | undefined;
// The temporary file of the opening in progress, until it becomes `temp`
// or is removed: discard() removes it too, so a panel destroyed or a page
// left in the middle of an opening leaves nothing behind.
let pending: TempFile | undefined;
// Only the latest opening may show its result; destroying the panel
// invalidates the one in progress, which then stops writing.
let openId = 0;
async function discard(): Promise<void> {
if (objectURL !== undefined) URL.revokeObjectURL(objectURL);
objectURL = undefined;
const files = [temp, pending];
temp = undefined;
pending = undefined;
await Promise.all(files.map((f) => f?.remove()));
}
onDestroy(() => {
openId++;
void discard();
});
// pagehide also fires when the page goes into the back/forward cache: if
// it comes back, it must not offer what was deleted here.
function leave(): void {
if (result?.download !== undefined) deleted = true;
void discard();
}
async function deleteNow(): Promise<void> {
await discard();
deleted = true;
announcement = 'Fichero temporal borrado.';
}
function onAccessKey(event: Event & { currentTarget: HTMLInputElement }): void {
accessKey = event.currentTarget.files?.[0];
}
async function submit(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (busy) return;
problem = undefined;
problemField = undefined;
const parsed = parseReleaseText(releaseInput, round);
if (!parsed.ok) {
await fail(parsed.problem, 'release');
return;
}
const id = ++openId;
const stale = (): boolean => id !== openId;
busy = true;
result = undefined;
deleted = false;
announcement = 'Abriendo la cápsula.';
await discard();
// This opening's temporary file, until it becomes `temp`: the finally
// block removes it on every other path.
let t: TempFile | undefined;
try {
const opener = await import('$lib/inspector/opener.ts');
if (stale()) return;
if (fixture === undefined) {
const platform = browserPlatform();
let free: number | undefined;
if (platform !== undefined) {
try {
free = await freeSpace(platform);
if (free === undefined || free >= payloadLength) t = pending = await createTempFile(platform);
} catch {
// The OPFS is there but the browser refuses it (a private window,
// site data blocked): the capsule opens in memory instead.
}
if (stale()) return;
}
if (t === undefined && payloadLength > MEMORY_LIMIT) {
await fail(
free !== undefined && free < payloadLength
? `El navegador deja ${formatByteCount(free)} libres para esta página y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Libera espacio o usa la CLI (datekeys decrypt).`
: `Este navegador no deja a la página un fichero temporal privado (OPFS) y el contenido cifrado ocupa ${formatByteCount(payloadLength)}, más de los ${formatByteCount(MEMORY_LIMIT)} que la página abre en memoria. Usa otro navegador o la CLI (datekeys decrypt).`,
);
return;
}
}
const out = t;
const attempt = await opener.openCapsule({
capsule,
release: parsed.release,
...(timeAndKey ? { identities } : {}),
...(timeAndKey && accessKey !== undefined ? { accessKey } : {}),
// A stale opening stops writing, so open aborts the file and stops.
...(out === undefined ? {} : { output: { writable: cancellable(out.writable, stale), file: () => out.file(), remove: () => out.remove() } }),
});
if (stale()) {
if (attempt.ok) attempt.plaintext?.fill(0);
return;
}
if (!attempt.ok) {
await fail(attempt.problem, attempt.field);
return;
}
const opened = attempt.opened.error === undefined;
let text: string | undefined;
if (opened && fixture !== undefined && attempt.plaintext !== undefined) {
text = printableText(attempt.plaintext);
}
const r = buildOpenReport(
attempt.opened,
attempt.digest === undefined
? undefined
: {
...attempt.digest,
...(fixture?.plaintextSHA256 === undefined ? {} : { expectedSHA256: fixture.plaintextSHA256 }),
...(text === undefined ? {} : { text }),
},
);
let download: Result['download'];
if (opened && fixture === undefined) {
const name = plaintextFileName(report.fileName);
if (t !== undefined) {
const file = await t.file();
if (stale()) return;
objectURL = URL.createObjectURL(file);
temp = t;
pending = undefined;
t = undefined;
download = { url: objectURL, name, size: file.size, temporary: true };
} else {
const blob = new Blob([attempt.plaintext! as Uint8Array<ArrayBuffer>]);
objectURL = URL.createObjectURL(blob);
download = { url: objectURL, name, size: blob.size, temporary: false };
}
}
attempt.plaintext?.fill(0);
result = download === undefined ? { report: r, ms: attempt.ms } : { report: r, ms: attempt.ms, download };
announcement = r.opened
? 'Cápsula abierta: pasos 9 a 18 superados.'
: `Apertura rechazada en el paso ${r.failure!.step}, ${r.failure!.code}.`;
await tick();
document.getElementById('open-result-title')?.focus();
} catch (err) {
if (!stale()) await fail(`No se pudo abrir: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
} finally {
// A file that did not become `temp`: a failure, or a stale opening.
if (t !== undefined) {
if (pending === t) pending = undefined;
await t.remove();
}
if (!stale()) busy = false;
}
}
// Shows why the opening could not run, announces it and moves the focus to
// the field at fault, or to the message: the submit button that had it is
// disabled while the opening runs.
async function fail(message: string, field?: ProblemField): Promise<void> {
problem = message;
problemField = field;
announcement = '';
await tick();
announcement = message;
document.getElementById(field === undefined ? 'open-problem' : FIELD_IDS[field])?.focus();
}
function seconds(ms: number): string {
return new Intl.NumberFormat('es-ES', { maximumFractionDigits: 2 }).format(ms / 1000);
}
</script>
<svelte:window onpagehide={leave} />
<section class="open" aria-labelledby="open-title">
<h3 id="open-title">Abrir la cápsula</h3>
{#if !due}
<p class="prose">
La fecha de apertura todavía no ha llegado según el reloj de este dispositivo. Hasta entonces drand no publica la
firma de la ronda {round} y nadie puede abrir la cápsula, tampoco esta página (paso 9, ERR_RELEASE_UNAVAILABLE).
</p>
{:else}
<form class="form" onsubmit={submit} novalidate>
{#if timeAndKey}
<fieldset>
<legend>Credencial de acceso</legend>
<p class="hint">
La política time_and_key pide, además de la firma de la ronda, una clave .dkk de esta cápsula o la identidad
X25519 de uno de sus destinatarios. No salen de este navegador.
</p>
<div class="field">
<label for="dkk-input">Clave .dkk</label>
<input
id="dkk-input"
type="file"
accept=".dkk"
onchange={onAccessKey}
aria-invalid={problemField === 'accessKey' ? 'true' : undefined}
aria-describedby={problemField === 'accessKey' ? 'open-problem' : undefined}
/>
</div>
<div class="field">
<label for="ids-input">Identidades X25519 de age</label>
<textarea
id="ids-input"
rows="2"
bind:value={identities}
autocomplete="off"
spellcheck="false"
placeholder="AGE-SECRET-KEY-1…"
aria-invalid={problemField === 'identities' ? 'true' : undefined}
aria-describedby={problemField === 'identities' ? 'open-problem ids-hint' : 'ids-hint'}
></textarea>
<p id="ids-hint" class="hint">Una por línea, como en un fichero de identidades de age.</p>
</div>
</fieldset>
{/if}
<div class="field">
<label for="release-input">Firma de la ronda {round}, el release que publica drand</label>
<textarea
id="release-input"
rows="3"
bind:value={releaseInput}
autocomplete="off"
spellcheck="false"
aria-invalid={problemField === 'release' ? 'true' : undefined}
aria-describedby={problemField === 'release' ? 'open-problem release-hint' : 'release-hint'}
></textarea>
<p id="release-hint" class="hint">
{#if fixture?.release !== undefined}
Viene del registro del fixture: es la que publicó drand para la ronda {round}, como muestra
<a href={drandURL} target="_blank" rel="noopener noreferrer">su página en drand</a>. Cámbiala para ver cómo la
rechaza el paso 10.
{:else}
Abre <a href={drandURL} target="_blank" rel="noopener noreferrer">la firma de la ronda {round} en drand</a> en
otra pestaña, copia todo lo que muestra y pégalo aquí; vale también la firma sola, en hexadecimal. La página no
se conecta a drand: la abres tú.
{/if}
Solo se leen la ronda y la firma, que se verifican aquí con la clave pública del perfil fijado (§51).
</p>
</div>
{#if problem}
<p id="open-problem" class="problem" tabindex="-1">{problem}</p>
{/if}
<div class="actions">
<button class="button" type="submit" disabled={busy}>{busy ? 'Abriendo…' : 'Abrir la cápsula'}</button>
{#if fixture === undefined}
<p class="hint">
El texto descifrado se escribe en un fichero temporal privado de este navegador y solo se ofrece si age lo
autentica entero (§56). Se borra cuando lo pides, al abrir o cargar otra cápsula y al salir de la página; sin
ese fichero, se abre en la memoria de la página hasta 64 MiB.
</p>
{/if}
</div>
</form>
{/if}
<p class="visually-hidden" role="status">{announcement}</p>
{#if result}
{@const r = result.report}
<div class={['verdict', r.opened ? 'valid' : 'invalid']}>
<h4 id="open-result-title" class="state-word" tabindex="-1">
{r.opened ? 'Abierta' : r.failure ? `Rechazada en el paso ${r.failure.step}` : 'Rechazada'}
</h4>
{#if r.opened}
<p>
Supera los pasos 9 a 18 en {seconds(result.ms)} s. age ha autenticado el texto entero con la clave de
PAYLOAD_AGE, pero eso no prueba quién lo escribió, ni que sea el original si otros abrieron la cápsula antes
(§55.1).
</p>
{:else if r.failure}
<p class="code">{r.failure.code}</p>
<p>{errorGloss(r.failure.code)}</p>
{#if r.steps.length === 0}
<p class="detail">{escapeInvisible(r.failure.message)}</p>
{/if}
{/if}
</div>
{#if r.steps.length > 0}
<section class="block" aria-labelledby="open-steps-title">
<h3 id="open-steps-title">Pasos 9 a 18</h3>
<StepList steps={r.steps} />
</section>
{/if}
{#if r.release}
<section class="block" aria-labelledby="release-title">
<h3 id="release-title">Release verificado</h3>
<dl>
<div>
<dt>Ronda</dt>
<dd class="mono">{r.release.round}</dd>
</div>
<div>
<dt>Firma</dt>
<dd class="mono">{r.release.signature}</dd>
</div>
</dl>
</section>
{/if}
{#if r.plaintext}
{@const p = r.plaintext}
<section class="block" aria-labelledby="plaintext-title">
<h3 id="plaintext-title">Texto en claro</h3>
<dl>
<div>
<dt>Tamaño</dt>
<dd>{formatByteCount(p.length)}</dd>
</div>
Read capsule format 2 of spec v0.9 Syncs testdata with datekeys-go at spec-v0.9 (7e2d83c) and moves the reader to the DateKeys Protocol Specification v0.9. Both capsule formats are read; a format 1 capsule keeps the verdict v0.8.2 gave it. - framing: the VERSION of the prelude is the capsule format, 1 or 2 (Prelude.format, FORMAT_1, FORMAT_2, isFormat). - control: decodeControl and encodeControl take the format; schema version 2 adds payload_length (8 bytes, at most L_MAX) and padding (1 or 2). - padding.ts: the rules bloque256 and reforzado of spec §29.1, exact up to L_MAX with BigInt bit lengths and ceil roundings, and the length of PAYLOAD_AGE. - open: exactly 16 stanzas in INNER_ACCESS_AGE of format 2 (step 12), P at step 16, and at step 17 a plaintext of exactly P bytes whose padding is zero; only the first L bytes are delivered, never the padding. Step 17 is recorded when it passes, and step 18 gives the bytes of content, as the reference does. Opened reports the format, L and, in format 2, the rule and P. - inspect: the JSON view carries format, as datekeys inspect -json. - The page shows the format, warns about format 1, and gives the padding rule and P once a format 2 capsule opens. Tests: the twelve fixtures, the 125 mutation cases through open from memory and from a Blob, the 4380 differential cases, padding.json, the format 2 CBOR vectors, and padding.test.ts against a BigInt statement of §29.1. The error texts of the 125 corpus cases were compared with capsule.Open at spec-v0.9. ibe-vectors.json gains the seven format 2 fixtures from scripts/ibe-go-vectors.go; its frozen values are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
{#if r.padding}
<div>
<dt>Relleno</dt>
<dd>
regla {r.padding.rule}: la cápsula sellaba {formatByteCount(r.padding.paddedLength)}, el contenido seguido de ceros,
que se comprobaron y no se entregan
</dd>
</div>
{/if}
Phase 2, step 8: the open action of /inspect After steps 1 to 8, a valid capsule whose date has passed on the device clock can be opened in the page: steps 9 to 18 of spec section 63 with open, loaded on demand with a dynamic import (opener.ts), so noble and age-encryption stay out of the first load of every page. - The release is supplied directly by the person (spec 63, step 10): drand's JSON answer or the bare signature, pasted after opening the drand URL the page links to, or the release in the record of an official fixture. The page never fetches it and reads only its round and signature (spec 11, 13). The CSP is unchanged. - time_and_key credentials: a .dkk (readAccessKey reads at most 12 bytes + 16 MiB + 1) or age identities, one per line. - The plaintext of the person's own file goes to a temporary OPFS file (tempfile.ts), committed only after step 18 (spec 56), offered for download and deleted on request, with another capsule, on pagehide and, if left over, on the next visit. One directory and one Web Lock per tab keep other tabs' clean-up away from files in use. Without OPFS, or when the browser refuses it, capsules up to 64 MiB open in memory. An opening in progress stops when another capsule is loaded. - opening.ts builds the page model of steps 9 to 18 as the reference records them; fixtures show their plaintext and compare its SHA-256 with their record. - licenses.txt: the notices of tlock-js (ibe.ts) and age (bech32.ts), the license of every package in the client bundle, Vite's and rolldown's runtime code, and the site's own license. check-build now fails if a notice is missing, or if a page loads noble, @scure/base or age-encryption with its first load. - The home page no longer says that the page never asks for keys. Checked in the browser on the production build: the time_only, time_and_key_portable (with its .dkk) and time_and_key_recipients (with a pasted identity) fixtures open with the SHA-256 of their records; a tampered signature fails at step 10 and a tampered STREAM chunk at step 17, with no download and no file left; an own file opens to OPFS, downloads without a CSP violation and is deleted with its lock; a left over directory goes on the next visit; no request leaves the origin. An adversarial review (four dimensions, each finding checked by a refuter) confirmed 15 findings, all fixed here. 2611 tests; coverage 100 % of the new modules, now a threshold. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
<div>
<dt>SHA-256</dt>
<dd class="mono">{p.sha256}</dd>
</div>
{#if p.expectedSHA256 !== undefined}
<div>
<dt>Registro del fixture</dt>
<dd class={p.expectedSHA256 === p.sha256 ? 'match' : 'mismatch'}>
{p.expectedSHA256 === p.sha256 ? 'coincide' : 'no coincide'}: <span class="mono">{p.expectedSHA256}</span>
</dd>
</div>
{/if}
</dl>
{#if p.text !== undefined}
{#if p.text === ''}
<p class="muted">El texto en claro está vacío.</p>
{:else}
<pre class="plaintext">{p.text.length > SHOWN_TEXT ? `${p.text.slice(0, SHOWN_TEXT)}\n…` : p.text}</pre>
{#if p.text.length > SHOWN_TEXT}
<p class="muted">Se muestran los primeros {formatInteger(SHOWN_TEXT)} caracteres.</p>
{/if}
{/if}
{:else if fixture !== undefined}
<p class="muted">No es texto UTF-8 imprimible, así que no se muestra.</p>
{/if}
{#if result.download}
{@const d = result.download}
{#if deleted}
<p class="muted">
{d.temporary
? 'Fichero temporal borrado. Para descargarlo otra vez, abre de nuevo la cápsula.'
: 'El texto descifrado ya no está en la página. Para descargarlo otra vez, abre de nuevo la cápsula.'}
</p>
{:else}
<div class="actions">
<a class="button" href={d.url} download={d.name}>Descargar {d.name}</a>
{#if d.temporary}
<button class="button quiet" type="button" onclick={deleteNow}>Borrar el fichero temporal</button>
{/if}
</div>
<p class="hint">
{#if d.temporary}
Está en un fichero temporal privado de este navegador ({formatByteCount(d.size)}). Se borra cuando lo pides, al
abrir o cargar otra cápsula y al salir de la página.
{:else}
Está en la memoria de esta página ({formatByteCount(d.size)}), porque el navegador no le deja un fichero
temporal privado. Se libera al abrir o cargar otra cápsula y al salir de la página.
{/if}
</p>
{/if}
{/if}
</section>
{/if}
{#if r.controlExtensions}
<section class="block" aria-labelledby="control-ext-title">
<h3 id="control-ext-title">Extensiones de CONTROL_CBOR</h3>
<ExtensionList extensions={r.controlExtensions} object="CONTROL_CBOR" />
</section>
{/if}
{/if}
</section>
<style>
.open {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
align-content: start;
}
/* minmax(0, 1fr) and min-width 0: a fieldset is as wide as its content
by default, which widens the page on a phone. */
.form {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.1rem;
max-width: var(--measure);
padding: 1.1rem 1.25rem 1.25rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
background: var(--paper-2);
}
fieldset {
display: grid;
grid-template-columns: minmax(0, 1fr);
min-width: 0;
gap: 0.9rem;
margin: 0;
padding: 0.75rem 1rem 1rem;
border: 1px solid var(--rule);
border-radius: var(--radius);
}
legend {
padding-inline: 0.35rem;
font-weight: 650;
}
.field {
display: grid;
gap: 0.35rem;
}
label {
font-weight: 600;
}
textarea {
width: 100%;
font-family: var(--mono);
font-size: 0.82rem;
line-height: 1.45;
padding: 0.5rem 0.6rem;
border: 1px solid var(--rule-strong);
border-radius: var(--radius);
background: var(--paper);
color: var(--ink);
resize: vertical;
overflow-wrap: anywhere;
}
input[type='file'] {
max-width: 100%;
font: inherit;
font-size: var(--t-small);
}
.hint {
font-size: var(--t-small);
color: var(--ink-muted);
max-width: var(--measure);
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
}
/* The download link quotes the file name, which may have no break points;
break-word would not let the flex item shrink below it on a phone. */
.actions a.button {
overflow-wrap: anywhere;
}
.button[disabled] {
opacity: 0.6;
cursor: progress;
}
.problem {
border-left: 4px solid var(--fail);
background: var(--fail-bg);
padding: 0.65rem 0.9rem;
border-radius: 0 var(--radius) var(--radius) 0;
overflow-wrap: anywhere;
}
.verdict {
display: grid;
gap: 0.5rem;
padding: 1.1rem 1.25rem 1.25rem;
border: 1px solid var(--rule);
border-top: 6px solid var(--state);
border-radius: var(--radius);
background: var(--paper-2);
max-width: var(--measure);
}
.verdict.valid {
--state: var(--pass);
}
.verdict.invalid {
--state: var(--fail);
}
.state-word {
font-size: var(--t-h2);
font-weight: 700;
letter-spacing: -0.015em;
line-height: 1.2;
color: var(--state);
}
.code,
.detail {
font-family: var(--mono);
font-weight: 600;
overflow-wrap: anywhere;
}
.detail {
font-size: 0.82rem;
font-weight: 500;
}
.block {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
align-content: start;
}
dl {
display: grid;
border-top: 1px solid var(--rule);
}
dl > div {
display: grid;
gap: 0.1rem 1rem;
padding-block: 0.55rem;
border-bottom: 1px solid var(--rule);
}
@media (min-width: 560px) {
dl > div {
grid-template-columns: 11rem minmax(0, 1fr);
}
}
dt {
font-size: var(--t-small);
font-weight: 600;
color: var(--ink-muted);
}
dd {
margin: 0;
overflow-wrap: anywhere;
}
dd.mono,
dd .mono {
font-size: 0.85rem;
}
dd.match {
color: var(--pass);
}
dd.mismatch {
color: var(--fail);
}
.plaintext {
max-height: 24rem;
overflow: auto;
padding: 0.85rem 1rem;
background: var(--paper-2);
border: 1px solid var(--rule);
border-radius: var(--radius);
font-size: 0.82rem;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
</style>

Powered by TurnKey Linux.