Ir al contenido
Portal de documentación del EGD
Menú de la sección

Ventana de firma digital

SignDocumentDialog (app/src/features/signing/components/), usada en emisión de documentos administrativos, Vistos Buenos, firma de anexos y el cargo de recepción PIDE (ver tabla de usos más abajo) -- el botón de prueba que tenía el dashboard del funcionario ya se quitó, el flujo real está integrado en esos módulos. Rediseño guiado por el diseño «Redesign digital signature modal» y por la referencia estilo Adobe, adaptado al Design System (paleta del tema activo, Inter, DialogContent/Header/Body/Footer; no se copian el rojo #EB1000 ni Source Sans del Figma).

Qué controla el SGD y qué no

ParteQuién
Esta ventana: visor, opciones, estados, descargaSGD (rediseñable)
Subir PDF y opciones (/api/firma-peru/iniciar), recibir el firmado (/api/firma-peru/download)SGD (rutas Next.js; sin cambios)
Ventana de certificado y PIN y la firma criptográficaFirma Perú, aplicación externa que lanza el agente local (sign_document por WebSocket). El SGD no la dibuja, no puede cerrarla ni sabe en qué paso va
Sello gráfico dentro del PDFLo estampa Firma Perú (signatureStyle 1, ≈220 × 130 pt -- ver corrección más abajo). El SGD solo elige página y posición y dibuja un recuadro de ubicación, no una vista previa del sello

Limitación concreta: el protocolo del agente no tiene «cancelar firma». Por eso, una vez abierta la ventana de Firma Perú, el modal no se puede cerrar (X oculta, Escape y clic exterior bloqueados con closePolicy="blocked", sin botón de cancelar) y se explica en pantalla; la única forma de detenerla es cancelar en la ventana de Firma Perú, lo que el agente informa como signing_cancelled. El SGD nunca muestra «cancelada» por su cuenta.

Distribución

  • Desde lg: dos columnas, visor (flexible) y panel de 22 rem, con altura min(40rem, 100dvh − 17rem); el visor y el panel se desplazan por dentro. Debajo de lg: una columna con el visor primero (min(32rem, 60dvh)) y un solo scroll (el cuerpo del modal); el pie con «Cancelar» y «Firmar documento» queda siempre visible (apilado en móvil, regla compartida de DialogFooter).
  • Cabecera: icono, título «Firma digital de documento» y el nombre del archivo (o documentLabel).
  • Visor (pdf-viewer.tsx, pdf-frame.tsx): el visor oficial de PDF.js (el mismo del visor de documentos de file-service, copia en public/pdfjs) dentro de un iframe del mismo origen, con la barra de herramientas del Design System: zoom (reducir, valor en % —clic ajusta al ancho— y aumentar), navegación (anterior/siguiente con flechitas hacia arriba y hacia abajo —expand_less/expand_more—, porque las hojas se recorren en vertical; campo «Ir a la página» con Enter, «n / total»), buscar (abre el buscador de PDF.js), «Leer texto del PDF» y «Ubicar sello». La barra propia de PDF.js se oculta (html[data-sgd-externo] en sgd-ajustes.css). El PDF se abre desde su blob: (una URL de otro origen se baja antes a un blob:); un PDF dañado o protegido con contraseña muestra «No se pudo abrir el PDF» (no se pide la contraseña dentro del visor). Se bloquean imprimir, guardar y abrir otro archivo (Ctrl/Cmd + P, S, O y soltar archivos).
  • Panel, en dos grupos distintos (separa la representación visual del proceso de firma):
    1. Firma digital (verified_user): se aplica siempre; solo se elige el motivo (FloatingUnderlineSelect, lista cerrada de SIGNATURE_REASONS). Texto: certificado y PIN se piden en la ventana de Firma Perú.
    2. Representación visible en el PDF (draw): «Mostrar firma en el documento» (Switch), y si está activo cargo (FloatingUnderlineInput, aparece en el sello) y ubicación (botón «Elegir ubicación en el PDF» con aria-pressed, estado por defecto/elegida y «Restablecer» con la variante brandLink). Sin sello la firma sigue siendo válida (invisible).
    • «Elegir otro PDF» (brandLink) cambia el documento.
  • Opciones reales conservadas: visible, reason, role, page, x, y (mismo JSON que antes a /api/firma-peru/iniciar). No se agregaron controles sin función.

Tamaño de la ventana: redimensionar y maximizar

Solo en esta ventana (opt-in; el resto de los diálogos no cambia). Implementación en src/components/ui/dialog-resizable.tsx (useResizableDialog, ResizeHandles, DialogMaximizeButton), sin modificar DialogContent (dialog.tsx no tiene cambios): el hook entrega un style que se pasa a DialogContent y los controles se montan como hijos.

  • Arrastrar: los cuatro bordes, las esquinas y la esquina inferior derecha (con indicador de puntos). El borde opuesto al que se arrastra no se mueve. Teclado: la esquina inferior derecha es enfocable («Cambiar el tamaño de la ventana…») y las flechas cambian el tamaño en pasos de 24 px (Mayús: 96 px).
  • Maximizar / Restaurar: botón en la esquina superior derecha, construido con la misma clase que la «X» (sgd-dialog-close) para que ambos tengan el mismo círculo de hover, color de icono y estados de foco; el hover de esta ventana es claramente visible (círculo al 12 % del color de texto, icono más oscuro; [data-sgd-window] en globals.css, solo aquí) y va en reglas planas, no en variantes hover: de Tailwind (que no se activan en navegadores que reportan hover: none, como el del usuario), con tooltip «Maximizar»/«Restaurar tamaño»; alineado en la misma línea que la «X» (misma caja de 44 px y borde superior, mismo color de icono; a su izquierda, o en el lugar de la X mientras el cierre está bloqueado) (aria-label «Maximizar»/«Restaurar tamaño», aria-pressed, tooltip de 1 s, operable con Tab + Enter/Espacio). Maximizado ocupa la pantalla con 16 px de margen y oculta los tiradores; restaurar devuelve exactamente el tamaño y la posición anteriores. Al cerrar, la siguiente apertura vuelve sin maximizar; el tamaño elegido a mano se conserva mientras la página siga abierta.
  • Límites: mínimo 640 × 480 px (nunca mayor que la pantalla disponible) y máximo la pantalla menos 8 px por lado; el diálogo no sale de la pantalla, tampoco si se achica la ventana del navegador (se vuelve a encajar). Por debajo de md (móvil) no hay tiradores ni botón: el diálogo ya ocupa casi toda la pantalla.
  • Contenido: el diseño responde al ancho del propio diálogo (container query @[52rem]): desde 52 rem, visor y panel en dos columnas; por debajo, una columna con el visor primero y un solo scroll. Una vez redimensionado o maximizado, el visor y el panel llenan el alto del cuerpo; el pie con «Cancelar» y «Firmar» queda siempre visible. El visor sigue ajustando las páginas a su ancho y conserva el zoom.
  • Estado conservado: el tamaño vive en SignDocumentDialog, fuera del flujo de firma, y cambiarlo solo cambia estilos: no se remonta nada. Verificado que motivo, cargo, ubicación del sello, zoom, página actual, PDF y la fase del proceso (incluida «Firmando» con el agente esperando) se conservan al redimensionar, maximizar y restaurar, y que el flujo termina igual (1 llamada a iniciar, 1 a sign_document, 1 a download).

Ubicación y zoom

Estilo y posición por defecto: Firma Perú soporta dos estilos de sello visible: signatureStyle 1 (horizontal, ≈106×34 pt) y signatureStyle 2 (vertical, más angosto y alto). signatureKind="main" (firma final) usa el estilo 1, esquina superior derecha de la página 1, a 24 pt de los bordes (DEFAULT_MARGIN_PT). signatureKind="vb" (visado) usa el estilo 2 (SigningOptions.vertical, ver /api/firma-peru/params) y arranca en x = 8 pt, y = 510 pt (puntos PDF desde la esquina superior izquierda de la página, igual que el resto de coordenadas) para el primer revisor -- valores confirmados con Firma Perú real; los primeros (6, 720), tomados de una integración previa en este repo (ver abajo), dejaban el sello fuera de la hoja con el tamaño real del sello vertical. Cada revisor siguiente según vbOrder (la posición 1-based en doc.vbRequests, la misma que se ve como «#N» en la pantalla de Vistos Buenos) sube el sello 70 pt: y = 510 - (vbOrder-1) × 70, con un piso de 20 pt. Así, varios V°B° en el mismo documento quedan apilados sin superponerse, sin depender de en qué orden real se den (el patrón -- slot por posición en la lista, estilo vertical para V°B° -- viene de una integración previa de Firma Perú en este mismo repo, old-sgd/.../use-firma-peru.ts: signatureStyle: isVoBo ? 2 : 1, positiony: isVoBo ? 720 - vbPositionIndex * 70 : 20; los valores numéricos de posición sí se ajustaron tras probarlos). El recuadro del visor usa VB_STAMP_SIZE_PT (70×90 pt, aproximado) en vez de STAMP_SIZE_PT para V°B°. Sigue siendo editable a mano con «Elegir ubicación en el PDF», en ambos casos. signatureKind="cargo" usa el mismo estilo 2 (vertical) que V°B°, pero con su propio punto de partida: x=11, y=61 (CARGO_STAMP_X_PT/CARGO_STAMP_Y_PT), esquina superior izquierda debajo del membrete -- confirmado contra Firma Perú real con un cargo de recepción de interoperabilidad PIDE (ver docs/estado/2026-10-15-1200-recepcion-fase3-cargo-generar-firmar-enviar.md). Al ser un único firmante, nunca pasa vbOrder, así que no se apila.

El recuadro ya colocado se puede volver a mover (antes, tras soltarlo quedaba fijo: era pointer-events-none, solo el modo «Elegir ubicación» lo reubicaba). Reutiliza la misma conversión px ↔ puntos de la colocación inicial (toPoints):

  • Arrastre con el puntero sobre el propio recuadro, cuantas veces haga falta, sin borrarlo ni crearlo de nuevo (siempre hay un solo recuadro). Cursor de mover (move) y, al arrastrar, grabbing con un relleno y una sombra algo más marcados. El cursor va en línea (style) porque la regla global de cursores (role="button" → pointer) pisa a las clases. Cerca del borde superior o inferior del visor el documento se desplaza solo para poder llegar a cualquier punto de la hoja.
  • Límites: el recuadro entero queda dentro de la hoja (x 0–506 pt, y 0–758 pt en una Letter con sello de 106 × 34 pt). Se mueve dentro de su página; para pasarlo a otra página se puede usar «Elegir ubicación en el PDF» o «Ubicar sello».
  • Al soltar se confirma (onPick) y esas son las coordenadas que se envían al firmar (comprobado: lo enviado a /api/firma-peru/iniciar es la última posición elegida). Un clic sin mover no cambia nada: la ubicación por defecto no pasa a «elegida por ti».
  • Teclado: el recuadro es enfocable (role="button", nombre con página y coordenadas); las flechas lo mueven 5 pt (Mayús 20 pt, Alt 1 pt) y cada pulsación se confirma; una región role="status" anuncia la nueva posición. La pista está en el panel («Puedes arrastrar el recuadro… o moverlo con las flechas»).
  • Firma en curso: el movimiento se bloquea (locked: sin arrastre ni foco, aria-disabled).
  • La posición se guarda en puntos de la página, por lo que se mantiene al hacer scroll, cambiar el zoom, redimensionar o maximizar (medido: la fracción del recuadro sobre la página coincide con las coordenadas a 50, 100, 150 y 250 %).

Las coordenadas siguen siendo puntos PDF desde la esquina superior izquierda de la página (origen de Firma Perú). El zoom solo cambia el ancho de dibujo (renderWidth); toda conversión px ↔ puntos usa ese ancho y el tamaño real de la página, de modo que el sello queda sobre el mismo punto a cualquier zoom. Cada página tiene su alto calculado de antemano (sin saltos mientras se dibuja). Una página dañada ya no desmonta el modal: PdfErrorBoundary captura InvalidPDFException y deja la firma deshabilitada con un aviso.

Lectura textual y ubicación sin puntero (15–16/10/2026)

  • PdfViewer usa el documento de PDF.js del visor: «Leer texto del PDF» abre PdfTextView, una región con nombre y foco que obtiene getTextContent() de la página actual. Anterior/siguiente e «Ir a la página» recorren también esta vista. «Ver documento» vuelve a la página actual del visor, sin perder zoom ni ubicación. Los cambios anuncian disponibilidad o error, sin leer todo el documento automáticamente; las respuestas tardías se ignoran al cambiar de página/archivo.
  • Es una alternativa textual, no una conversión del PDF a documento semántico: el orden depende del original, no reconstruye tablas, etiquetas, anotaciones ni enlaces. Una página sin texto indica que requiere una versión accesible; no realiza OCR. La accesibilidad del documento generado debe evaluarse por separado.
  • PdfPlacementControls compone Popover, Input y Button existentes. «Ubicar sello» permite introducir página y coordenadas decimales; muestra límites derivados de los metadatos reales de esa página y del tamaño de sello vigente. «Aplicar ubicación» confirma mediante el mismo onPick, anuncia la ubicación y cierra el popover devolviendo el foco. No modifica el contrato ni los tamaños/posiciones por defecto de firma, visado o cargo.
  • Sin representación visible no se ofrece «Ubicar sello». Con locked se ocultan los controles de ubicación y se impiden clic, hover de colocación, arrastre y teclado de movimiento; la lectura sigue disponible. La firma recibida no vuelve a ofrecer una ubicación editable.
  • En móvil el popover limita su alto al espacio disponible de Radix y desplaza su propio contenido cuando es necesario; el botón Aplicar sigue siendo alcanzable por teclado. No ocultar contenido para disimular desbordamientos.
  • Muestra local: /design-system/componentes#pdf-heading (PdfSigningShowcase). Usa un archivo elegido por la persona, revoca sus object URLs y no monta el agente ni sube archivos. Pruebas del helper: node --test scripts/pdf-accessibility.test.mjs desde app/.

Evidencia, criterios WCAG y límites de verificación: informe compartido docs/accesibilidad/2026-10-15-evaluacion-lineamiento-pcm-sgtd.md §11. Pendientes: NVDA, zoom real y recorrido autenticado completo; no se declara conformidad global del visor ni de Firma Perú.

Fluidez con documentos de varias hojas (visor oficial de PDF.js)

Hasta el 28/10/2026 el visor dibujaba las hojas con react-pdf (un canvas por hoja dentro de una lista de React). Con PDFs de cientos de hojas el desplazamiento se trababa. El motor era el mismo (PDF.js); el cuello de botella era la capa de React: una sola lista con todas las hojas que se volvía a pintar entera ante cada cambio de estado (hoja que entra o sale de la zona visible, hoja dibujada, tamaño cargado, movimiento del mouse al elegir la ubicación), y además se pedía al abrir el tamaño de todas las hojas.

Medición (PDF sintético de 600 hojas con texto y gráficos, Chrome, servidor de desarrollo, 3 s de desplazamiento continuo de 300 px por fotograma; mismas condiciones para ambos):

Visor anterior (react-pdf)Visor oficial de PDF.js
Fotogramas logrados95 (≈ 30 por segundo)181 (60 por segundo)
Hojas recorridas2058
Fotogramas de más de 50 ms150
Peor fotograma183 ms33 ms
Tiempo en tareas largas934 ms (≈ 31 %)0

Causa confirmada con el perfil de fotogramas largos: todo el tiempo era trabajo de renderizado de React (53 a 205 ms por tarea). El coste por renderizado crecía con las hojas: 1,8 ms de mediana y 34 ms en el peor caso con 100 hojas; 4,9 ms y 117 ms con 600. Memorizar las hojas bajaba el peor caso a 48 ms pero no igualaba al visor.

Ahora el desplazamiento lo resuelve PDF.js (virtualiza las páginas, prioriza la visible y cancela lo que sale de pantalla). Dentro del nuevo visor, con el mismo PDF de 600 hojas: 0 tareas largas al desplazar. Los tamaños de página se piden solo cuando hacen falta (primera hoja, la que se ve, la del sello y la que se escribe en «Ubicar sello»), no las N hojas al abrir.

Recuadro del sello dentro de PDF.js

pdf-stamp-overlay.ts (StampOverlay) pone el recuadro dentro de la página de PDF.js, medido en puntos PDF desde la esquina superior izquierda (el origen de Firma Perú) con las variables --sello-x/y/w/h; los estilos de public/pdfjs/sgd-ajustes.css los llevan a píxeles con el factor de escala de la propia página, así que sigue al zoom sin recalcular (comprobado a 170, 186, 190, 210 y 240 %: siempre en (482, 24) pt y de 106 pt de ancho). PDF.js vacía la página cada vez que la redibuja: un MutationObserver vuelve a poner el recuadro en el mismo instante.

  • Colocar: con «Elegir ubicación» activo aparece un recuadro flotante («Firma aquí») pegado al puntero con su esquina superior izquierda como ancla; el clic confirma, informa la página y apaga el modo.
  • Arrastrar: con el puntero, cuantas veces haga falta, sin salir de la hoja y desplazando el documento cerca de los bordes; se confirma al soltar solo si de verdad se movió.
  • Teclado: el recuadro es un botón enfocable; flechas 5 pt (Mayús 20 pt, Alt 1 pt), con anuncio de la posición en una región role="status" de la ventana.
  • Firma en curso (locked): sin arrastre ni foco (aria-disabled), se oculta «Ubicar sello».
  • Un icono de lápiz propio (SVG en el CSS) en lugar de la fuente de iconos, que el visor no carga.
  • Verificado en navegador: teclado (pasos y límites 0 y 506 pt en una hoja Letter), arrastre real con el mouse (de (482, 24) a (254, 147) pt, consistente con la distancia recorrida), clic, bloqueo, «Ubicar sello» a una hoja no leída, búsqueda, lectura de texto, PDF dañado y modo oscuro. No probado: lector de pantalla real, PDFs reales escaneados de cientos de hojas, hojas con rotación propia ni la compilación de producción.

Mantenimiento

  • public/pdfjs es una copia del visor de PDF.js (hoy 6.4.299) que también existe en file-service y en verify; el SGD le añade al final de sgd-ajustes.css las reglas del recuadro. Al actualizar PDF.js, repetir la copia y comprobar los identificadores que usa pdf-frame.tsx (viewerContainer, findbar, viewFindButton, passwordDialog) y los eventos pagesinit, pagechanging, scalechanging, pagerendered y documenterror.
  • La política de seguridad de viewer.html no admite estilos en línea: todo estilo del recuadro va en sgd-ajustes.css (se ajustan variables con style.setProperty, que sí está permitido).
  • Con el foco dentro del visor, Escape no llega a la ventana: antes de firmar se cierra con la X; durante la firma la ventana ya no se puede cerrar.
  • react-pdf se quitó de las dependencias: el visor usa solo el visor oficial de PDF.js alojado en public/pdfjs/.

Estados

FaseQué muestraPie
Cargando el documento«Abriendo el documento…» en el visorFirmar deshabilitado hasta conocer la página
Listo (idle)Panel de opciones; aviso del agente si no está conectadoSolo Firmar documento (sin «Cancelar»: para salir está la «X» de la ventana)
Preparando (preparing)Paso 1 «Preparando el documento» (envío del PDF y opciones)Cancelar (aborta la subida, vuelve a las opciones; no se firmó nada) · «Procesando…»
Firmando (signing)Paso 2 «Firmando en Firma Perú»; aviso de que no se puede cerrarSolo «Firmando…» (deshabilitado); sin botón de cancelar ni «X»
Recibiendo (downloading)Paso 3 «Recibiendo el documento firmado»deshabilitados
Éxito (success)«Documento firmado» y resumen (archivo, motivo, firma visible/página); el visor pasa a mostrar el PDF firmado recibidoSin onEmit (firma suelta, solo prueba): Descargar PDF firmado. Con onEmit (emisión de documento o visado de V°B°, ver abajo): Cancelar + Emitir documento/Visar documento -- si onEmit sale bien, el modal se cierra solo (sin mostrar «Documento emitido correctamente» ni un botón de descargar: no hay nada más que hacer ahí, quien llamó a onEmit avisa con su propio toast). Si falla, el modal no se cierra: queda el mensaje de error, Cancelar + Reintentar, y sí un enlace de descarga (para no perder el PDF ya firmado aunque la subida haya fallado)
Cancelada«Firma cancelada» (la cancelación ocurrió en Firma Perú); opciones intactasReintentar (sin «Cerrar»: la «X» cierra)
ErrorMensaje del agente o del servidor y «El documento no se firmó»Reintentar (sin «Cerrar»: la «X» cierra)

«Documento firmado» solo aparece tras recibir el PDF firmado del servidor (descarga correcta). El estado se anuncia con role="status"/role="alert" y los pasos con aria-current="step" y texto «(listo)/(en curso)/(pendiente)». Sin envíos duplicados: una referencia submitting ignora el segundo clic aunque ocurra antes de que React repinte, y mientras no hay idle el botón de firma no existe/está deshabilitado. Reintentar conserva el archivo y las opciones. No se muestran hash ni sello de tiempo del Figma: el flujo actual no los devuelve.

Después de firmar: subir el PDF y cambiar el estado (onEmit)

Firmar con Firma Perú solo produce el PDF firmado en el navegador (signedFile); por sí solo no sube nada ni cambia el estado del documento. Quien abre el modal decide si hace falta ese paso adicional pasando onEmit: (signedFile: File) => Promise<void> — si no se pasa (por ejemplo, una firma suelta de prueba), el modal se queda en "descargar el PDF firmado" tal como antes.

Con onEmit, tras la fase de éxito el pie no sube el archivo solo: muestra "Cancelar" (cierra el modal sin subir nada; el PDF firmado no se conserva, hay que volver a firmar si se reintenta más tarde) junto a "Emitir documento" (firma final del encargado) o "Visar documento" (V°B° de un revisor, signatureKind="vb"). Recién al pulsar ese botón se llama a onEmit, que termina en el backend (Emit/ApproveVB, server/internal/modules/document/app/document/emit.go y vb_review.go):

  1. Valida que el documento siga en el estado correcto (PENDING_VB) y, para la firma final, que todos los V°B° solicitados estén aprobados.
  2. Lee las firmas del PDF (pdfsig.ListSignatures) y confirma que contenga la firma del encargado actual (por DNI) y, si hubo V°B°, la firma de cada revisor aprobado -- si falta alguna, nada se sube ni cambia (ErrEmitSignatureMissing/ErrEmitVBSignatureMissing/ErrVBSignatureMissing).
  3. Solo entonces sube el PDF a file-service y cambia el estado (documento a signed + trámite a EMITIDO en la firma final; el V°B° solicitado queda aprobado en el visado).

Si onEmit sale bien, el modal se cierra solo (onClose, en SignDocumentDialog -- cambia el open que controla quien abrió el modal directamente, sin pasar por closePolicy/Radix, así que cierra aunque la fase siga en success). No queda nada que confirmar ni descargar ahí: quien llamó a onEmit es responsable de avisar (notifySuccess, ver documents-emission-actions.tsx, new-document-form.tsx y vb-review-content.tsx) y de refrescar su propia vista (estado/fila).

Si onEmit falla (red o alguna de esas validaciones), el modal no se cierra: el pie muestra el mensaje de error devuelto y Cancelar + Reintentar emisión/Reintentar registro (reintenta llamando a onEmit de nuevo con el mismo signedFile, sin volver a firmar) junto al enlace de descarga, para no perder el PDF ya firmado aunque la subida haya fallado.

Cinco usos de SignDocumentDialog con onEmit, mismo componente (la validación de backend es la de cada operación):

PantallaonEmitsignatureKind
Emisión de documentos administrativos, bandeja "Para Despacho" (documents-emission-actions.tsx)emitDocument(doc.id, signedFile)main (por defecto)
Formulario "Nuevo documento", firma final (new-document-form.tsx)emitDocument(documentId, signedFile)main
Revisión de Vistos Buenos (vb-review-content.tsx)approveVB(documentId, vbRequestId, signedFile)vb
Cargo de recepción por interoperabilidad PIDE (registro-sgd-dialog.tsx)storeCargoFile(recepcion.id, signedFile)cargo
Anexos de un documento, «Firmar» y «Visar» (nuevo/anexos/attachments-modal.tsx)signAttachment(documentId, attachmentId, signedFile)main / vb, con target="anexo"

target="anexo" (por defecto "documento") solo cambia los textos, no el flujo: para un anexo, onEmit reemplaza el archivo del anexo por el PDF firmado y quita su marca «Requiere firma» (SignAttachment, server); no emite ni cambia el estado del documento. Por eso el título es «Firma digital de anexo» o «Visto bueno de anexo», el botón previo «Firmar anexo» (o «Dar visto bueno»), el de confirmar «Guardar anexo firmado» o «Guardar anexo visado» (ícono save), mientras guarda «Guardando anexo…» / «Guardando visto bueno…» y tras un error «Reintentar guardado». El panel de estado recibe noun (documento/anexo): «Anexo firmado», «Preparando el anexo», «El anexo no se firmó», etc.

Texto de éxito del panel: SigningStatusPanel recibe confirmsToSave (hay onEmit). Con onEmit dice que se revise el resultado, que se confirme con el botón de abajo y que «Cancelar» descarta el PDF firmado (y, en anexos, que al confirmar el archivo del anexo se reemplaza por el PDF firmado). Sin onEmit (firma suelta de prueba) conserva «Aún no se guarda en el SGD: por ahora puedes descargarlo», que antes se mostraba también en los flujos con onEmit, donde no era cierto.

signatureKind="cargo" fuerza el motivo "En señal de conformidad" y el sello vertical (mismo estilo que vb), pero no es una revisión: es la firma de quien registra el cargo de recepción sobre el propio documento principal ya recibido por PIDE (loadInitialFile trae ese PDF tal cual, sin generar ningún documento nuevo -- ver app/iotdtreception.PrepareCargoDraft, server). A diferencia de "Emitir documento"/"Visar documento", onEmit acá solo guarda el archivo firmado (POST .../cargo/file) -- por eso el botón dice "Guardar cargo" (ícono save), no "Enviar": el envío real por PIDE lo dispara después el botón "Enviar"/"Observar" del propio modal registro-sgd-dialog.tsx, no este diálogo (el primer nombre, "Enviar cargo", se cambió por confundir al usuario sobre cuál de los dos botones manda el cargo por PIDE). Al no haber varios firmantes posibles (a diferencia de V°B°), no usa vbOrder: el sello siempre arranca en x=11, y=61, editable a mano como cualquier otro.

registro-sgd-dialog.tsx solo ofrece "Firmar Cargo" (este diálogo) -- se evaluó un "Subir Cargo" aparte con el agente local (tramitedoc.readFile, igual que "Cargar Doc." de Emisión) para aceptar un PDF ya firmado por fuera, pero se descartó: exigía guardar el archivo en la carpeta del agente con un nombre exacto antes de poder subirlo, un paso extra innecesario cuando firmar en vivo ya sube el resultado directo al servidor.

Reutilizado

Dialog/DialogContent/DialogHeader/DialogBody/DialogFooter (con closePolicy), Button (incluidos size="icon" con tooltip de 1 s y brandLink, cuyo subrayado cubre solo el texto), FloatingUnderlineSelect, FloatingUnderlineInput, Switch, Badge, Dropzone, tokens primary/success/warning/destructive/muted/border. Iconos nuevos: zoom_in, zoom_out (subconjunto regenerado: 202 iconos).

Decisiones frente al Figma

No se adoptaron por no tener función en el flujo: «Previsualización» de la firma y «Apariencia», botón de rotar, «Cargo / rol» como lista (aquí es texto libre), «Localización geográfica» (el componente de Firma Perú no la acepta), hash y hora de la firma. El selector de motivo es una lista cerrada (4 motivos normativos), por lo que no usa búsqueda.

Verificación (2026-10-06)

Chrome real (CDP) sobre una página temporal ya retirada, con agente y servidor simulados (WebSocket y rutas /api/firma-peru/* falsas) y PDF de 5 páginas generados para la prueba:

  • Anchos 1440, 1280, 1024, 768, 390 y 640 px a 2× (zoom 200 %): sin scroll horizontal, pie visible y barra del visor sin cortes; en móvil el visor va primero y el panel debajo con el pie fijo.
  • Zoom 100 → 150 → 250 %: el clic en el mismo punto de la página 2 da (306, 238) pt y el recuadro se dibuja en esas coordenadas a cada zoom; navegación por botones y por el campo de página; límites de zoom y de página.
  • Flujos: éxito (visor cambia a las 3 páginas del PDF «firmado»), cancelación y reintento con opciones conservadas, error del agente, error al iniciar, cancelar durante la preparación (0 llamadas a sign_document), PDF dañado (aviso y firma deshabilitada), doble clic (1 sola llamada a iniciar/sign/download), Escape y clic exterior bloqueados durante la firma.
  • axe-core (WCAG 2.2 AA) sin violaciones en reposo, firmando y tras el éxito; en LATAM, Institucional y Adobe Spectrum (claro) y LATAM e Institucional (oscuro). Tab recorre el modal en orden lógico y lo mantiene dentro (trampa de foco).

No verificado / pendientes: PDF reales de gran tamaño o con páginas de distintos tamaños (los de prueba son Letter); lector de pantalla; Firefox y Safari; Adobe Spectrum en oscuro. Pendiente de diseño: la ubicación del sello solo se elige con el puntero (la ubicación por defecto sí es operable por teclado); falta una alternativa por teclado (p. ej. mover el recuadro con flechas o campos numéricos).

Corrección: tamaño real del sello horizontal (signatureStyle 1)

Primera firma real con signatureKind="main" (antes solo se había probado V°B° y el cargo de recepción, ambos verticales): Firma Perú rechazó la posición por defecto y canceló la firma con "The new signature field position is outside the page dimensions! Signature Field : [minX=456.0, maxX=656.7411, minY=594.67, maxY=710.92], Page : [minX=0.0, maxX=595.32, minY=0.0, maxY=841.92]" -- el campo real que reportó el error mide 200.74 × 116.25 pt, muy por encima de los 106 × 34 pt que STAMP_SIZE_PT asumía (un valor nunca verificado contra el agente real, quedaba anotado como pendiente en este mismo documento). El sello horizontal real trae escudo + 4 líneas de texto (firmante, cargo, motivo, fecha), no cabe en una sola línea de 34 pt de alto. Con la posición por defecto (esquina superior derecha, DEFAULT_MARGIN_PT=24) y un ancho subestimado, el sello real se salía por el borde derecho de la hoja.

Corregido: STAMP_SIZE_PT pasó a { w: 220, h: 130 } (features/signing/types.ts) -- el tamaño medido más un margen de seguridad para firmantes/cargos con nombres más largos que los del documento de prueba. Al depender de esta misma constante, tanto la posición X por defecto (sign-document-dialog.tsx) como el recuadro de ubicación y sus límites de arrastre (pdf-viewer.tsx, vía la prop stampSize) se ajustaron solos, sin tocar su lógica. Sigue pendiente de confirmar con un firmante de nombre/cargo largo, para descartar que el ancho real siga creciendo más allá del margen agregado.

Corrección: el logo del sello se estampaba gigante

Misma firma real: el logo institucional (subido en /admin/configuracion-firma-peru, PNG de 500×500 px) se estampó enorme, muy por encima del resto del sello y de otros sellos del mismo PDF (comparado con el de otra entidad en el documento). Causa: imageToStamp no tiene ningún parámetro de ancho/alto en el JSON de parámetros -- Firma Perú pega la imagen tal cual, al tamaño de PÍXEL del propio archivo (sin reescalarla a la caja del sello), así que un PNG de alta resolución sale gigante.

Corregido en server/internal/modules/signature/app/firmaperu/logo_resize.go (resizeLogoPNG): el endpoint GET /api/public/firma-peru/logo reduce (nunca agranda) la imagen a 90×90 px como máximo antes de servirla al agente -- promedio de bloque (box filter), sin librería externa. El límite es una primera estimación (no una medida confirmada como las de STAMP_SIZE_PT), basada en la proporción del logo de referencia visto en el PDF de prueba. No hace falta volver a subir el logo ya cargado: el redimensionado ocurre al servirlo, no al guardarlo, así que el mismo archivo se sirve más chico en el próximo intento de firma. Pendiente de confirmar con otra firma real que el tamaño sea el correcto (podría necesitar ajustarse, la constante está documentada y aislada a propósito para eso; subido de 90×90 a 100×100 px tras la primera prueba y, confirmado que el resto del sello ya quedaba bien, a 110×110 px en una segunda ronda).

Corrección: el recuadro de ubicación se veía mucho más grande que el sello real

El usuario comparó, con capturas de un PDF ya firmado, el recuadro que PdfViewer dibuja antes de firmar (usando STAMP_SIZE_PT, 220×130 pt desde la corrección anterior) contra el sello que Firma Perú realmente estampa: mucho más chico y compacto (escudo + 4 líneas de texto, sin el espacio vacío que dejaba ver el recuadro). Aclaró explícitamente que solo quería cambiar el recuadro -- una ayuda visual para elegir dónde va la firma --, no la posición que se manda a firmar (positionx/positiony, lo único que Firma Perú recibe: nunca un ancho/alto del sello, así que STAMP_SIZE_PT nunca viajó al agente, solo decidía nuestra posición por defecto y los límites de arrastre).

Corregido separando ambos usos: PdfViewer ganó previewSize (opcional, solo dibuja -- si no se pasa, usa stampSize como antes) además de stampSize (posición por defecto y límites de arrastre). Para signatureKind="main", sign-document-dialog.tsx pasó a usar stampSize={STAMP_SIZE_PT} (220×130, el margen de seguridad) junto con previewSize={STAMP_PREVIEW_SIZE_PT} (106×34, solo para el dibujo) -- ver más abajo, revertido en la siguiente corrección.

Revertido: STAMP_SIZE_PT vuelve a 106×34 (posición, arrastre y dibujo, sin separar)

El usuario probó la separación anterior y reportó, con captura, que el recuadro ya se veía chico (bien) pero el arrastre no llegaba hasta el borde derecho de la hoja -- quedaba un hueco de ~114 pt (220−106) antes del borde, porque los límites de arrastre seguían usando stampSize (220×130), no previewSize. Pidió explícitamente volver al comportamiento de antes de agrandar STAMP_SIZE_PT (106×34 para los tres usos), razonando que, con el logo ya acotado a 100×100 px (corrección anterior), ya no haría falta el margen extra.

Aclaración importante que quedó registrada en el propio hilo, no verificada todavía: el error real de Firma Perú ("outside the page dimensions", campo 200.74×116.25 pt) fue sobre el campo de texto (stampTextSize/stampWordWrap), una validación de Firma Perú separada de cómo pega la imagen (imageToStamp, sin validación de tamaño, causa de la corrección del logo). Arreglar el logo no necesariamente arregla el campo de texto -- son dos mecanismos distintos. Revertido a pedido explícito del usuario, documentando el riesgo: si el error "outside the page dimensions" vuelve a aparecer en otra firma real, es por el campo de texto, no el logo, y habría que volver a subir STAMP_SIZE_PT (y, si se quiere mantener el recuadro chico, reintroducir previewSize -- el mecanismo se dejó en pdf-viewer.tsx, solo dejó de usarse en sign-document-dialog.tsx, por si hace falta de nuevo sin tener que rehacerlo).