backend dry

This commit is contained in:
2025-10-29 11:52:19 +01:00
parent 788858f0c1
commit 2a17f2d333
10 changed files with 180 additions and 211 deletions
+7
View File
@@ -56,6 +56,8 @@ pub struct AppConfig {
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
#[serde(default = "default_tenant_slug")] #[serde(default = "default_tenant_slug")]
pub default_tenant_slug: String, pub default_tenant_slug: String,
#[serde(default = "default_worker_max_document_bytes")]
pub worker_max_document_bytes: u64,
} }
impl AppConfig { impl AppConfig {
@@ -68,6 +70,7 @@ impl AppConfig {
pool_size = config.database_max_pool_size, pool_size = config.database_max_pool_size,
quickwit_enabled = config.quickwit_endpoint.is_some(), quickwit_enabled = config.quickwit_endpoint.is_some(),
s3_bucket = %config.s3_bucket, s3_bucket = %config.s3_bucket,
worker_max_document_bytes = config.worker_max_document_bytes,
"loaded backend configuration" "loaded backend configuration"
); );
Ok(config) Ok(config)
@@ -149,6 +152,10 @@ fn default_tenant_slug() -> String {
"admin".to_string() "admin".to_string()
} }
fn default_worker_max_document_bytes() -> u64 {
200 * 1024 * 1024
}
fn redact_database_url(raw: &str) -> String { fn redact_database_url(raw: &str) -> String {
match Url::parse(raw) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
+23 -15
View File
@@ -18,7 +18,13 @@ use crate::{
storage::TenantStorage, storage::TenantStorage,
}; };
use super::{ocr::OCR_TEXT_ASSET_TYPE, JobExecution, JobHandler}; use super::{
fetch_version_object,
handle_fetch_error,
ocr::OCR_TEXT_ASSET_TYPE,
JobExecution,
JobHandler,
};
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct IndexPayload { struct IndexPayload {
@@ -114,21 +120,23 @@ impl JobHandler for IndexDocumentTextJob {
} }
let s3_key = context.text_s3_key.unwrap(); let s3_key = context.text_s3_key.unwrap();
let text = match storage.get_object(&s3_key).await { let bytes = match fetch_version_object(
Ok(bytes) => match String::from_utf8(bytes) { &context.version,
Ok(text) => text, &storage,
Err(err) => { &s3_key,
warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8"); state.config.worker_max_document_bytes,
return JobExecution::Failed { )
error: "ocr text not valid UTF-8".into(), .await
}; {
} Ok(bytes) => bytes,
}, Err(err) => return handle_fetch_error(job, err, "failed to download ocr text"),
};
let text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(err) => { Err(err) => {
warn!(job_id = %job.id, error = %err, "failed to download ocr text"); warn!(job_id = %job.id, error = %err, "ocr text not valid UTF-8");
return JobExecution::Retry { return JobExecution::Failed {
delay: Duration::from_secs(30), error: "ocr text not valid UTF-8".into(),
error: err.to_string(),
}; };
} }
}; };
+66
View File
@@ -1,5 +1,6 @@
use std::{collections::HashMap, sync::Arc, time::Duration}; use std::{collections::HashMap, sync::Arc, time::Duration};
use anyhow::Error as AnyhowError;
use async_trait::async_trait; use async_trait::async_trait;
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@@ -147,3 +148,68 @@ pub fn default_handlers() -> Vec<Arc<dyn JobHandler>> {
Arc::new(index::IndexDocumentTextJob::new()), Arc::new(index::IndexDocumentTextJob::new()),
] ]
} }
pub(crate) fn check_worker_document_limit(
size_bytes: i64,
limit_bytes: u64,
) -> Result<(), (u64, u64)> {
let size = size_bytes.max(0) as u64;
if size > limit_bytes {
Err((size, limit_bytes))
} else {
Ok(())
}
}
pub(crate) enum FetchVersionError {
TooLarge { size: u64, limit: u64 },
Storage(AnyhowError),
}
pub(crate) async fn fetch_version_object(
version: &crate::models::DocumentVersion,
storage: &TenantStorage,
s3_key: &str,
limit_bytes: u64,
) -> Result<Vec<u8>, FetchVersionError> {
check_worker_document_limit(version.size_bytes, limit_bytes).map_err(|(size, limit)| {
FetchVersionError::TooLarge {
size,
limit,
}
})?;
storage
.get_object(s3_key)
.await
.map_err(FetchVersionError::Storage)
}
pub(crate) fn handle_fetch_error(
job_id: crate::models::Job,
err: FetchVersionError,
message: &str,
) -> JobExecution {
match err {
FetchVersionError::TooLarge { size, limit } => {
warn!(
job_id = %job_id.id,
size_bytes = size,
limit_bytes = limit,
"document exceeds worker size limit"
);
JobExecution::Failed {
error: format!(
"document size {size} bytes exceeds worker limit of {limit} bytes"
),
}
}
FetchVersionError::Storage(err) => {
warn!(job_id = %job_id.id, error = %err, "{message}");
JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
}
}
}
}
+10 -9
View File
@@ -30,7 +30,7 @@ use crate::{
utils::storage_paths::document_asset_object_prefix, utils::storage_paths::document_asset_object_prefix,
}; };
use super::{JobExecution, JobHandler}; use super::{fetch_version_object, handle_fetch_error, JobExecution, JobHandler};
pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text"; pub const OCR_TEXT_ASSET_TYPE: &str = "ocr-text";
const MIN_TEXT_LENGTH: usize = 50; const MIN_TEXT_LENGTH: usize = 50;
@@ -99,15 +99,16 @@ impl JobHandler for GenerateOcrTextJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match storage.get_object(&context.version.s3_key).await { let bytes = match fetch_version_object(
&context.version,
&storage,
&context.version.s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => return handle_fetch_error(job, err, "failed to fetch document for ocr"),
warn!(job_id = %job.id, error = %err, "failed to fetch document for ocr");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
}; };
let doc_meta = PdfDocumentMeta { let doc_meta = PdfDocumentMeta {
+16 -9
View File
@@ -24,7 +24,13 @@ use crate::{
utils::storage_paths::document_asset_object_key, utils::storage_paths::document_asset_object_key,
}; };
use super::{analyze::determine_thumbnail_support, JobExecution, JobHandler}; use super::{
analyze::determine_thumbnail_support,
fetch_version_object,
handle_fetch_error,
JobExecution,
JobHandler,
};
const THUMBNAIL_WIDTH: u32 = 512; const THUMBNAIL_WIDTH: u32 = 512;
const THUMBNAIL_HEIGHT: u32 = 512; const THUMBNAIL_HEIGHT: u32 = 512;
@@ -96,15 +102,16 @@ impl JobHandler for GenerateThumbnailsJob {
return JobExecution::Success; return JobExecution::Success;
} }
let bytes = match storage.get_object(&initial.version.s3_key).await { let bytes = match fetch_version_object(
&initial.version,
&storage,
&initial.version.s3_key,
state.config.worker_max_document_bytes,
)
.await
{
Ok(bytes) => bytes, Ok(bytes) => bytes,
Err(err) => { Err(err) => return handle_fetch_error(job, err, "thumbnail fetch failed; will retry"),
warn!(job_id = %job.id, error = %err, "thumbnail fetch failed; will retry");
return JobExecution::Retry {
delay: Duration::from_secs(30),
error: err.to_string(),
};
}
}; };
let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) { let generation = match generate_preview_and_thumbnail(&initial.document, &bytes) {
+12 -105
View File
@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { import {
DownloadIcon, DownloadIcon,
EditIcon, EditIcon,
@@ -15,6 +14,7 @@ import { formatFileSize } from '../utils/format';
import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager'; import { resolveDocumentAssetUrl, createAssetView } from '../asset_manager';
import { useAssetNavigator } from '../hooks/useAssetNavigator'; import { useAssetNavigator } from '../hooks/useAssetNavigator';
import PreviewZoomOverlay from './PreviewZoomOverlay'; import PreviewZoomOverlay from './PreviewZoomOverlay';
import { openOcrTextInNewTab } from '../utils/ocr';
const MAX_PREVIEW_STACK_ITEMS = 15; const MAX_PREVIEW_STACK_ITEMS = 15;
@@ -300,6 +300,7 @@ const DetailPanel = ({
onUpdateTitle = async () => false, onUpdateTitle = async () => false,
ensureAssetUrl = null, ensureAssetUrl = null,
getDocumentAsset = () => null, getDocumentAsset = () => null,
ensurePreviewData = () => Promise.resolve(),
correspondents = [], correspondents = [],
onCorrespondentAdd, onCorrespondentAdd,
onCorrespondentRemove, onCorrespondentRemove,
@@ -329,10 +330,6 @@ const DetailPanel = ({
const [titleDraft, setTitleDraft] = useState(''); const [titleDraft, setTitleDraft] = useState('');
const [titleSaving, setTitleSaving] = useState(false); const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState(null); const [titleError, setTitleError] = useState(null);
const [ocrOpen, setOcrOpen] = useState(false);
const [ocrUrl, setOcrUrl] = useState(null);
const [ocrLoading, setOcrLoading] = useState(false);
const [ocrError, setOcrError] = useState(null);
const [zoomedPreview, setZoomedPreview] = useState(null); const [zoomedPreview, setZoomedPreview] = useState(null);
useEffect(() => { useEffect(() => {
@@ -341,10 +338,6 @@ const DetailPanel = ({
setTitleDraft(''); setTitleDraft('');
setTitleError(null); setTitleError(null);
setTitleSaving(false); setTitleSaving(false);
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
return; return;
} }
@@ -356,13 +349,6 @@ const DetailPanel = ({
} }
}, [singleDoc, titleEditDocId]); }, [singleDoc, titleEditDocId]);
useEffect(() => {
setOcrOpen(false);
setOcrLoading(false);
setOcrError(null);
setOcrUrl(null);
}, [singleDoc?.id]);
useEffect(() => { useEffect(() => {
setZoomedPreview(null); setZoomedPreview(null);
}, [selectionKey]); }, [selectionKey]);
@@ -420,59 +406,21 @@ const DetailPanel = ({
[singleDoc, getDocumentAsset], [singleDoc, getDocumentAsset],
); );
const loadOcrUrl = useCallback(async () => { const openOcr = useCallback(async () => {
if (!singleDoc) { if (!singleDoc) {
return; return;
} }
const asset = getDocumentAsset(singleDoc, 'ocr-text');
if (!asset) {
setOcrError('No OCR text available for this document.');
setOcrUrl(null);
return;
}
setOcrLoading(true);
setOcrError(null);
try { try {
let entry = asset; await openOcrTextInNewTab({
let url = entry?.url || document: singleDoc,
resolveDocumentAssetUrl(singleDoc, 'ocr-text', { ensurePreviewData,
ensureAssetUrl, getDocumentAsset,
getAsset: getDocumentAsset, ensureAssetUrl,
}); });
if (!url && typeof ensureAssetUrl === 'function') {
const ensured = await ensureAssetUrl(singleDoc.id, asset, { force: false });
if (ensured) {
entry = ensured;
}
url = entry?.url ||
resolveDocumentAssetUrl(singleDoc, 'ocr-text', {
ensureAssetUrl,
getAsset: getDocumentAsset,
});
}
if (!url) {
throw new Error('OCR text URL is unavailable.');
}
setOcrUrl(url);
} catch (error) { } catch (error) {
setOcrError(error.message || 'Failed to load OCR text.'); /* noop */
setOcrUrl(null);
} finally {
setOcrLoading(false);
} }
}, [singleDoc, ensureAssetUrl, getDocumentAsset]); }, [singleDoc, ensurePreviewData, getDocumentAsset, ensureAssetUrl]);
const openOcrModal = useCallback(() => {
if (!singleDoc) {
return;
}
setOcrOpen(true);
loadOcrUrl();
}, [singleDoc, loadOcrUrl]);
const closeOcrModal = useCallback(() => {
setOcrOpen(false);
}, []);
const singlePreviewNavigator = useAssetNavigator({ const singlePreviewNavigator = useAssetNavigator({
document: singleDoc, document: singleDoc,
@@ -1138,47 +1086,6 @@ const DetailPanel = ({
<pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre> <pre className="detail-metadata__block">{JSON.stringify(metadata, null, 2)}</pre>
</div> </div>
)} )}
{hasOcrAsset && ocrOpen
? createPortal(
<div className="modal-backdrop" role="presentation" onClick={closeOcrModal}>
<div
className="modal modal--ocr"
role="dialog"
aria-modal="true"
aria-label="OCR text"
onClick={(event) => event.stopPropagation()}
>
<div className="modal__header">
<h3>OCR Text</h3>
<button
type="button"
className="icon-button ghost"
onClick={closeOcrModal}
aria-label="Close OCR text"
>
×
</button>
</div>
<div className="modal__body detail-ocr__content">
{ocrLoading ? (
<div className="meta">Loading OCR text</div>
) : ocrError ? (
<div className="status-inline error">{ocrError}</div>
) : ocrUrl ? (
<iframe
title="OCR text"
src={ocrUrl}
className="detail-ocr__frame"
/>
) : (
<div className="meta">OCR text empty.</div>
)}
</div>
</div>
</div>,
document.body,
)
: null}
</> </>
); );
}; };
@@ -1349,7 +1256,7 @@ const DetailPanel = ({
className="icon-button ghost" className="icon-button ghost"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
openOcrModal(); openOcr();
}} }}
aria-label="View OCR text" aria-label="View OCR text"
title="View OCR text" title="View OCR text"
+2 -10
View File
@@ -1127,6 +1127,7 @@ export const createDocumentsSurface = ({
onNavigateParent, onNavigateParent,
renderSidebarToggle, renderSidebarToggle,
detailProps, detailProps,
detailOpen = false,
}) => { }) => {
const { const {
currentFolderName, currentFolderName,
@@ -1176,16 +1177,7 @@ export const createDocumentsSurface = ({
) )
: null; : null;
const detail = (() => { const detail = detailOpen && detailProps ? <DetailPanel {...detailProps} /> : null;
if (!detailProps) {
return null;
}
const count = detailProps.selectedDocuments?.length || 0;
if (!count) {
return null;
}
return <DetailPanel {...detailProps} />;
})();
return { return {
key: 'documents', key: 'documents',
+33 -2
View File
@@ -735,6 +735,14 @@ const AppLayout = () => {
[selectedRowKeys], [selectedRowKeys],
); );
const [detailPanelOpen, setDetailPanelOpen] = useState(() => selectedDocumentIds.length > 0);
useEffect(() => {
if (selectedDocumentIds.length > 0 && !detailPanelOpen) {
setDetailPanelOpen(true);
}
}, [selectedDocumentIds, detailPanelOpen]);
const resetWorkspaceState = useCallback(() => { const resetWorkspaceState = useCallback(() => {
const rootNode = createRootNode(); const rootNode = createRootNode();
setFolderNodes(new Map([[rootNode.id, rootNode]])); setFolderNodes(new Map([[rootNode.id, rootNode]]));
@@ -762,6 +770,7 @@ const AppLayout = () => {
setActiveTagFilters([]); setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME }); setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null); setActivePreviewId(null);
setDetailPanelOpen(false);
assetManager.reset(); assetManager.reset();
setPreviewEntries(() => new Map()); setPreviewEntries(() => new Map());
previewInflightRef.current = new Map(); previewInflightRef.current = new Map();
@@ -4845,6 +4854,11 @@ const AppLayout = () => {
const lastScrolledDetailDocRef = useRef(null); const lastScrolledDetailDocRef = useRef(null);
useEffect(() => { useEffect(() => {
if (!detailPanelOpen) {
lastScrolledDetailDocRef.current = null;
return;
}
if (!orderedSelectedDocuments.length) { if (!orderedSelectedDocuments.length) {
lastScrolledDetailDocRef.current = null; lastScrolledDetailDocRef.current = null;
return; return;
@@ -4870,6 +4884,10 @@ const AppLayout = () => {
return; return;
} }
if (typeof document === 'undefined') {
return;
}
const targetElement = const targetElement =
document.getElementById(`document-row-${lastSelectedId}`) || document.getElementById(`document-row-${lastSelectedId}`) ||
document.getElementById(`document-card-${lastSelectedId}`); document.getElementById(`document-card-${lastSelectedId}`);
@@ -4882,7 +4900,7 @@ const AppLayout = () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); targetElement.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}); });
}, [orderedSelectedDocuments, selectionOrder, documentsViewMode]); }, [orderedSelectedDocuments, selectionOrder, documentsViewMode, detailPanelOpen]);
const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => { const { breadcrumbs, missingBreadcrumbAncestors } = useMemo(() => {
const chain = []; const chain = [];
@@ -5267,6 +5285,11 @@ const AppLayout = () => {
[sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar], [sidebarProps, openTagsModal, openCorrespondentsModal, collapseSidebar],
); );
const handleDetailPanelClose = useCallback(() => {
setDetailPanelOpen(false);
clearDocumentSelection();
}, [clearDocumentSelection]);
const detailPanelProps = { const detailPanelProps = {
selectedDocuments: orderedSelectedDocuments, selectedDocuments: orderedSelectedDocuments,
tags, tags,
@@ -5286,12 +5309,13 @@ const AppLayout = () => {
onUpdateTitle: handleDocumentTitleUpdate, onUpdateTitle: handleDocumentTitleUpdate,
ensureAssetUrl, ensureAssetUrl,
getDocumentAsset, getDocumentAsset,
ensurePreviewData,
correspondents, correspondents,
onCorrespondentAdd: handleCorrespondentAdd, onCorrespondentAdd: handleCorrespondentAdd,
onCorrespondentRemove: handleCorrespondentRemove, onCorrespondentRemove: handleCorrespondentRemove,
resolveApiPath, resolveApiPath,
onFolderNavigate: selectFolder, onFolderNavigate: selectFolder,
onClose: clearDocumentSelection, onClose: handleDetailPanelClose,
resolveFolderPath, resolveFolderPath,
}; };
@@ -5369,6 +5393,8 @@ const AppLayout = () => {
openTagsModal, openTagsModal,
openCorrespondentsModal, openCorrespondentsModal,
openSettingsModal, openSettingsModal,
detailPanelOpen,
setDetailPanelOpen,
}), }),
[ [
token, token,
@@ -5410,6 +5436,8 @@ const AppLayout = () => {
openTagsModal, openTagsModal,
openCorrespondentsModal, openCorrespondentsModal,
openSettingsModal, openSettingsModal,
detailPanelOpen,
setDetailPanelOpen,
], ],
); );
@@ -5575,6 +5603,7 @@ const DocumentsRoute = () => {
sidebarProps, sidebarProps,
documentsTableProps, documentsTableProps,
detailPanelProps, detailPanelProps,
detailPanelOpen,
workspaceMode, workspaceMode,
skeuoWorkspaceProps, skeuoWorkspaceProps,
openTagsModal, openTagsModal,
@@ -5653,6 +5682,7 @@ const DocumentsRoute = () => {
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null, onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
renderSidebarToggle, renderSidebarToggle,
detailProps: detailPanelProps, detailProps: detailPanelProps,
detailOpen: detailPanelOpen,
}); });
}, [ }, [
documentsTableProps, documentsTableProps,
@@ -5660,6 +5690,7 @@ const DocumentsRoute = () => {
handleNavigateParent, handleNavigateParent,
renderSidebarToggle, renderSidebarToggle,
detailPanelProps, detailPanelProps,
detailPanelOpen,
]); ]);
const previewSurface = useMemo(() => { const previewSurface = useMemo(() => {
+7 -13
View File
@@ -1,7 +1,7 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { resolveDocumentAssetUrl } from '../asset_manager';
import { formatFileSize } from '../utils/format'; import { formatFileSize } from '../utils/format';
import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons'; import { DownloadIcon, TextScanIcon, AnalyzeIcon, CloseIcon } from '../ui/icons';
import { openOcrTextInNewTab } from '../utils/ocr';
const PreviewWorkspace = ({ const PreviewWorkspace = ({
document, document,
@@ -106,21 +106,15 @@ export const createPreviewWorkspaceHeaderActions = ({
const hasOcr = Boolean(ocrAsset); const hasOcr = Boolean(ocrAsset);
const handleOcrClick = async () => { const handleOcrClick = async () => {
if (!ocrAsset) {
return;
}
try { try {
const ensured = await ensureAssetUrl(document.id, ocrAsset, { force: false }); const success = await openOcrTextInNewTab({
const entry = ensured || ocrAsset; document,
const url = entry?.url getDocumentAsset,
|| resolveDocumentAssetUrl(document, 'ocr-text', { ensureAssetUrl,
ensureAssetUrl, });
getAsset: getDocumentAsset, if (!success) {
});
if (!url) {
throw new Error('OCR text URL unavailable.'); throw new Error('OCR text URL unavailable.');
} }
window.open(url, '_blank', 'noopener,noreferrer');
} catch (error) { } catch (error) {
notifyApiError(error, 'Unable to open OCR text.'); notifyApiError(error, 'Unable to open OCR text.');
} }
+4 -48
View File
@@ -2120,54 +2120,6 @@ button.danger:hover:not([disabled]) {
} }
.detail-ocr__content {
max-height: 60vh;
overflow: auto;
padding: 0.75rem;
background: var(--surface-soft);
border-radius: 6px;
box-shadow: inset 0 1px 2px var(--shadow-faint);
}
.detail-ocr__content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
font-size: 0.85rem;
line-height: 1.4;
}
.detail-ocr__frame {
width: 100%;
min-height: 360px;
height: 60vh;
background: var(--surface-subtle);
border-radius: 4px;
box-shadow: inset 0 1px 2px var(--shadow-faint);
}
.modal__body {
width: 100%;
}
.modal--ocr {
width: min(720px, 100%);
max-height: 80vh;
gap: 0.75rem;
}
.modal--ocr .modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.modal--ocr .modal__body {
padding: 0;
}
.preview-pane { .preview-pane {
margin-top: 0.4rem; margin-top: 0.4rem;
border-radius: 0; border-radius: 0;
@@ -2743,6 +2695,10 @@ form.inline {
padding: 0; padding: 0;
} }
.modal__body {
width: 100%;
}
.modal h3 { .modal h3 {
margin: 0; margin: 0;
font-size: 1.15rem; font-size: 1.15rem;