Skip to content
Open
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
11 changes: 10 additions & 1 deletion .github/workflows/alpha-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ name: Alpha Release
on:
workflow_dispatch:
inputs:
package:
description: The package to release (the SDK and the vite-plugin are versioned on their own)
type: choice
required: true
default: '@nanoforge-dev/editor'
options:
- '@nanoforge-dev/editor'
- '@nanoforge-dev/editor-sdk'
- '@nanoforge-dev/editor-vite-plugin'
dry_run:
description: Perform a dry run?
type: boolean
Expand Down Expand Up @@ -35,7 +44,7 @@ jobs:
- name: Release alpha
uses: ./node_modules/@nanoforge-dev/actions/actions/release-dev
with:
package: '@nanoforge-dev/editor'
package: ${{ inputs.package }}
tag: alpha
dry: ${{ inputs.dry_run }}
env:
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,15 @@ name: Pre-Release
on:
workflow_dispatch:
inputs:
package:
description: The package to release (the SDK and the vite-plugin are versioned on their own)
type: choice
required: true
default: '@nanoforge-dev/editor'
options:
- '@nanoforge-dev/editor'
- '@nanoforge-dev/editor-sdk'
- '@nanoforge-dev/editor-vite-plugin'
version:
description: New version for the packages
type: string
Expand Down Expand Up @@ -40,7 +49,7 @@ jobs:
- name: Create release PR
uses: ./node_modules/@nanoforge-dev/actions/actions/create-packages-release-pr
with:
packages: '@nanoforge-dev/editor'
packages: ${{ inputs.package }}
version: ${{ inputs.version }}
dry: ${{ inputs.dry_run }}
env:
Expand Down
57 changes: 52 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/')
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/editor@')
steps:
- name: Checkout repository
uses: actions/checkout@v7
Expand All @@ -48,23 +48,70 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

# The SDK and the vite-plugin are versioned on their own (releases/editor-sdk@1.2.0), tagged
# @nanoforge-dev/<name>@<version>. Only the editor's release pushes the image and the docs.
release-single-package:
name: Release single package
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && startsWith(github.head_ref, 'releases/') && !startsWith(github.head_ref, 'releases/editor@')
steps:
- name: Checkout repository
uses: actions/checkout@v7

- name: Prepare
uses: ./.github/actions/prepare
env:
NODE_ENV: development
PUBLIC_MODE: OFFLINE

- name: Get package name
id: package
shell: bash
env:
BRANCH: ${{ github.head_ref }}
run: |
name="${BRANCH#releases/}"
echo "name=@nanoforge-dev/${name%%@*}" >> "$GITHUB_OUTPUT"

- name: Release package
uses: ./node_modules/@nanoforge-dev/actions/actions/release-packages
with:
packages: ${{ steps.package.outputs.name }}
latest: false
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

beta-release:
name: Beta release
name: Beta release (${{ matrix.package }})
runs-on: ubuntu-latest
if: github.repository_owner == 'NanoForge-dev' && github.event.pull_request.merged == true && !startsWith(github.head_ref, 'releases/')
strategy:
fail-fast: false
matrix:
include:
- package: '@nanoforge-dev/editor'
paths: '^(apps|packages|plugins|tooling)/|^(pnpm-lock|pnpm-workspace)\.yaml$|^turbo\.json$'
# The SDK bundles the editor packages it re-exports.
- package: '@nanoforge-dev/editor-sdk'
paths: '^packages/|^(pnpm-lock|pnpm-workspace)\.yaml$'
- package: '@nanoforge-dev/editor-vite-plugin'
paths: '^tooling/vite-plugin/|^packages/kernel/|^(pnpm-lock|pnpm-workspace)\.yaml$'
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0

# A pull request that changes no code (docs, CI, issue forms) publishes no beta.
# A pull request that changes none of the package's code (docs, CI, issue forms) publishes no beta of it.
- name: Check for code changes
id: changes
shell: bash
env:
PATHS: ${{ matrix.paths }}
run: |
if git diff --name-only "${{ github.event.pull_request.merge_commit_sha }}^" "${{ github.event.pull_request.merge_commit_sha }}" \
| grep -qE '^(apps|packages|plugins|tooling)/|^(pnpm-lock|pnpm-workspace)\.yaml$|^turbo\.json$'; then
| grep -qE "$PATHS"; then
echo "code=true" >> "$GITHUB_OUTPUT"
else
echo "code=false" >> "$GITHUB_OUTPUT"
Expand All @@ -81,7 +128,7 @@ jobs:
if: steps.changes.outputs.code == 'true'
uses: ./node_modules/@nanoforge-dev/actions/actions/release-dev
with:
package: '@nanoforge-dev/editor'
package: ${{ matrix.package }}
tag: beta
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
Expand Down
17 changes: 15 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The NanoForge editor, rewritten from scratch. The full plan, with status per pha
Ask the user before building anything that depends on them.

