Working in index.html
The interface is one 3,000-line file on purpose and it stays that way. No build step, no
bundler, no framework — the hub's whole promise is node hub.mjs and a browser. An edit
that needs a toolchain is the wrong edit.
Only genuinely reusable, genuinely testable logic leaves the file, and only into a plain
ES module the Node test suite can import: navigation.mjs (routes, bookmarks, recents,
path resolution), pictures-client.mjs, reports.mjs. That is the bar — "it would be
tidier in its own file" is not.
1. Adding a view
renderPage() is the whole router, in order:
if (S.query) return viewSearch(page);
const n = S.byId.get(S.sel) || S.byId.get(defaultRootId());
if (n.kind === 'projects') return viewPortfolio(page, n);
if (n.docFirst && n.doc && !S.docOff.has(n.id)) return viewDocFirst(page, n); // opens on the README
if (n.kind === 'root') return viewOverview(page, n);
if (n.kind === 'cli') return viewCli(page, n);
if (n.kind === 'repo') return viewRepo(page, n);
if (ENTITY.has(n.kind)) return viewEntity(page, n);
if (n.kind === 'image') return viewImage(page, n);
if (['md','config','file','html','pdf'].includes(n.kind)) return viewFile(page, n);
return viewFolder(page, n); // the fallthrough, and usually the right answer
Order matters. docFirst sits above every kind branch, so anything that documents
itself opens on its README; roots and CLI runtimes never set the flag because their own
pages are the reason to click them. A kind with no branch lands in viewFolder and renders
fine — reach for a new view only when the folder listing is actually wrong.
A new kind needs the five client tables kept in step: TINT, ROUND, DIRISH,
ENTITY, KIND_LABEL — plus the server-side TINT in hub.mjs. See the
project-hub-scan skill.
Every view takes (page, n) and appends to page. Build with el(tag, cls, txt) and
$(sel); use activate(node, handler, role) for anything clickable so keyboard and ARIA
come with it. Never innerHTML with scan data — textContent, or md2html output
from /api/file, which is the only sanitised path.
2. Colours are variables, never literals
Eight schemes — midnight oxide cobalt paper plum nord sepia mono — each one block of CSS
variables under html[data-theme="…"]. The comment at the top of the sheet is the contract:
Every surface, text and accent color in the sheet comes from one of these blocks. Adding a scheme = copy a block, change the values, add an
<option>to#theme. Nothing else.
So a hard-coded #5fe3a1 anywhere in a rule is a bug in seven themes. Server-side TINT
values are var(--green)-style strings for exactly this reason: the scan ships variable
names, not colours, and the theme resolves them.
Two light themes exist (paper, sepia). Check both — a contrast that works on
near-black frequently vanishes on warm white.
[!TIP] The pre-paint
<script nonce="%NONCE%">in<head>readshub.themebefore first paint so a non-default scheme does not flash midnight. Anything that must beat first paint goes there and nowhere else.
3. State, reindex, and why your button stopped working
S holds everything: data, byId, parent, index, sel, open, query, sig.
reindex(data) rebuilds the maps; it runs only when data.sig !== S.sig, and when it
does, every DOM node the previous render produced is discarded.
Consequences, all of which show up as "it worked until the page refreshed itself":
- A closure holding a node object from before a reindex is stale. Hold the
id. doLoad()returns early whensigis unchanged — repainting on every watcher tick would blow away scroll and selection for no reason. Pass{ force: true }when you genuinely changed something locally that the payload does not describe.- Anything appended asynchronously must re-check
host.isConnectedbefore touching the DOM. The folder list view does this; copy it. - View-local UI state that must survive a repaint belongs on
S(S.mdMode,S.htmlMode,S.repoFilter,S.docOff,S.hitLimit), not in a local.
4. Routes and per-viewer state
Hash routes come from navigation.mjs — routeHash(id, heading) / parseRoute,
searchHash / parseSearch. go() writes the hash itself, so the hashchange
listener guards by comparing to S.sel rather than tracking a flag. A search URL restores
the query, scope and limit, not just a document — that is why syncSearchRoute(replace)
exists and why restoredInitialRoute fires once.
Per-viewer preferences are localStorage, never the scan payload:
| Key | Holds |
|---|---|
hub.theme hub.rail hub.sbw |
scheme, collapsed sidebar, sidebar width |
hub.collapsed hub.folderview hub.reader |
collapsed sections, grid/list, reader settings |
hub.bookmarks hub.recent |
paths only — via navigation.mjs, which is where the list logic is tested |
Every read and write is wrapped in try/catch. Private windows throw on access, and a
throwing preference read must not take the page down.
5. The server contract
Four placeholders are substituted per request, and index.html is the only file served
non-verbatim: %TITLE%, %FAVICON%, %PORT%, and %NONCE% (twice). The nonce is why
every <script> in the file must carry nonce="%NONCE%" — CSP is script-src 'self' 'nonce-…', so an inline handler attribute or a new unnonced script is silently dead.
Styles are 'unsafe-inline'; scripts are not.
HUB_PORT is compared against location.port before opening the EventSource, so the page
does not try to stream from a static host. On reconnect the client refetches rather than
waiting for a change event: reconnecting means the server restarted, which is exactly when
the payload changed and no event will ever describe it.
Opening index.html over file:// or a static preview serves the shell but not the API —
showFatal() names that case specifically instead of spinning forever. Keep that path
working; it is the first thing a new user hits.
[!WARNING]
Demo/build-demo.mjsrewrites exact string literals in this file and asserts the match count. Editing the/api/raw?path=//api/preview?path=call sites, the<script type="module">tag or a placeholder will fail the demo build in CI — which is the point. Fix thesub()call in the same commit; see theproject-hub-demoskill.
6. Checklist
- Does the fallthrough already work?
viewFolderhandles more than you think. - New kind → five client tables + server
TINT+ onerenderPage()branch. - Colours from variables only; check
paperandsepia. - Clickable →
activate(). Text →textContent. NeverinnerHTMLon scan data. - Hold ids, not nodes. Durable UI state on
S. Async appends checkisConnected. - New
<script>→nonce="%NONCE%". - Touched a literal the demo rewrites → update
sub()inDemo/build-demo.mjs. npm testfromHub/— the byte-level control-character test coversindex.html, and it exists because scripted edits corrupted this file three times.- Load the page, rescan, resize the sidebar, follow a deep link, hit back.
7. Anti-patterns
- Adding a build step, a framework or an npm dependency. Zero dependencies is the product.
- Splitting index.html "for tidiness." Extract only what the Node tests will import.
- A literal colour in a CSS rule. Seven themes break silently.
innerHTMLwith anything from the scan.md2html/sanitizeHtmlis the only sanitised path.- A new inline script without the nonce. CSP drops it with no visible error.
- Repainting on every watcher tick. The
sigcomparison exists to protect scroll and selection. - Preferences in the scan payload. They are per-viewer; they live in localStorage.
Related
| For | See |
|---|---|
| Layout, view-by-view notes, keyboard map, extraction rules | references/client-map.md |
Where a new kind comes from, and the API it arrives on |
the project-hub-scan skill |
| The literals the demo build rewrites in this file | the project-hub-demo skill |