{"article":{"slug":"a-font-that-reads-what-you-wrote","title":"A font that reads what you wrote","subtitle":null,"summary":"Rohan Adwankar introduces semfont: a small library that automatically highlights, colors, bolds, and italicizes text so typography tracks meaning as you write.","content_type":"blog_post","language":"en","canonical_url":"https://rohanadwankar.github.io/posts/semfont.html","author":{"name":"Rohan Adwankar","url":"https://rohanadwankar.github.io/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Rohan Adwankar","url":"https://rohanadwankar.github.io/","listing_slug":null,"listing":null},"topics":[{"name":"Design","slug":"design","url":"https://listedarticles.com/topics/design"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"User Experience","slug":"user-experience","url":"https://listedarticles.com/topics/user-experience"},{"name":"Open Source","slug":"open-source","url":"https://listedarticles.com/topics/open-source"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1295,"reading_minutes":6,"published_at":"2026-09-21T18:00:00.000Z","added_at":"2026-09-22T12:25:12.468Z","updated_at":"2026-09-22T12:25:12.468Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/a-font-that-reads-what-you-wrote","markdown_url":"https://listedarticles.com/articles/a-font-that-reads-what-you-wrote.md","example":false,"citation":"Rohan Adwankar, Rohan Adwankar. \"A font that reads what you wrote.\" 21 Sept 2026. https://rohanadwankar.github.io/posts/semfont.html (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://rohanadwankar.github.io/posts/semfont.html"},"body_markdown":"# A font that reads what you wrote\n\nsemfont is a small library that sets typography automatically.\nAs you can see below it automatically highlights, colors, bolds, and italicizes text which aims to make it easier to read.\n\ntheme\n editorial\n loud\n monochrome\n technical\n\n## How it works\n\nEvery word gets four scores. Each one starts as a dictionary lookup and is then adjusted by a couple of rules over the words around it.\n\nValence is how good or bad the word is, from -1 to 1. A negator up to three words back flips the sign and damps it, because `not great` is a mild complaint rather than the mirror image of praise. An intensifier up to two words back scales it instead.\n\n`let v = VALENCE[word] ?? 0;          // great -> 0.75\nif (negatorWithin(3)) v = -v * 0.74; // not great -> -0.55\nv *= gain;                           // really great -> 0.98\n`\n\nSalience is how much the word is worth looking at, 0 to 1. A frequency list gives each word a rarity, 0 for one of the hundred most common English words and 1 for one it has never seen. Rarity alone is not enough, so the score also rises with how often the word repeats in this particular text: an uncommon word you keep saying is what the text is about.\n\n`const seen = Math.min(1, (repeats - 1) / 2);\nconst repetition = 0.45 + 0.55 * seen;\nlet s = SALIENCE[word] ?? 0;\ns = Math.max(s, 0.55 * rarity * repetition);\n\n// rarity('the') 0.00, rarity('kubelet') 0.93\n// kubelet said once   -> 0.23\n// kubelet said 3 times -> 0.51\n`\n\nSurprise is where the sentence turns, 0 to 1. Some words announce it on their own, like `suddenly` or `ironically`. Otherwise it comes from position: everything for six words after a contrast word gets it, decaying with distance, and so does any word much rarer than the rest of the passage.\n\n`let s = SURPRISE[word] ?? 0;         // suddenly -> 0.85\nif (afterContrast) {\n  s = Math.max(s, 0.45 * 0.82 ** (distance - 1));\n}\ns += 0.3 * Math.max(0, rarity - passageMeanRarity - 0.25);\n\n// 'The tests failed'            -> failed 0.16\n// 'It compiled, but the tests failed' -> failed 0.44\n`\n\nCertainty is how sure the writer sounds, -1 hedged to 1 asserted. Words like `probably` and `definitely` are in a table. But if you write `The build probably failed`, you are not unsure about the word `probably`, you are unsure about whether it failed. So the hedge keeps its own score and every other word in the sentence gets 55% of it, and the whole line leans a little instead of one word in the middle of it.\n\n`c = CERTAINTY[word] ?? sentenceCertainty * 0.55;\n\n// 'The build probably failed.'\n// probably -0.40, every other word -0.22\n`\n\nThen a theme maps each score to one typographic axis: valence to colour, salience to weight, surprise to a highlight, certainty to slant. Each axis has a threshold, so most words come out untouched.\n\n## Improving the algorithm\n\nThose rules only look a few words either side, and that window has a blind spot. Write `I would not go so far as to call the new editor great` and `great` stays green, because the `not` that cancels it sits nine words back. Write `We fixed the crash` and you get one green word and one red one, because nothing connects `fixed` to the thing it fixed.\n\nSo a second pass now runs after the window rules and reads each clause as a whole. A negator reaches to the end of its clause and fades with distance, which turns `great` red. A verb like `fixed`, `recovered` or `avoided` marks whatever s it as the thing that got better, which turns `crash` green. The same pass reads `less broken` and `fewer complaints` as improvements, `too simple` as a complaint, and a lone `Great,` in front of bad news as sarcasm.\n\nEvery change it makes is recorded on the word, so you can ask why a word came out the colour it did:\n\n`analyze('We fixed the crash.').tokens[6];\n// { text: 'crash', valence: 0.44,\n//   notes: ['resolved by \"fixed\"'] }\n`\n\nIt costs about as much as the first pass and stays inside the budget, so there is no switch to flip. These are the ten sentences that led to it, including those two. Left is the first version, right is now.\n\nbeforenow\nGreat, another outage. Just what I needed today.Great, another outage. Just what I needed today.\nWe fixed the crash and closed the security hole before anyone noticed.We fixed the crash and closed the security hole before anyone noticed.\nThe cluster recovered from the crash in under a minute.The cluster recovered from the crash in under a minute.\nWe avoided a catastrophic outage by catching the bug in staging.We avoided a catastrophic outage by catching the bug in staging.\nI would not go so far as to call the new editor great.I would not go so far as to call the new editor great.\nLess broken than last week, and far fewer complaints.Less broken than last week, and far fewer complaints.\nThe memory leak is gone.The memory leak is gone.\nThe reviewer called it \"terrible\", which is wrong.The reviewer called it \"terrible\", which is wrong.\nThe API is too simple and the docs are too clever.The API is too simple and the docs are too clever.\n\n## Using it\n\n`import { SemanticText } from 'semfont';\n\n<SemanticText as=\"p\">\n  The migration ran clean on staging. In production it deleted the index,\n  and the rollback failed too.\n</SemanticText>\n`\n\nPick a theme, or only the channels you want:\n\n`<SemanticText text={incident} theme=\"monochrome\" />\n<SemanticText text={incident} channels={['valence']} />\n`\n\nTeach it your own vocabulary:\n\n`<SemanticText\n  lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}\n  text={incident}\n/>\n`\n\nNow for the case I started this for. AI system stream in a large amount of text and its hard to read all of it so the intention of this is a library that can easily be tossed into most streaming components to make the text easier to read:\n\n`import { useChat } from '@ai-sdk/react';\nimport { SemanticText } from 'semfont';\n\nfunction Chat() {\n  const { messages } = useChat();\n  return messages.map((m) => {\n    const text = m.parts.filter((p) => p.type === 'text').map((p) => p.text).join('');\n    return m.role === 'assistant'\n      ? <SemanticText key={m.id} as=\"p\" text={text} />\n      : <p key={m.id}>{text}</p>;\n  });\n}\n`\n\n#### plain text\n\n#### semfont\n\nOr skip React and take the scores. `analyze` is the engine alone, four numbers per word, no CSS, and these imports work with no React installed:\n\n`import { analyze } from 'semfont/analyze';\nimport { styleFor, themes } from 'semfont/theme';\n\nconst { tokens } = analyze('The rollback failed too.');\ntokens[4];   // { text: 'failed', valence: -0.7, salience: 0.23, surprise: 0.06, certainty: 0, ... }\nstyleFor(tokens[4], themes.editorial).style;   // { color: 'color-mix(in oklab, currentColor, oklch(0.58 0.19 25) 53%)' }\n`\n\nThat last form is how this page works. There is no bundler here, so one import map tells the browser where `semfont/analyze` and `semfont/theme` live, pinned to a version on npm, and the demo box above is the same three lines as the React component: analyze, style, render.\n\n`<script type=\"importmap\">\n{ \"imports\": {\n  \"semfont/analyze\": \"https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/analyze.js\",\n  \"semfont/theme\":   \"https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/theme.js\"\n} }\n</script>\n<script type=\"module\">\n  import { analyze } from 'semfont/analyze';\n  import { styleFor, themes } from 'semfont/theme';\n</script>\n`\n\n## Next Steps\n\nAs you can probably guess based on the implementation it will be essentially impossible to get perfect classification while also being fast enough to not slow down the streaming. However for the purpose of making text easier to read it doesn't have to be perfect and some simple heuristics may end up taking us far enough away. That being said there are some case like sarcasm which would be interesting to try to tackle with heuristics and some cases like negation with embedded clauses which may be possible to parse out. Furthermore, for streaming coding agents there are technical words which could be worth including in the vocabulary.\n\nCode and demo at github.com/RohanAdwankar/semfont.","body_html":"<h1 id=\"a-font-that-reads-what-you-wrote\">A font that reads what you wrote</h1>\n<p>semfont is a small library that sets typography automatically.\nAs you can see below it automatically highlights, colors, bolds, and italicizes text which aims to make it easier to read.</p>\n<p>theme\n editorial\n loud\n monochrome\n technical</p>\n<h2 id=\"how-it-works\">How it works</h2>\n<p>Every word gets four scores. Each one starts as a dictionary lookup and is then adjusted by a couple of rules over the words around it.</p>\n<p>Valence is how good or bad the word is, from -1 to 1. A negator up to three words back flips the sign and damps it, because <code>not great</code> is a mild complaint rather than the mirror image of praise. An intensifier up to two words back scales it instead.</p>\n<p><code>let v = VALENCE[word] ?? 0;          // great -&gt; 0.75\nif (negatorWithin(3)) v = -v * 0.74; // not great -&gt; -0.55\nv *= gain;                           // really great -&gt; 0.98\n</code></p>\n<p>Salience is how much the word is worth looking at, 0 to 1. A frequency list gives each word a rarity, 0 for one of the hundred most common English words and 1 for one it has never seen. Rarity alone is not enough, so the score also rises with how often the word repeats in this particular text: an uncommon word you keep saying is what the text is about.</p>\n<p>`const seen = Math.min(1, (repeats - 1) / 2);\nconst repetition = 0.45 + 0.55 * seen;\nlet s = SALIENCE[word] ?? 0;\ns = Math.max(s, 0.55 * rarity * repetition);</p>\n<p>// rarity(&#39;the&#39;) 0.00, rarity(&#39;kubelet&#39;) 0.93\n// kubelet said once   -&gt; 0.23\n// kubelet said 3 times -&gt; 0.51\n`</p>\n<p>Surprise is where the sentence turns, 0 to 1. Some words announce it on their own, like <code>suddenly</code> or <code>ironically</code>. Otherwise it comes from position: everything for six words after a contrast word gets it, decaying with distance, and so does any word much rarer than the rest of the passage.</p>\n<p>`let s = SURPRISE[word] ?? 0;         // suddenly -&gt; 0.85\nif (afterContrast) {\n  s = Math.max(s, 0.45 * 0.82 ** (distance - 1));\n}\ns += 0.3 * Math.max(0, rarity - passageMeanRarity - 0.25);</p>\n<p>// &#39;The tests failed&#39;            -&gt; failed 0.16\n// &#39;It compiled, but the tests failed&#39; -&gt; failed 0.44\n`</p>\n<p>Certainty is how sure the writer sounds, -1 hedged to 1 asserted. Words like <code>probably</code> and <code>definitely</code> are in a table. But if you write <code>The build probably failed</code>, you are not unsure about the word <code>probably</code>, you are unsure about whether it failed. So the hedge keeps its own score and every other word in the sentence gets 55% of it, and the whole line leans a little instead of one word in the middle of it.</p>\n<p>`c = CERTAINTY[word] ?? sentenceCertainty * 0.55;</p>\n<p>// &#39;The build probably failed.&#39;\n// probably -0.40, every other word -0.22\n`</p>\n<p>Then a theme maps each score to one typographic axis: valence to colour, salience to weight, surprise to a highlight, certainty to slant. Each axis has a threshold, so most words come out untouched.</p>\n<h2 id=\"improving-the-algorithm\">Improving the algorithm</h2>\n<p>Those rules only look a few words either side, and that window has a blind spot. Write <code>I would not go so far as to call the new editor great</code> and <code>great</code> stays green, because the <code>not</code> that cancels it sits nine words back. Write <code>We fixed the crash</code> and you get one green word and one red one, because nothing connects <code>fixed</code> to the thing it fixed.</p>\n<p>So a second pass now runs after the window rules and reads each clause as a whole. A negator reaches to the end of its clause and fades with distance, which turns <code>great</code> red. A verb like <code>fixed</code>, <code>recovered</code> or <code>avoided</code> marks whatever s it as the thing that got better, which turns <code>crash</code> green. The same pass reads <code>less broken</code> and <code>fewer complaints</code> as improvements, <code>too simple</code> as a complaint, and a lone <code>Great,</code> in front of bad news as sarcasm.</p>\n<p>Every change it makes is recorded on the word, so you can ask why a word came out the colour it did:</p>\n<p><code>analyze(&#39;We fixed the crash.&#39;).tokens[6];\n// { text: &#39;crash&#39;, valence: 0.44,\n//   notes: [&#39;resolved by &quot;fixed&quot;&#39;] }\n</code></p>\n<p>It costs about as much as the first pass and stays inside the budget, so there is no switch to flip. These are the ten sentences that led to it, including those two. Left is the first version, right is now.</p>\n<p>beforenow\nGreat, another outage. Just what I needed today.Great, another outage. Just what I needed today.\nWe fixed the crash and closed the security hole before anyone noticed.We fixed the crash and closed the security hole before anyone noticed.\nThe cluster recovered from the crash in under a minute.The cluster recovered from the crash in under a minute.\nWe avoided a catastrophic outage by catching the bug in staging.We avoided a catastrophic outage by catching the bug in staging.\nI would not go so far as to call the new editor great.I would not go so far as to call the new editor great.\nLess broken than last week, and far fewer complaints.Less broken than last week, and far fewer complaints.\nThe memory leak is gone.The memory leak is gone.\nThe reviewer called it &quot;terrible&quot;, which is wrong.The reviewer called it &quot;terrible&quot;, which is wrong.\nThe API is too simple and the docs are too clever.The API is too simple and the docs are too clever.</p>\n<h2 id=\"using-it\">Using it</h2>\n<p>`import { SemanticText } from &#39;semfont&#39;;</p>\n<p>&lt;SemanticText as=&quot;p&quot;&gt;\n  The migration ran clean on staging. In production it deleted the index,\n  and the rollback failed too.\n&lt;/SemanticText&gt;\n`</p>\n<p>Pick a theme, or only the channels you want:</p>\n<p><code>&lt;SemanticText text={incident} theme=&quot;monochrome&quot; /&gt;\n&lt;SemanticText text={incident} channels={[&#39;valence&#39;]} /&gt;\n</code></p>\n<p>Teach it your own vocabulary:</p>\n<p><code>&lt;SemanticText\n  lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}\n  text={incident}\n/&gt;\n</code></p>\n<p>Now for the case I started this for. AI system stream in a large amount of text and its hard to read all of it so the intention of this is a library that can easily be tossed into most streaming components to make the text easier to read:</p>\n<p>`import { useChat } from &#39;@ai-sdk/react&#39;;\nimport { SemanticText } from &#39;semfont&#39;;</p>\n<p>function Chat() {\n  const { messages } = useChat();\n  return messages.map((m) =&gt; {\n    const text = m.parts.filter((p) =&gt; p.type === &#39;text&#39;).map((p) =&gt; p.text).join(&#39;&#39;);\n    return m.role === &#39;assistant&#39;\n      ? &lt;SemanticText key={m.id} as=&quot;p&quot; text={text} /&gt;\n      : &lt;p key={m.id}&gt;{text}&lt;/p&gt;;\n  });\n}\n`</p>\n<h4 id=\"plain-text\">plain text</h4>\n<h4 id=\"semfont\">semfont</h4>\n<p>Or skip React and take the scores. <code>analyze</code> is the engine alone, four numbers per word, no CSS, and these imports work with no React installed:</p>\n<p>`import { analyze } from &#39;semfont/analyze&#39;;\nimport { styleFor, themes } from &#39;semfont/theme&#39;;</p>\n<p>const { tokens } = analyze(&#39;The rollback failed too.&#39;);\ntokens[4];   // { text: &#39;failed&#39;, valence: -0.7, salience: 0.23, surprise: 0.06, certainty: 0, ... }\nstyleFor(tokens[4], themes.editorial).style;   // { color: &#39;color-mix(in oklab, currentColor, oklch(0.58 0.19 25) 53%)&#39; }\n`</p>\n<p>That last form is how this page works. There is no bundler here, so one import map tells the browser where <code>semfont/analyze</code> and <code>semfont/theme</code> live, pinned to a version on npm, and the demo box above is the same three lines as the React component: analyze, style, render.</p>\n<p><code>&lt;script type=&quot;importmap&quot;&gt;\n{ &quot;imports&quot;: {\n  &quot;semfont/analyze&quot;: &quot;https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/analyze.js&quot;,\n  &quot;semfont/theme&quot;:   &quot;https://cdn.jsdelivr.net/npm/semfont@0.2.0/src/theme.js&quot;\n} }\n&lt;/script&gt;\n&lt;script type=&quot;module&quot;&gt;\n  import { analyze } from &#39;semfont/analyze&#39;;\n  import { styleFor, themes } from &#39;semfont/theme&#39;;\n&lt;/script&gt;\n</code></p>\n<h2 id=\"next-steps\">Next Steps</h2>\n<p>As you can probably guess based on the implementation it will be essentially impossible to get perfect classification while also being fast enough to not slow down the streaming. However for the purpose of making text easier to read it doesn&#39;t have to be perfect and some simple heuristics may end up taking us far enough away. That being said there are some case like sarcasm which would be interesting to try to tackle with heuristics and some cases like negation with embedded clauses which may be possible to parse out. Furthermore, for streaming coding agents there are technical words which could be worth including in the vocabulary.</p>\n<p>Code and demo at github.com/RohanAdwankar/semfont.</p>","headings":[{"level":1,"text":"A font that reads what you wrote","id":"a-font-that-reads-what-you-wrote"},{"level":2,"text":"How it works","id":"how-it-works"},{"level":2,"text":"Improving the algorithm","id":"improving-the-algorithm"},{"level":2,"text":"Using it","id":"using-it"},{"level":2,"text":"Next Steps","id":"next-steps"}]}}