- **Repository standards follow the engine** (`../engine`, `origin/main`):
- `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12;
- `package.json` fields in the engine's order (`$schema`, `name`, `version`, `description`, `keywords`, `homepage`, `bugs`, `license`, `contributors`, `files`, `main`, `module`, `types`, `exports`, `type`, `directories`, `repository`, `funding`, `scripts`, `dependencies`, `peerDependencies`, `peerDependenciesMeta`, `devDependencies`, `packageManager`, `engines`, `private`, `publishConfig`, `lint-staged`), dependencies sorted, Node 26 and pnpm 12;
- every workspace has the engine's `README.md` (badges, About, Links, Contributing, Help), `LICENSE` and `.prettierignore`; published ones (`apps/editor`, `packages/sdk`, `tooling/vite-plugin`) also `CHANGELOG.md`, `cliff.toml` and `.cliff-jumperrc.json`;
- each workspace has a `<group>:<name>` label (`.github/labels.yml`, `labeler.yml`, `issue-labeler.yml`) and a choice in the issue forms. A new workspace gets all of them;
- the editor keeps single quotes (its Prettier override of the engine config).
Expand Down Expand Up @@ -103,7 +103,7 @@ pnpm e2e:last-failure # error-context.md of the last failed test, its last fra
- `viewport` (Game and Scene screens)
- `history-panel`
- `settings-ui`
- `ecs`
- `ecs` (its extension points: `ecs.sources`, `ecs.fieldEditors`, `ecs.paramPresets`, `ecs.entityActions`, whose `when` sees `ecsEntity` and `ecsComponent:<class>`; a component's layout warnings are diagnostics of source `@nanoforge/ecs`)
- `console` (Console and Problems panels)
- `command-palette` (the palette, and the Keyboard shortcuts page of Settings)
- `inspectors` (Profiler, Network and World panels)
Expand Down Expand Up @@ -155,6 +155,13 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na
3. its name is accepted as a commit scope automatically.
- **Plugins only import the SDK** (ESLint enforces it). If a plugin needs something from core, export it from the SDK: add a token in the owning package, provide it in `apps/editor/src/lib/editor/create-editor.ts`, re-export it, and update the snapshot.
- **Dialogs from a plugin:** mount a Svelte component with `mount`/`unmount` from a command (see `plugins/settings-ui/src/index.ts`).
- **Published SDK and vite-plugin (M7).**
- They are versioned on their own and tagged `@nanoforge-dev/<name>@<version>` (the editor keeps bare `<version>` tags). Pre-release and alpha take the package; a `releases/editor@…` branch releases the editor (image and docs), any other `releases/<name>@…` that package; betas run per package, each when its own code changed.
- The SDK bundles every editor package it reaches (tsdown `alwaysBundle`, subpaths included). `scripts/build-types.ts` emits the declarations they reach (Svelte components through svelte2tsx), rewrites their specifiers to relative paths, prunes the rest and type-checks what is left: a clash in emitted declarations, or an import of a package that is not published, fails the build.
- `./ui` is published as types plus a stub whose exports throw when used (`scripts/build-ui-stub.ts`): plugins built with the vite-plugin take the editor's own module, and their unit tests mock it.
- The acceptance test: `pnpm pack` both, install the tarballs in a copy of `examples/counter-plugin` outside the workspace, then svelte-check, `vite build`, and a node import of `/`, `/worker` and `/ui`.
- **Code editor side panels:** `codeEditor.open(path, { sidePanel: 'ecs.component' })` opens a file with that panel shown (_Edit layout_).
- **Kit helpers:** `saveAsFile(name, content, type)` hands the browser a file to save (exports of the Console, inspectors and settings).

### Folder trust (local editors)

Expand Down Expand Up @@ -202,6 +209,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na
- **Actions and shortcuts:**
- An _action_ is a command with its arguments (`actionId`). The palette and the keymap page list `listActions`: titled commands, plus menu entries that give arguments.
- A command that needs arguments sets `palette: false` (in `commands.register` or in the manifest), or it shows in the palette and does nothing.
- A modifier twice is a double tap (`Shift Shift`, the IntelliJ preset's palette): a modifier pressed and released alone twice within 500 ms. A single tap never starts a chord.
- A browser tab never gets Ctrl+T, Ctrl+N, Ctrl+Shift+N, Ctrl+W, Ctrl+F4 or Ctrl+F5 (and Linux browsers keep Alt+1…9): don't bind them, even in a preset.
- Write default shortcuts with `Mod` (Ctrl, or Cmd on macOS). The keymap is layered: `KEYBINDINGS`, the preset, the account's `keymap.overrides`, this machine's. Edit lists with `setShortcut`, never by hand.
- The kit's `Select` trigger is a `button` named by its label (not a `combobox`): `getByRole('button', { name: 'Preset' })`.

Expand Down Expand Up @@ -256,6 +265,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na
- **Formatting:** each workspace runs its own Prettier (`lint`, `format`, its `lint-staged`) with its own `.prettierignore`; the root one skips `apps/`, `packages/`, `plugins/` and `tooling/`. A folder Prettier must not touch (test fixtures: golden tests compare their text) goes in that workspace's `.prettierignore`.
- **Doc comments:** TypeScript counts a comment right after `(` or `,` as that token's trailing comment. Read param docs with `leadingDocComments` (editor-meta), not ts-morph's `getLeadingCommentRanges`.
- **Worker mirror:** it follows document changes asynchronously. `CodeService.analyze`/`transform` mirror the file's current text first; code that talks to the engine directly must too.
- **Theme in e2e:** `appearance.theme` defaults to `system` (light or dark as the system is), so `playwright.config.ts` sets `colorScheme: 'dark'`: the suite runs on the dark theme. A test of the light one emulates it (`page.emulateMedia`).
- **`when` clauses:** the left of `in` is a key, never a string (`'Position' in list` does not parse): give a key per member instead (`ecsComponent:Position`, like `engineLib:<name>`). Lists of contributions evaluate each `when` in a `try`, so one bad clause hides its entry, not the menu.
- **Saved layout and widget state in e2e:** they outlive a test (server-side, saved after 500 ms). A test that changes tabs or expands folders resets them at the end and waits before the page closes (see `e2e/ecs.spec.ts`).
- **Plugin activation order changes timing:** adding an `onStartup` plugin delays the others, which exposed focus races. Test the full suite after adding one.
- **Workspace changes (Project screen):**
Expand All @@ -272,6 +283,8 @@ Everything in our registry has a `nanoforge.manifest.json` and a name `@scope/na
- **File manager:**
- Fast repeated commands race: they run through a serialized queue.
- After a drag and drop, focus goes back to the tree (`refocus`), so Ctrl+Z reaches the Files history rather than the layout.
- `FILE_TEMPLATES` entries are offered where their `when` holds: it sees `fileFolder` and `appType` (`client`, `server`, `lib`, empty outside apps; `appAt` finds the app of a path). The ECS plugin's _Component_ and _System_ need an app.
- **Asset params:** an `@asset .png` param picks among the app's assets, written as the game loads them: the CLI copies the assets folder to the build's root, so `<assets>/textures/hero.png` is `/textures/hero.png`.

## Status (2026-10-02)

Expand Down
15 changes: 10 additions & 5 deletions apps/editor/e2e/appearance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const restore = async (page: Page) => {
await expect(open).toBeHidden();
const dialog = await openSettings(page);
const panel = dialog.getByRole('region', { name: 'Appearance' });
await panel.getByRole('radio', { name: 'NanoForge Dark' }).click();
await panel.getByRole('radio', { name: 'Match the system' }).click();
await panel.getByRole('radio', { name: 'Normal' }).click();
await panel.getByRole('button', { name: 'Icon theme' }).click();
await page.getByRole('option', { name: 'NanoForge', exact: true }).click();
Expand All @@ -41,7 +41,7 @@ test('themes, density and icons from a plugin, chosen in Settings › Appearance
const dialog = await openSettings(page);
const panel = dialog.getByRole('region', { name: 'Appearance' });
const themes = panel.getByRole('radiogroup', { name: 'Theme' });
await expect(themes.getByRole('radio', { name: 'NanoForge Dark' })).toHaveAttribute(
await expect(themes.getByRole('radio', { name: 'Match the system' })).toHaveAttribute(
'aria-checked',
'true',
);
Expand Down Expand Up @@ -79,8 +79,13 @@ test('themes, density and icons from a plugin, chosen in Settings › Appearance
} finally {
await restore(page);
}
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.nfTheme))
.toBe('nanoforge-dark');
const theme = () => page.evaluate(() => document.documentElement.dataset.nfTheme);
await expect.poll(theme).toBe('nanoforge-dark');
expect(await variable(page, '--nf-control-height')).toBe('26px');

/** Match the system (the default) follows the light or dark mode of the system as it changes. */
await page.emulateMedia({ colorScheme: 'light' });
await expect.poll(theme).toBe('nanoforge-light');
await page.emulateMedia({ colorScheme: 'dark' });
await expect.poll(theme).toBe('nanoforge-dark');
});
29 changes: 28 additions & 1 deletion apps/editor/e2e/command-palette.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';

