The public note on /inspect, plain errors, and no reload on the first opening

/inspect shows the public note under the verdict, as text of the creator
that nobody checked, with the warning of Go's showNote. The pages no longer
show the message of an unexpected exception: unexpectedProblem says in
Spanish what to do, and logs the exception. vite.config.ts pre-bundles the
dependencies the pages load on demand, so that the dev server does not
reload the page on the first opening and break the import in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main
dev 1 day ago
parent a741efe268
commit 2c2a28dc9f

@ -4,6 +4,13 @@ Cambios notables de la librería TypeScript y de la página. El proyecto usa ver
## Especificación 0.10, en la rama `v0.10` — sin versión
### La nota pública en `/inspect` (06-10-2026)
- `/inspect` muestra la nota pública justo debajo del veredicto: el texto del creador, en la letra serif de la página y sobre el fondo de aviso, con dos líneas que dicen que nadie la ha comprobado, que la lee cualquiera que tenga el fichero y que antes de la fecha nadie puede comprobar quién creó la cápsula ni si va firmada, como `showNote` de la CLI de Go. Una nota que no cumple las reglas de texto no se muestra, y la página lo dice.
- `buildReport` lleva la nota en `Report.note`, con su prueba. Comprobado en el navegador con el fixture `format3_note`.
- **Errores que no se esperaban.** Las páginas ya no enseñan el mensaje de una excepción, que podía venir en inglés y con una URL interna, como «Failed to fetch dynamically imported module: http://localhost:5188/src/lib/inspector/opener.ts». `unexpectedProblem` de `format.ts` lo dice en español y con lo que hay que hacer: recargar si no llegó una parte de la página, volver a elegir un fichero que el navegador ya no deja leer, liberar espacio, o, en otro caso, recargar y usar la herramienta de línea de órdenes. La excepción va a la consola. Lo usan la apertura, la lectura de una cápsula en `/inspect` y la creación en `/create`.
- **El servidor de desarrollo ya no recarga la página al abrir.** `vite.config.ts` hace que Vite prepare desde el arranque las dependencias que las páginas cargan bajo demanda (`optimizeDeps.include`). Sin eso, la primera apertura le hacía preparar dos módulos de noble y recargar la página, y la carga en curso fallaba con ese mensaje. `dependencies.test.ts` comprueba que la lista está completa. La versión compilada no tenía el problema.
### NAT64, del borrador v0.13 (06-10-2026)
- `testdata` se sincroniza con `datekeys-go` en `a83b44d`, el borrador v0.13, sin aprobar: añade `vectors/resolved_ip.json`, y ningún otro fichero cambia. `SPEC_VERSION` sigue en `0.12`.

@ -140,6 +140,7 @@ Sitio SvelteKit estático (`@sveltejs/adapter-static`, `strict`): las tres pági
- cada paso con su número, su nombre de la CLI, `superado` o el código normativo, y el detalle (los caracteres invisibles o de control se escriben como `\uXXXX`);
- el veredicto, `capsule_id`, la DateKey compacta y decodificada (red y ronda), el perfil fijado, la fecha de apertura en UTC y en la hora local del navegador, `access_policy`, los campos del prelude con el formato de la cápsula (con un aviso en el veredicto si es el 1, que no oculta el número de credenciales ni la longitud exacta del contenido, §55.2), los argumentos del stanza `tlock` frente al perfil fijado y el número y tipo de stanzas de OUTER_TIME_AGE y PAYLOAD_AGE;
- la nota pública, justo debajo del veredicto, como texto del creador sin comprobar, en la letra serif de la página y sobre el fondo de aviso, con el aviso de que antes de la fecha nadie puede comprobar quién creó la cápsula ni si va firmada, como `showNote` de la CLI de Go; si la cabecera lleva una nota que no cumple las reglas de texto, solo dice que no se muestra (§24.1);
- las extensiones de PUBLIC_HEADER según el contrato del plan §8: id (entre comillas y escapado si tiene caracteres no imprimibles), versión, crítica o no, conocida o no, longitud y hex (plegado si pasa de 64 bytes); texto si los bytes son UTF-8 imprimible; vista CBOR con `walk` si son un ítem del perfil de §58, marcada "informativo, no validado por el protocolo"; y el aviso de que son públicas, de que nada las vincula al resto de la cápsula antes del paso 15 y de que ni entonces prueban autoría (§55.1);
- **Copiar JSON**, que copia exactamente la salida de `datekeys inspect -json` (`cliJSON`: el `json.Encoder` de Go con sangría de dos espacios, `<`, `>`, `&`, U+2028 y U+2029 escapados y salto de línea final). `file` es el nombre del fichero.

