diff --git a/package.json b/package.json index 8646102..1f7a7db 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev:installer": "tauri dev -- -- --install", "build": "vite build", "preview": "vite preview", + "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts scripts/frontMatterDisclosure.test.ts", "test:frontmatter": "node --test --import tsx scripts/frontMatter.test.ts", "test:workflows": "node --test --import tsx scripts/exportHtml.test.ts scripts/exportOpenPrompt.test.ts scripts/reloadOpenToolbar.test.ts scripts/editorToolbar.test.ts scripts/titlebarToolbar.test.ts scripts/toolbarCustomizationWiring.test.ts", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", diff --git a/scripts/frontMatterDisclosure.test.ts b/scripts/frontMatterDisclosure.test.ts new file mode 100644 index 0000000..1e09b44 --- /dev/null +++ b/scripts/frontMatterDisclosure.test.ts @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { test } from 'node:test'; + +const viewerSource = readFileSync(new URL('../src/lib/MarkdownViewer.svelte', import.meta.url), 'utf8'); + +test('front matter panel uses native details disclosure so collapsed summary remains expandable', () => { + const panelStart = viewerSource.indexOf('frontmatter-panel'); + assert.notEqual(panelStart, -1); + + const panelSource = viewerSource.slice(panelStart - 160, panelStart + 900); + + assert.match(panelSource, /]*class="frontmatter-panel"/); + assert.match(panelSource, /]*class="frontmatter-summary"/); + assert.doesNotMatch(panelSource, /]*class="frontmatter-summary"/); +}); + +test('front matter panel is collapsed by default until the user opens it', () => { + assert.match(viewerSource, /frontMatterCollapsedByKey\[frontMatterPanelKey\]\s*\?\?\s*true/); + assert.match(viewerSource, /open=\{!isFrontMatterCollapsed\}/); +}); + +test('front matter list fields render as editable tags instead of one comma input', () => { + const listBranchStart = viewerSource.indexOf("field.kind === 'list'"); + assert.notEqual(listBranchStart, -1); + + const listBranch = viewerSource.slice(listBranchStart, listBranchStart + 4000); + + assert.match(listBranch, /class="frontmatter-tags"/); + assert.match(listBranch, /class="frontmatter-tag-remove"/); + assert.match(listBranch, /class="frontmatter-tag-add-button"/); + assert.match(listBranch, /startFrontMatterTagEdit/); + assert.match(listBranch, /value=\{getFrontMatterTagDraft\(field\)\}/); +}); diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 4e357e8..6062768 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -24,6 +24,17 @@ import { askToOpenExportedFile } from './utils/openExportedFile.js'; import ZoomOverlay from './components/ZoomOverlay.svelte'; import { processMarkdownHtml } from './utils/markdown'; +import { + addFrontMatterListItems, + getMarkdownBodyWithoutFrontMatter, + getFrontMatterListItems, + parseFrontMatter, + parseFrontMatterEditableValue, + removeFrontMatterListItem, + updateFrontMatterListItem, + updateFrontMatterField, + type FrontMatterField, +} from './utils/frontMatter.js'; import { decodeLinkPath, getMarkdownLinkTarget as getRelativeMarkdownTarget, @@ -161,9 +172,17 @@ import { t } from './utils/i18n.js'; let isEditing = $derived(activeTab?.isEditing ?? false); let rawContent = $derived(activeTab?.rawContent ?? ''); let isSplit = $derived(activeTab?.isSplit ?? false); + let frontMatterInfo = $derived(parseFrontMatter(rawContent)); // derived from tab manager let currentFile = $derived(tabManager.activeTab?.path ?? ''); + let frontMatterPanelKey = $derived(currentFile || tabManager.activeTabId || 'untitled'); + let frontMatterCollapsedByKey = $state>({}); + let frontMatterEditErrors = $state>({}); + let frontMatterTagDrafts = $state>({}); + let frontMatterTagEditIndexes = $state>({}); + let frontMatterTagEditDrafts = $state>({}); + let isFrontMatterCollapsed = $derived(frontMatterCollapsedByKey[frontMatterPanelKey] ?? true); const markdownLinkExtensions = ['.md', '.markdown', '.mdown', '.mkd', '.txt']; let isMarkdown = $derived(hasMarkdownLinkExtension(currentFile)); let editorLanguage = $derived(getLanguage(currentFile)); @@ -501,6 +520,210 @@ import { t } from './utils/i18n.js'; } } + async function renderMarkdownPreview(raw: string, filePath: string) { + const body = getMarkdownBodyWithoutFrontMatter(raw); + const html = (await invoke('render_markdown', { content: body })) as string; + return processMarkdownHtml(html, filePath, collapsedHeaders); + } + + async function renderTabPreviewFromRaw(tab: { id: string; path: string; rawContent: string; [key: string]: any }) { + const processed = await renderMarkdownPreview(tab.rawContent, tab.path); + tabManager.updateTabContent(tab.id, processed); + tab._lastRenderedRawContent = tab.rawContent; + await tick(); + renderRichContent(); + } + + function frontMatterFieldId(key: string) { + return `frontmatter-${key.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + } + + function frontMatterFieldStateKey(field: FrontMatterField) { + return `${frontMatterPanelKey}:${field.key}`; + } + + function tagsEqual(left: string[], right: string[]) { + return left.length === right.length && left.every((value, index) => value === right[index]); + } + + function focusAndSelect(node: HTMLInputElement) { + requestAnimationFrame(() => { + node.focus(); + node.select(); + }); + } + + function setFrontMatterCollapsed(collapsed: boolean) { + frontMatterCollapsedByKey = { + ...frontMatterCollapsedByKey, + [frontMatterPanelKey]: collapsed, + }; + } + + function clearFrontMatterEditError(key: string) { + if (!frontMatterEditErrors[key]) return; + const next = { ...frontMatterEditErrors }; + delete next[key]; + frontMatterEditErrors = next; + } + + async function handleFrontMatterEdit(field: FrontMatterField, value: string) { + const tab = tabManager.activeTab; + if (!tab) return; + + try { + const nextValue = parseFrontMatterEditableValue(field, value); + const nextRaw = updateFrontMatterField(tab.rawContent, field.key, nextValue); + tabManager.updateTabRawContent(tab.id, nextRaw); + clearFrontMatterEditError(field.key); + await renderTabPreviewFromRaw(tab); + } catch (error) { + frontMatterEditErrors = { + ...frontMatterEditErrors, + [field.key]: String(error), + }; + } + } + + function getFrontMatterTagDraft(field: FrontMatterField) { + return frontMatterTagDrafts[frontMatterFieldStateKey(field)] ?? ''; + } + + function setFrontMatterTagDraft(field: FrontMatterField, value: string) { + frontMatterTagDrafts = { + ...frontMatterTagDrafts, + [frontMatterFieldStateKey(field)]: value, + }; + clearFrontMatterEditError(field.key); + } + + function clearFrontMatterTagDraft(field: FrontMatterField) { + const key = frontMatterFieldStateKey(field); + if (!frontMatterTagDrafts[key]) return; + + const next = { ...frontMatterTagDrafts }; + delete next[key]; + frontMatterTagDrafts = next; + } + + function getFrontMatterTagEditIndex(field: FrontMatterField) { + return frontMatterTagEditIndexes[frontMatterFieldStateKey(field)] ?? null; + } + + function getFrontMatterTagEditDraft(field: FrontMatterField, fallback: string) { + return frontMatterTagEditDrafts[frontMatterFieldStateKey(field)] ?? fallback; + } + + function setFrontMatterTagEditDraft(field: FrontMatterField, value: string) { + frontMatterTagEditDrafts = { + ...frontMatterTagEditDrafts, + [frontMatterFieldStateKey(field)]: value, + }; + clearFrontMatterEditError(field.key); + } + + function startFrontMatterTagEdit(field: FrontMatterField, index: number, value: string) { + const key = frontMatterFieldStateKey(field); + frontMatterTagEditIndexes = { + ...frontMatterTagEditIndexes, + [key]: index, + }; + frontMatterTagEditDrafts = { + ...frontMatterTagEditDrafts, + [key]: value, + }; + clearFrontMatterEditError(field.key); + } + + function clearFrontMatterTagEdit(field: FrontMatterField) { + const key = frontMatterFieldStateKey(field); + const nextIndexes = { ...frontMatterTagEditIndexes }; + const nextDrafts = { ...frontMatterTagEditDrafts }; + delete nextIndexes[key]; + delete nextDrafts[key]; + frontMatterTagEditIndexes = nextIndexes; + frontMatterTagEditDrafts = nextDrafts; + } + + async function handleFrontMatterListChange(field: FrontMatterField, nextItems: string[]) { + const tab = tabManager.activeTab; + if (!tab) return; + + try { + const nextRaw = updateFrontMatterField(tab.rawContent, field.key, nextItems); + tabManager.updateTabRawContent(tab.id, nextRaw); + clearFrontMatterEditError(field.key); + await renderTabPreviewFromRaw(tab); + } catch (error) { + frontMatterEditErrors = { + ...frontMatterEditErrors, + [field.key]: String(error), + }; + } + } + + async function commitFrontMatterTagAdd(field: FrontMatterField) { + const draft = getFrontMatterTagDraft(field); + if (!draft.trim()) return; + + const currentItems = getFrontMatterListItems(field); + const nextItems = addFrontMatterListItems(currentItems, [draft]); + if (tagsEqual(currentItems, nextItems)) { + clearFrontMatterTagDraft(field); + return; + } + + await handleFrontMatterListChange(field, nextItems); + clearFrontMatterTagDraft(field); + } + + async function removeFrontMatterTag(field: FrontMatterField, index: number) { + const currentItems = getFrontMatterListItems(field); + const nextItems = removeFrontMatterListItem(currentItems, index); + if (tagsEqual(currentItems, nextItems)) return; + + await handleFrontMatterListChange(field, nextItems); + } + + async function commitFrontMatterTagEdit(field: FrontMatterField, index: number) { + if (getFrontMatterTagEditIndex(field) !== index) return; + + const draft = getFrontMatterTagEditDraft(field, ''); + const currentItems = getFrontMatterListItems(field); + const nextItems = updateFrontMatterListItem(currentItems, index, draft); + + clearFrontMatterTagEdit(field); + if (tagsEqual(currentItems, nextItems)) return; + + await handleFrontMatterListChange(field, nextItems); + } + + function handleFrontMatterTagAddKeydown(event: KeyboardEvent, field: FrontMatterField) { + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + void commitFrontMatterTagAdd(field); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + clearFrontMatterTagDraft(field); + } + } + + function handleFrontMatterTagEditKeydown(event: KeyboardEvent, field: FrontMatterField, index: number) { + if (event.key === 'Enter') { + event.preventDefault(); + void commitFrontMatterTagEdit(field, index); + return; + } + + if (event.key === 'Escape') { + event.preventDefault(); + clearFrontMatterTagEdit(field); + } + } + type LoadMarkdownOptions = { navigate?: boolean; skipTabManagement?: boolean; @@ -540,11 +763,11 @@ import { t } from './utils/i18n.js'; if (tab && !options.preserveEditState && !existing) { tab.isEditing = settings.startInEditor; } - const [html, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; + const [, content, isFull] = await invoke('open_markdown_preview', { path: filePath, maxBytes: 50000 }) as [string, string, boolean]; if (pendingNavigateTabId) { tabManager.navigate(pendingNavigateTabId, filePath); } - const processedInfo = processMarkdownHtml(html, filePath, collapsedHeaders); + const processedInfo = await renderMarkdownPreview(content, filePath); tabManager.updateTabContent(activeId, processedInfo); tabManager.setTabRawContent(activeId, content); @@ -553,10 +776,7 @@ import { t } from './utils/i18n.js'; tick().then(() => { if (markdownBody) isAtBottom = markdownBody.scrollHeight <= markdownBody.clientHeight + 100; }); - Promise.all([ - invoke('open_markdown', { path: filePath }) as Promise, - invoke('read_file_content', { path: filePath }) as Promise - ]).then(([fullHtml, fullContent]) => { + (invoke('read_file_content', { path: filePath }) as Promise).then((fullContent) => { const applyFull = () => { try { if (isScrolling) { @@ -564,15 +784,22 @@ import { t } from './utils/i18n.js'; return; } if (tabManager.tabs.find((t) => t.id === activeId)?.path === filePath) { - const fullProcessed = processMarkdownHtml(fullHtml, filePath, collapsedHeaders); - tabManager.updateTabContent(activeId, fullProcessed); - tabManager.setTabRawContent(activeId, fullContent); - loadingTabs = loadingTabs.filter((id) => id !== activeId); - if (tabManager.activeTabId === activeId) { - tick().then(() => { - setTimeout(renderRichContent, 10); + renderMarkdownPreview(fullContent, filePath) + .then((fullProcessed) => { + tabManager.updateTabContent(activeId, fullProcessed); + tabManager.setTabRawContent(activeId, fullContent); + loadingTabs = loadingTabs.filter((id) => id !== activeId); + if (tabManager.activeTabId === activeId) { + tick().then(() => { + setTimeout(renderRichContent, 10); + }); + } + }) + .catch((renderErr) => { + console.error("render full markdown error:", renderErr); + addToast('Error processing full markdown: ' + String(renderErr), 'error'); + loadingTabs = loadingTabs.filter((id) => id !== activeId); }); - } } else { loadingTabs = loadingTabs.filter((id) => id !== activeId); } @@ -589,7 +816,7 @@ import { t } from './utils/i18n.js'; setTimeout(applyFull, 100); } }).catch((e) => { - console.error("Promise.all error:", e); + console.error("read full markdown error:", e); addToast('Backend Error loading full markdown: ' + String(e), 'error'); loadingTabs = loadingTabs.filter((id) => id !== activeId); }); @@ -1360,16 +1587,15 @@ import { t } from './utils/i18n.js'; tab.isEditing = false; if (tab.path !== '') { await loadMarkdown(tab.path, { preserveEditState: true }); - } else { - // Untitled: render the in-memory buffer for the preview. - try { - const html = (await invoke('render_markdown', { content: tab.rawContent })) as string; - const processedInfo = processMarkdownHtml(html, '', collapsedHeaders); - tabManager.updateTabContent(tab.id, processedInfo); - } catch (e) { - console.error('Failed to render markdown', e); + } else { + // Untitled: render the in-memory buffer for the preview. + try { + const processedInfo = await renderMarkdownPreview(tab.rawContent, ''); + tabManager.updateTabContent(tab.id, processedInfo); + } catch (e) { + console.error('Failed to render markdown', e); + } } - } } else { // Switch to edit if (tab.path !== '') { @@ -1970,9 +2196,8 @@ import { t } from './utils/i18n.js'; clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - invoke('render_markdown', { content: tab.rawContent }) - .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + renderMarkdownPreview(tab.rawContent, tab.path) + .then((processed) => { tabManager.updateTabContent(tab.id, processed); (tab as any)._lastRenderedRawContent = tab.rawContent; tick().then(renderRichContent); @@ -2371,9 +2596,8 @@ import { t } from './utils/i18n.js'; tabManager.restoreState(savedData); for (const tab of tabManager.tabs) { if (!tab.content && tab.rawContent) { - invoke('render_markdown', { content: tab.rawContent }) - .then((html) => { - const processed = processMarkdownHtml(html as string, tab.path, collapsedHeaders); + renderMarkdownPreview(tab.rawContent, tab.path) + .then((processed) => { tabManager.updateTabContent(tab.id, processed); if (tabManager.activeTabId === tab.id) { tick().then(renderRichContent); @@ -2848,19 +3072,124 @@ import { t } from './utils/i18n.js'; {markdownBody} language={settings.language} /> -
-
{ if(e.key === 'Enter' || e.key === ' ') handleLinkClick(e as unknown as MouseEvent); }} - tabindex="-1" - style="outline: none; font-family: {settings.previewFont}, sans-serif; font-size: {settings.previewFontSize}px; flex: 1;"> -
- {#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom} +
+
{ + const target = e.target as HTMLElement; + if (target.closest('.frontmatter-panel')) return; + if(e.key === 'Enter' || e.key === ' ') handleLinkClick(e as unknown as MouseEvent); + }} + tabindex="-1" + style="outline: none; font-family: {settings.previewFont}, sans-serif; font-size: {settings.previewFontSize}px; flex: 1;"> + {#if frontMatterInfo.exists} +
setFrontMatterCollapsed(!(e.currentTarget as HTMLDetailsElement).open)}> + + + Properties + {frontMatterInfo.valid ? frontMatterInfo.fields.length : 0} + + + {#if frontMatterInfo.valid} +
+ {#each frontMatterInfo.fields as field (field.key)} + +
+ {#if field.editable} + {#if field.kind === 'boolean'} + + {:else if field.kind === 'list'} +
+
+ {#each getFrontMatterListItems(field) as tag, index (`${tag}-${index}`)} + + {#if getFrontMatterTagEditIndex(field) === index} + setFrontMatterTagEditDraft(field, (e.currentTarget as HTMLInputElement).value)} + onkeydown={(e) => handleFrontMatterTagEditKeydown(e, field, index)} + onblur={() => commitFrontMatterTagEdit(field, index)} /> + {:else} + + + {/if} + + {/each} +
+
+ setFrontMatterTagDraft(field, (e.currentTarget as HTMLInputElement).value)} + onkeydown={(e) => handleFrontMatterTagAddKeydown(e, field)} /> + +
+
+ {:else} + handleFrontMatterEdit(field, (e.currentTarget as HTMLInputElement).value)} /> + {/if} + {:else} + {field.displayValue} + {/if} + {#if frontMatterEditErrors[field.key]} +
{frontMatterEditErrors[field.key]}
+ {/if} +
+ {/each} +
+ {:else} +
{frontMatterInfo.error}
+ {/if} + +
+ {/if} + {@html sanitizedHtml} +
+ {#if tabManager.activeTabId && loadingTabs.includes(tabManager.activeTabId) && isAtBottom}
{t('common.loadingFullDocument', settings.language)} diff --git a/src/styles.css b/src/styles.css index ca405e1..da5d86e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -233,6 +233,241 @@ body { vertical-align: text-bottom; } +.markdown-body .frontmatter-panel { + margin: 0 0 1.5rem; + border: 1px solid var(--color-border-default); + border-radius: 8px; + background: var(--color-canvas-subtle); + overflow: hidden; +} + +.markdown-body .frontmatter-summary { + width: 100%; + display: flex; + align-items: center; + gap: 0.5rem; + min-height: 2.25rem; + padding: 0.45rem 0.75rem; + border: 0; + background: transparent; + color: var(--color-fg-default); + font: inherit; + font-weight: 600; + text-align: left; + cursor: pointer; + list-style: none; + user-select: none; + -webkit-user-select: none; +} + +.markdown-body .frontmatter-summary::marker, +.markdown-body .frontmatter-summary::-webkit-details-marker { + display: none; + content: ""; +} + +.markdown-body .frontmatter-summary:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: -3px; +} + +.markdown-body .frontmatter-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + inline-size: 1rem; + block-size: 1rem; + color: var(--color-fg-muted); + transform: rotate(90deg); + transition: transform 0.15s ease; +} + +.markdown-body .frontmatter-panel:not([open]) .frontmatter-chevron { + transform: rotate(0deg); +} + +.markdown-body .frontmatter-title { + flex: 1; +} + +.markdown-body .frontmatter-count { + min-inline-size: 1.5rem; + padding: 0.05rem 0.45rem; + border: 1px solid var(--color-border-muted); + border-radius: 999px; + color: var(--color-fg-muted); + font-size: 0.78em; + font-weight: 500; + text-align: center; +} + +.markdown-body .frontmatter-grid { + display: grid; + grid-template-columns: fit-content(12rem) minmax(0, 1fr); + column-gap: 0.75rem; + row-gap: 0.45rem; + align-items: start; + padding: 0.75rem; + border-top: 1px solid var(--color-border-muted); +} + +.markdown-body .frontmatter-key { + padding-block: 0.35rem; + color: var(--color-fg-muted); + font-size: 0.9em; + overflow-wrap: anywhere; +} + +.markdown-body .frontmatter-value { + min-width: 0; +} + +.markdown-body .frontmatter-value input, +.markdown-body .frontmatter-value select { + width: 100%; + min-height: 2rem; + box-sizing: border-box; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + color: var(--color-fg-default); + font: inherit; + font-size: 0.92em; + padding: 0.3rem 0.5rem; +} + +.markdown-body .frontmatter-value input:focus-visible, +.markdown-body .frontmatter-value select:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: 1px; +} + +.markdown-body .frontmatter-value code { + display: block; + padding: 0.35rem 0.5rem; + border: 1px solid var(--color-border-muted); + border-radius: 6px; + background: var(--color-canvas-default); + white-space: pre-wrap; +} + +.markdown-body .frontmatter-tags { + display: flex; + flex-direction: column; + gap: 0.45rem; + min-width: 0; +} + +.markdown-body .frontmatter-tag-list { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + min-height: 2rem; +} + +.markdown-body .frontmatter-tag { + display: inline-flex; + align-items: stretch; + max-width: 100%; + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + overflow: hidden; +} + +.markdown-body .frontmatter-tag-text, +.markdown-body .frontmatter-tag-remove, +.markdown-body .frontmatter-tag-add-button { + min-height: 1.8rem; + border: 0; + background: transparent; + color: var(--color-fg-default); + font: inherit; + font-size: 0.9em; +} + +.markdown-body .frontmatter-tag-text { + max-width: min(18rem, 100%); + padding: 0.2rem 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: text; +} + +.markdown-body .frontmatter-tag-remove { + inline-size: 1.7rem; + border-left: 1px solid var(--color-border-muted); + color: var(--color-fg-muted); + cursor: pointer; +} + +.markdown-body .frontmatter-tag-add { + display: grid; + grid-template-columns: minmax(8rem, 1fr) 2rem; + gap: 0.35rem; + width: min(100%, 24rem); +} + +.markdown-body .frontmatter-tag-add-button { + border: 1px solid var(--color-border-default); + border-radius: 6px; + background: var(--color-canvas-default); + color: var(--color-fg-muted); + cursor: pointer; +} + +.markdown-body .frontmatter-value .frontmatter-tag-edit-input { + width: min(14rem, 100%); + min-height: 1.8rem; + border: 0; + border-radius: 0; + background: transparent; + padding: 0.2rem 0.5rem; +} + +.markdown-body .frontmatter-tag-text:hover, +.markdown-body .frontmatter-tag-remove:hover, +.markdown-body .frontmatter-tag-add-button:hover { + background: var(--color-neutral-muted); + color: var(--color-fg-default); +} + +.markdown-body .frontmatter-tag-text:focus-visible, +.markdown-body .frontmatter-tag-remove:focus-visible, +.markdown-body .frontmatter-tag-add-button:focus-visible { + outline: 2px solid var(--color-accent-fg); + outline-offset: 1px; +} + +.markdown-body .frontmatter-error, +.markdown-body .frontmatter-field-error { + color: var(--color-danger-fg); + font-size: 0.9em; +} + +.markdown-body .frontmatter-error { + padding: 0.75rem; + border-top: 1px solid var(--color-border-muted); + white-space: pre-wrap; +} + +.markdown-body .frontmatter-field-error { + margin-top: 0.25rem; +} + +@media (max-width: 720px) { + .markdown-body .frontmatter-grid { + grid-template-columns: minmax(0, 1fr); + row-gap: 0.25rem; + } + + .markdown-body .frontmatter-key { + padding-block-end: 0; + } +} + .markdown-body h1:hover .anchor .octicon-link:before, .markdown-body h2:hover .anchor .octicon-link:before, .markdown-body h3:hover .anchor .octicon-link:before,