DRAFT v0.7 -- tag form canonical -- this page is written in the format it specifies. v0.7 renames the fact child <sem-distractor> to <incorrect-statement> (breaking; no backward-compatible alias). Normative source: spec/conventions.md[1]; where the two diverge, the Markdown wins.
SemText is an HTML vocabulary of custom elements: <sem-enhanced-document>, <sem-note variant="tip">, <sem-facts view-as="quiz">, <sem-fact> with <statement> and <conclusion>, <sem-step status="done">, <sem-property key="…">. Element names say what content is; attributes are attributes (view-as, status, key, kind, tags, controls), never data-*. That is the form every example in this spec, the schemas and the reference documents use, and the form every consumer -- theme CSS, the one script, extraction and the JS-off artifact -- accepts as its first-class input.
The v0.4 baseline rendered the same vocabulary as classes on plain elements with data-* parameters. That spelling is retained as a compatibility alias: the mechanical mapping is in Appendix A, the script and the CSS keep accepting it, and a document may mix the two forms. New documents and new tooling write the tag form.
sem-source wrapper: press Source to see the markup exactly as it was written here -- bare attributes, no data-*, no classes.CSS and script layering, in both distribution forms (§7): (1) the base stylesheet semtext.css (the vocabulary, offline-safe) and the theme file -- --sem-* tokens on [data-sem-theme]; (2) semtext.js -- the one script, a Lit 3 bundle that also carries the vanilla handlers for the elements no component defines, the reading and Markdown renderers, and extraction. The single-file form inlines all of it in <head> (the build's sem:inline markers); the folder form links the same files by relative path. Neither form carries page-local CSS or JS beyond a small layout layer. A document without the script shows its authored text -- not styled, not interactive (§4). ? = open questions (§10).
<head>, no inline CSS or JS. Its scripts-stripped twin, conventions.nojs.html, is the JS-off proof.The SemText vocabulary groups into seven conceptual buckets; NPL elements are not bucketed and form a single display family of their own. Each element appears in its primary bucket below; every element is also listed with its full entry in §5.
Every element's normative schema lives under spec/schema/<element>.md (NPL schemas under spec/schema/npl/). The class-form alias for every element is one row of Appendix A.
One file, three consumers. Element names are semantic (sem-fact, sem-detail) -- what content is; presentation is parameterized (view-as="quiz|flashcards|list") -- how it renders. NPL conventions live inside the tags, never as a pre-HTML grammar.
Double-click, styled, interactive. The theme CSS renders the vocabulary; the script adds the behaviour.
The same HTML read structurally; attributes qualify every datum; the same XML vocabulary NPL's MCP server already speaks.
Browser render or DOM-text extraction; the content is complete without JS.
A SemText element is a custom element in the sem- namespace. Custom elements are valid HTML whether or not a script ever defines them: the browser parses <sem-fact> as an unknown element, the vocabulary CSS gives it a block box (sem-*:not(:defined) base, §6), and semtext.js -- when it runs -- defines the interactive ones and upgrades them in place (§4 rule 2). Without it the document is plain text in flow.
<!doctype html>
<html lang="en" data-sem-theme="minimal-tech-light">
<head>
<meta charset="utf-8">
<title>Authoring Guide</title>
<link rel="stylesheet" href="semtext/semtext.css">
<link rel="stylesheet" href="semtext/themes/semtext-theme-minimal-tech-light.css">
<script defer src="semtext/semtext.js"></script> <!-- the one script (§4) -->
</head>
<body>
<sem-enhanced-document>
<sem-reader controls="outline,progress,focus,type,color,print,audience"></sem-reader>
<!-- document-level metadata: bare semantic children -->
<agent>
<name>Infra Guide</name>
<bio>Deployment runbook assistant</bio>
<instructions>Answer from sem-fact ids only.</instructions>
</agent>
<h1 data-kind="title">Authoring Guide</h1>
<sem-note variant="warning">Rotation is <strong>per session</strong>.</sem-note>
<sem-facts view-as="flashcards">
<sem-fact id="f-jwt" kind="concept">
<statement>JWTs rotate per session</statement>
<conclusion>Short-lived access; the refresh grant issues a new pair.</conclusion>
</sem-fact>
</sem-facts>
<!-- content: sem-* vocabulary + ordinary semantic HTML -->
</sem-enhanced-document>
</body>
</html>
<sem-enhanced-document> is the required root wrapper (the script scopes to it). It may carry a landmark role (role="main") -- it is the document. The alias form may put the sem-enhanced-document class on a <main> element for the native landmark; the tag form trades that for one element that is the same in every consumer, and states the landmark with role.<sem-reader>, when used, is the wrapper's first child (one per document; §5).agent, and future org, context, audience) are bare semantic tags -- machine-facing, unstyled.<statement>, <conclusion>, <incorrect-statement> inside sem-fact; <highlight> inside any prose element; <name>, <bio>, <instructions> inside agent.sem-* enhances where interactivity pays. Plain HTML carrying data-kind or data-tags is a record too (extraction §3) -- on a standard element the qualifier keeps the data-* spelling, which is the only valid custom attribute HTML5 allows there (§2).sem--prefix them? Lean and status in §10.Global attributes (any sem-* element), spelled bare:
Element-specific parameters (variant, key, name, summary, value, label, when, until, lang, filename, mark, wrap, href, cite, implies, sticky, outline-depth) follow the same rule: bare on the element, documented per schema.
Scope of the bare spelling. Bare attributes are canonical on sem-* custom elements and their bare part children (<statement>, <highlight>, <agent> › <name>), where any attribute name is valid. On a standard HTML element (<h2>, <p>, <a>, <td>) a global qualifier keeps the data-* spelling -- <h2 data-kind="section" data-tags="auth">, <p data-audience="operator">, <td data-value="900"> -- because HTML5 allows no other custom attribute there and §8 promises tool-parseable documents. The script reads both spellings on either kind of element.
view-as is the core inversion: <sem-fact> is a fact in every view; view-as only selects rendering. Unknown view-as ⇒ falls back to list/plain + a console warning. CSS-only modes exist too: sem-note view-as="margin" (right-gutter aside on wide viewports), sem-chronology view-as="timeline|list". sem-properties view-as="glossary" adds term previews from the script.
data-<name> is accepted everywhere <name> is, and wins when both are present (extraction §3.6; the script copies each bare parameter to its data-* twin before any handler runs, §4 rule 1). Writing both spellings on one element is an authoring error -- the precedence exists so extraction stays stable and the mirror is a no-op, not so an author can mean two things; a sem-source fence always shows the markup as authored, so a mixed element would display a parameter the script does not use.data-* -- data-active on the shown view, data-view-as rewritten by a sem-source / sem-md toggle, the data-sem-upgraded marker -- so an authored bare attribute is never overwritten and a sem-source fence still shows the markup as written.&-joined segments (src/shared/state.ts): bare id / container/child deep links, and name=value parameters (sem-audience=<profile>). Every writer edits its own segment only, so #deploy/argocd&sem-audience=operator survives a tab switch..sem-<element> and .sem-<element>-*: the alias form's identity class and the element's generated chrome (.sem-facts-chrome, .sem-note-summary, .sem-code-line). No other element may reuse that bare class name for its own chrome. One exception: <incorrect-statement>'s class form is the unprefixed .incorrect-statement, matching its tag name exactly (user decision, v0.7). .sem-progress names only the progress element -- a facts-chrome label needing similar styling is .sem-facts-meter, never .sem-progress borrowed from another element. The npl-* namespace mirrors the rule: each NPL element owns exactly .npl-<element> and .npl-<element>-* -- its class-form identity class (Appendix A, NPL namespace) and its generated chrome (.npl-agent-header, .npl-section-header) -- and no other element may reuse that bare class name for its own chrome.Compact notation is legal inside element text where the schema allows; attributes are canonical, notation is sugar:
<statement>/<conclusion> children always win over :: compact form when both present; authors pick one per fact.
semtext/semtext.js -- a Lit 3 IIFE, global SemText -- is the format's only script. It defines the interactive custom elements, runs the vanilla handlers for the elements no component defines (sem-views, sem-reveal, sem-progress, audience gating, deep-link resolution, print disclosure, the alias mirror, the sem-source snapshot pass), the prose-reading renderers (sem-code chrome, reference / glossary previews, backlinks, the sem-reader chrome, sem-table sort / filter), the Markdown renderer, and extraction -- SemText.extractRecords / SemText.extractText work on the same page that renders, so a document needs no second program to be read by a machine. Every handler selects both authoring forms from one selector (:is(sem-x, .sem-x), the child parts :is(conclusion, .sem-conclusion)) and skips elements the components have already upgraded. The core's first handler snapshots every sem-source wrapper's markup (a DOM clone in an inert template.sem-source-raw child) before any other handler runs; the reading renderers build the fence from that copy. A core handler mirrors each bare tag-form parameter (view-as, variant, name, active, summary, value, label, key) to its data-* twin when the twin is absent, so the gated CSS rules and every later handler read one spelling. The list is exactly the parameters a core handler or a gated CSS rule reads; controls, sticky, lang, filename, mark, wrap, outline-depth, href, cite, implies, when, until, status, kind and tags are read in both spellings by the handler or CSS rule that consumes them and need no mirror. status in particular is never read by a script: its only consumers are the no-script CSS rules of sem-step, sem-event and sem-progress, each written :is([data-status="x"], [status="x"]), which select on a value rather than render an attr() -- both spellings of the same value produce the same declaration, so no :not([data-status]) guard is needed and the alias precedence of §2 cannot be observed unless an element carries two different values, which is the authoring error §2 already names.data-sem-upgraded at parse time (anti-flash gating needs it); the vanilla handlers -- which stamp the same marker on the elements they wire -- skip elements already claimed, and a component implementation supersedes the same-named vanilla behaviour. semtext.js stamps html with data-sem-upgraded once it has run, for hide rules whose host element is deliberately unmarked (a list-view deck's distractors). The zero-JS elements (sem-procedure, sem-chronology, sem-audiences) and the handler-only ones (sem-views, sem-reveal, sem-progress) define no custom element: they are :not(:defined) in every document, by design. The former two-script handoff (data-sem-fallback, cleared on upgrade) is gone with the merged bundle -- one runtime, one marker.:is(sem-x, .sem-x) throughout themes/_vocabulary.css (D10). Rules that must hold with no script read the bare and the data-* spelling ([status="done"], [data-status="done"]; attr(name), attr(data-name)), and a bare-spelling rule is guarded with :not([data-<name>]) so data-* still wins when both are present (§2). Rules that only apply once a tier is driving the element key on the data-* spelling alone, which rule 1 guarantees is present. A tag-form document is therefore presentable before and without any script, with no layout shift on upgrade where feasible.[data-sem-upgraded] (D12). With no script in the page nothing is hidden and nothing is styled or interactive: incorrect statements render labelled, views stack under their names, collapsed bodies and every conclusion show, audience-qualified content is all visible -- a document without JavaScript shows its authored text, in flow, and is not styled or interactive. dist/demo/*.nojs.html and dist/spec/*.nojs.html are the artifacts that prove it: script-free pages that assert the text is present and unstyled..sem-facts-chrome, .sem-views-tabs, .sem-note-summary) so extraction can skip it; a collapsed note's body is the element's own content, wrapped in .sem-note-body by the code that hides it.@media print shows everything a reader could open and no chrome (beforeprint opens reveal disclosures; CSS cannot); prefers-reduced-motion drops transitions and smooth scrolling.<script>. The build prints every artifact against its budget; npm run build:strict fails on an overrun, so a budget is raised deliberately in scripts/build.mjs and recorded in the roadmap, never silently.Each entry shows the canonical markup in a listing, then runs a live example wrapped in sem-source. The example is the spec executing itself; its Source view is the markup of this page, not a second copy. Entries are grouped by conceptual bucket (see the Element buckets table above). Elements marked Zero-JS render entirely from CSS and markup with no script dependency. The class-form alias for every element is one row of Appendix A.
Every SemText document wraps its vocabulary in one. Normative statement: §1 Document skeleton -- the script's handlers scope to it, Lit components register against it, and it may carry a role="main" landmark; the alias form may use <main class="sem-enhanced-document">.
Normative: spec/schema/sem-reader.md.
<sem-reader controls="outline,progress,focus,type,color,print,audience" outline-depth="3">
<nav aria-label="Contents">…optional, used verbatim…</nav>
</sem-reader>One per document, first child of the wrapper. Reading bundle inserts .sem-reader-chrome (role="region"), generates the outline from h2…h<depth> when no nav is authored (aria-current="location" tracks the heading in view), and writes data-sem-mode|type|font and data-color-mode on <html> -- persisted via localStorage, fail-open. Audience writes the sem-audience hash parameter. No global key shortcuts. Mints nothing; extraction skips it whole (extraction §3 rule 7). The theme control is R/W3. The live example is the bar at the top of this page.
Metadata, mints nothing. Normative: spec/schema/sem-audiences.md. Active profile = hash sem-audience param, else audience on the root wrapper / data-audience on <html>. The script sets native hidden on non-matching elements; JS-off everything shows; fail-open on unknown tokens.
<sem-audiences>
<sem-profile id="reader" label="Reader"></sem-profile>
<sem-profile id="operator" label="Operator" implies="reader"></sem-profile>
</sem-audiences>
<sem-note audience="operator">…</sem-note>Documented in §1 Document skeleton above. Children: <name>, <bio>, <instructions>. Machine-facing, unstyled -- no sem- prefix. Future metadata children (org, context, audience) will follow the same bare-tag pattern.
Normative: spec/schema/sem-md.md.
<sem-md id="m-lifetimes" label="Token lifetimes" controls="toggle,copy">
| Token | Lifetime | Rotates |
| :------ | -------: | :-----: |
| access | 15 min | no |
| refresh | 30 days | yes |
</sem-md>The element's text is Markdown (GFM tables, headings, lists, emphasis, links, code, quotes, rules; raw HTML renders as text). The script adds .sem-md-chrome (label, Markdown toggle with aria-pressed, copy, status region), renders into .sem-md-body (real <table> with th[scope="col"], alignment row honoured) and keeps the normalised source in a sem-code fence (.sem-md-raw); view-as="rendered|raw" picks the initial mode and the toggle rewrites data-view-as. JS-off: the source reads as pre-wrapped text. Extraction keeps source normalised (dedented), never the rendering. Every table on this page -- §2, §3, Element buckets, §10, Appendix A -- is this element, with rows identical to the Markdown source.
Normative: spec/schema/sem-note.md.
<sem-note variant="warning">Rotation is <strong>per session</strong>.</sem-note>
<sem-note variant="tip" collapsed>Shuffle flashcards before each review session.</sem-note>variant="info|warning|tip|danger"; the body is the element's content; collapsed ⇒ ten-word teaser, click to expand (the component wraps the body in .sem-note-body to hide it); view-as="margin" ⇒ CSS-only right-gutter aside on wide viewports, inline otherwise. Light DOM, role="note".
Normative: spec/schema/sem-code.md.
<sem-code lang="ts" filename="src/rotate.ts" mark="2,4-5" controls="copy,wrap">
<pre><code>…</code></pre>
</sem-code>Reading bundle adds .sem-code-chrome (filename, lang, buttons, status region), wraps lines (span|mark.sem-code-line, text byte-identical), tabindex="0" on an overflowing <pre>. JS-off: plain <pre> with a CSS caption. Extraction keeps source verbatim.
export function rotate(pair: Pair): Pair {
const next = mint();
retire(pair.refresh);
return next;
}
Normative: spec/schema/sem-browser.md.
<sem-browser src="/demo/example-deck.sem.html" label="the deck">
<figure class="code-window">
<div aria-hidden="true">← → ⟳</div>
<div>demo/example-deck.sem.html</div>
<iframe src="/demo/example-deck.sem.html" title="the deck" loading="lazy"></iframe>
</figure>
</sem-browser>With a src the element fetches the document (parsed with DOMParser, <script> and on* stripped before adoption) into a focusable .sem-window-viewport (role="region"); the authored children are the zero-JS fallback and are hidden once wired. An address bar (.sem-browser-address) shows inert nav glyphs and the declared src as text; the tab title resolves label → the document's <title> → the src basename. Adopted elements are offered to window.SemTextLazy.hydrate, so a lazy-built host renders the deck live. Failures render one role="alert" line; sem-browser-load / sem-browser-error report the outcome. Ships lazy-first (chunks/sem-browser.js) and rides the full bundle after promotion. JS-off: the authored children are the document. No live example here: the element fetches a second document, which a standalone spec page cannot promise.
Normative: spec/schema/sem-details.md.
<sem-details view-as="quiz">
<sem-detail>
The OIDC token travels in the <highlight>Authorization</highlight> header.
</sem-detail>
</sem-details>Plain view: <highlight> renders <em>. Quiz view: occluded (▮▮▮), reveal per item, self-check or auto-check.
<details>-backedNormative: spec/schema/sem-reveal.md.
<sem-reveal summary="Why not localStorage?" collapsed>
Tokens in localStorage are readable by any script on the page…
</sem-reveal>summary optional -- first body line (≤60 chars) derives it, by the one rule in src/shared/summary.ts that render and extraction share; collapsed starts hidden, otherwise open. The component wraps it in native <details>/<summary>; JS-off: fully visible (summary as small-caps heading). Non-assertive counterpart to sem-fact Q/A shape.
Normative: spec/schema/sem-source.md.
<sem-source label="Facts -- flashcards">
<h2>Facts -- flashcards</h2>
<sem-facts view-as="flashcards">…</sem-facts>
</sem-source>Chrome Rendered | Source (aria-pressed); view-as="source" starts in source mode, showing the literal markup in a sem-code (copy, wrap) built from the core's pre-enhancement snapshot; the toggle rewrites data-view-as. Transparent to extraction; JS-off: the rendered children, nothing else. Every live example on this page is one; a sem-source never nests inside another, so this entry has no example of its own.
Normative: spec/schema/sem-views.md.
<sem-views id="deploy">
<sem-view name="Helm" active role="tabpanel">content…</sem-view>
<sem-view name="ArgoCD" role="tabpanel">content…</sem-view>
</sem-views>name unique per container; active marks initial view (first wins if absent; the component keeps the shown view in data-active); it builds the tab bar (.sem-views-tabs, role="tab" buttons), arrow-key roving focus; deep-link #deploy/argocd; fires sem-navigate {id, name, index}. JS-off: all views stacked, name-headed. Q3 resolved v1: sem-views.
main. Deep link: #x-deploy/argocd.Normative: spec/schema/sem-facts.md.
<sem-facts view-as="quiz" controls="shuffle,filter">
<sem-fact>…</sem-fact>
<sem-fact>…<incorrect-statement>decoy conclusion</incorrect-statement></sem-fact>
</sem-facts>view-as="list" (default): filter box, tag chips.view-as="flashcards": front = statement (+<highlight> prompt), back = conclusion; flip click/Space; ←/→/swipe; progress 3/12; shuffle.view-as="quiz": statement shown; candidates = sibling conclusions, <incorrect-statement> children win when present; accuracy on sem-complete.sem-navigate {index}, sem-flip {face}, sem-complete {correct,total,ms}.sem-fact is a fact in list, flashcard and quiz views -- view-as only changes rendering.semtext.js script.<sem-fact id="f-jwt" kind="concept">
<statement>JWTs rotate per session</statement>
<conclusion>Short-lived access; refresh grants a new pair.</conclusion>
</sem-fact>Compact form: <sem-fact>JWT rotation :: short-lived access</sem-fact>. Machine view: statement+conclusion = structured assertion.
<sem-question type="mc">
<p data-kind="prompt">Which header carries the OIDC token?</p>
<sem-option correct>Authorization</sem-option>
<sem-option>Cookie</sem-option>
</sem-question>
Types mc|multi|blank|match|order|tf|short; correct attr canonical, ✓ sugar; retry control; results block; machine view = assertable Q/A.
Normative: spec/schema/sem-table.md.
<sem-table controls="sort,filter" sticky>
<table><caption>…</caption><thead><tr><th scope="col">…</th></tr></thead>
<tbody><tr><td data-value="900">15 min</td></tr></tbody></table>
</sem-table>The <table> is the contract (no JSON payload). The script stamps every body row data-sem-source-index once, wraps headers in button.sem-table-sort (aria-sort cycle), filters with native hidden, announces via .sem-table-status. Extraction returns {caption, columns[], rows[][]} in authored order from the stamp -- the recorded §5 exception. sticky header and kind="comparison" first column are CSS in every tier. (td[data-value] is a sort key on a plain HTML cell, not a vocabulary parameter -- it stays data-*.)
| Token | Lifetime | Rotates |
|---|---|---|
| access | 15 min | no |
| refresh | 30 days | yes |
| id | 1 hour | no |
Schema files defined: spec/schema/sem-data-source.md, spec/schema/sem-data-query.md, spec/schema/sem-data-join.md, spec/schema/sem-data-visualizer.md. Implementation pending.
Normative: spec/schema/sem-chronology.md.
<sem-chronology kind="release-history" role="list">
<sem-event role="listitem" when="2026-03-02" status="done">
<time datetime="2026-03-02">2 Mar 2026</time> v0.1 tagged.</sem-event>
</sem-chronology>when/until canonical; <time> child is the visible label (else CSS renders when); status optional, same vocabulary as sem-step; view-as="timeline|list". DOM order = chronological order; ordinals positional. Zero-JS element.
Normative: spec/schema/sem-procedure.md.
<sem-procedure kind="runbook" role="list">
<sem-step role="listitem" status="done">provision Infisical path</sem-step>
<sem-step role="listitem" status="current">port-forward MinIO</sem-step>
<sem-step role="listitem">run migrations</sem-step>
<sem-step role="listitem" status="blocked">cut release</sem-step>
</sem-procedure>status: done|current|todo (default) |blocked; DOM order = execution order (ordinals positional, CSS counters); status sugar (✓/→/✗) is display-only -- status canonical. Zero-JS element.
Normative: spec/schema/sem-progress.md.
<sem-progress value="0.62" label="coverage"
role="meter" aria-valuemin="0" aria-valuemax="1" aria-valuenow="0.62"></sem-progress>value canonical 0..1 (render clamps, attr untouched); label default progress; the component renders track + fill + label :: N%; JS-off: text-only via CSS attr() -- no fake bar; the caption is gated on the data-sem-upgraded marker, so it never duplicates the text-only form. status="done" ⇔ 1 by convention.
Normative: spec/schema/sem-properties.md.
<sem-properties kind="config">
<sem-property key="token ttl" role="definition" aria-label="token ttl">15m</sem-property>
</sem-properties>key = the term, property text = the value. Zero-JS: key column renders via CSS attr(key); AT gets the pair via role="definition" + aria-label = key. Compact term :: value sugar (§3) is display-only -- key canonical. Renders <dl>-equivalent grid; Q2 resolved v1: pure definition list, no copy/search chrome.
Normative: spec/schema/sem-references.md.
<p>…per use<a href="#r-rfc">[1]</a>.</p>
<sem-references kind="bibliography" role="list">
<sem-reference id="r-rfc" role="listitem" href="https://…" cite="RFC 6749">…</sem-reference>
</sem-references>CSS-counter numbering in every tier; the script adds hover / focus previews on citing anchors (.sem-references-ref, popover role="tooltip", Esc closes), backlinks (aria-label="Back to citation N") and an external link. Citations mint nothing; print shows (href) inside the block only. The live example is this page's own reference list in References.
NPL elements are loaded by npl.js / npl-lazy.js on top of the SemText core. Each has a normative schema under spec/schema/npl/. The core bundle (semtext.js, semtext-lazy.js) carries no NPL code. Each element also carries the class-form alias of Appendix A's NPL namespace (.npl-<element> identity class, data-<parameter> parameters, .npl-<part> part aliases).
sem-chronology / sem-event when -- shipped (R/W1, Tier 0).sem-table -- shipped (R/W2, Tier 0); sem-query deferred.sem-themes controls="picker" -- subsumed by sem-reader's theme control (R/W3, after Track T).sem-md -- shipped (R/W2.2, Tier 0): Markdown inside a SemText document. A whole-document md→SemText authoring aid remains optional and never required.data-sem-theme="<slug>" on <html>; one theme per doc; themes compiled from TRP-format YAML → semtext/themes/semtext-theme-<slug>.css; --sem-* tokens; the vocabulary base gives every sem-* element its box before any definition (sem-*:not(:defined) and the never-defined zero-JS elements alike -- custom elements are inline by default, the vocabulary is block-level).minimal-tech-light (default), nocturne-console, organic-warm, editorial-settings; auto light/dark via prefers-color-scheme, data-color-mode forces.doc.html + relative semtext/ (IIFE semtext.js + themes/).semtext-lazy.js + chunks/ over HTTP(S), and for NPL documents npl-lazy.js + npl-chunks/ the same way (npl.js, the optional full NPL bundle, loads on top of semtext.js instead when no chunk loading is wanted). Each loader imports only the component chunks its document's sem-* / npl-* tags need; core (semtext.js, semtext-lazy.js) carries no npl code. Not portable: dynamic import needs a server, so the page cannot be saved to disk and reopened from file://.semtext.js is linked (or inlined) in full in every portable form regardless of which of (1)-(3) it takes -- there is no smaller script to fall back to. Form (5), hosted lazy, is the served-pages exception: it swaps the full bundle for on-demand chunks and cannot survive offline.<statement>, <conclusion>, <highlight>) + attrs (kind, status, tags, view-as) qualify every datum.<sem-fact kind="concept"> is one element with one attribute, no class list to tokenise and no data- prefix to strip. The document stays valid HTML5 as well: bare attributes appear only on custom elements, and a standard element carries its qualifier as data-* (§2).For any document D, where E is extraction, R(D) is D after semtext.js has run, and I(D) is D after any sequence of user interactions the vocabulary supports (tab switches, card flips, reveals, table sorts and filters, audience profile switches, sem-source / sem-md view toggles, reader chrome changes):
E(D) = E(R(D)) = E(I(R(D)))
Equality is deep equality of the record array, including sourceOrder, and the same guarantee covers every view-as mode. This is the format's central promise: the machine consumer does not get a degraded reading of what the human consumer saw. The full formal statement -- the three refusal rules that make it hold (extraction never looks at generated chrome, runtime state, or presentation attributes: data-view-as, data-active, collapsed) and the one recorded exception (sem-table row reordering, made safe by the data-sem-source-index stamp) -- is normative and lives in spec/extraction.md §5; this section does not weaken or restate it.
Normative, each item enforced by the tests named below:
sourceOrder = array index, containment a parent index tree; record shape fixed (type, id, kind, tags, audience, parent, fields, text, sourceOrder -- spec/extraction.md §2).audience is carried verbatim on every record and never removes one; filtering by audience is a consumer operation on the extracted array.extractText / renderRecordsAsText) is a pure function of the records, so it inherits the invariant unchanged.data-active is a state marker, excluded from extraction by design. An author marks a primary perspective with a durable qualifier (kind, tags) on the view.fields: {}, prose text); their payloads are best-effort until a schema defines them.Evidence (each bullet is asserted, not aspirational): test/e2e/extraction.cy.js (invariant core: JS-off = JS-on, every view-as, before/after interaction), test/e2e/extraction-reading.cy.js (reading elements; sort + filter deep-equal baseline), test/e2e/sem-table.cy.js (the recorded reordering exception), test/e2e/sem-audiences.cy.js (invariant across profile switches), test/e2e/spec-pages.cy.js (this document itself: records identical JS-off, JS-on, and after interaction).
This vocabulary is the XML variant of NPL: NPL yaml already models agents (name/bio/instructions), instructions, and structured prompt sections via the MCP server; this format is its document-shaped rendering target. Alignment tasks (tracked, not blocking M1): map <agent> children to NPL agent schema; map sem-fact to NPL fact/knowledge records; define sem-enhanced-document as a valid MCP NPLLoad/render target.
The v0.4 baseline spelled the vocabulary as classes on plain elements with data-* parameters. The script and the CSS still accept that spelling and a document may mix forms; it is an alias, not a second vocabulary. The mapping is mechanical: identity = a sem-<element> class on a div (or span for inline parts), every bare parameter = data-<parameter>, every bare child part = a div/span classed .sem-<part>. One exception: <incorrect-statement>'s class form is the unprefixed .incorrect-statement, matching its tag name exactly (user decision, v0.7).
<!-- canonical -->
<sem-note variant="warning">Rotation is <strong>per session</strong>.</sem-note>
<!-- class-form alias: same note, same record -->
<div class="sem-note" data-variant="warning">
<div class="sem-note-body">Rotation is <strong>per session</strong>.</div>
</div>Rules the alias inherits unchanged: a data-* parameter wins over a bare one on the same element (§2); the hide rules, the marker and events are identical (§4); extraction yields the same records from either form (spec/extraction.md, the cross-form equality test in test/e2e/sem-source.cy.js). Reference class-form documents: web/demo/index.html, web/demo/reading.html.
The NPL namespace (npl-*, spec/schema/npl/) aliases by the same mechanical mapping: identity = class="npl-<element>" on a div (or span for inline parts), every bare parameter = data-<parameter>, every bare child part = a div/span classed .npl-<part> -- parts may be read either way where a schema documents it (npl-ref sections, npl-critique children). Parameters read in either spelling with data-<name> winning, the same contract as §2 (the nplParam reader implements it). One recorded exception: collapsed on npl-cot -- the component mirrors the boolean to data-collapsed for CSS/extraction parity but reads the bare attribute only; its schema is normative for that attribute.
The alias form is the same compatibility contract Q6 keeps for the sem-* tier: supported in both namespaces, tag form canonical. Each element's schema (spec/schema/npl/<element>.md) is normative for its attribute list.