import { openProject, resetLayout, saved, showPanel } from './helpers';
import { openProject, panelTab, resetLayout, saved, showPanel } from './helpers';
import { type Page, expect, test } from './test';
import { PONG } from './workspace';

Expand Down Expand Up @@ -212,3 +212,30 @@ test('keymap: chords, conflicts, conditions, removals, presets, kept on this mac
await expect(previous.locator('kbd')).toHaveCount(0);
await settings(page).getByRole('button', { name: 'Cancel' }).click();
});

test('keymap: the IntelliJ preset, Shift Shift and Alt+1', async ({ page }) => {
await open(page);
await openKeymap(page);
await settings(page).getByRole('button', { name: 'Preset' }).click();
await page.getByRole('option', { name: 'IntelliJ' }).click();
await find(page, 'command palette');
await expect(shortcut(page, 'View: Command palette…')).toContainText('Shift Shift');
await settings(page).getByRole('button', { name: 'OK' }).click();
await expect(settings(page)).toHaveCount(0);

await showPanel(page, 'Hierarchy');
await page.locator('[data-nf-part="screen"]').click();
await page.keyboard.press('Alt+1');
await expect(panelTab(page, 'Files')).toHaveAttribute('aria-selected', 'true');

/** One tap types on; two in a row open the palette. */
await page.keyboard.press('Shift');
await page.waitForTimeout(700);
await page.keyboard.press('Shift');
await expect(box(page)).toHaveCount(0);
await page.keyboard.press('Shift');
await page.keyboard.press('Shift');
await expect(box(page)).toBeFocused();
await page.keyboard.press('Escape');
await expect(box(page)).toHaveCount(0);
});
30 changes: 29 additions & 1 deletion apps/editor/e2e/console.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,21 @@ test('console: groups, levels, search, repeats, values and file links', async ({
await expect(hello).toContainText('Hello state');
await page.getByLabel('Search console').fill('');

/** Ctrl+Alt+E: errors only, then every level again. */
await page.keyboard.press('Control+Alt+e');
await expect(level(page, 'Errors')).toHaveAttribute('aria-pressed', 'true');
await expect(level(page, 'Info')).toHaveAttribute('aria-pressed', 'false');
await expect(hello.filter({ hasText: 'Hello state' })).toHaveCount(0);
await page.keyboard.press('Control+Alt+e');
await expect(level(page, 'Info')).toHaveAttribute('aria-pressed', 'true');
await expect(hello.filter({ hasText: 'Hello state' })).toHaveCount(1);

const download = page.waitForEvent('download');
await page.getByRole('button', { name: 'Save shown lines' }).click();
const file = await download;
expect(file.suggestedFilename()).toMatch(/^console-[\d-]+\.log$/);
expect(readFileSync((await file.path())!, 'utf8')).toContain('Hello state');

await page.getByRole('button', { name: 'Sources' }).click();
await page.getByRole('menuitem', { name: 'Plugins' }).click();
await page.getByRole('menuitem', { name: '@acme/hello' }).click();
Expand Down Expand Up @@ -129,7 +144,20 @@ test('problems: a file that is not open, grouped by file, opens at its position'
.click();
await page.getByLabel('Search problems').fill('');

await problem.click();
/** One Tab stop: arrows go through files and problems, Enter opens one. */
const header = file.getByRole('button', { name: /scratch\/broken\.ts/ });
await header.focus();
await page.keyboard.press('ArrowRight');
await expect(problem).toBeFocused();
await expect(header).toHaveAttribute('tabindex', '-1');
await page.keyboard.press('ArrowLeft');
await expect(header).toBeFocused();
await page.keyboard.press('ArrowLeft');
await expect(problem).toHaveCount(0);
await page.keyboard.press('ArrowRight');
await page.keyboard.press('ArrowDown');
await expect(problem).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.getByRole('tab', { name: 'broken.ts' })).toBeVisible();
await expect(page.locator('.monaco-editor .active-line-number')).toHaveText('2');

Expand Down
6 changes: 5 additions & 1 deletion apps/editor/e2e/ecs-live.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,12 @@ test('moves an entity by dragging it in the Game screen, and applies it to the c
await expect.poll(async () => Number(await x.inputValue())).toBeLessThan(870);
expect(Number(await x.inputValue())).toBeGreaterThan(840);
expect(readFileSync(MAIN, 'utf8')).toBe(original);
await page.getByRole('button', { name: 'Apply to code' }).click();
/** The Hierarchy applies every entity's changes while the game plays. */
await showPanel(page, 'Hierarchy');
const applyAll = page.getByRole('button', { name: 'Apply all changes to code' });
await applyAll.click();
await expect.poll(() => readFileSync(MAIN, 'utf8')).toMatch(/new Position\(8[4-6]\d, 0\)/);
await expect(applyAll).toBeDisabled();

await move.click();
await expect(overlay).toHaveCount(0);
Expand Down
Loading
Loading