Niveus is the interface language the project uses from here on -- this site
first, applications after it. Rebuilding the site on it is what proves the
system is one, rather than a folder of CSS with a README.
**`style.css` is gone.** 21 KB that transcribed include/theme.h by hand:
palette, type scale, shadows, spacing, every component. The intent was right --
"a site describing an operating system should look like the operating system"
-- and the mechanism was a copy, which means two files that have to be kept in
step by remembering to. What replaced it is `app.css`, which is layout and
nothing else, and one declaration in each page:
:root { --nv-brand: #7C5CFF; }
That is SCH_ACCENT. Niveus derives the accent and its states, the link colour,
the focus ring, the tint in the greys and how far the status colours lean, for
light and dark both. One number to keep true instead of forty.
**The site is now adaptive.** Dark by default, because the OS is dark, with a
Light/Dark/Auto group in the bar that is real. The choice is kept in
localStorage and re-applied by an inline script before first paint, so the page
never renders in one appearance and corrects itself while you watch.
**PSOGM keeps its one purple, and now says so structurally.** #4B1D9A is
spelled the way Niveus spells a subtree with its own colour -- `data-nv-brand`
with its own `--nv-brand` on that section -- so every role inside re-derives
from it rather than being painted on. Still exactly one place, which is what
branding/README.md asks for.
Content is unchanged; what changed is what it is made of. Cards are `.nv-card`,
tags are `.nv-badge`, the support table is `.nv-table`, the crash section is a
`.nv-bed`, and the docs reader's two rails are `.nv-nav--vertical` with the
current document marked by `aria-current` rather than a class of our own.
`docs.js` emits `.nv-table` and `.nv-code`, so a change to the system reaches
the documentation too.
Three things were found by looking at it in a browser rather than by reading
it, and all three are the same mistake: **a `.nv-card` carries no padding of
its own -- its parts do.** The stat tiles, the "One ISO, one stick" band and
the card headers were all built from bare children, so numbers ran to the card
edge, the USB mark hung off it, and a title and its badge sat at opposite ends
of a row meant for a title and a trailing slot. Where the component had a right
way to say it, the markup now says it that way; where it did not -- a tile has
no parts -- `app.css` supplies the padding and says why.
Checked: every nv- class used across the three pages and docs.js exists in the
CSS, every token referenced by app.css is defined, and app.css contains no hex
value at all. Niveus's own tools/check.py passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
322 lines
11 KiB
JavaScript
322 lines
11 KiB
JavaScript
/* ==========================================================================
|
|
Niveus — continuity helpers
|
|
--------------------------------------------------------------------------
|
|
Optional. Nothing in Niveus needs this file to render; every component works
|
|
without it. What it adds is continuity for the changes CSS cannot animate on
|
|
its own — a selection moving to a different element, a panel replacing
|
|
another, the whole surface changing appearance. All three are the same
|
|
problem: two states that exist at different moments, with no single element
|
|
transitioning between them. A view transition is the browser's answer, and
|
|
this file is the small amount of wiring it needs.
|
|
|
|
Everything here is a thin wrapper. An application that already owns its
|
|
state (a router, a framework) should call nv.transition() around its own
|
|
update and ignore the rest.
|
|
|
|
nv.transition(update, { direction, kind }) run an update continuously
|
|
nv.setTheme("dark" | "light" | "system") cross-fade the appearance
|
|
nv.openDialog(dialog, trigger) open it out of its trigger
|
|
nv.selectTab(tablist, tab) move through the same surface
|
|
|
|
Markup wiring, applied automatically on load:
|
|
|
|
[data-nv-theme="dark"] button that sets the appearance
|
|
[data-nv-dialog="id"] button that opens a dialog from itself
|
|
[data-nv-tabs] tablist whose [role=tab] children switch
|
|
their aria-controls panels
|
|
[data-nv-close] button that closes its dialog or popover
|
|
[data-nv-scroller] frame whose [data-nv-scroll] controls move
|
|
its .nv-scroller a page at a time
|
|
|
|
Wiring also names every navigation item, which is what lets a travelling
|
|
selection marker pass *behind* the labels rather than over them.
|
|
|
|
Reduced motion is honoured by skipping the view transition entirely: the
|
|
update still runs, it simply arrives instead of travelling.
|
|
========================================================================== */
|
|
|
|
(function (global) {
|
|
"use strict";
|
|
|
|
var root = document.documentElement;
|
|
|
|
function prefersReducedMotion() {
|
|
return global.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
}
|
|
|
|
function canTransition() {
|
|
return typeof document.startViewTransition === "function" && !prefersReducedMotion();
|
|
}
|
|
|
|
/**
|
|
* Run `update` so the user can follow the change.
|
|
* `direction` ("forward" | "backward") tells content which way to travel;
|
|
* `kind` ("theme") marks a change the whole surface takes part in.
|
|
*/
|
|
function transition(update, options) {
|
|
options = options || {};
|
|
|
|
if (!canTransition()) {
|
|
return Promise.resolve(update());
|
|
}
|
|
|
|
if (options.direction) root.dataset.nvDirection = options.direction;
|
|
if (options.kind) root.dataset.nvTransition = options.kind;
|
|
|
|
var view = document.startViewTransition(update);
|
|
|
|
function clear() {
|
|
delete root.dataset.nvDirection;
|
|
delete root.dataset.nvTransition;
|
|
}
|
|
view.finished.then(clear, clear);
|
|
|
|
return view.finished;
|
|
}
|
|
|
|
/** "light" | "dark" | "system" — system means: follow the environment. */
|
|
function setTheme(theme) {
|
|
var done = transition(function () {
|
|
if (theme === "system") {
|
|
root.removeAttribute("data-theme");
|
|
} else {
|
|
root.setAttribute("data-theme", theme);
|
|
}
|
|
syncThemeControls(theme);
|
|
}, { kind: "theme" });
|
|
|
|
root.dispatchEvent(new CustomEvent("nv:theme", { detail: { theme: theme } }));
|
|
return done;
|
|
}
|
|
|
|
function syncThemeControls(theme) {
|
|
var controls = document.querySelectorAll("[data-nv-theme]");
|
|
for (var i = 0; i < controls.length; i++) {
|
|
controls[i].setAttribute("aria-pressed", String(controls[i].dataset.nvTheme === theme));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open a <dialog> so it appears to come from `trigger`: the entrance scales
|
|
* away from the trigger's centre instead of from the middle of the screen.
|
|
*/
|
|
function openDialog(dialog, trigger) {
|
|
if (typeof dialog === "string") dialog = document.getElementById(dialog);
|
|
if (!dialog) return null;
|
|
|
|
dialog.showModal();
|
|
|
|
if (trigger) {
|
|
// showModal() has already laid the dialog out, so both boxes are final.
|
|
var from = trigger.getBoundingClientRect();
|
|
var to = dialog.getBoundingClientRect();
|
|
|
|
// A trigger far outside the viewport — one the page has scrolled past —
|
|
// would otherwise put the origin hundreds of pixels away and turn a
|
|
// 3% scale into a lunge across the screen. One dialog-length in any
|
|
// direction is enough to say where it came from.
|
|
var clamp = function (value, size) {
|
|
return Math.max(-size, Math.min(value, size * 2));
|
|
};
|
|
|
|
dialog.style.setProperty(
|
|
"--nv-origin",
|
|
clamp(from.left + from.width / 2 - to.left, to.width).toFixed(1) + "px " +
|
|
clamp(from.top + from.height / 2 - to.top, to.height).toFixed(1) + "px"
|
|
);
|
|
} else {
|
|
dialog.style.removeProperty("--nv-origin");
|
|
}
|
|
|
|
return dialog;
|
|
}
|
|
|
|
function tabsOf(tablist) {
|
|
return Array.prototype.slice.call(tablist.querySelectorAll('[role="tab"]'));
|
|
}
|
|
|
|
/** Move the selection, and the panel, in the direction the user is going. */
|
|
function selectTab(tablist, tab) {
|
|
var tabs = tabsOf(tablist);
|
|
var next = tabs.indexOf(tab);
|
|
var current = -1;
|
|
|
|
for (var i = 0; i < tabs.length; i++) {
|
|
if (tabs[i].getAttribute("aria-selected") === "true") current = i;
|
|
}
|
|
if (next < 0 || next === current) return Promise.resolve();
|
|
|
|
var done = transition(function () {
|
|
tabs.forEach(function (candidate, index) {
|
|
var selected = index === next;
|
|
candidate.setAttribute("aria-selected", String(selected));
|
|
candidate.tabIndex = selected ? 0 : -1;
|
|
|
|
var panel = document.getElementById(candidate.getAttribute("aria-controls"));
|
|
if (panel) panel.hidden = !selected;
|
|
});
|
|
}, { direction: next > current ? "forward" : "backward" });
|
|
|
|
tab.focus();
|
|
return done;
|
|
}
|
|
|
|
function initTabs(tablist) {
|
|
tablist.addEventListener("click", function (event) {
|
|
var tab = event.target.closest('[role="tab"]');
|
|
if (tab && tablist.contains(tab)) {
|
|
event.preventDefault();
|
|
selectTab(tablist, tab);
|
|
}
|
|
});
|
|
|
|
// The ARIA tabs pattern: arrows move selection, Home and End jump to the
|
|
// ends, and only the selected tab is in the tab order.
|
|
tablist.addEventListener("keydown", function (event) {
|
|
var tabs = tabsOf(tablist);
|
|
var index = tabs.indexOf(document.activeElement);
|
|
if (index < 0) return;
|
|
|
|
var vertical = tablist.getAttribute("aria-orientation") === "vertical";
|
|
var back = vertical ? "ArrowUp" : "ArrowLeft";
|
|
var forward = vertical ? "ArrowDown" : "ArrowRight";
|
|
var target = null;
|
|
|
|
if (event.key === back) target = tabs[(index - 1 + tabs.length) % tabs.length];
|
|
else if (event.key === forward) target = tabs[(index + 1) % tabs.length];
|
|
else if (event.key === "Home") target = tabs[0];
|
|
else if (event.key === "End") target = tabs[tabs.length - 1];
|
|
|
|
if (target) {
|
|
event.preventDefault();
|
|
selectTab(tablist, target);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Wire a scroller's prev/next controls. Scrolling itself is the browser's;
|
|
* this only gives pointers without a horizontal axis somewhere to click, and
|
|
* hides a control once there is nothing left in that direction.
|
|
*/
|
|
function initScroller(frame) {
|
|
var track = frame.querySelector(".nv-scroller");
|
|
if (!track) return;
|
|
|
|
var controls = frame.querySelectorAll("[data-nv-scroll]");
|
|
|
|
function step() {
|
|
var item = track.firstElementChild;
|
|
if (!item) return track.clientWidth * 0.8;
|
|
var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
|
|
return item.getBoundingClientRect().width + gap;
|
|
}
|
|
|
|
function update() {
|
|
// A sub-pixel slack, or a fractional scrollWidth leaves a control
|
|
// enabled at an end it cannot move past.
|
|
var atStart = track.scrollLeft <= 1;
|
|
var atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
|
|
|
|
controls.forEach(function (control) {
|
|
control.disabled = control.dataset.nvScroll === "prev" ? atStart : atEnd;
|
|
});
|
|
|
|
// The same two facts decide which edge is allowed to fade.
|
|
frame.toggleAttribute("data-nv-at-start", atStart);
|
|
frame.toggleAttribute("data-nv-at-end", atEnd);
|
|
}
|
|
|
|
controls.forEach(function (control) {
|
|
control.addEventListener("click", function () {
|
|
var direction = control.dataset.nvScroll === "next" ? 1 : -1;
|
|
track.scrollBy({ left: direction * step(), behavior: "smooth" });
|
|
});
|
|
});
|
|
|
|
track.addEventListener("scroll", update, { passive: true });
|
|
if (typeof ResizeObserver === "function") new ResizeObserver(update).observe(track);
|
|
update();
|
|
}
|
|
|
|
/**
|
|
* Give every item in a selection group a view-transition name, so it is
|
|
* captured as its own layer and rides *above* the travelling marker instead
|
|
* of being painted over by it.
|
|
*
|
|
* This cannot live in CSS: a view-transition-name must be unique across the
|
|
* whole document, and a stylesheet cannot count how many navigations a page
|
|
* happens to contain. Items already carrying a name are left alone, so an
|
|
* application can assign its own.
|
|
*/
|
|
var carried = 0;
|
|
|
|
function liftLabels(scope) {
|
|
var groups = scope.querySelectorAll(".nv-nav, .nv-nav-tabs, [data-nv-travel]");
|
|
|
|
for (var i = 0; i < groups.length; i++) {
|
|
var items = groups[i].querySelectorAll(".nv-nav__item");
|
|
|
|
for (var j = 0; j < items.length; j++) {
|
|
if (items[j].style.viewTransitionName) continue;
|
|
carried += 1;
|
|
items[j].style.viewTransitionName = "nv-carried-" + carried;
|
|
}
|
|
}
|
|
}
|
|
|
|
function wire(scope) {
|
|
scope = scope || document;
|
|
|
|
scope.querySelectorAll("[data-nv-theme]").forEach(function (control) {
|
|
control.addEventListener("click", function () {
|
|
setTheme(control.dataset.nvTheme);
|
|
});
|
|
});
|
|
|
|
scope.querySelectorAll("[data-nv-dialog]").forEach(function (trigger) {
|
|
trigger.addEventListener("click", function () {
|
|
openDialog(trigger.dataset.nvDialog, trigger);
|
|
});
|
|
});
|
|
|
|
scope.querySelectorAll("[data-nv-tabs]").forEach(initTabs);
|
|
scope.querySelectorAll("[data-nv-scroller]").forEach(initScroller);
|
|
liftLabels(scope);
|
|
|
|
scope.querySelectorAll("[data-nv-close]").forEach(function (control) {
|
|
control.addEventListener("click", function () {
|
|
var owner = control.closest("dialog, [popover]");
|
|
if (!owner) return;
|
|
if (owner.tagName === "DIALOG") owner.close();
|
|
else owner.hidePopover();
|
|
});
|
|
});
|
|
|
|
// A dialog closes when the scrim is clicked, but not when a drag merely
|
|
// ends there.
|
|
scope.querySelectorAll("dialog.nv-dialog").forEach(function (dialog) {
|
|
dialog.addEventListener("mousedown", function (event) {
|
|
if (event.target === dialog) dialog.close();
|
|
});
|
|
});
|
|
}
|
|
|
|
global.nv = {
|
|
transition: transition,
|
|
setTheme: setTheme,
|
|
syncThemeControls: syncThemeControls,
|
|
openDialog: openDialog,
|
|
selectTab: selectTab,
|
|
initScroller: initScroller,
|
|
liftLabels: liftLabels,
|
|
wire: wire,
|
|
prefersReducedMotion: prefersReducedMotion,
|
|
};
|
|
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", function () { wire(); });
|
|
} else {
|
|
wire();
|
|
}
|
|
})(window);
|