SemText -- Format Spec & Conventions

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.

Every vocabulary element on this page is live and written as a tag. Each entry in §5 sits inside a 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).

This page ships the folder form: two theme stylesheets, the vocabulary stylesheet, one layout stylesheet and the one bundle linked from <head>, no inline CSS or JS. Its scripts-stripped twin, conventions.nojs.html, is the JS-off proof.

Element buckets

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.

| Bucket | Elements | What it covers | | :-- | :-- | :-- | | **Document & structure** | `sem-enhanced-document`, `sem-reader`, `sem-audiences` / `sem-profile`, `agent` | The root wrapper, reading chrome, audience declarations and document-level metadata that frame a SemText document | | **Prose & code** | `sem-md`, `sem-note`, `sem-code`, `sem-browser` | Content blocks beyond plain HTML -- Markdown bodies, callouts and source listings with provenance | | **Disclosure & views** | `sem-details` / `sem-detail`, `highlight`, `sem-reveal`, `sem-source`, `sem-views` / `sem-view` | The same content under different reading interactions -- occluded detail, Q-A reveal, markup flip, perspective tabs | | **Facts & recall** | `sem-facts` / `sem-fact`, `statement`, `conclusion`, `incorrect-statement`, `sem-question` / `sem-option` | Assertable statement/conclusion pairs -- the citable unit -- with the quiz candidates and distractors built on them | | **Data & tables** | `sem-table`, `sem-data-visualizer`, `sem-data-source`, `sem-data-join`, `sem-data-query` | Authored tables and the data-visualizer's named buckets, joins and editable queries | | **Sequences & progress** | `sem-chronology` / `sem-event`, `sem-procedure` / `sem-step`, `sem-progress` | Ordered, status-annotated content -- timelines, procedures and completion meters | | **Definitions & citations** | `sem-properties` / `sem-property`, `sem-references` / `sem-reference` | Closed key/value assertions and the numbered works the prose cites | | **NPL** | `npl-agent`, `npl-directive`, `npl-section`, `npl-rubric`, `npl-critique`, `npl-declaration`, `npl-example`, `npl-flag` / `npl-flags`, `npl-hormones`, `npl-mindread`, `npl-mode`, `npl-mood`, `npl-poa`, `npl-ref`, `npl-thought`, `npl-vos`, `npl-artifact`, `npl-cot`, `npl-intent` | NPL prompt-engineering elements; loaded by `npl.js` / `npl-lazy.js` on top of the SemText core |

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.

0. Model

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.

1. Document skeleton

<!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>
? Q1 -- metadata children: keep bare vs sem--prefix them? Lean and status in §10.

2. Attribute catalog

Global attributes (any sem-* element), spelled bare:

| Attribute | Values | Machine meaning | | :-- | :-- | :-- | | `kind` | free token (`concept`, `anti-pattern`, `caveat`, …) | what the data IS | | `tags` | comma list | classification | | `view-as` | element-defined modes | presentation parameter (identity unchanged) | | `status` | `done`, `current`, `todo`, `blocked`, `pass`, `fail` | lifecycle/verdict | | `controls` | comma flags: `shuffle`, `filter`, `retry`, `picker` | which controls render | | `collapsed` | boolean | pre-collapse state | | `audience` | profile spec: `a`, `a, b`, `!a` (profiles from `sem-audiences`) | who the datum is for -- **`spec/schema/sem-audiences.md`**; carried verbatim by extraction, never suppresses a record | | `id` | doc-unique | stable anchor, cite target -- a `#id` or `#container/child` hash reaches it through any closed reveal, collapsed note, inactive view or non-current card (deep-link resolver) | | `data-*` | free | extension point; also the **alias spelling** of every parameter above (Appendix A) |

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. State the script writes is always 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. The hash is &-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. Each element owns exactly the class namespace .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.

3. NPL notation inside tags

Compact notation is legal inside element text where the schema allows; attributes are canonical, notation is sugar:

| Notation | Meaning | Where | | :-- | :-- | :-- | | `<highlight>` | recall target -- occludes in quiz/flashcard views, `<em>` in plain view | any prose element | | `[[cloze]]` | inline occlusion sugar (equivalent to `<highlight>`) | `sem-detail`, `sem-fact` | | `term :: value` | pair | `sem-property` text, `sem-fact` compact form | | `✓ …` / `✗ …` | correct / distractor | `sem-option` sugar | | `→ …` | current | `sem-step` status sugar | | `[hint \| reveal]` | agent-facing instruction, human-rendered hint | instruction-bearing elements (define in `sem-note` schema first) |

<statement>/<conclusion> children always win over :: compact form when both present; authors pick one per fact.

4. The script & degradation rules

One script, always. 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. One runtime, one marker. Components claim 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. Theme CSS styles both authoring forms from one rule -- :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. Every hide rule is gated on [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. Content lives in light DOM (searchable, copyable); shadow DOM carries interactive chrome only. Generated chrome is classed (.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. Cross-cutting, also in the vocabulary CSS: @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. Why the budgets are stated raw: a portable single-file document inlines its scripts, and nothing gzips an inlined <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.

5. Element entries

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.

Document & structure

sem-enhanced-document -- the required root wrapper

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">.

sem-reader -- reading chrome: outline, progress, focus, type, colour, print, audience

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.

sem-audiences / sem-profile -- reader profiles

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>
Operators only: an operator implies reader. Pick a profile from the reading bar's Audience select, or open #sem-audience=operator.

agent -- document-level metadata (bare semantic tag)

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.

Prose & code

sem-md -- a Markdown block, rendered in the browser

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.

| Token | Lifetime | Rotates | | :------ | -------: | :-----: | | access | 15 min | no | | refresh | 30 days | yes |

sem-note -- callout

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".

Rotation is per session. Shuffle flashcards before each review session for better recall; the deck remembers nothing between sessions.

sem-code -- verbatim listing with provenance and copy / wrap

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;
}

sem-browser -- chrome-like document window

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.

Disclosure & views

sem-detail / sem-details -- prose with occludable content

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.

The OIDC token travels in the Authorization header; refresh tokens never reach the browser.

sem-reveal -- Q→A disclosure, <details>-backed

Normative: 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.

A note is an annotation (variant tone); a reveal is exposition (Q→A). Same mechanism, different semantics -- machines distinguish them.

sem-source -- a section that flips between rendered and authored markup

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.

sem-views / sem-view -- same content, switchable perspectives

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.

Helm renders the chart; the image tag is bumped by CI. ArgoCD auto-syncs from the chart repo's main. Deep link: #x-deploy/argocd.

Facts & recall

sem-facts -- collection; the flagship surface

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>
Semantic identity, parameterized presentation sem-fact is a fact in list, flashcard and quiz views -- view-as only changes rendering. What guarantees interactivity? The one semtext.js script. The Tailwind CDN script.

sem-fact -- the atomic unit; assertable Q/A pair

<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 / sem-option -- quiz question types (Tier 1 -- flagship buildout)

<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.

Data & tables

sem-table -- sortable, filterable authored table

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 lifetimes
TokenLifetimeRotates
access15 minno
refresh30 daysyes
id1 hourno

sem-data-source, sem-data-query, sem-data-join, sem-data-visualizer -- data connectors

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.

Sequences & progress

sem-chronology / sem-event -- dated, ordered events; CSS-only timeline

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.

v0.1 tagged -- Lit bundle ships. Reading-experience waves land one PR at a time; the tag form becomes canonical.

sem-procedure / sem-step -- ordered, status-annotated procedure

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.

provision Infisical path port-forward MinIO run migrations cut release

sem-progress -- completion meter

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.

Definitions & citations

sem-properties / sem-property -- definition/properties block

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.

15m httpOnly cookie

sem-references / sem-reference -- numbered citable entries

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

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).

