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
@ -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; }
}