02 — JavaScript
50 files, no framework, no build step, and a component system nobody has written down. This is the most original and least documented convention in the estate.
1. The complete example
components/sg-compute/sg-compute-left-nav/v0/v0.1/v0.1.0/sg-compute-left-nav.js, verbatim:
/**
* sg-compute-left-nav — vertical icon-rail navigation for the admin dashboard.
*
* Items: Compute / Storage / Settings / Diagnostics.
* Click fires sp-cli:nav.selected { view } on document.
*
* @module sg-compute-left-nav
* @version 0.1.0
*/
import { SgComponent } from 'https://dev.tools.sgraph.ai/components/base/v1/v1.0/v1.0.0/sg-component.js'
class SgComputeLeftNav extends SgComponent {
static jsUrl = import.meta.url
get resourceName() { return 'sg-compute-left-nav' }
get sharedCssPaths() { return ['https://dev.tools.sgraph.ai/components/tokens/v1/v1.0/v1.0.0/sg-tokens.css'] }
onReady() {
this._current = 'compute'
this.shadowRoot.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => this._select(btn.dataset.view))
})
this._update()
}
_select(view) {
if (view === this._current) return
this._current = view
this._update()
document.dispatchEvent(new CustomEvent('sp-cli:nav.selected', {
detail: { view },
bubbles: true, composed: true,
}))
}
_update() {
this.shadowRoot.querySelectorAll('.nav-item').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.view === this._current)
btn.setAttribute('aria-current', btn.dataset.view === this._current ? 'page' : 'false')
})
}
}
customElements.define('sg-compute-left-nav', SgComputeLeftNav)
2. The component system
Native web components. 41 customElements.define across 50 files. No React, no Vue, no bundler, no build step. The browser is the runtime.
The three-file triplet
Every component is exactly three files, same basename:
sg-compute-left-nav.js behaviour
sg-compute-left-nav.html markup
sg-compute-left-nav.css styles
static jsUrl = import.meta.url is what makes this work: the component knows its own URL, so SgComponent can fetch the sibling .html and .css without anything being told where they live. Self-locating components are the mechanism that removes the build step.
The versioned CDN path
https://dev.tools.sgraph.ai/components/<name>/v1/v1.0/v1.0.0/<file>.js
^^^ ^^^^ ^^^^^^
major minor patch — as directories
Three nested directories, one per semver level. A consumer pins at whatever depth it wants stability: /v1/ follows the major, /v1.0/ follows the minor, /v1.0.0/ is frozen. Immutable URLs, no lockfile, no node_modules, cacheable forever. The same scheme is used locally under components/.
What comes from the CDN today: SgComponent (the base class), sg-tokens.css (design tokens), sg-vault-client.js, sg-vault-write.js.
The base class contract
SgComponent supplies the lifecycle; components override three things:
| Member | Purpose |
|---|---|
static jsUrl = import.meta.url | self-location — required |
get resourceName() | the basename of the sibling .html / .css |
get sharedCssPaths() | tokens and shared sheets to adopt |
onReady() | the lifecycle hook — not connectedCallback directly |
onReady() rather than connectedCallback is the tell: the base class handles the async fetch of the sibling files and calls onReady() once the shadow root is populated, so a component never has to think about whether its markup has arrived.
Shadow DOM
6 files call attachShadow directly; the rest inherit it from SgComponent, and every component addresses its own markup through this.shadowRoot. Styles are scoped by :host (see 03__).
3. Formatting, measured
| Convention | Evidence |
|---|---|
| 4-space indent | 3,791 indented lines are a multiple of 4; 148 are not |
| Single quotes | 4,006 single vs 400 double — 91% |
| No semicolons | 531 statement lines without vs 401 with — the newer components/ tree is consistently semicolon-free; the older shared/ tree is not. See §6 |
| Trailing commas | in multi-line object and array literals, consistently |
| Aligned object keys | detail: { view }, — the same alignment discipline as Python and CSS |
_ prefix for private | universal in JS, against Python rule 9. See 06__ §3 |
| ESM everywhere | 48 type="module" script tags; no UMD, no globals |
| Banner comments | 15 of 50 files open with // ── name — description ── |
Two banner styles exist: JSDoc blocks (/** … @module … @version */) on components, and // ── … ── box-drawing rules on shared modules. Python uses # ═══. The estate has three banner characters for one idea — 05__ §2.
4. Events, state and data
Events are namespaced and go through document:
document.dispatchEvent(new CustomEvent('sp-cli:nav.selected', {
detail: { view },
bubbles: true, composed: true,
}))
bubbles: true, composed: true is what lets an event escape the shadow root. The namespace is sp-cli: — the old CLI name — and it appears in 23 files. That is a rename surface nobody has counted; see 06__ §3.
State is instance fields, _-prefixed, set in onReady(). No store, no observable, no framework state layer. Cross-component state goes through the event bus and through purpose-built shared modules — settings-bus.js, vault-bus.js, poll.js.
Constants are frozen and centralised. shared/launch-defaults.js, verbatim:
// ── launch-defaults.js — canonical launch constants ────────────────────────── //
// Single source of truth. Both sg-compute-compute-view and sg-compute-launch-form
// import from here. Update here only — do not duplicate locally.
export const REGIONS = Object.freeze([
'eu-west-2', 'us-east-1', 'ap-southeast-1', 'eu-west-1', 'us-west-2',
])
Object.freeze on every exported constant, and a comment that names the consumers and forbids local duplication. That is the same single source of truth instinct that drives manifest.py and the repo-root version file.
5. Why no build step is the interesting claim
It is worth arguing on the site rather than just reporting.
What it costs: no TypeScript, no JSX, no tree-shaking, no minification, no dependency resolution, one network request per component file.
What it buys: the source that runs is the source you read — no source maps, no build cache, no node_modules, no bundler upgrade treadmill, and an immutable URL per version instead of a lockfile. A component is deployable by copying three files to a path. And — the point that connects to 05__ §3 — an agent reading the running page reads the actual code, with no transpilation between what it sees and what executes.
Where it stops working: at the point you need a dependency graph deeper than one level, or a package that only ships as CJS. Neither has happened yet in 50 files.
6. Inconsistencies to fix before publishing
- Semicolons.
components/is semicolon-free;shared/is not. Pick one — the newer tree suggests dropping them — and write the config. - Two banner styles. JSDoc on components,
// ──on shared modules. Pick one per file type and say which. - The
sp-cli:event namespace is legacy naming in 23 files. Rename with the rest (sg-computepack02__), and note that renaming an event namespace is a breaking change for any listener outside the repo. _privatein JS vs rule 9 in Python. The rule set says no underscore prefix; the JavaScript uses it universally. State that rule 9 is Python-only, or change one of the two.
7. What a linter config would encode
eslint with: 4-space indent · single quotes · no semicolons · trailing commas in multiline · object-curly-spacing · a custom rule requiring static jsUrl = import.meta.url in any class extending SgComponent · a ban on bare connectedCallback overrides in favour of onReady() · and a check that every component directory contains all three of .js / .html / .css.
The last two are the ones that would actually catch bugs.
This document is released under the Creative Commons Attribution 4.0 International licence (CC BY 4.0).
source: /briefs/03__html-and-css.md