Agent definition: name, bio, instructions, model settings. An instruction the agent must follow. Section marker for prompt structure. Scoring guide or evaluation criteria. Evaluation of output against a rubric. Statement of intent or principle. Worked example (few-shot). Warning, note, or status flag. Intent or subtext analysis. Reasoning trace or chain-of-thought output. Plan of action. Post-hoc reflection over a finished turn (assessment, improvements, validation, eval, fine-tune). Voice or style specification. Output artifact marker. Operating mode (e.g. planning, executing, reviewing). Tone or emotional register. Drive or motivation settings. Chain-of-thought prompting. Intent declaration for a section or response.

Tier status -- what shipped when

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.

6. Theme conventions

7. Distribution forms

doc.html + relative semtext/ (IIFE semtext.js + themes/). script + CSS + data all inlined; one file shares anywhere. multi-page bundle. served pages only -- 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://. Hard rules: no ES-module scripts; no runtime fetch in portable docs; 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.

8. Machine-readability contract

The extraction invariant (normative)

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.

What a consumer may rely on

Normative, each item enforced by the tests named below:

Best-effort only (not guaranteed)

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).

9. NPL XML-variant alignment

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.

? Q4 -- who owns the mapping: this repo exports it, or NPL MCP consumes a schema file we publish?

10. Open questions

| # | Question | My lean | | :-- | :-- | :-- | | Q1 | bare `<agent>` metadata children vs `sem-`-prefixed | bare (per your example; matches NPL yaml shape) | | Q2 | `sem-properties` pure `<dl>` v1 | **resolved (this branch)** -- pure `<dl>`, chrome only if a consumer demands it | | Q3 | tabs-family name: `sem-views` vs `sem-perspectives` | **resolved (this branch)** -- `sem-views` | | Q4 | NPL MCP alignment ownership | we publish the schema file; MCP consumes | | Q5 | fallback handler scope: baseline interactivity only vs full quiz logic | **resolved, superseded (2026-10)** -- the vanilla fallback is retired; one Lit bundle carries everything (spec v0.6) | | Q6 | class-form alias lifetime: keep indefinitely, or retire once every reference document is tag-form | keep -- it costs one `:is()` per rule and lets HTML generators that cannot emit custom elements participate |

Appendix A -- Class-form alias (v0.4 compatibility)

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>
| Canonical tag form | Class-form alias | | :-- | :-- | | `<sem-enhanced-document>` | `div.sem-enhanced-document` | | `<agent>` + name/bio/instructions | `div.sem-agent` › `.sem-agent-name/-bio/-instructions` | | `<sem-note variant="warning">` body | `div.sem-note[data-variant="warning"]` › `.sem-note-body`; `collapsed` attr | | `<sem-audiences>` / `<sem-profile id label implies>` | `div.sem-audiences` › `div.sem-profile[id][data-label][data-implies]`; `audience="…"` → `data-audience` | | `<sem-facts view-as="quiz">` | `div.sem-facts[data-view-as="quiz"]` | | `<sem-fact>` › `<statement>` / `<conclusion>` | `div.sem-fact` › `.sem-statement`, `.sem-conclusion` | | `<incorrect-statement>` | `div.incorrect-statement` | | `<sem-details>` / `<sem-detail>` | `div.sem-details` › `div.sem-detail` | | `<highlight>` | `span.sem-highlight` (occluded form: `.sem-occluded`) | | `<sem-procedure>` / `<sem-step status>` | `div.sem-procedure` › `div.sem-step[data-status]` | | `<sem-properties>` / `<sem-property key>` | `div.sem-properties` › `div.sem-property[data-key]` | | `<sem-properties view-as="glossary">` | `div.sem-properties[data-view-as="glossary"]` › `div.sem-property[id][data-key]`; term anchors `<a href="#id">`, `<dfn>` optional | | `<sem-views>` / `<sem-view name active>` | `div.sem-views[id]` › `div.sem-view[data-name]`, `data-active` marker | | `<sem-reveal summary>` | `div.sem-reveal[data-summary]`, `collapsed` attr | | `<sem-progress value label>` | `div.sem-progress[data-value][data-label]` | | `<sem-chronology view-as>` / `<sem-event when until status>` | `div.sem-chronology[data-view-as]` › `div.sem-event[data-when][data-until][data-status]` (+ `<time datetime>`) | | `<sem-code lang filename mark wrap controls>` › `<pre><code>` | `div.sem-code[data-lang][data-filename][data-mark][data-controls]` › `<pre><code>` | | `<sem-references kind>` / `<sem-reference id href cite>` | `div.sem-references[data-kind]` › `div.sem-reference[id][data-href][data-cite]`; citations are plain `<a href="#id">` | | `<sem-source label view-as="html\|source">` › any content | `div.sem-source[data-label][data-view-as]` › any content; transparent wrapper, mints nothing | | `<sem-reader controls outline-depth>` › optional `<nav aria-label="Contents">` | `div.sem-reader[data-controls][data-outline-depth]` › same optional nav; chrome, mints nothing | | `<sem-md label view-as="rendered\|raw" controls>` › Markdown text | `div.sem-md[data-label][data-view-as][data-controls]` › Markdown text | | `<sem-table controls sticky>` › `<table>` | `div.sem-table[data-controls][data-sticky]` › authored `<table>` (`<th scope="col">`, `td[data-value]` sort keys) | | `<sem-browser src label>` › fallback figure | `div.sem-browser[data-src]` › the fallback children -- with no script the div IS the framed document |

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.

