Every word on screen that names a concept comes from one managed term, so a concept cannot pick up a second name at a second call site. A label is composed from the term, never typed. A term that reads badly is renamed, not rephrased where it is used: the term is the concept’s one name in the code, on the screen and in the documentation.
Where terms live
Terms are notes in a second vault, docs-vault/, separate from this design vault. The two have different readers and different contracts: this vault is for developers and free to restructure; the docs vault is for users, and it is a build input, so moving or renaming it breaks @datler/core. Terms are Markdown notes rather than strings in source because an article is prose, and prose escaped into string literals does not get written; the generator turns them into source. Each term is a note in docs-vault/content/terms/ of kind term:
| field | what it holds |
|---|---|
term | the word, the only string that reaches a user as a label |
plural | the plural, where the term has one; never made at a call site |
short | a designated short form for a control the term does not fit; never chosen at a call site |
gloss | one sentence: the tooltip and the popover |
aliases | other spellings that mean the term, such as its plural; core calls them allowed |
banned | spellings that must not be used for the concept, such as the words it replaced |
| the body | the article, in Markdown |
A [[wikilink]] in an article names another term and becomes navigation in the app. pnpm run docs:validate runs vault-kit’s validation and unresolved-link check over the docs vault.
The pipeline
pnpm run docs:gen-glossary runs scripts/docs/gen-glossary.ts, which reads the term notes and writes three things:
packages/core/src/glossary.generated.ts, every term and its gloss, loaded with the app because every tooltip needs one;packages/core/src/glossary-articles.generated.ts, the articles, loaded on demand as a separate chunk byloadArticle;- the term table in this vault’s Glossary note, between its generated markers.
Both modules are committed, because core imports them and the WebView can read no files at runtime. The generator refuses a term without term or gloss, a duplicate id, and an article linking to a term that does not exist. scripts/docs/generated-is-current.test.ts regenerates in memory and fails when a committed copy no longer matches its notes, and when the Glossary note lacks a row for a term.
packages/core/src/glossary.ts is the API: getTerm, termWord, termPlural (the singular when there is no plural), termShort (the term when there is no short form), lookupTerm (any spelling to its term), allTerms, bannedSpellings and loadArticle. An unknown id comes back as itself rather than throwing, so a missing term shows as a visible content bug instead of a blank label.
The Term component and the (i)
components/common/Term.tsx renders a term at four depths, in rising cost to the reader:
| renders | reveals | |
|---|---|---|
<Term id="aggregation" /> | the word | nothing |
<Term id="aggregation" hint /> | the word, dotted underline | the gloss in a tooltip on hover |
<Term id="aggregation" info /> | the word and an (i) | the gloss in a popover, with Read more |
<Term id="aggregation" info="article" /> | the word and an (i) | the article in a drawer |
plural and short pick those forms. TermInfo is the (i) on its own, for a heading or control whose text is not the term itself; FormSection takes an info term id and puts one beside its legend. The (i) is a button named “About X”. Its popover passes container so it works inside a dialog. The drawer follows a link between terms in place, with Previous term and Next term stepping through the ones read there. Its article (ArticleBody) is drawn by the library’s Markdown with the wikilinks plugin from @jeliroco-multimedia/blunt-content: a link to a term is a control that moves the drawer to it, and a link naming no term reads as plain text. The (i) is Datler’s composition; the button, popover, tooltip and sheet under it are the library’s (see Jeliroco Components).
termWord, termPlural and termShort serve the places JSX cannot reach: an aria-label, a placeholder, a string in a nav entry.
Writing with terms
- A label is the term. A parenthetical in a label is a description with nowhere else to go: “Direct (you log it)” is the term Direct with the explanation on its (i).
- An ambiguous word is a reason to coin a term, not to rephrase around it at the call site.
- A plural or a short form is declared on the term, because picking one locally is drift with a smaller font.
- A stored literal is not a term. A tag is stored as
kind: "none"; the term is Tag, and the literal is an implementation detail. - The scope is what a person reads: user-facing strings and documentation. Source identifiers and comparisons in code are out of it.
Checks
apps/renderer/src/ui-copy.test.tsfails on any string or JSX text in the renderer’s source that starts with “Its”, on alabel,title,aria-labelorplaceholderwith “its” anywhere in it (a string, or a string in the expression that chooses one), and on an option’s text with “its” in it. It checks nothing else about wording.pnpm run docs:check-vocabulary(scripts/docs/check-vocabulary.ts) scans this vault’s prose for every term’s banned spellings. It blanks code, wikilink targets and the frontmatteraliaseslist first, since a link target or an alias is a note’s name and changes by renaming the note. It skipsaudits/,devlog/and_generated/, which are dated records, and a short list of notes whose subject is the vocabulary itself. A hit that is a different sense of the word (group by, a DOMevent, amuscle group,viewin its ordinary sense) is ignored per spelling, each with its reason, so the same line can still report another word. It reports rather than fails, with--strictto fail,--allfor every hit and--by-fileto group them by note. Nothing scans the renderer’s source for banned spellings: there the words mostly carry other senses, so composed text is what keeps it right.