The website on Niveus: window-framed hero, feature cards with drawn icons, screenshot tabs, a light/dark/auto switch, downloads and build steps as cards; still all words in content.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nexoniarz 2026-10-20 16:58:42 +02:00
commit 04c8985374
13 changed files with 4948 additions and 229 deletions

View file

@ -19,19 +19,19 @@ window.FRY = {
},
features: [
{ icon: "▣", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." },
{ icon: "✎", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." },
{ icon: "⊕", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. The result is always closed and ready to print." },
{ icon: "◫", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, merge. Shift+click picks a whole loop, Shift+Ctrl+click a ring." },
{ icon: "◎", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." },
{ icon: "▤", title: "Copies and thickness", text: "Copies in a row or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." },
{ icon: "✐", title: "Paint and stickers", text: "Paint on the model or on its flat picture. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." },
{ icon: "▦", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." },
{ icon: "⎙", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." },
{ icon: "☀", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." },
{ icon: "▭", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." },
{ icon: "▯", title: "On your phone too", text: "Not a viewer: the whole modeller. Big buttons, one finger turns the view, two move and pinch to zoom, holding still is a right-click. Files through the phone's own picker." },
{ icon: "⟲", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." },
{ icon: "cube", title: "Shapes that stay editable", text: "Cube, cylinder, sphere, torus, stairs, arch and more, plus starter models like a table, chair or barrel. Change their sizes any time in Properties." },
{ icon: "pen", title: "Draw a shape anywhere", text: "Click an outline on the ground or straight on the face of another object: it becomes a new part, gets added to it, or cuts into it." },
{ icon: "combine", title: "Add, subtract, overlap", text: "Combine objects: add them together, take one out of another, or keep only where they overlap. The result is always closed and ready to print." },
{ icon: "points", title: "Real shape editing", text: "Points, edges and faces. Extrude, inset, round edges, loop cut, knife, merge. Shift+click picks a whole loop, Shift+Ctrl+click a ring." },
{ icon: "hole", title: "Holes for screws and nuts", text: "Round, rectangle, triangle or hexagon. M2 to M8 screw and nut sizes ready, with a little room added so the print actually fits." },
{ icon: "copies", title: "Copies and thickness", text: "Copies in a row or round in a circle. Make a surface thick, or a solid hollow with walls exactly as thick as you need." },
{ icon: "brush", title: "Paint and stickers", text: "Paint on the model or on its flat picture. Paste any picture (Ctrl+V) and it goes on like a sticker that bends over edges and corners." },
{ icon: "layout", title: "Layout tab", text: "Move, turn and size the flat frames of the surface by hand. The paint follows, so the model looks the same." },
{ icon: "print", title: "Made for 3D printing", text: "Your printer's plate in the view, sizes in millimetres, checks for holes and flipped faces with fix buttons, overhangs shown red, weight and filament." },
{ icon: "sun", title: "See it rendered", text: "Solid view for modelling, a real renderer with sky, sun and soft shadows when you want to see it properly. Save a picture with F12." },
{ icon: "folder", title: "Folders and groups", text: "Layers with folders inside folders, drag and drop, show/hide and lock whole groups, and a button on every row that brings the camera to it." },
{ icon: "phone", title: "On your phone too", text: "Not a viewer: the whole modeller. Big buttons, one finger turns the view, two move and pinch to zoom, holding still is a right-click. Files through the phone's own picker." },
{ icon: "save", title: "Never lose work", text: "Its own packed .fry file, autosave in the background, recovery after a crash, and a Save / Don't save / Cancel when closing." },
],
shots: [

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,8 +1,9 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
@ -17,7 +18,7 @@ with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
@ -43,7 +44,7 @@ new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font

View file

@ -9,76 +9,162 @@
<meta property="og:description" content="A 3D modeller that does what you'd expect. Pick a tool, click, drag.">
<meta property="og:image" content="img/model.webp">
<link rel="icon" href="img/icon.png">
<link rel="stylesheet" href="style.css">
<!-- The look is Niveus (github.com/Nexoniarz/niveus), the same as the app's. site.css is only
layout: no colours, shadows or type sizes of its own. -->
<link rel="stylesheet" href="niveus/niveus.css">
<script src="niveus/niveus.js" defer></script>
<link rel="stylesheet" href="site.css">
<script>
// The saved appearance before the first paint, so the page never flashes the wrong one.
try {
const t = localStorage.getItem("fry-theme");
if (t === "light" || t === "dark") document.documentElement.setAttribute("data-theme", t);
} catch (e) {}
</script>
<style>:root { --nv-brand: #ff7828; }</style>
</head>
<body>
<!-- The page is built from content.js by site.js: edit content.js to update it. -->
<header class="top">
<a class="brand" href="#"><img src="img/logo.svg" alt=""><span>Fry3DX</span></a>
<nav>
<a href="#features">Features</a>
<a href="#look">Look</a>
<a href="#get">Get it</a>
<a href="#versions">Versions</a>
<a class="gh" id="gh-top" href="#">GitHub</a>
</nav>
</header>
<nav class="nv-nav" id="top-nav" aria-label="Main">
<a class="nv-nav__brand" href="#top"><img class="brand-logo" src="img/logo.svg" alt=""> Fry3DX</a>
<ul class="nv-nav__list nav-links">
<li><a class="nv-nav__item" href="#features">Features</a></li>
<li><a class="nv-nav__item" href="#look">Look</a></li>
<li><a class="nv-nav__item" href="#scripting">Scripting</a></li>
<li><a class="nv-nav__item" href="#get">Get it</a></li>
<li><a class="nv-nav__item" href="#versions">Versions</a></li>
</ul>
<span class="nv-nav__spacer"></span>
<div class="nv-button-group theme-switch" role="group" aria-label="Appearance">
<button class="nv-button nv-button--sm" data-nv-theme="light" aria-pressed="false">Light</button>
<button class="nv-button nv-button--sm" data-nv-theme="dark" aria-pressed="false">Dark</button>
<button class="nv-button nv-button--sm" data-nv-theme="system" aria-pressed="true">Auto</button>
</div>
<a class="nv-button nv-button--sm nv-button--ghost" id="gh-top" href="#">GitHub</a>
<a class="nv-button nv-button--sm nv-button--primary" href="#get">Download</a>
</nav>
<main>
<section class="hero">
<div class="hero-text">
<p class="kicker" id="long"></p>
<h1 id="tagline"></h1>
<p class="lead" id="intro"></p>
<div class="buttons">
<a class="btn hot" href="#get">Get Fry3DX</a>
<a class="btn" id="gh-hero" href="#">Source on GitHub</a>
<main id="top">
<header class="shell hero">
<div class="hero__copy">
<span class="nv-badge nv-badge--accent"><span class="nv-badge__dot"></span><span id="latest"></span></span>
<p class="nv-overline" id="long"></p>
<h1 class="nv-display hero__title" id="tagline"></h1>
<p class="nv-prose nv-text-secondary" id="intro"></p>
<div class="nv-cluster">
<a class="nv-button nv-button--primary nv-button--lg" href="#get">Get Fry3DX</a>
<a class="nv-button nv-button--lg" id="gh-hero" href="#">Source on GitHub</a>
</div>
<div class="nv-cluster nv-cluster--tight" id="platforms"></div>
</div>
<div class="nv-bed hero__bed">
<div class="nv-window app-window">
<div class="nv-window__titlebar">
<div class="nv-window__controls">
<span class="nv-window__control nv-window__control--close" aria-hidden="true"></span>
<span class="nv-window__control nv-window__control--minimize" aria-hidden="true"></span>
<span class="nv-window__control nv-window__control--zoom" aria-hidden="true"></span>
</div>
<span class="nv-window__title">Fry3DX — kitchen.fry</span>
</div>
<img class="app-window__shot" src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate">
</div>
</div>
<figure class="hero-shot"><img src="img/model.webp" alt="Fry3DX with a table, chair, barrel and crate"></figure>
</section>
</header>
<section class="why" id="why"></section>
<section id="features">
<h2>What it does</h2>
<div class="grid" id="feature-grid"></div>
</section>
<section id="look">
<h2>How it looks</h2>
<div class="tabs" id="shot-tabs" role="tablist"></div>
<figure class="shot"><img id="shot-img" alt=""><figcaption id="shot-text"></figcaption></figure>
</section>
<section class="two">
<div>
<h2>Files</h2>
<div id="formats"></div>
</div>
<div>
<h2>Keys that make sense</h2>
<table class="keys" id="keys"></table>
<section class="shell section">
<div class="nv-card nv-card--inset why">
<div class="nv-card__body nv-stack">
<h2 class="nv-heading" id="why-title"></h2>
<div class="nv-prose nv-stack" id="why-text"></div>
</div>
</div>
</section>
<section id="get">
<h2>Get it</h2>
<section class="shell section" id="features">
<div class="section__head">
<span class="nv-overline">What it does</span>
<h2 class="nv-heading">The useful parts of the big modellers, without the hundred menus.</h2>
</div>
<div class="features" id="feature-grid"></div>
</section>
<section class="shell section" id="look">
<div class="section__head">
<span class="nv-overline">How it looks</span>
<h2 class="nv-heading">Five tabs: what you're doing, not where it's hidden.</h2>
</div>
<div class="nv-nav-tabs" role="tablist" aria-label="Screenshots" id="shot-tabs" data-nv-tabs></div>
<div id="shot-panels" class="shots"></div>
</section>
<section class="shell section" id="scripting" hidden>
<div class="split">
<div class="section__head">
<span class="nv-overline">Scripting</span>
<h2 class="nv-heading" id="script-title"></h2>
<p class="nv-prose nv-text-secondary" id="script-text"></p>
<ul class="ticks" id="script-points"></ul>
</div>
<div class="nv-card nv-card--elevated code-card">
<div class="nv-card__header code-card__head">
<span class="nv-overline" id="script-file"></span>
<button class="nv-button nv-button--sm nv-button--ghost" data-copy="script-code">Copy</button>
</div>
<pre class="code" id="script-code"></pre>
</div>
</div>
</section>
<section class="shell section split">
<div class="nv-card">
<div class="nv-card__header"><h2 class="nv-card__title">Files</h2></div>
<div class="nv-card__body nv-stack" id="formats"></div>
</div>
<div class="nv-card">
<div class="nv-card__header"><h2 class="nv-card__title">Keys that make sense</h2></div>
<div class="nv-table-frame"><table class="nv-table nv-table--compact"><tbody id="keys"></tbody></table></div>
</div>
</section>
<section class="shell section" id="get">
<div class="section__head">
<span class="nv-overline">Get it</span>
<h2 class="nv-heading">Free, open source, for the computer you've got.</h2>
</div>
<div class="downloads" id="downloads"></div>
<p id="build-text"></p>
<div class="code"><button class="copy" id="copy">Copy</button><pre id="build-steps"></pre></div>
<p class="muted" id="build-needs"></p>
<div class="nv-card nv-card--inset build">
<div class="nv-card__body nv-stack">
<p class="nv-body" id="build-text"></p>
<div class="code-card">
<div class="code-card__head">
<span class="nv-overline">Terminal</span>
<button class="nv-button nv-button--sm nv-button--ghost" data-copy="build-steps">Copy</button>
</div>
<pre class="code" id="build-steps"></pre>
</div>
<p class="nv-caption nv-text-tertiary" id="build-needs"></p>
</div>
</div>
</section>
<section id="versions">
<h2>Versions</h2>
<div id="version-list"></div>
<section class="shell section" id="versions">
<div class="section__head">
<span class="nv-overline">Versions</span>
<h2 class="nv-heading">What's new</h2>
</div>
<div class="nv-disclosure-group" id="version-list"></div>
</section>
</main>
<footer>
<p>Fry3DX, made by <a id="author" href="#"></a>.</p>
<p class="muted">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0) · Fonts: Inter, JetBrains Mono (OFL).</p>
<footer class="shell footer">
<img class="footer__logo" src="img/logo.svg" alt="">
<div class="nv-stack nv-stack--tight">
<p class="nv-body">Fry3DX, made by <a id="author" href="#"></a>.</p>
<p class="nv-caption nv-text-tertiary">Open source under the Apache License 2.0 · Sky pictures from Poly Haven (CC0) ·
Fonts: Instrument Sans, JetBrains Mono (OFL) · Look: <a href="https://github.com/Nexoniarz/niveus">Niveus</a>.</p>
</div>
</footer>
<script src="content.js"></script>

4163
docs/niveus/niveus.css Normal file

File diff suppressed because it is too large Load diff

353
docs/niveus/niveus.js Normal file
View file

@ -0,0 +1,353 @@
/* ==========================================================================
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, scope }) 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
Only what changes is lifted. A transition names the selection marker and
the items of the groups it is given as `scope`, and only for as long as it
runs; everything else stays in the one page snapshot. Naming every item on
the page permanently made every transition — a theme change included —
tear the page into dozens of layers, which is both slow and what pulled
items out from under frosted chrome.
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;
* `scope` (an element or a list of them) names the selection groups whose
* marker should travel. Without a scope, nothing is lifted and the change
* cross-fades in place.
*/
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 groups = [].concat(options.scope || []).filter(Boolean);
var lifted = [];
groups.forEach(function (group) {
group.setAttribute("data-nv-travelling", "");
lifted = lifted.concat(liftLabels(group));
});
var view = document.startViewTransition(update);
function clear() {
delete root.dataset.nvDirection;
delete root.dataset.nvTransition;
groups.forEach(function (group) { group.removeAttribute("data-nv-travelling"); });
lifted.forEach(function (item) { item.style.viewTransitionName = ""; });
}
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();
// The outgoing and incoming panels carry one name between them — the old
// one in the old state, the new one in the new — so the browser treats
// them as one surface whose content changes. Named only for the length of
// the change, so any number of tab sets can live on one page.
var panelOf = function (candidate) {
return candidate ? document.getElementById(candidate.getAttribute("aria-controls")) : null;
};
var outgoing = panelOf(tabs[current]);
var incoming = panelOf(tab);
if (outgoing && canTransition()) outgoing.style.viewTransitionName = "nv-panel";
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 = panelOf(candidate);
if (panel) panel.hidden = !selected;
});
if (outgoing) outgoing.style.viewTransitionName = "";
if (incoming && canTransition()) incoming.style.viewTransitionName = "nv-panel";
}, { direction: next > current ? "forward" : "backward", scope: tablist });
var settle = function () { if (incoming) incoming.style.viewTransitionName = ""; };
done.then(settle, settle);
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 `scope` 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. Returns the items it named, so the caller can take
* the names back off; transition() does this for its `scope` and clears
* them when the change is over.
*
* This cannot live in CSS: a view-transition-name must be unique across the
* whole document, and a stylesheet cannot count items. Items already
* carrying a name are left alone, so an application can assign its own.
*/
var carried = 0;
function liftLabels(scope) {
var items = scope.querySelectorAll(".nv-nav__item");
var named = [];
for (var j = 0; j < items.length; j++) {
if (items[j].style.viewTransitionName) continue;
carried += 1;
items[j].style.viewTransitionName = "nv-carried-" + carried;
named.push(items[j]);
}
return named;
}
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);
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);

142
docs/site.css Normal file
View file

@ -0,0 +1,142 @@
/* Fry3DX's site: layout only. Every colour, shadow, radius, type size and duration comes from
Niveus (niveus/niveus.css); if a hex value turns up here, Niveus is missing a token. */
html { scroll-padding-top: 5rem; }
body { background: var(--nv-color-background); }
.shell {
max-inline-size: var(--nv-layout-max-width);
margin-inline: auto;
padding-inline: var(--nv-layout-gutter);
}
.section { padding-block: var(--nv-space-16); }
.section__head {
max-inline-size: var(--nv-measure);
margin-block-end: var(--nv-space-8);
display: flex;
flex-direction: column;
gap: var(--nv-space-3);
}
/* --- Bar ------------------------------------------------------------------- */
.brand-logo { inline-size: 1.75rem; block-size: 1.75rem; }
@media (max-width: 60rem) { .nav-links { display: none; } }
@media (max-width: 34rem) { .theme-switch, #gh-top { display: none; } }
/* --- Hero ------------------------------------------------------------------ */
.hero {
display: grid;
gap: var(--nv-space-12);
padding-block: var(--nv-space-16) var(--nv-space-8);
align-items: center;
}
@media (min-width: 64rem) {
.hero { grid-template-columns: 5fr 7fr; gap: var(--nv-space-12); }
}
.hero__copy {
display: flex;
flex-direction: column;
gap: var(--nv-space-5);
align-items: flex-start;
}
.hero__title { font-size: clamp(var(--nv-font-size-4xl), 5.5vw, var(--nv-font-size-5xl)); }
.hero__bed { padding: var(--nv-space-6); min-inline-size: 0; }
.app-window { max-inline-size: 100%; }
.app-window__shot, .shot__img {
display: block;
inline-size: 100%;
block-size: auto;
}
/* --- Why ------------------------------------------------------------------- */
.why { max-inline-size: 52rem; margin-inline: auto; }
/* --- Features -------------------------------------------------------------- */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(17rem, 100%), 1fr));
gap: var(--nv-space-5);
}
.feature__icon {
inline-size: var(--nv-space-10);
block-size: var(--nv-space-10);
display: grid;
place-items: center;
margin-block-end: var(--nv-space-4);
border-radius: var(--nv-radius-md);
background-color: var(--nv-color-accent-subtle);
color: var(--nv-color-accent-text);
}
.feature__icon svg { inline-size: var(--nv-space-5); block-size: var(--nv-space-5); }
.feature .nv-card__title { margin-block-end: var(--nv-space-2); }
/* --- Screenshots ----------------------------------------------------------- */
.shots { margin-block-start: var(--nv-space-6); }
.shot { display: grid; gap: var(--nv-space-4); }
.shot[hidden] { display: none; }
/* --- Two side by side -------------------------------------------------------- */
.split {
display: grid;
gap: var(--nv-space-6);
align-items: start;
}
@media (min-width: 56rem) { .split { grid-template-columns: 1fr 1fr; } }
.ticks { display: grid; gap: var(--nv-space-2); padding-inline-start: var(--nv-space-5); }
/* --- Code -------------------------------------------------------------------- */
.code-card { overflow: hidden; }
.code-card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--nv-space-3);
}
.code {
margin: 0;
padding: var(--nv-space-4);
overflow-x: auto;
border-radius: var(--nv-radius-md);
background: var(--nv-color-surface-inset);
box-shadow: var(--nv-elevation-inset-sm);
font: var(--nv-font-code);
color: var(--nv-color-text-primary);
}
.code-card > .code { margin: 0 var(--nv-space-4) var(--nv-space-4); }
.code .k { color: var(--nv-color-accent-text); }
.code .c { color: var(--nv-color-text-tertiary); }
.code .s { color: var(--nv-color-success-text); }
/* --- Files and keys ------------------------------------------------------------ */
.fmt { display: grid; gap: var(--nv-space-2); }
kbd {
display: inline-block;
padding: var(--nv-space-0-5) var(--nv-space-2);
border-radius: var(--nv-radius-sm);
background: var(--nv-color-surface-elevated);
background-image: var(--nv-sheen-raised);
box-shadow: var(--nv-bevel), var(--nv-outline), var(--nv-elevation-1);
font: var(--nv-font-code);
white-space: nowrap;
}
/* --- Downloads ----------------------------------------------------------------- */
.downloads {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
gap: var(--nv-space-5);
margin-block-end: var(--nv-space-8);
}
.download .nv-card__body { display: grid; gap: var(--nv-space-2); }
.download__file { overflow-wrap: anywhere; }
/* --- Footer -------------------------------------------------------------------- */
.footer {
display: flex;
gap: var(--nv-space-4);
align-items: center;
padding-block: var(--nv-space-12);
border-block-start: var(--nv-border-width) solid var(--nv-color-border-subtle);
}
.footer__logo { inline-size: var(--nv-space-12); block-size: var(--nv-space-12); }

View file

@ -9,91 +9,176 @@
return e;
};
// Feature icons: 24px line drawings in the text's colour.
const ICONS = {
cube: '<path d="M12 3 20 7.5v9L12 21l-8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/>',
pen: '<path d="M4 20h4L19 9l-4-4L4 16z"/><path d="m13.5 6.5 4 4"/>',
combine: '<circle cx="9" cy="12" r="5.5"/><circle cx="15" cy="12" r="5.5"/>',
points: '<rect x="5" y="5" width="14" height="14" rx="1"/><circle cx="5" cy="5" r="1.6" fill="currentColor"/><circle cx="19" cy="5" r="1.6" fill="currentColor"/><circle cx="5" cy="19" r="1.6" fill="currentColor"/><circle cx="19" cy="19" r="1.6" fill="currentColor"/>',
hole: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="m12 7 4.3 2.5v5L12 17l-4.3-2.5v-5z"/><circle cx="12" cy="12" r="1.8"/>',
copies: '<rect x="3" y="9" width="6" height="6" rx="1"/><rect x="10" y="9" width="6" height="6" rx="1"/><rect x="17" y="9" width="4" height="6" rx="1"/>',
brush: '<path d="M14.5 4.5 19.5 9.5 11 18l-5-5z"/><path d="M6 13c-2 0-3 1.5-3 3.5S2 20 2 20s3.5.5 5.5-.5S9 17 9 16"/>',
layout: '<rect x="3.5" y="3.5" width="17" height="17" rx="2"/><path d="M3.5 12h17M12 3.5v17"/>',
print: '<path d="M7 9V4h10v5"/><rect x="3.5" y="9" width="17" height="8" rx="2"/><path d="M7 14h10v6H7z"/>',
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2.5v2.5M12 19v2.5M2.5 12H5M19 12h2.5M5.3 5.3l1.8 1.8M16.9 16.9l1.8 1.8M5.3 18.7l1.8-1.8M16.9 7.1l1.8-1.8"/>',
folder: '<path d="M3.5 6.5a1 1 0 0 1 1-1h5l2 2h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1z"/>',
phone: '<rect x="7" y="2.5" width="10" height="19" rx="2.5"/><path d="M11 18.5h2"/>',
save: '<path d="M4 12a8 8 0 1 0 2.4-5.7"/><path d="M4 4v4h4M12 8v4l3 2"/>',
code: '<path d="m8 7-5 5 5 5M16 7l5 5-5 5M13.5 4l-3 16"/>',
sculpt: '<path d="M4 18c3-1 4-6 8-6s5 5 8 6"/><circle cx="12" cy="7" r="3"/>',
sphere: '<circle cx="12" cy="12" r="8.5"/><path d="M3.5 12c2.5 2 14.5 2 17 0"/><circle cx="9" cy="8.5" r="1.5" fill="currentColor" stroke="none"/>',
};
const icon = (name) => {
const s = el("span", "feature__icon");
s.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name] || ICONS.cube}</svg>`;
return s;
};
// ── Hero ──
$("latest").textContent = `Version ${F.versions[0].v}: ${F.versions[0].items[0]}`;
$("long").textContent = F.long;
$("tagline").textContent = F.tagline;
$("intro").textContent = F.intro;
for (const id of ["gh-top", "gh-hero"]) $(id).href = F.repo;
for (const d of F.downloads) $("platforms").append(el("span", "nv-badge", d.os));
$("author").textContent = F.author;
$("author").href = F.authorUrl;
const why = $("why");
why.append(el("h2", null, F.why.title));
for (const p of F.why.text) why.append(el("p", null, p));
$("why-title").textContent = F.why.title;
for (const p of F.why.text) $("why-text").append(el("p", "nv-body", p));
const grid = $("feature-grid");
// ── Features ──
for (const f of F.features) {
const card = el("article", "card");
card.append(el("div", "icon", f.icon), el("h3", null, f.title), el("p", null, f.text));
grid.append(card);
const card = el("article", "nv-card feature");
const body = el("div", "nv-card__body");
body.append(icon(f.icon), el("h3", "nv-card__title", f.title), el("p", "nv-body nv-text-secondary", f.text));
card.append(body);
$("feature-grid").append(card);
}
// Screenshots: a tab each.
const tabs = $("shot-tabs");
const show = (i) => {
const s = F.shots[i];
$("shot-img").src = s.img;
$("shot-img").alt = s.title;
$("shot-text").textContent = s.text;
[...tabs.children].forEach((b, j) => b.setAttribute("aria-selected", i === j));
};
// ── Screenshots: a tab each, the picture in a window ──
F.shots.forEach((s, i) => {
const b = el("button", "tab", s.title);
b.setAttribute("role", "tab");
b.onclick = () => show(i);
tabs.append(b);
new Image().src = s.img; // (loaded ahead, so switching is instant)
});
show(0);
const tab = el("button", "nv-nav__item", s.title);
Object.assign(tab, { id: `shot-tab-${i}`, role: "tab", tabIndex: i ? -1 : 0 });
tab.setAttribute("role", "tab");
tab.setAttribute("aria-controls", `shot-${i}`);
tab.setAttribute("aria-selected", String(i === 0));
$("shot-tabs").append(tab);
const fm = $("formats");
const panel = el("div", "nv-tab-panel shot");
Object.assign(panel, { id: `shot-${i}`, hidden: i !== 0 });
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tab.id);
const win = el("div", "nv-window");
const bar = el("div", "nv-window__titlebar");
const lights = el("div", "nv-window__controls");
for (const k of ["close", "minimize", "zoom"]) {
const l = el("span", `nv-window__control nv-window__control--${k}`);
l.setAttribute("aria-hidden", "true");
lights.append(l);
}
bar.append(lights, el("span", "nv-window__title", `Fry3DX — ${s.title}`));
const img = el("img", "shot__img");
Object.assign(img, { src: s.img, alt: s.title, loading: i ? "lazy" : "eager" });
win.append(bar, img);
panel.append(win, el("p", "nv-body nv-text-secondary", s.text));
$("shot-panels").append(panel);
});
// ── Scripting ──
if (F.scripting) {
const S = F.scripting;
$("scripting").hidden = false;
$("script-title").textContent = S.title;
$("script-text").textContent = S.text;
for (const p of S.points) $("script-points").append(el("li", "nv-body", p));
$("script-file").textContent = S.file;
$("script-code").innerHTML = highlightLua(S.code);
}
// ── Files and keys ──
const row = (label, items) => {
const d = el("div", "fmt");
d.append(el("span", "fmt-label", label));
for (const x of items) d.append(el("span", "chip", x));
fm.append(d);
d.append(el("span", "nv-overline", label));
const c = el("div", "nv-cluster nv-cluster--tight");
for (const x of items) c.append(el("span", "nv-badge", x));
d.append(c);
$("formats").append(d);
};
row("Save", F.formats.save);
row("Export", F.formats.export);
fm.append(el("p", "muted", F.formats.note));
const keys = $("keys");
$("formats").append(el("p", "nv-caption nv-text-tertiary", F.formats.note));
for (const [k, what] of F.keys) {
const tr = el("tr");
const td = el("td");
td.append(el("kbd", null, k));
tr.append(td, el("td", null, what));
keys.append(tr);
tr.append(td, el("td", "nv-body", what));
$("keys").append(tr);
}
const dl = $("downloads");
// ── Downloads and building ──
for (const d of F.downloads) {
const a = el("a", "download");
const a = el("a", "nv-card nv-card--interactive download");
a.href = F.releases;
a.append(el("strong", null, d.os), el("span", "muted", d.what), el("span", "file", "Fry3DX-…-" + d.file), el("span", null, d.how));
dl.append(a);
const b = el("div", "nv-card__body");
b.append(el("h3", "nv-card__title", d.os), el("p", "nv-caption nv-text-tertiary", d.what),
el("code", "nv-code download__file", "Fry3DX-…-" + d.file), el("p", "nv-body nv-text-secondary", d.how));
a.append(b);
$("downloads").append(a);
}
$("build-text").textContent = F.build.text;
$("build-steps").textContent = F.build.steps.join("\n");
$("build-needs").textContent = F.build.needs;
$("copy").onclick = async () => {
try {
await navigator.clipboard.writeText(F.build.steps.join("\n"));
$("copy").textContent = "Copied";
} catch {
$("copy").textContent = "Select and copy";
}
setTimeout(() => ($("copy").textContent = "Copy"), 1800);
};
for (const btn of document.querySelectorAll("[data-copy]")) {
btn.onclick = async () => {
try {
await navigator.clipboard.writeText($(btn.dataset.copy).textContent);
btn.textContent = "Copied";
} catch {
btn.textContent = "Select and copy";
}
setTimeout(() => (btn.textContent = "Copy"), 1800);
};
}
const vl = $("version-list");
for (const v of F.versions) {
const d = el("div", "version");
const h = el("h3");
h.append(el("span", "v", "v" + v.v), el("span", "muted", " " + v.date));
const ul = el("ul");
for (const it of v.items) ul.append(el("li", null, it));
d.append(h, ul);
vl.append(d);
// ── Versions ──
F.versions.forEach((v, i) => {
const d = el("details", "nv-disclosure");
d.open = i === 0;
const s = el("summary", "nv-disclosure__summary");
s.append(el("span", "nv-title", "v" + v.v), el("span", "nv-caption nv-text-tertiary", " " + v.date));
const ch = el("span", "nv-disclosure__chevron");
ch.setAttribute("aria-hidden", "true");
s.append(ch);
const body = el("div", "nv-disclosure__body");
const ul = el("ul", "ticks");
for (const it of v.items) ul.append(el("li", "nv-body", it));
body.append(ul);
d.append(s, body);
$("version-list").append(d);
});
// ── The appearance, remembered; the bar's shadow once scrolled ──
document.documentElement.addEventListener("nv:theme", (e) => {
try { localStorage.setItem("fry-theme", e.detail?.theme ?? "system"); } catch (_) {}
});
const saved = (() => { try { return localStorage.getItem("fry-theme"); } catch (_) { return null; } })();
if (saved) addEventListener("DOMContentLoaded", () => window.nv?.syncThemeControls(saved));
const nav = $("top-nav");
const onScroll = () => nav.toggleAttribute("data-scrolled", scrollY > 4);
addEventListener("scroll", onScroll, { passive: true });
onScroll();
// A little Lua colouring for the scripting sample.
function highlightLua(src) {
const esc = (t) => t.replace(/&/g, "&amp;").replace(/</g, "&lt;");
const words = /\b(local|function|end|for|in|do|if|then|else|elseif|return|while|and|or|not|nil|true|false)\b/g;
return src.split("\n").map((line) => {
const at = line.indexOf("--");
const code = at >= 0 ? line.slice(0, at) : line, comment = at >= 0 ? line.slice(at) : "";
const parts = code.split(/("[^"]*")/);
const out = parts.map((p) => (p.startsWith('"') ? `<span class="s">${esc(p)}</span>` : esc(p).replace(words, '<span class="k">$1</span>'))).join("");
return out + (comment ? `<span class="c">${esc(comment)}</span>` : "");
}).join("\n");
}
})();

View file

@ -1,111 +0,0 @@
/* The site's own fonts (SIL Open Font License, fonts/OFL-*.txt), so it looks the same everywhere. */
@font-face { font-family: "Inter"; src: url("fonts/inter-latin-wght-normal.woff2") format("woff2"); font-weight: 100 900; font-display: swap; }
@font-face { font-family: "JetBrains Mono"; src: url("fonts/jetbrains-mono-latin-wght-normal.woff2") format("woff2"); font-weight: 100 800; font-display: swap; }
:root {
--bg: #161618;
--panel: #1f1f23;
--panel2: #26262b;
--line: #34343b;
--ink: #ecebe6;
--dim: #9c9a94;
--hot: #ff7828;
--hot2: #ffc24a;
--radius: 10px;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 16px/1.6 "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
a { color: var(--hot); text-decoration: none; }
a:hover { text-decoration: underline; }
img { max-width: 100%; display: block; }
h1, h2, h3 { line-height: 1.2; margin: 0 0 .6em; }
h2 { font-size: 1.7rem; }
h2::after { content: ""; display: block; width: 48px; height: 4px; margin-top: .4em; border-radius: 2px; background: linear-gradient(90deg, var(--hot), var(--hot2)); }
.muted { color: var(--dim); }
.top {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 10px max(16px, calc((100% - 1120px) / 2));
background: rgba(22, 22, 24, .9); backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; color: var(--ink); font-weight: 700; font-size: 1.15rem; }
.brand img { width: 32px; height: 32px; }
.brand:hover { text-decoration: none; }
nav { display: flex; gap: 18px; flex-wrap: wrap; justify-content: flex-end; }
nav a { color: var(--dim); font-size: .95rem; }
nav a:hover { color: var(--ink); text-decoration: none; }
nav .gh { color: var(--hot); }
main { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
section { padding: 56px 0; border-bottom: 1px solid var(--line); }
section:last-child { border-bottom: 0; }
.hero { display: grid; grid-template-columns: 1fr 1.25fr; gap: 40px; align-items: center; padding-top: 64px; }
.kicker { color: var(--hot); font-weight: 600; letter-spacing: .08em; text-transform: uppercase; font-size: .85rem; margin: 0 0 .6em; }
.hero h1 { font-size: clamp(2rem, 4.5vw, 3.2rem); }
.lead { color: var(--dim); font-size: 1.1rem; }
.buttons { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 24px; }
.btn { display: inline-block; padding: 12px 20px; border-radius: var(--radius); background: var(--panel2); color: var(--ink); font-weight: 600; border: 1px solid var(--line); }
.btn:hover { text-decoration: none; border-color: var(--hot); }
.btn.hot { background: linear-gradient(135deg, var(--hot), #ff4a1c); border-color: transparent; color: #fff; }
.hero-shot { margin: 0; }
.hero-shot img, .shot img { border-radius: var(--radius); border: 1px solid var(--line); box-shadow: 0 20px 60px rgba(0, 0, 0, .45); }
.why { max-width: 760px; }
.why p { font-size: 1.08rem; color: #cfcdc7; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 20px; transition: border-color .15s, transform .15s; }
.card:hover { border-color: var(--hot); transform: translateY(-2px); }
.card .icon { font-size: 1.5rem; color: var(--hot); margin-bottom: 6px; }
.card h3 { font-size: 1.05rem; }
.card p { margin: 0; color: var(--dim); font-size: .95rem; }
.tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
.tab { font: inherit; color: var(--dim); background: var(--panel); border: 1px solid var(--line); border-radius: 999px; padding: 6px 16px; cursor: pointer; }
.tab:hover { color: var(--ink); }
.tab[aria-selected="true"] { color: #fff; background: var(--hot); border-color: var(--hot); }
.shot { margin: 0; }
.shot figcaption { color: var(--dim); margin-top: 12px; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.fmt { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; }
.fmt-label { width: 64px; color: var(--dim); }
.chip { background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 2px 10px; font-size: .9rem; }
.keys { border-collapse: collapse; width: 100%; }
.keys td { padding: 7px 0; border-bottom: 1px solid var(--line); }
.keys td:first-child { width: 45%; }
kbd { font: .85rem "JetBrains Mono", ui-monospace, Consolas, monospace; background: var(--panel2); border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 5px; padding: 2px 7px; }
.downloads { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 28px; }
.download { display: flex; flex-direction: column; gap: 4px; padding: 18px 20px; border-radius: var(--radius); background: var(--panel); border: 1px solid var(--line); color: var(--ink); }
.download:hover { text-decoration: none; border-color: var(--hot); }
.download strong { font-size: 1.15rem; color: var(--hot); }
.download .file { font: .85rem "JetBrains Mono", ui-monospace, Consolas, monospace; color: #cfcdc7; }
.code { position: relative; background: #0f0f11; border: 1px solid var(--line); border-left: 3px solid var(--hot); border-radius: var(--radius); }
.code pre { margin: 0; padding: 18px 20px; overflow-x: auto; font: .92rem/1.7 "JetBrains Mono", ui-monospace, Consolas, monospace; color: #d6d4cd; }
.copy { position: absolute; top: 10px; right: 10px; font: inherit; font-size: .85rem; color: var(--ink); background: var(--panel2); border: 1px solid var(--line); border-radius: 6px; padding: 4px 12px; cursor: pointer; }
.copy:hover { border-color: var(--hot); }
.version { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 22px; margin-bottom: 12px; }
.version h3 { margin-bottom: .3em; }
.version .v { color: var(--hot); }
.version ul { margin: 0; padding-left: 20px; color: #cfcdc7; }
footer { text-align: center; padding: 40px 16px; border-top: 1px solid var(--line); color: var(--dim); }
footer p { margin: 4px 0; }
@media (max-width: 820px) {
.hero, .two { grid-template-columns: 1fr; }
.hero { padding-top: 32px; gap: 28px; }
nav a:not(.gh) { display: none; }
section { padding: 40px 0; }
}