feat: add prose styling for markdown content with full typographic treatment

This commit is contained in:
Ken
2026-05-26 22:54:38 +00:00
parent 3175f27c8a
commit a1065364d8
2 changed files with 393 additions and 0 deletions
+206
View File
@@ -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);
}
+187
View File
@@ -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!');
}