NPL namespace (npl-*)

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.

| Canonical tag form | Class-form alias | | :-- | :-- | | `<npl-agent name>` | `.npl-agent[data-name]` | | `<npl-directive glyph>` | `.npl-directive[data-glyph]` | | `<npl-section type>` | `.npl-section[data-type]` | | `<npl-rubric title>` | `.npl-rubric[data-title]` > `.npl-criterion`, `.npl-overall-score` | | `<npl-critique>` | `.npl-critique` > `.npl-subject`, `.npl-perspective`, `.npl-strengths`, `.npl-weaknesses`, `.npl-verdict` | | `<npl-declaration version>` | `.npl-declaration[data-version]` | | `<npl-example label>` | `.npl-example[data-label]` | | `<npl-flag name>` | `.npl-flag[data-name]` | | `<npl-flags preset>` | `.npl-flags[data-preset]` | | `<npl-mindread confidence>` | `.npl-mindread[data-confidence]` | | `<npl-thought type>` | `.npl-thought[data-type]` | | `<npl-poa direction>` | `.npl-poa[data-direction]` | | `<npl-ref variant>` | `.npl-ref[data-variant]` | | `<npl-vos phase>` | `.npl-vos[data-phase]` | | `<npl-artifact type>` | `.npl-artifact[data-type]` > `.npl-title`, `.npl-content` | | `<npl-mode primary>` | `.npl-mode[data-primary]` | | `<npl-mood mood>` | `.npl-mood[data-mood]` | | `<npl-hormones variant>` | `.npl-hormones[data-variant]` | | `<npl-cot>` | `.npl-cot`; `collapsed` stays bare in both forms (like `sem-note`, `sem-reveal`) | | `<npl-intent>` | `.npl-intent`; parts in both spellings -- `.npl-intent-assumptions` etc. |

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.

References

The normative Markdown source of this page. Extraction contract: record shape, emission rules, the central invariant, annotated plain text. Schema -- sem-note. Schema -- sem-audiences / sem-profile. Schema -- sem-facts / sem-fact. Schema -- sem-details / sem-detail. Schema -- sem-procedure / sem-step. Schema -- sem-properties / sem-property. Schema -- sem-views / sem-view. Schema -- sem-reveal. Schema -- sem-chronology / sem-event. Schema -- sem-code. Schema -- sem-browser. Schema -- sem-source. Schema -- sem-md. Schema -- sem-references / sem-reference. Schema -- sem-reader. Schema -- sem-table. Schema -- sem-progress.