@ -141,6 +141,18 @@
</div>
</div>
{#if report.note && 'text' in report.note}
<figure class="note">
<blockquote>{report.note.text}</blockquote>
<figcaption>
<p>Nota pública del creador, sin comprobar: la puede leer cualquiera que tenga el fichero.</p>
<p>Nadie puede comprobar antes de la fecha quién creó la cápsula ni si va firmada.</p>
</figcaption>
</figure>
{:else if report.note}
<p class="note-unusable">La cápsula lleva una nota pública que no cumple las reglas de texto: no se muestra.</p>
{/if}
<section class="block" aria-labelledby="steps-title">
<h3 id="steps-title">Pasos 1 a 8</h3>
<StepList steps={report.steps} />
@ -425,6 +437,38 @@
background: var(--caution-bg);
font-size: var(--t-small);
}
/* The note is the creator's voice, not the page's: the serif of the
letter, on the caution of what nobody has checked. */
.note {
margin: -1.25rem 0 0;
padding: 1rem 1.25rem;
border-left: 4px solid var(--orange);
border-radius: 0 var(--radius) var(--radius) 0;
background: var(--caution-bg);
}
.note blockquote {
margin: 0;
max-width: var(--measure);
font-family: var(--serif);
font-size: var(--t-lead);
line-height: 1.5;
overflow-wrap: anywhere;
}
.note figcaption {
margin-top: 0.6rem;
font-size: var(--t-small);
color: var(--orange-text);
}
.note figcaption p {
margin: 0;
}
.note-unusable {
margin: -1.25rem 0 0;
padding: 0.5rem 0.75rem;
border-radius: var(--radius);
background: var(--caution-bg);
font-size: var(--t-small);
}
.state-word {
font-size: var(--t-h2);
font-weight: 700;

@ -26,7 +26,7 @@
import { onDestroy, tick } from 'svelte';
import { toHex } from '$lib/dkc/index.ts';
import type { Fixture } from '$lib/inspector/fixtures.ts';
import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger } from '$lib/inspector/format.ts';
import { errorGloss, escapeInvisible, formatByteCount, formatDateTime, formatInteger, unexpectedProblem } from '$lib/inspector/format.ts';
import type { OpenedFiles } from '$lib/inspector/opener.ts';
import { buildOpenReport, contentExtension, type OpenReport, plaintextFileName, plaintextPreview, SHOWN_TEXT } from '$lib/inspector/opening.ts';
import { fetchRelease } from '$lib/inspector/drand.ts';
@ -332,7 +332,7 @@
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))}`);
if (!stale()) await fail(unexpectedProblem('abrir la cápsula', err));
} finally {
// A file that did not become `temp`: a failure, or a stale opening.
if (t !== undefined) {

@ -308,6 +308,16 @@ describe('runtime dependencies', () => {
}
});
it('vite.config.ts pre-bundles every runtime dependency that the pages import, so that the dev server never reloads during a lazy import', async () => {
const { default: config } = (await import(pathToFileURL(join(ROOT, 'vite.config.ts')).href)) as { default: { optimizeDeps?: { include?: string[] } } };
const used = new Set<string>();
for (const s of sources()) {
if (/\.test\.ts$/.test(s.name) || s.name.startsWith(TESTING_DIR)) continue;
for (const spec of specifiers(s.text)) if (spec.startsWith('@noble/') || spec === 'age-encryption') used.add(spec);
}
expect([...(config.optimizeDeps?.include ?? [])].sort()).toEqual([...used].sort());
});
it('src/ resolves noble to the root copies, 2.4.0', async () => {
// No node_modules directory inside src/ can shadow the root ones.
expect(walk(join(ROOT, 'src'), () => true).filter((p) => rel(p).split('/').includes('node_modules'))).toEqual([]);

@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { ERROR_CODES, type InspectView } from '../dkc/index.ts';
import {
cliJSON,
@ -20,6 +20,7 @@ import {
stepGloss,
stepName,
viewerTimeZone,
unexpectedProblem,
} from './format.ts';
const te = new TextEncoder();
@ -194,3 +195,30 @@ describe('escapeInvisible of a checked text', () => {
expect(escapeInvisible(`a${zwj}b`)).toBe(`a${String.fromCharCode(92)}u200db`);
});
});
describe('unexpectedProblem', () => {
it('says what to do in Spanish, never the message of the exception, which goes to the console', () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
try {
const lazy = new TypeError('Failed to fetch dynamically imported module: http://localhost:5188/src/lib/inspector/opener.ts');
expect(unexpectedProblem('abrir la cápsula', lazy)).toBe(
'No se pudo abrir la cápsula: la página no consiguió cargar una de sus partes. Recarga la página y vuelve a intentarlo.',
);
expect(log).toHaveBeenCalledWith(lazy);
expect(unexpectedProblem('x', new TypeError('Importing a module script failed.'))).toMatch(/cargar una de sus partes/);
expect(unexpectedProblem('x', new TypeError('error loading dynamically imported module'))).toMatch(/cargar una de sus partes/);
expect(unexpectedProblem('leer a.dkc', new DOMException('The file could not be read', 'NotReadableError'))).toBe(
'No se pudo leer a.dkc: el navegador ya no deja leer el fichero, quizá porque cambió o se movió. Vuelve a elegirlo.',
);
expect(unexpectedProblem('x', new DOMException('gone', 'NotFoundError'))).toMatch(/Vuelve a elegirlo/);
expect(unexpectedProblem('crear la cápsula', new DOMException('full', 'QuotaExceededError'))).toMatch(/no deja más espacio/);
const other = unexpectedProblem('crear la cápsula', new Error('capsule: internal error: ‮ secret'));
expect(other).toBe(
'No se pudo crear la cápsula por un fallo de la página. Recarga la página y vuelve a intentarlo; si vuelve a pasar, usa la herramienta de línea de órdenes de DateKeys.',
);
expect(unexpectedProblem('x', 'a string')).toMatch(/por un fallo de la página/);
} finally {
log.mockRestore();
}
});
});

@ -244,6 +244,32 @@ export function escapeInvisible(s: string, joiners = false): string {
return out;
}
/**
* What the page says when something it did not expect fails while it was
* trying to `what` ("abrir la cápsula"): a sentence in plain Spanish that
* says what to do, never the message of the exception, which may be in
* English, name an internal URL or carry the text of a file. The exception
* itself goes to the console, for whoever debugs the page.
*/
export function unexpectedProblem(what: string, err: unknown): string {
console.error(err);
const name = err instanceof Error ? err.name : '';
const message = err instanceof Error ? err.message : '';
// A part of the page that loads on demand did not arrive: the server went
// away, the connection dropped, or a new version replaced the old files.
// Each browser words it its own way.
if (/dynamically imported module|Importing a module script failed|Failed to fetch|NetworkError|Load failed/i.test(message)) {
return `No se pudo ${what}: la página no consiguió cargar una de sus partes. Recarga la página y vuelve a intentarlo.`;
}
if (name === 'NotReadableError' || name === 'NotFoundError') {
return `No se pudo ${what}: el navegador ya no deja leer el fichero, quizá porque cambió o se movió. Vuelve a elegirlo.`;
}
if (name === 'QuotaExceededError') {
return `No se pudo ${what}: el navegador no deja más espacio a esta página. Libera espacio y vuelve a intentarlo.`;
}
return `No se pudo ${what} por un fallo de la página. Recarga la página y vuelve a intentarlo; si vuelve a pasar, usa la herramienta de línea de órdenes de DateKeys.`;
}
// ---------------------------------------------------------------------------
// Numbers and dates

@ -72,6 +72,16 @@ describe('buildReport', () => {
}
});
it('carries the public note, or that a note is not shown', () => {
const note = readBytes('fixtures/format3_note.dkc');
const base = inspectWith(note, registry);
expect(report(note).note).toBeUndefined(); // inspectWith does not read the note: inspect does
const r = (inspection: typeof base): Report => buildReport({ fileName: 'n.dkc', bytes: note, size: note.length, inspection });
expect(r({ ...base, publicNote: 'Cartas del viaje', unusableNote: false }).note).toEqual({ text: 'Cartas del viaje' });
expect(r({ ...base, unusableNote: true }).note).toEqual({ unusable: true });
expect(r(base).note).toBeUndefined();
});
it('shows header extension data as text, never as CBOR when it is not', () => {
const r = report(timeOnly);
expect(r.extensions).toEqual([

@ -136,6 +136,12 @@ export interface Report {
readonly tlock?: readonly Comparison[];
/** Critical extensions first, then noncritical; undefined until PUBLIC_HEADER decoded. */
readonly extensions?: readonly ExtensionRow[];
/**
* The public note of the header (spec §24.1): text of the creator that
* nobody has checked, or `unusable` when the header holds a note that
* breaks the rules of text, which is not shown. Undefined without a note.
*/
readonly note?: { readonly text: string } | { readonly unusable: true };
}
export interface ReportInput {
@ -230,6 +236,8 @@ export function buildReport(input: ReportInput): Report {
];
}
}
if (r.publicNote !== undefined) out.note = { text: r.publicNote };
else if (r.unusableNote === true) out.note = { unusable: true };
return out;
}

@ -32,7 +32,7 @@
} from '$lib/inspector/create-files.ts';
import { quickWords } from '$lib/dkc/wordkey.ts';
import { type CapsulePlan, chooseFiles, type CreateField, downloadName, planCapsule, readRecipients } from '$lib/inspector/create-input.ts';
import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, viewerTimeZone } from '$lib/inspector/format.ts';
import { escapeInvisible, formatByteCount, formatDateTime, formatInteger, formatRelative, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
import { isTimeZone, localParts, supportedTimeZones, timeZoneList, UTC } from '$lib/inspector/localtime.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
import { browserPlatform, CREATE_AREA, createTempFile, freeSpace, OPEN_AREA, removeStaleTempFiles, type TempFile } from '$lib/inspector/tempfile.ts';
@ -453,7 +453,7 @@
notice = announcement =
err instanceof TooManyFiles
? tooMany()
: `No se pudo leer lo que has soltado: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`;
: unexpectedProblem('leer lo que has soltado', err);
} finally {
reading = false;
}
@ -470,7 +470,7 @@
try {
c = await loadChecker();
} catch (err) {
await fail(`No se pudieron cargar las reglas de las rutas: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
await fail(unexpectedProblem('preparar la comprobación de los nombres de los ficheros', err));
return;
}
if (epoch !== createId || busy || reading) return;
@ -587,7 +587,7 @@
nowMs = Date.now();
await fail(PAST, 'date');
} else {
await fail(`No se pudo crear la cápsula: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
await fail(unexpectedProblem('crear la cápsula', err));
}
return;
}
@ -621,7 +621,7 @@
await tick();
document.getElementById('created-title')?.focus();
} catch (err) {
if (!stale()) await fail(`No se pudo crear la cápsula: ${escapeInvisible(err instanceof Error ? err.message : String(err))}`);
if (!stale()) await fail(unexpectedProblem('crear la cápsula', err));
} finally {
// A file that did not become `temp`: a failure, or a stale writing.
if (t !== undefined) {

@ -3,7 +3,7 @@
import { type CapsuleBytes, inspect, readCapsule } from '$lib/dkc/index.ts';
import { FIXTURES, type Fixture, fetchFixture } from '$lib/inspector/fixtures.ts';
import { buildReport, type Report } from '$lib/inspector/report.ts';
import { displayText, escapeInvisible, viewerTimeZone } from '$lib/inspector/format.ts';
import { displayText, unexpectedProblem, viewerTimeZone } from '$lib/inspector/format.ts';
import { browserPlatform, CREATE_AREA, OPEN_AREA, removeStaleTempFiles } from '$lib/inspector/tempfile.ts';
import InspectionReport from '$lib/components/InspectionReport.svelte';
import OpenPanel from '$lib/components/OpenPanel.svelte';
@ -102,7 +102,7 @@
if (/Web Crypto/.test(message)) {
return 'Este navegador solo ofrece Web Crypto (crypto.subtle) en contextos seguros, y el inspector lo necesita para comprobar el perfil fijado. Abre la página por https o en localhost.';
}
return `No se pudo leer ${name}: ${escapeInvisible(message)}`;
return unexpectedProblem(`leer ${name}`, err);
}
function inspectFile(file: File, readNotice?: string): void {

@ -196,6 +196,24 @@ export default defineConfig({
// must be separate same-origin files, whatever their size.
assetsInlineLimit: 0,
},
optimizeDeps: {
// Every dependency the pages load on demand, pre-bundled when the dev
// server starts. Found only on the first import, as the opening of a
// capsule does, the dev server bundles it then and reloads the page, and
// the import in flight fails: "Failed to fetch dynamically imported
// module". src/lib/dependencies.test.ts checks that the list is complete.
include: [
'@noble/ciphers/chacha.js',
'@noble/curves/bls12-381.js',
'@noble/curves/ed25519.js',
'@noble/curves/nist.js',
'@noble/curves/utils.js',
'@noble/hashes/hkdf.js',
'@noble/hashes/legacy.js',
'@noble/hashes/sha2.js',
'age-encryption',
],
},
server: {
fs: {
// The dev server serves the official fixtures straight from testdata/,

Loading…
Cancel
Save

Powered by TurnKey Linux.