From a1065364d8ba6ed9d5e029bf9f6a0e74bfa977eb Mon Sep 17 00:00:00 2001 From: Ken Date: Tue, 26 May 2026 22:54:38 +0000 Subject: [PATCH] feat: add prose styling for markdown content with full typographic treatment --- src/styles/prose.css | 206 +++++++++++++++++++++++++++++++++++++++++ tests/verify-prose.mjs | 187 +++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 src/styles/prose.css create mode 100644 tests/verify-prose.mjs diff --git a/src/styles/prose.css b/src/styles/prose.css new file mode 100644 index 0000000..dee6f77 --- /dev/null +++ b/src/styles/prose.css @@ -0,0 +1,206 @@ +/* Prose — typographic treatment for rendered markdown */ + +/* --- Headings (Structure voice: Inter) --- */ + +.prose h2 { + font-family: var(--font-structure); + font-size: var(--type-2xl); + font-weight: 600; + line-height: var(--leading-snug); + letter-spacing: -0.015em; + margin-top: var(--space-8); + margin-bottom: var(--space-2); +} + +.prose h3 { + font-family: var(--font-structure); + font-size: var(--type-xl); + font-weight: 600; + line-height: 1.3; + letter-spacing: -0.01em; + margin-top: var(--space-5); + margin-bottom: var(--space-1-5); +} + +.prose h4 { + font-family: var(--font-structure); + font-size: var(--type-lg); + font-weight: 600; + line-height: var(--leading-heading); + letter-spacing: -0.005em; + margin-top: var(--space-4); + margin-bottom: var(--space-1); +} + +/* --- Body (Prose voice: Source Serif 4) --- */ + +.prose p { + margin-bottom: var(--space-3); +} + +.prose p:last-child { + margin-bottom: 0; +} + +/* --- Links --- */ + +.prose a { + color: var(--accent-primary); + text-decoration: none; + transition: color var(--duration-fast) var(--ease-default); +} + +.prose a:hover { + color: var(--accent-primary-hover); + text-decoration: underline; +} + +/* --- Blockquotes --- */ + +.prose blockquote { + font-family: var(--font-prose); + font-size: 1.25rem; + font-style: italic; + line-height: var(--leading-relaxed); + border-left: 3px solid var(--accent-primary); + padding-left: var(--space-3); + margin-top: var(--space-3); + margin-bottom: var(--space-3); +} + +/* --- Lists --- */ + +.prose ul, +.prose ol { + padding-left: var(--space-3); + margin-bottom: var(--space-3); +} + +.prose li { + margin-bottom: var(--space-1); +} + +.prose li > ul, +.prose li > ol { + margin-top: var(--space-1); + margin-bottom: 0; +} + +/* --- Inline code (Evidence voice: JetBrains Mono) --- */ + +.prose code { + font-family: var(--font-evidence); + font-size: 0.9em; + color: var(--code-text); + background-color: var(--surface-code); + padding: 0.15em 0.35em; + border-radius: var(--rounded-sm); +} + +/* --- Pre code reset --- */ + +.prose pre code { + font-size: 1rem; + background: none; + padding: 0; + border-radius: 0; + color: inherit; +} + +/* --- Code blocks (breakout width) --- */ + +.prose pre { + position: relative; + left: 50%; + transform: translateX(-50%); + width: min(var(--width-code), calc(100vw - var(--gutter) * 2)); + max-width: var(--width-code); + border: 1px solid var(--border-subtle); + border-left: 3px solid var(--accent-primary); + border-radius: var(--rounded-md); + padding: var(--space-3); + margin-top: var(--space-5); + margin-bottom: var(--space-5); + overflow-x: auto; + line-height: var(--leading-relaxed); + font-family: var(--font-evidence); + tab-size: 2; +} + +/* Warm scrollbar styling */ +.prose pre::-webkit-scrollbar { + height: 6px; +} + +.prose pre::-webkit-scrollbar-track { + background: var(--surface-inset); + border-radius: var(--rounded-full); +} + +.prose pre::-webkit-scrollbar-thumb { + background: var(--border-default); + border-radius: var(--rounded-full); +} + +.prose pre::-webkit-scrollbar-thumb:hover { + background: var(--border-strong); +} + +/* Focus-visible outline */ +.prose pre:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +/* --- Horizontal rules --- */ + +.prose hr { + border: none; + border-top: 1px solid var(--border-subtle); + margin-top: var(--space-6); + margin-bottom: var(--space-6); + max-width: 5rem; +} + +/* --- Strong / Em --- */ + +.prose strong { + font-weight: 600; +} + +.prose em { + font-style: italic; +} + +/* --- Images --- */ + +.prose img { + max-width: 100%; + height: auto; + border-radius: var(--rounded-md); + margin-top: var(--space-3); + margin-bottom: var(--space-3); +} + +/* --- Tables --- */ + +.prose table { + width: 100%; + border-collapse: collapse; + margin-top: var(--space-3); + margin-bottom: var(--space-3); + font-size: var(--type-sm); +} + +.prose th { + font-family: var(--font-structure); + font-weight: 600; + text-align: left; + padding: var(--space-1) var(--space-2); + border-bottom: 2px solid var(--border-default); +} + +.prose td { + padding: var(--space-1) var(--space-2); + border-bottom: 1px solid var(--border-subtle); +} diff --git a/tests/verify-prose.mjs b/tests/verify-prose.mjs new file mode 100644 index 0000000..43343d2 --- /dev/null +++ b/tests/verify-prose.mjs @@ -0,0 +1,187 @@ +/** + * Verification test for Prose Styling (Task 8) + * + * Checks that: + * 1. src/styles/prose.css exists + * 2. Contains .prose wrapper class + * 3. Headings: h2, h3, h4 with correct tokens + * 4. Paragraphs with bottom margin + * 5. Links with accent colors + * 6. Blockquotes with left border + * 7. Lists with padding and spacing + * 8. Inline code with evidence font + * 9. Code blocks with breakout width (position/transform technique) + * 10. Horizontal rules + * 11. Strong and em + * 12. Images + * 13. Tables + * 14. All values reference CSS custom properties from tokens.css + */ +import { readFileSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = join(__dirname, '..'); + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (condition) { + console.log(` ✓ ${message}`); + passed++; + } else { + console.error(` ✗ ${message}`); + failed++; + } +} + +const prosePath = join(root, 'src/styles/prose.css'); + +// === 1. File exists === +console.log('\n1. prose.css exists'); +assert(existsSync(prosePath), 'src/styles/prose.css exists'); + +if (!existsSync(prosePath)) { + console.log('\nFile not found, skipping remaining tests.'); + console.log(`\n${'='.repeat(40)}`); + console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`); + process.exit(1); +} + +const css = readFileSync(prosePath, 'utf8'); + +// === 2. .prose wrapper === +console.log('\n2. .prose wrapper'); +assert(css.includes('.prose'), 'contains .prose class'); + +// === 3. Headings === +console.log('\n3. Headings (Structure voice: Inter)'); +// h2 +assert(css.includes('.prose h2'), '.prose h2 selector exists'); +assert(css.includes('--font-structure'), 'headings use --font-structure'); +assert(css.includes('--type-2xl'), 'h2 uses --type-2xl'); +assert(/\.prose h2[\s\S]*?font-weight:\s*600/.test(css), 'h2 weight 600'); +assert(css.includes('--leading-snug'), 'h2 uses --leading-snug'); +assert(css.includes('-0.015em'), 'h2 tracking -0.015em'); +assert(css.includes('--space-8'), 'h2 uses --space-8 top margin'); +// h3 +assert(css.includes('.prose h3'), '.prose h3 selector exists'); +assert(css.includes('--type-xl'), 'h3 uses --type-xl'); +assert(css.includes('-0.01em'), 'h3 tracking -0.01em'); +assert(css.includes('--space-5'), 'h3 uses --space-5 top margin'); +assert(css.includes('--space-1-5'), 'h3 uses --space-1-5 bottom margin'); +// h4 +assert(css.includes('.prose h4'), '.prose h4 selector exists'); +assert(css.includes('--type-lg'), 'h4 uses --type-lg'); +assert(css.includes('--leading-heading'), 'h4 uses --leading-heading'); +assert(css.includes('-0.005em'), 'h4 tracking -0.005em'); + +// === 4. Paragraphs === +console.log('\n4. Paragraphs (Prose voice)'); +assert(css.includes('.prose p'), '.prose p selector exists'); +assert(css.includes('--space-3'), 'p uses --space-3 bottom margin'); +assert(css.includes(':last-child'), 'p:last-child margin 0 rule'); + +// === 5. Links === +console.log('\n5. Links'); +assert(css.includes('.prose a'), '.prose a selector exists'); +assert(css.includes('--accent-primary'), 'links use --accent-primary'); +assert(css.includes('text-decoration: none') || css.includes('text-decoration:none'), 'links have no underline by default'); +assert(css.includes('--accent-primary-hover'), 'link hover uses --accent-primary-hover'); +// hover should add underline +assert(/\.prose a:hover[\s\S]*?underline/.test(css), 'link hover adds underline'); + +// === 6. Blockquotes === +console.log('\n6. Blockquotes'); +assert(css.includes('.prose blockquote'), '.prose blockquote selector exists'); +assert(css.includes('--font-prose'), 'blockquotes use --font-prose'); +assert(css.includes('1.25rem'), 'blockquote 1.25rem size'); +assert(css.includes('italic') || css.includes('font-style'), 'blockquote italic'); +assert(css.includes('--leading-relaxed'), 'blockquote --leading-relaxed'); +assert(css.includes('3px'), 'blockquote 3px left border'); + +// === 7. Lists === +console.log('\n7. Lists'); +assert(css.includes('.prose ul') || css.includes('.prose ol'), '.prose ul/ol selector exists'); +assert(css.includes('.prose li'), '.prose li selector exists'); + +// === 8. Inline code === +console.log('\n8. Inline code (Evidence voice)'); +assert(css.includes('.prose code'), '.prose code selector exists'); +assert(css.includes('--font-evidence'), 'inline code uses --font-evidence'); +assert(css.includes('0.9em'), 'inline code 0.9em size'); +assert(css.includes('--code-text'), 'inline code uses --code-text color'); +assert(css.includes('--surface-code'), 'inline code uses --surface-code background'); +assert(css.includes('0.15em'), 'inline code 0.15em vertical padding'); +assert(css.includes('0.35em'), 'inline code 0.35em horizontal padding'); +assert(css.includes('--rounded-sm'), 'inline code uses --rounded-sm'); + +// === 9. Pre code reset === +console.log('\n9. Pre code reset'); +assert(css.includes('.prose pre code'), '.prose pre code selector exists'); +assert(/\.prose pre code[\s\S]*?1rem/.test(css) || /\.prose pre code[\s\S]*?font-size/.test(css), 'pre code resets font size'); + +// === 10. Code blocks with breakout width === +console.log('\n10. Code blocks (breakout width)'); +assert(css.includes('.prose pre'), '.prose pre selector exists'); +assert(css.includes('position: relative') || css.includes('position:relative'), 'code block position relative'); +assert(css.includes('left: 50%') || css.includes('left:50%'), 'code block left 50%'); +assert(css.includes('translateX(-50%)'), 'code block translateX(-50%)'); +assert(css.includes('--width-code'), 'code block references --width-code'); +assert(css.includes('--gutter'), 'code block references --gutter'); +assert(css.includes('--rounded-md'), 'code block uses --rounded-md'); +assert(css.includes('overflow-x: auto') || css.includes('overflow-x:auto'), 'code block overflow-x auto'); +assert(css.includes('--font-evidence'), 'code block uses --font-evidence'); +assert(css.includes('tab-size: 2') || css.includes('tab-size:2'), 'code block tab-size 2'); +assert(css.includes('focus-visible'), 'code block has focus-visible outline'); + +// === 11. Horizontal rules === +console.log('\n11. Horizontal rules'); +assert(css.includes('.prose hr'), '.prose hr selector exists'); +assert(css.includes('border: none') || css.includes('border:none') || css.includes('border: 0') || css.includes('border:0'), 'hr no border'); +assert(css.includes('--border-subtle') || css.includes('border-top'), 'hr has subtle top border'); +assert(css.includes('--space-6'), 'hr uses --space-6 margins'); +assert(css.includes('5rem'), 'hr max-width 5rem'); + +// === 12. Strong and em === +console.log('\n12. Strong and em'); +assert(css.includes('.prose strong'), '.prose strong selector exists'); +assert(/\.prose strong[\s\S]*?font-weight:\s*600/.test(css), 'strong weight 600'); +assert(css.includes('.prose em'), '.prose em selector exists'); + +// === 13. Images === +console.log('\n13. Images'); +assert(css.includes('.prose img'), '.prose img selector exists'); +assert(css.includes('max-width: 100%') || css.includes('max-width:100%'), 'images max-width 100%'); +assert(css.includes('height: auto') || css.includes('height:auto'), 'images auto height'); + +// === 14. Tables === +console.log('\n14. Tables'); +assert(css.includes('.prose table'), '.prose table selector exists'); +assert(css.includes('border-collapse') || css.includes('border-collapse: collapse'), 'table collapse'); +assert(css.includes('--type-sm'), 'table uses --type-sm'); +assert(css.includes('.prose th'), '.prose th selector exists'); +assert(/\.prose th[\s\S]*?font-weight:\s*600/.test(css) || /\.prose th[\s\S]*?--font-structure/.test(css), 'th uses structure font or weight 600'); +assert(css.includes('.prose td'), '.prose td selector exists'); + +// === 15. Uses CSS custom properties from tokens.css (no raw hex) === +console.log('\n15. Token references'); +// Check that key styling values use var() references +const varCount = (css.match(/var\(--/g) || []).length; +assert(varCount >= 30, `uses var(--) references extensively (found ${varCount}, expected >=30)`); + +// Verify no raw color hex values used (except within specific contexts like border shorthand) +// Allow #-style in comments but main properties should use tokens +const hexInProperties = css.match(/:\s*#[0-9a-fA-F]{3,8}\b/g) || []; +assert(hexInProperties.length === 0, `no raw hex color values in properties (found ${hexInProperties.length})`); + +// === Summary === +console.log(`\n${'='.repeat(40)}`); +console.log(`Results: ${passed} passed, ${failed} failed out of ${passed + failed}`); +if (failed > 0) { + process.exit(1); +} else { + console.log('All prose verification tests passed!'); +}