@obvi/blueprint
v1.9.2
Published
A classless-first CSS design system for beautiful technical blueprint documents.
Readme
@obvi/blueprint
The CSS design system behind Obvious blueprints.
This package is built for and used by Obvious. It is published so Obvious-generated documents resolve their stylesheet, not as a general-purpose framework — outside of an Obvious blueprint there is little reason to reach for it.
The idea
A blueprint is a technical document that should look like one: monochrome ink on paper, drafting-blue reserved for illustrations, and structure that carries its own meaning.
- Semantic HTML is the API. Headings, paragraphs, lists, tables, figures, and landmarks render as a finished document with no classes to memorize. You write meaning; the design arrives for free.
- Classless-first, not class-only. Components exist only for the few patterns native HTML cannot express, and each is a composition of real semantic elements rather than a replacement for them.
- Interactive blocks read as working surfaces. Live components (choices, pre-flights, source, mockups, galleries) sit on a faint dot-grid mat, so a reader can tell an interactive surface from settled prose at a glance — a texture distinct from the diagonal hatch that marks held or rejected regions.
- Zero-specificity by contract. Everything ships in cascade layers wrapped in
:where(), so the library never fights your own CSS. Your styles always win, with no!importantand no parent-scoping. - Runtime-complete package. The default stylesheet includes the chrome that
blueprint.jsinjects, the published browser entry bundles Choice components, and runtime icons are inline SVG, so installed or host-inlined documents do not depend on sibling modules or repository-only assets.
The result is one portable stylesheet, no build step, that turns plain semantic markup into a polished blueprint.
Host decision integration
<bp-choice> keeps its author-facing HTML contract while exposing a semantic
runtime boundary for Obvious. A connected choice emits bp-choice-ready with
its stable manifest. Committing and reconsidering emit the bubbling, composed
bp-choice-commit and bp-choice-reconsider events. Tab preview emits nothing.
Hosts apply ephemeral owner state through choice.applyDecisionState(state):
choice.applyDecisionState({ status: 'locked', value: 'phased', busy: false })
choice.applyDecisionState({ status: 'open', value: 'phased', busy: false })
choice.applyDecisionState(null) // restore the authored open/resolved stateThe component retains its authored option model and owns every rerender. Hosts
must use the manifest, events, and method instead of querying generated
.bp-choice__* markup. Applying host state never mutates the authored
resolved attribute, so a host can present an owner-only reconsideration while
the stored document remains at its last committed decision.
Streaming document root
<bp> is a streaming document root: on connect it paints a deterministic
skeleton at once, then fills append-only as content blocks arrive, so a
reader sees structure instantly and the document grows in place without a
whole-page re-render (focus and scroll survive). It is host-driven, taking
content over four channels in priority order:
root.content = { type: 'bp:content', blocks: [{ html: '<h2>…</h2>' }], done: true }
window.__BP_CONTENT__ = { blocks: […] } // read once on connect unless `ignore-global`
frame.postMessage({ type: 'bp:content', blocks: [{ html }], done: true }, '*')
// or a `src` URL the element fetches (AbortController-guarded)Each block is a raw HTML string appended to the persistent root; nested bp-*
elements upgrade themselves on insertion. appendBlock(html) and finalize()
expose the same behavior imperatively — done: true or finalize() drops the
skeleton and stamps the done state.
Char-level streaming (v1)
For a token-level reveal, the same bp:content channel also accepts three
additive ops that stream a single live "tail" block:
root.content = { type: 'bp:content', op: 'open-block', id: 'b1' }
root.content = { type: 'bp:content', op: 'append-text', id: 'b1', text: 'Hello ' }
root.content = { type: 'bp:content', op: 'commit-block', id: 'b1', html: '<h2>Hello world</h2>' }open-block starts an empty tail; append-text buffers tokens that a
velocity-adaptive reveal drains into the tail — as textContent only,
never live HTML, so a half-arrived tag is inert and there is no mid-stream
XSS surface. The reveal rate is bound to buffer depth (chars/sec proportional
to the backlog, clamped to 40–1200), so it catches up a burst without a
visible jump and idles the caret honestly through a pause instead of running a
fixed-cadence metronome that overruns bursts and stalls into an empty buffer.
commit-block swaps the tail for the block's sanitized final HTML in one
shot and locks it (append-only from there), ready for the next open-block. A
blinking caret marks the write head while a block is open. The ops are
backward-compatible: they never carry blocks[], a receiver that only
understands blocks[] ignores an unknown op, and commit-block is
equivalent to appending one whole block. Under prefers-reduced-motion the
reveal collapses to a synchronous drain so output stays deterministic (the
fixed synchronous path is the reduced-motion fallback).
Because blocks are inserted as live HTML, the postMessage channel is
accepted from the same origin only by default. A trusted cross-origin host
opts in with an allow-origin allowlist attribute (space-separated origins, or
*). Every appended block is also sanitized before insertion as
defense-in-depth — script/iframe/object/embed elements, the
document-scoped base/meta[http-equiv=refresh] elements, on*
event-handler attributes, and javascript:/data: URLs in href/src are
stripped — matching the server-side sanitization that runs when the artifact
commits. The one-shot seed channels — the pre-upgrade .content preset, the
global, and the initial src fetch — each apply once per element, so
reattaching a <bp> never re-seeds the stream; assigning .content after
upgrade is a live append (like appendBlock), not a one-shot seed. See
streaming.html for a live real-time demo
(skeleton → append → finalize()) with playback controls.
Keyed node/patch (v1)
Alongside the append-only path, two keyed ops give in-place reconciliation —
a node is addressable by a stable id, so a later patch re-renders the same
element instead of appending a duplicate:
root.content = { op: 'node', id: 'card-7', type: 'bp-card', props: { eyebrow: 'Option A' } }
root.content = { op: 'patch', id: 'card-7', props: { title: 'Adopt the typed stream' }, html: '<p>…</p>' }.content is a direct property setter that dispatches on op alone — it never
inspects a type envelope field, so keyed ops assigned this way skip the
{ type: 'bp:content', … } wrapper used elsewhere in this doc. That wrapper
matters only for the postMessage channel, which does gate on
data.type === 'bp:content'; because a node op's own type names the
element's tag, the two meanings collide on one JS object key over
postMessage (the tag silently overwrites the envelope discriminator, and the
message is dropped). Send node/patch ops through .content or
window.__BP_CONTENT__ rather than raw postMessage when the op carries a
custom type.
node paints a typed element: type is the tag (validated and denied against
executable/document-scoped tags, else a div), props are reflected as
sanitized attributes (on* handlers and javascript:/data: URLs stripped,
just like block content), html is its sanitized inner content, and slot
names a keyed parent to nest into (else the stream root). A new id
appends; a re-sent id updates in place — never duplicates. patch re-renders
the same keyed element in place; an unknown id is a no-op, so a superseded
or out-of-order op can never duplicate or corrupt the tree. This reconciler is
deliberately minimal (create on a new key, mutate on an existing one — no
signals, no whole-tree diffing). A pre-rendered typed skeleton (children
authored inside <bp> before upgrade) is adopted into the stream on
connect rather than wiped, preserving the skeleton-first paint; its keyed nodes
are indexed so later patch ops target them.
Runtime chrome is reconciled from the current data-sidebar headings when
finalize() completes, so a streaming <bp> that streams in headings gets the
sidebar, reading progress, doc switcher, and <bp-toc> after it finalizes;
block/tail reconciliation is append-only (committed blocks are never rewritten).
The keyed node/patch reconciler ships as v1, and the per-component reactivity
retrofit is done — every bp-* component with patchable props re-renders in
place from a captured #source on attributeChangedCallback, so a patch op
actually repaints instead of being a no-op.
Producer idiom — frame-first: paint a section or card's frame immediately
with a node op carrying null or placeholder props, then patch the same id
as real values resolve. Each patch repaints only that element — siblings keep
their focus and scroll position. Reserve the char-level ops above for prose
that should read as a live token tail; use node/patch for typed structure
whose values resolve once rather than stream character by character.
Learn more
Blueprints are created and rendered through Obvious. To see what they are and how to use them, go to obvious.ai.
License
MIT. See LICENSE and THIRD_PARTY_NOTICES.md.
