{"_id":"semfont","_rev":"2-e26f65e63613a9e7e7fe11b8e166b0b6","name":"semfont","dist-tags":{"latest":"0.2.0"},"versions":{"0.1.0":{"name":"semfont","version":"0.1.0","keywords":["typography","variable-fonts","sentiment","semantic","react","text","emphasis"],"author":{"name":"Rohan Adwankar"},"license":"MIT","_id":"semfont@0.1.0","maintainers":[{"name":"rohanadwankar","email":"rohan.adwankar@gmail.com"}],"homepage":"https://github.com/RohanAdwankar/semfont#readme","bugs":{"url":"https://github.com/RohanAdwankar/semfont/issues"},"dist":{"shasum":"a05732f34b1f9706973f1c7f7d3d43d71e19325c","tarball":"https://registry.npmjs.org/semfont/-/semfont-0.1.0.tgz","fileCount":9,"integrity":"sha512-LeWL9hOuiA7znMjixoDp6ctFbyovSDASyYnXXH4qbu1w7+wqHFCgxWfOg7kOeoEUc4qVeT5Quxwaik0Dzfgdlw==","signatures":[{"sig":"MEYCIQD3CH6OtvG434MMbbSTmSR1Hcl5TK468HeHgLtRiUO0LQIhAKnQiRtDuQCH+UWjjyXGxECZt3JE828agKcOT4YrMtHN","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":57449},"main":"./src/index.js","type":"module","exports":{".":"./src/index.js","./theme":"./src/theme.js","./analyze":"./src/analyze.js","./lexicon":"./src/lexicon.js"},"gitHead":"62881c804da994e64d6baf6c17d9c42370e7ff8b","scripts":{"test":"node --test test/*.test.js"},"_npmUser":{"name":"rohanadwankar","email":"rohan.adwankar@gmail.com"},"repository":{"url":"git+https://github.com/RohanAdwankar/semfont.git","type":"git"},"_npmVersion":"11.3.0","description":"Typography that modulates on meaning","directories":{},"sideEffects":false,"_nodeVersion":"23.8.0","_hasShrinkwrap":false,"peerDependencies":{"react":">=18"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/semfont_0.1.0_1789452645402_0.9539281661511112","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"_id":"semfont@0.2.0","bugs":{"url":"https://github.com/RohanAdwankar/semfont/issues"},"dist":{"shasum":"7b24b4633eb88ce0f9833cb0fa8164e4ab71e0bc","tarball":"https://registry.npmjs.org/semfont/-/semfont-0.2.0.tgz","fileCount":10,"integrity":"sha512-jXZYITy8oTa8wFcfTkd+8iCMW/mPgKe4u43kDo0pp6Nkr4dNpcVXPgBj3477x1KXZJietlWXeK7hnuufTTGJzQ==","signatures":[{"sig":"MEQCIEFqbZtgbyr8x3Ff+jd3z2J3k9mrCv0NZG/+SOQ5SJu5AiB6Qefklytx/eRBkz35dMFy33VrNezcyjyU8PtJsDOj3w==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQDbLVGtTaoAPoOLvQEJyhwgHl+mWBZ0e7BvQFX1evpPeQIhAOnmLPhtUOWZ5vbEdGEH40A6id9oom9fVH4Hf2Cb0ier"}],"unpackedSize":129041},"main":"./src/index.js","name":"semfont","type":"module","author":{"name":"Rohan Adwankar"},"exports":{".":"./src/index.js","./theme":"./src/theme.js","./analyze":"./src/analyze.js","./lexicon":"./src/lexicon.js"},"gitHead":"9a22f454121856454ddd2599409f874f3292c725","license":"MIT","scripts":{"test":"node --test test/*.test.js","bench":"node test/bench.mjs"},"version":"0.2.0","_npmUser":{"name":"rohanadwankar","email":"rohan.adwankar@gmail.com"},"homepage":"https://github.com/RohanAdwankar/semfont#readme","keywords":["typography","variable-fonts","sentiment","semantic","react","text","emphasis"],"repository":{"url":"git+https://github.com/RohanAdwankar/semfont.git","type":"git"},"_npmVersion":"11.3.0","description":"Typography that modulates on meaning","directories":{},"maintainers":[{"name":"rohanadwankar","email":"rohan.adwankar@gmail.com"}],"sideEffects":false,"_nodeVersion":"23.8.0","_hasShrinkwrap":false,"peerDependencies":{"react":">=18"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/semfont_0.2.0_1789875830075_0.25863992043053186"}}},"time":{"created":"2026-09-15T06:10:45.190Z","modified":"2026-09-20T03:43:50.363Z","0.1.0":"2026-09-15T06:10:45.559Z","0.2.0":"2026-09-20T03:43:50.172Z"},"bugs":{"url":"https://github.com/RohanAdwankar/semfont/issues"},"author":{"name":"Rohan Adwankar"},"license":"MIT","homepage":"https://github.com/RohanAdwankar/semfont#readme","keywords":["typography","variable-fonts","sentiment","semantic","react","text","emphasis"],"repository":{"url":"git+https://github.com/RohanAdwankar/semfont.git","type":"git"},"description":"Typography that modulates on meaning","maintainers":[{"name":"rohanadwankar","email":"rohan.adwankar@gmail.com"}],"readme":"# semantic font\n\nTypography that modulates on meaning instead of on markup. Negative things\nrender red, important things get heavier, surprising things get highlighted,\nhedged things lean, and nothing in the pipeline is a model.\n\n![two panes of the same paragraph side by side, labelled the same text set conventionally and set by semfont: on the left every word is the same grey, on the right clean comes out green, production and deleted come out heavy, might leans, postmortem is highlighted and failed and painful come out red; then a second sentence is typed in green and two nots are dropped into it, and it turns red](demo/demo.gif)\n\n```jsx\nimport { 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\nNo markup went in. `clean` comes out green, `deleted` heavier and larger, and\n`failed` red, because the engine read the sentence.\n\n## What this is not\n\nThe closest things a reader already has, and why each is a different shape of\nproblem:\n\n| you might reach for | what it keys on | why this is not that |\n|---|---|---|\n| syntax highlighting | grammar, from a parser | the categories are fixed by the language. Prose has no keywords, and `failed` is not a token type |\n| Bionic Reading | word position, first *n* letters | one rule applied uniformly. It never reads a word, so every word gets the same treatment |\n| a sentiment dashboard | a document, after the fact | reports a number about your text somewhere else. This sets the text itself, in place, as you write it |\n| `<em>` and `<strong>` | your decision, hand-made | the file keeps the emphasis and forgets the reason, so it stays put when the sentence changes |\n| an LLM | everything, better | see below. It reads sarcasm; it cannot run inside a keystroke |\n| variable font sliders | nothing | a control surface, not a decision. Something still has to decide what `wght` should be for this word |\n\nThe line through all of them: this is the only one where the typography is a\n*function of the sentence*, recomputed whenever the sentence changes.\n\n## Why not an LLM\n\nBecause typography has to keep up with typing. `analyze()` is a pure\nsynchronous function over lexicons and local rules: about a millisecond for a\npage of prose, no network, no key, no async, nothing leaving the browser, and\nthe same input always gives the same output. That is what makes it usable as\na *font* rather than as a feature: it can run on every keystroke, in a\n`useMemo`, during SSR, on a plane.\n\nAn LLM would read sarcasm better. It could not run 60 times a second inside a\ntextarea.\n\n## The channels\n\nEvery token gets a score per channel, and each score drives a different\ntypographic axis so they compose instead of collide:\n\n| channel | range | signals | typography |\n|---|---|---|---|\n| `valence` | −1..1 | sentiment lexicon, negation, intensifiers | colour |\n| `salience` | 0..1 | emphasis lexicon, caps, numerals, repeated rare words | weight (`wght`), size |\n| `surprise` | 0..1 | surprise markers, contrast conjunctions, local rarity spikes | highlight |\n| `certainty` | −1..1 | hedges and assertions, spread over the clause | slant (`slnt`), opacity |\n| `technicality` | 0..1 | camelCase, underscores, letters welded to digits, a short jargon list | `MONO`, in the `technical` theme |\n\nThe first four are on in every theme. `technicality` is scored always and\nmapped only by `technical`, which is the pattern for adding your own: scoring a\nchannel costs a lookup per token, and a theme that ignores it pays nothing.\n\nTwo rules do most of the work. **Negation flips and damps**: `not great` is\nmildly negative, not the mirror image of `great`. **Rarity is relative to the\npassage**: a word is only remarkable next to the company it keeps, so the\nthreshold comes from this text rather than from a global corpus, which is how the\ntopic terms of a paragraph float up without anyone tagging them.\n\n## API\n\n```bash\nnpm install semfont          # react is a peer, only needed for the component\n```\n\n```js\nimport { SemanticText, useSemanticText, analyze, themes, styleFor } from 'semfont';\n```\n\nThe root import needs React present, because it exports the component. For\nthe engine alone, with no React installed at all, import the subpaths:\n\n```js\nimport { analyze } from 'semfont/analyze';\nimport { styleFor, themes } from 'semfont/theme';\n```\n\n`<SemanticText>` props:\n\n| prop | default | |\n|---|---|---|\n| `text` / `children` | none | the passage |\n| `theme` | `'editorial'` | `'editorial'`, `'loud'`, `'monochrome'`, or a theme object |\n| `channels` | all four | which channels may style |\n| `sensitivity` | `1` | global gain on every score |\n| `lexicon` | none | extra entries per channel, merged over the defaults |\n| `as` | `'span'` | element to render |\n| `debug` | `false` | emit the scores as `data-*` attributes |\n| `onAnalyze` | none | passage-level readout |\n\n`useSemanticText(text, options)` returns the scored tokens and the runs, for\nrendering it yourself. `analyze(text, options)` is the engine alone, without\nReact, and `styleFor(token, theme)` is the mapping alone.\n\nTeach it your vocabulary with a lexicon:\n\n```jsx\n<SemanticText\n  lexicon={{ valence: { flaky: -0.7, oncall: -0.4 }, salience: { rollback: 0.8 } }}\n  text={incident}\n/>\n```\n\n## Taking only the part you want\n\nThe four channels are independent all the way down, and there are four places\nto cut, from coarsest to finest.\n\n**Pick channels.** Nothing but colour:\n\n```jsx\n<SemanticText text={incident} channels={['valence']} />\n```\n\nEvery other channel scores 0 and emits nothing, so the spans carry exactly one\nCSS property. `demo/react.html` mounts the same paragraph three times this\nway.\n\n**Pick axes.** A theme's `map` is a list of rows, one per channel-to-axis\npairing, so how many modulations you get is yours to set. Weight without the\nsize change is one row removed:\n\n```jsx\nimport { themes } from 'semfont';\n\n<SemanticText\n  theme={{ map: themes.editorial.map.filter((row) => row.render !== 'size') }}\n  text={incident}\n/>\n```\n\nA row is `{ channel, render, ...options }`, plus an optional\n`side: 'negative' | 'positive'` to fire on only half of a bipolar channel. The\nrenderers are `color`, `weight`, `size`, `highlight`, `slant`, `tracking`,\n`fade`, `underline`, and Recursive's own `mono`, `casual` and `cursive`. Rows\nare independent and additive, so another one is a line of data:\n\n```jsx\n<SemanticText\n  theme={{ map: [...themes.editorial.map,\n                 { channel: 'technicality', render: 'mono' }] }}\n  text={incident}\n/>\n```\n\nCost, measured on a 2,875-character page: scoring five channels takes 1.43ms,\nand running a nine-row map over every token takes 0.47ms. The ceiling on how\nmany modulations to use is legibility rather than speed, since emphasis works\nby contrast and a page where everything moves has nothing left to move\nagainst.\n\n**Keep the scores, render it yourself.** `useSemanticText` hands back the\ntokens and runs, so the styling can be your own classes, a `<mark>`, an\nARIA annotation, a minimap, anything.\n\n**Or skip the typography entirely.** `analyze(text)` is the engine alone: no\nReact, no CSS, four numbers per token. It is also useful as a plain text\nsignal: sorting a log by salience, flagging hedged sentences in review.\n\n## As a static site\n\nEverything is client-side; there is no server component to any of it.\n\n`src/analyze.js` and `src/theme.js` import nothing at all, so a static page\ncan load them directly, which is exactly what `demo/index.html` does, and it\nneeds only a file server (`python3 -m http.server`, GitHub Pages, an S3\nbucket). ES modules do need HTTP rather than `file://`.\n\n`SemanticText.js` imports `react` as a bare specifier. Inside any bundler or\nstatic-site generator that resolves itself. In a page with no bundler, one\nimport map is the whole setup:\n\n```html\n<script type=\"importmap\">\n  { \"imports\": { \"react\": \"https://esm.sh/react@18.3.1\",\n                 \"react-dom/client\": \"https://esm.sh/react-dom@18.3.1/client\" } }\n</script>\n```\n\nSee `demo/react.html`, which runs the component with no build step of any\nkind. And because `analyze()` is synchronous and pure, the component renders\nunder `renderToStaticMarkup`, so a static site can prerender the typography\ninto the HTML and ship no JavaScript at all.\n\n## Speed\n\n`analyze()` runs in under a millisecond per hundred words on a laptop, linear\nin the length of the text, and that is a budget rather than a measurement. A\nrule that would put the default engine over it does not go in. If one earns\nits place at a higher cost it ships as a separate model, selected explicitly,\nso the default never gets slower.\n\n```bash\nnpm run bench                 # ms per hundred words on this README\nnpm run bench -- essay.md     # or on your own text\n```\n\n## Vocabulary\n\nValence comes from two layers. The hand-written table in `src/lexicon.js` is\nthe vocabulary of software and incidents, a few hundred words with the scores\nthe demos were tuned on. Under it sits about four thousand everyday words from\nthe [VADER](https://github.com/cjhutto/vaderSentiment) sentiment lexicon\n(Hutto and Gilbert, 2014, MIT), filtered to the words its raters felt at least\nmoderately about, minus anything that belongs to another channel and a short\nlist of words VADER rates by their happiest sense. The hand table wins wherever\nthey overlap. `node scripts/vader.mjs` regenerates `src/vader.js`.\n\n## Two passes\n\nThe first pass gives each word its lexicon entry and a fixed window of two or\nthree neighbours. On its own that reads `fixed the crash` as one good word and\none bad word, leaves `great` green six words after a `not`, and takes `Great,\nanother outage` at face value.\n\nThe second pass re-derives valence over clauses instead of windows. Still no\nmodel, still deterministic, about half the total cost. Five rules:\n\n| rule | example | window alone | with the clause pass |\n|---|---|---|---|\n| a negator reaches to the end of its clause | I would not go so far as to call it great | great | ~~great~~ |\n| a resolver flips the harm it resolves | we fixed the crash; the leak is gone | crash, leak | crash, leak |\n| less of a bad thing is good | less broken, fewer complaints | broken | broken |\n| too turns praise into a complaint | too simple | simple | simple |\n| a lone opener before bad news is sarcasm; a quote the writer calls wrong is not the writer's word | Great, another outage. / called it \"terrible\", which is wrong | Great, terrible | Great, terrible |\n\nEvery change the clause pass makes is written to `token.notes`, so a debug\npanel can say why a word came out the colour it did:\n`['resolved by \"fixed\"']`.\n\n## Themes\n\n`editorial` is deliberately quiet: high thresholds, small ranges, most words\nleft completely alone. If every word is styled, none of them is emphasised.\n`loud` turns the same scores up for a headline or a demo. `monochrome` emits\nno colour at all, for print, e-ink, and for the fact that colour alone is not\nan accessible channel. Losing colour means the four channels have to be\nre-seated rather than merely recoloured, so valence takes the slant, salience\nkeeps weight and size, surprise takes an underline, and certainty rides\ntracking in both directions, loosening when hedged and tightening when\nassertive. `technical` adds the fifth channel on `MONO`, so identifiers shift\ntoward monospace, and puts hedges on `CASL` as well as `slnt`.\n\n## Running it\n\n```bash\nnode --test test/*.test.js      # engine + theme tests, no dependencies\nnpm install react react-dom     # only for the React render tests\npython3 -m http.server          # then open /demo/, or / for a whole page of it\n```\n\n`demo/` is the fastest way to see it: five sample passages, live editing,\nper-channel toggles, and a hover readout of every score.\n\n`index.html` at the root is the engine set loose on a whole page. Every word of\nit is scored and styled at load, the rail re-runs the page when you change a\nchannel, the sensitivity or the theme, and the box at the top takes your own\ntext. It imports `src/` directly, so there is no second copy of the engine and\nno build step.\n\n`src/` has no dependencies and no build step. It is ESM that runs in Node and\nin the browser as-is, using `createElement` rather than JSX so it needs no\ntransform. React is a peer, and only `SemanticText.js` imports it.\n\n## What it gets wrong\n\nSarcasm, irony, and domain jargon it has not been taught. The lexicons are a\nfew hundred entries, so anything specialised needs a `lexicon` prop. It scores\nEnglish only. And it reads words, not arguments: it will not notice that a\ncalm sentence is describing a catastrophe.\n","readmeFilename":"README.md"}