Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 45 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ permissions:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Finish a main run's Pages deployment before starting its successor.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

jobs:
ubuntu-checks:
Expand Down Expand Up @@ -210,17 +211,17 @@ jobs:
target/debug/deps
target/debug/build
target/debug/.fingerprint
target/web/deps
target/web/build
target/web/.fingerprint
target/release/deps
target/release/build
target/release/.fingerprint
target/wasm32-unknown-unknown/debug/deps
target/wasm32-unknown-unknown/debug/build
target/wasm32-unknown-unknown/debug/.fingerprint
target/wasm32-unknown-unknown/web/deps
target/wasm32-unknown-unknown/web/build
target/wasm32-unknown-unknown/web/.fingerprint
target/wasm32-unknown-unknown/release/deps
target/wasm32-unknown-unknown/release/build
target/wasm32-unknown-unknown/release/.fingerprint
target/.rustc_info.json
key: n3-v1-wasm-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }}
key: n3-v2-wasm-release-${{ runner.os }}-${{ runner.arch }}-${{ steps.environment.outputs.platform }}-${{ hashFiles('rust-toolchain.toml', 'Cargo.toml', 'Cargo.lock', 'vendor/**') }}
- name: Restore pinned browser build tools
id: tools
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -239,10 +240,18 @@ jobs:
just web-setup
# Compile and generate glue on every run. The native lanes continue to
# own behavioral tests and exact guide receipts; this job needs no GPU.
- name: Lint the WASM target and generate the browser application
run: just web-verify
- name: Test and lint WASM, then build the optimized browser application
run: |
just web-test
just web-lint
just web-build --profile release
- name: Check the optional WASM measurement adapter
run: just benchmark-web-check
- name: Upload the production site for GitHub Pages
if: github.repository == 'gridaco/n3' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: build/web
- name: Save verified WASM dependencies and build output
if: success() && github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.dependencies.outputs.cache-hit != 'true'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
Expand All @@ -253,15 +262,15 @@ jobs:
target/debug/deps
target/debug/build
target/debug/.fingerprint
target/web/deps
target/web/build
target/web/.fingerprint
target/release/deps
target/release/build
target/release/.fingerprint
target/wasm32-unknown-unknown/debug/deps
target/wasm32-unknown-unknown/debug/build
target/wasm32-unknown-unknown/debug/.fingerprint
target/wasm32-unknown-unknown/web/deps
target/wasm32-unknown-unknown/web/build
target/wasm32-unknown-unknown/web/.fingerprint
target/wasm32-unknown-unknown/release/deps
target/wasm32-unknown-unknown/release/build
target/wasm32-unknown-unknown/release/.fingerprint
target/.rustc_info.json
key: ${{ steps.dependencies.outputs.cache-primary-key }}
- name: Save verified browser build tools
Expand All @@ -276,3 +285,23 @@ jobs:
.cache/web-tools/.crates.toml
.cache/web-tools/.crates2.json
key: ${{ steps.tools.outputs.cache-primary-key }}

pages:
name: Publish web app to GitHub Pages
if: github.repository == 'gridaco/n3' && github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [ubuntu-checks, ubuntu-guide, macos-native, wasm]
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Read the configured Pages site
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Deploy the verified site artifact
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ N3 is open source, macOS-first, and in early development.

![N3 workspace with Layers, a 3D viewport, and live Properties](docs/guide/assets/workspace-layout.webp)

[Open web app](https://gridaco.github.io/n3/) ·
[User guide](docs/guide/README.md) · [Animated walkthroughs](docs/guide/gizmo.md) ·
[Contributing](CONTRIBUTING.md)

Expand Down Expand Up @@ -55,6 +56,10 @@ generated from the application, with visible cursor, key, and gesture cues.

## Try N3

[Open N3 in your browser](https://gridaco.github.io/n3/). Use a desktop browser
with WebGPU available. The web app follows verified `main` builds and shares the
editor UI and guide; native-only capabilities such as the shell are omitted.

Run N3 from source on macOS. Install Rust through `rustup`, `just` (verified with
1.46.0), and the macOS command-line developer tools. The repository selects Rust
1.98.1 through `rust-toolchain.toml`. From the repository root:
Expand Down
35 changes: 30 additions & 5 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,21 +95,24 @@ just web-setup
just web-check
just web-verify
just web-build
just web-build --profile release
just web
```

`web-setup` installs the `wasm32-unknown-unknown` target for the repository's Rust
toolchain and the matching `wasm-bindgen-cli` version, 0.2.129, under
`.cache/web-tools`. `web-check` checks the library for that target with the locked
dependency graph. `web-build` uses the `web` Cargo profile and writes the static
dependency graph. `web-build` defaults to the `web` development Cargo profile;
`--profile release` selects the optimized production build. Both write the static
application to ignored `build/web/`, including `pkg/n3.js`, `pkg/n3_bg.wasm`, and
the bundled font licenses.
It requires no Node, bundler, React installation, Docker, or application server.

`web-verify` runs the wrapper lifecycle regressions with Node.js 24, Clippy with
warnings denied for the WASM library, and the complete static-site build. A
dedicated Ubuntu WASM CI job installs the optional tooling and runs this gate;
it supplements the native jobs without requiring a browser GPU on that runner.
dedicated Ubuntu WASM CI job runs the same wrapper tests and lint, builds the
release artifact, and checks the optional measurement adapter. It supplements the
native jobs without requiring a browser GPU on that runner.

The pinned `egui-winit` 0.36.2 adapter requires narrow WASM compatibility patches
for its dropped-file trait and browser OS modifier mapping. The maintained source, upstream
Expand All @@ -127,8 +130,8 @@ just web --no-open --port 8001
Keep the server running while using the application; Ctrl-C stops it. A desktop
browser with WebGPU enabled and available is required. Remote hosting requires
HTTPS; opening the generated HTML as a local `file:` URL is not the supported
launch path. The output is static and can be copied to an appropriate host when
deployment is separately requested.
launch path. The output is static; module and WASM URLs resolve relative to the
site, including when it is hosted under a repository subpath.

Target compilation and browser runtime checks are separate from `just verify`.
The native verification command continues to check shared behavior and the
Expand All @@ -152,6 +155,28 @@ the input adapter exists. Keep subsequent focus events ordered through the usual
adapter. The local server disables HTTP caching, but an already-open tab retains
its loaded WASM until reloaded after a rebuild.

### GitHub Pages

The canonical repository publishes the web app through
[the CI workflow](../.github/workflows/ci.yml). Its WASM job packages only
`build/web/` as a GitHub Pages artifact. The deployment job runs for a push to
`gridaco/n3`'s `main` branch after all four CI lanes succeed. Pull requests and
forks build and verify without publishing. Failed verification leaves the
previous deployment in place.

GitHub Pages uses the **GitHub Actions** source and the `github-pages` environment.
Only the deployment job receives Pages and OIDC write permissions. Main CI runs
finish before their successor starts, so a new push cannot interrupt an active
deployment. To retry a failed deployment, rerun the failed job while its artifact
is retained; otherwise rerun the entire main CI run to rebuild the artifact.

Generated WASM, JavaScript glue, and site copies stay in ignored `build/` and
Actions artifacts. No deployment branch or generated commits are needed. The
published site tracks verified main development; it is not a versioned release.
See GitHub's [custom Pages workflows](https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages)
for the hosting contract. A new fork must explicitly configure its own site and
adjust the canonical-repository deployment conditions before publishing.

### Browser gestures

The browser host captures wheel and WebKit gesture events on its canvas, then
Expand Down
4 changes: 2 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,8 @@ web-test:
node --test tools/tests/web_wrapper.test.mjs

# Build the shared editor as WASM and write a self-contained site to build/web.
web-build:
python3 tools/web.py build
web-build *args:
python3 tools/web.py build "$@"

# Optional browser gate: lint Rust and generate runnable WASM/glue output.
web-verify: web-test web-lint web-build
Expand Down
86 changes: 63 additions & 23 deletions tools/tests/test_web.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from http.client import HTTPConnection
import io
import os
from pathlib import Path
import subprocess
Expand Down Expand Up @@ -106,16 +107,17 @@ def test_mismatched_generator_preserves_site_and_does_not_compile(self):
def test_compile_and_glue_failures_preserve_previous_site_and_remove_staging(self):
self.install_generator()
self.prepare_site()
for failure in ["cargo", str(self.generator)]:
with self.subTest(failure=failure):
for profile, failure in [(profile, failure) for profile in ("web", "release")
for failure in ("cargo", str(self.generator))]:
with self.subTest(profile=profile, failure=failure):
def run(command, **kwargs):
if command[0] == failure:
raise subprocess.CalledProcessError(1, command)

with patch.object(web.subprocess, "run", side_effect=run), patch.object(
web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}",
), self.assertRaises(subprocess.CalledProcessError):
web.build(self.root)
web.build(self.root, profile=profile)
self.assertEqual(
(self.root / "build/web/index.html").read_text(), "previous runnable wrapper",
)
Expand All @@ -124,6 +126,35 @@ def run(command, **kwargs):
)
self.assertEqual(list((self.root / "build").glob("web-stage-*")), [])

def test_invalid_api_profile_preserves_site_without_running_tools(self):
self.prepare_site()
for profile in ("dev", "debug", "", None):
with self.subTest(profile=profile), patch.object(web, "require_bindgen") as generator, patch.object(
web.subprocess, "run",
) as process, self.assertRaisesRegex(ValueError, "web or release"):
web.build(self.root, profile=profile)
generator.assert_not_called()
process.assert_not_called()
self.assertEqual(
(self.root / "build/web/pkg/n3_bg.wasm").read_text(), "previous runnable WASM",
)

def test_build_cli_defaults_to_web_and_restricts_explicit_profiles(self):
for arguments, expected in [
(["build"], "web"),
(["build", "--profile", "web"], "web"),
(["build", "--profile", "release"], "release"),
]:
with self.subTest(arguments=arguments), patch.object(web, "build") as build:
self.assertEqual(web.main(arguments), 0)
build.assert_called_once_with(profile=expected)
with patch.object(web, "build") as build, patch("sys.stderr", new_callable=io.StringIO), self.assertRaises(
SystemExit,
) as error:
web.main(["build", "--profile", "dev"])
self.assertEqual(error.exception.code, 2)
build.assert_not_called()

def test_successful_build_honors_cargo_overrides_and_ships_font_notices(self):
self.install_generator()
self.prepare_site()
Expand All @@ -141,26 +172,35 @@ def run(command, **kwargs):
(output / "n3.js").write_text("new glue", encoding="utf-8")
(output / "n3_bg.wasm").write_bytes(b"new WASM")

with patch.dict(os.environ, {"CARGO_HOME": cargo_home, "CARGO_TARGET_DIR": target_dir}), patch.object(
web.subprocess, "run", side_effect=run,
) as process, patch.object(
web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}",
), patch("builtins.print"):
web.build(self.root)
self.assertEqual(os.environ["CARGO_TARGET_DIR"], target_dir)
compile_step, glue = process.call_args_list
self.assertEqual(compile_step.kwargs["env"]["CARGO_HOME"], cargo_home)
self.assertEqual(compile_step.kwargs["env"]["CARGO_TARGET_DIR"], target_dir)
self.assertEqual(glue.args[0][1], str(Path(target_dir) / web.TARGET / "web/n3.wasm"))
site = self.root / "build/web"
self.assertEqual((site / "index.html").read_text(), "new wrapper")
self.assertEqual((site / "n3.js").read_text(), "new interface")
self.assertEqual((site / "pkg/n3_bg.wasm").read_bytes(), b"new WASM")
self.assertEqual((site / "licenses/Inter.txt").read_text(), "Inter notice")
self.assertEqual((site / "licenses/Lucide.txt").read_text(), "Lucide notice")
self.assertFalse((site / "measure.html").exists())
self.assertFalse((site / "measure.js").exists())
self.assertEqual(list((self.root / "build").glob("web-stage-*")), [])
for profile in ("web", "release"):
with self.subTest(profile=profile):
with patch.dict(os.environ, {"CARGO_HOME": cargo_home, "CARGO_TARGET_DIR": target_dir}), patch.object(
web.subprocess, "run", side_effect=run,
) as process, patch.object(
web.subprocess, "check_output", return_value=f"wasm-bindgen {self.version}",
), patch("builtins.print"):
if profile == "web":
web.build(self.root)
else:
web.build(self.root, profile=profile)
self.assertEqual(os.environ["CARGO_TARGET_DIR"], target_dir)
compile_step, glue = process.call_args_list
self.assertEqual(compile_step.args[0], [
"cargo", "build", "--locked", "--target", web.TARGET,
"--lib", "--profile", profile,
])
self.assertEqual(compile_step.kwargs["env"]["CARGO_HOME"], cargo_home)
self.assertEqual(compile_step.kwargs["env"]["CARGO_TARGET_DIR"], target_dir)
self.assertEqual(glue.args[0][1], str(Path(target_dir) / web.TARGET / profile / "n3.wasm"))
site = self.root / "build/web"
self.assertEqual((site / "index.html").read_text(), "new wrapper")
self.assertEqual((site / "n3.js").read_text(), "new interface")
self.assertEqual((site / "pkg/n3_bg.wasm").read_bytes(), b"new WASM")
self.assertEqual((site / "licenses/Inter.txt").read_text(), "Inter notice")
self.assertEqual((site / "licenses/Lucide.txt").read_text(), "Lucide notice")
self.assertFalse((site / "measure.html").exists())
self.assertFalse((site / "measure.js").exists())
self.assertEqual(list((self.root / "build").glob("web-stage-*")), [])


class WebServerTests(unittest.TestCase):
Expand Down
13 changes: 8 additions & 5 deletions tools/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,13 @@ def setup(root=ROOT):
require_bindgen(root)


def build(root=ROOT):
def build(root=ROOT, *, profile="web"):
if profile not in ("web", "release"):
raise ValueError("Browser build profile must be web or release")
environment = cargo_environment(root)
generator = require_bindgen(root)
subprocess.run(
["cargo", "build", "--locked", "--target", TARGET, "--lib", "--profile", "web"],
["cargo", "build", "--locked", "--target", TARGET, "--lib", "--profile", profile],
cwd=root, env=environment, check=True,
)
# Build in a staging directory so failures preserve the last runnable site.
Expand All @@ -83,7 +85,7 @@ def build(root=ROOT):
licenses.mkdir(exist_ok=True)
shutil.copyfile(root / "assets/fonts/inter/LICENSE.txt", licenses / "Inter.txt")
shutil.copyfile(root / "assets/fonts/lucide/LICENSE", licenses / "Lucide.txt")
artifact = Path(environment["CARGO_TARGET_DIR"]) / TARGET / "web/n3.wasm"
artifact = Path(environment["CARGO_TARGET_DIR"]) / TARGET / profile / "n3.wasm"
subprocess.run([
str(generator), str(artifact), "--target", "web", "--out-name", "n3",
"--out-dir", str(stage / "pkg"),
Expand Down Expand Up @@ -163,7 +165,8 @@ def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
commands.add_parser("setup", help="install the optional target and matching glue generator")
commands.add_parser("build", help="compile WASM and copy the authored web wrapper")
build_parser = commands.add_parser("build", help="compile WASM and copy the authored web wrapper")
build_parser.add_argument("--profile", choices=("web", "release"), default="web")
serve = commands.add_parser("serve", help="serve the existing build on localhost")
serve.add_argument("--port", type=int, default=8000)
serve.add_argument("--no-open", action="store_true")
Expand All @@ -173,7 +176,7 @@ def main(argv=None):
setup()
return 0
if args.command == "build":
build()
build(profile=args.profile)
return 0
if not 0 <= args.port <= 65535:
parser.error("port must be between 0 and 65535")
Expand Down
Loading