Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5959152
feather: add more shade graph nodes
Kyonru May 21, 2026
d1dc8d8
feather: add more shade graph nodes
Kyonru May 21, 2026
fb15951
feather: add shader preview
Kyonru May 22, 2026
7a0bedb
feather: add color input for vec4
Kyonru May 22, 2026
2397454
feather: improve vertex shaders
Kyonru May 22, 2026
2a519bb
feather: improve sdf nodes
Kyonru May 22, 2026
1e9ebee
feather: add TruchetMode
Kyonru May 22, 2026
0283185
feather: add composite node
Kyonru May 22, 2026
7d1697f
feather: add halftone node
Kyonru May 22, 2026
af0f88d
feather: add lab colors
Kyonru May 22, 2026
1c5dbc0
feather: add patterns
Kyonru May 22, 2026
ef8f0a5
feather: add pixel perfect
Kyonru May 22, 2026
0a7ee67
feather: add complex math
Kyonru May 22, 2026
0d3d661
feather: add quaternion operations
Kyonru May 22, 2026
e5fe5e6
feather: add symmetry
Kyonru May 22, 2026
3d251ca
feather: add random
Kyonru May 22, 2026
cb6f4f4
feather: add texture input
Kyonru May 22, 2026
2388312
feather: add custom function node
Kyonru May 22, 2026
4db1c8f
feather: add showcase
Kyonru May 22, 2026
b0eb1b5
lua: fix linter
Kyonru May 22, 2026
da3f79c
feather: merge
Kyonru May 22, 2026
d64a706
feather: fix package.json
Kyonru May 22, 2026
1b194b1
feather: fix bridge
Kyonru May 22, 2026
b53bd37
feather: fix round functions
Kyonru May 22, 2026
84a978b
feather: improve standanlone preview
Kyonru May 22, 2026
da7db70
feather: improve graph UI
Kyonru May 22, 2026
770a3d2
feather: improve showcase app
Kyonru May 22, 2026
b0cfcce
feather: improve shader support for webgl
Kyonru May 22, 2026
93b8afe
cli: fix tests
Kyonru May 22, 2026
f2b3d34
ci: fix tests
Kyonru May 22, 2026
fcd5490
ci: add pre-push
Kyonru May 22, 2026
f9af96f
ci: add standalone website config
Kyonru May 22, 2026
c397023
ci: add love to ci
Kyonru May 22, 2026
c002da7
feather: add vector math to shader graph
Kyonru May 22, 2026
693b2f1
feather: add subgraphs
Kyonru May 22, 2026
e5ca529
feather: improve subgraphs
Kyonru May 22, 2026
192c1ff
feather: copy paste in shadergraph
Kyonru May 23, 2026
dae44bb
feather: add params
Kyonru May 23, 2026
38c0b89
feather: fix shader context menu
Kyonru May 23, 2026
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
10 changes: 8 additions & 2 deletions .github/workflows/cli-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,15 @@ jobs:
sudo apt-get update
sudo apt-get install -y love squashfs-tools

- name: Install squashfs-tools (macOS)
- name: Install LÖVE and squashfs-tools (macOS)
if: runner.os == 'macOS'
run: brew install squashfs
run: |
curl -L https://github.com/love2d/love/releases/download/11.5/love-11.5-macos.zip -o love-macos.zip
ditto -x -k love-macos.zip love-macos
sudo cp -R love-macos/love.app /Applications/love.app
sudo ln -sf /Applications/love.app/Contents/MacOS/love /usr/local/bin/love
love --version
brew install squashfs

- name: Install dependencies
run: npm ci
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ lerna-debug.log*

node_modules
dist
dist-showcase
.showcase-vendor
dist-ssr
*.local

Expand Down
12 changes: 12 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env sh
set -eu

if [ "${SKIP_PRE_PUSH_TESTS:-}" = "1" ]; then
echo "[feather] SKIP_PRE_PUSH_TESTS=1 set; skipping pre-push tests."
exit 0
fi

npm run test:cli:e2e
npm run test:lua:e2e
npm run test:app:e2e
npm run test:showcase:e2e
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- CLI Fastlane draft support
- Lua plugin unit tests
- Lua callback bus with priority overrides
- Standalone Showcase build for trying Shader Graph and Particle System Playground in the browser without connecting a game.
- Static love.js preview bridge for standalone shader and particle previews, including showcase build/preview scripts and a publishing workflow.
- Shader Graph custom function, texture input, texture uniform color, texture upload, and preview texture support.
- Expanded Shader Graph node library with complex math, quaternion operations, symmetry, random, pixel-perfect primitives, patterns, halftone, Lab color helpers, composite, Truchet tiles, improved SDF nodes, and broader vertex shader authoring.
- Added Shader Graph vector math nodes for component-wise `vec2`/`vec3`/`vec4` add, subtract, multiply, divide, and scalar scaling.
- Added Shader Graph right-click node insertion, preset insertion, dirty preset replacement confirmation, graph-local subgraph instances, and deterministic link suggestions.
- Shader Graph preset and UI improvements for graph authoring, node inspection, code preview, and standalone preview controls.
- Session Replay checkpoints, seeking, baseline restore, sparse state deltas, and Lua E2E coverage for replay flows.
- Pre-push hook that runs CLI E2E, Lua E2E, app Playwright E2E, and showcase Playwright E2E before pushing.

### Changed

Expand All @@ -29,13 +38,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved CLI Linux vendoring
- Updated roadmap
- Shim hooks on load
- Improved standalone Shader Graph and Particle System Playground ergonomics, including local in-browser state for showcase mode.
- Improved Shader Graph WebGL preview adaptation for LÖVE shader syntax and precision-qualified uniforms.
- Improved Particle System Playground preview payload handling and emitter list behavior.
- Improved Lua E2E reliability in headless CI by disabling audio for E2E runs and making the Lua E2E timeout configurable.
- Improved audio-debug behavior when `love.audio` is unavailable, allowing headless smoke tests to run.
- Improved doctor reporting for dangerous installed plugins even when bundled catalog data is unavailable or stale.
- Moved standalone showcase deployment toward Railway hosting so love.js previews can be served with COOP/COEP/CSP headers.

### Fixed

- Fixed regex issues in CLI
- Fixed optimistic plugin updates
- Fixed input replay
- Fixed stack overflow on LÖVE hooks
- Fixed Shader Graph `u_time` precision mismatch between vertex and fragment shaders in love.js/WebGL.
- Fixed Shader Graph rounded functions and generated GLSL/WebGL preview handling.
- Fixed standalone showcase Playwright coverage so the showcase test runs only in the dedicated showcase config and uses current preview iframe selectors.
- Fixed CLI Linux AppImage vendor extraction fallback to use `unsquashfs -offset` when direct AppImage execution fails or is non-native.
- Fixed CI-only Lua E2E ALSA/audio startup failures in xvfb environments.
- Fixed doctor E2E fixture setup for dangerous bundled plugin trust checks.

### Tests

- Added standalone showcase Playwright smoke test and dedicated showcase Playwright config.
- Added Lua plugin E2E smoke coverage, including session replay flows.
- Added CLI doctor trust coverage for unknown and dangerous installed plugins.
- Added CLI Linux AppImage fallback tests for `unsquashfs` offset detection and actionable extraction errors.
- Added pre-push local verification for CLI, Lua, app, and showcase E2E lanes.

## [v1.2.0] - 2026-05-19 - The one with the shader and particles playground

Expand Down
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,17 @@ Focused guidance:

If `love`, `luacheck`, Android SDK, Xcode, Fastlane, or other local tooling is missing, document that in the PR or final handoff. Do not pretend the check passed.

The pre-push hook runs the heavier local test lanes before pushing:

```sh
npm run test:cli:e2e
npm run test:lua:e2e
npm run test:app:e2e
npm run test:showcase:e2e
```

When you need to push from a machine that cannot run those tools, use `SKIP_PRE_PUSH_TESTS=1 git push` and call out the skipped checks.

## Generated Files

Some files are generated and must stay in sync:
Expand Down
6 changes: 4 additions & 2 deletions cli/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,8 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {})
add(checks, 'Project', 'feather.config.lua', 'warn', 'missing', 'Run `feather init` to create a shared config.');
}

const managedMode = configSource ? parseManagedValue(configSource, 'mode') : null;
const configManagedMode = typeof config?.managed === 'string' ? config.managed : null;
const managedMode = (configSource ? parseManagedValue(configSource, 'mode') : null) ?? configManagedMode;
const managedInstallDir = configSource ? parseManagedValue(configSource, 'installDir') : null;
const effectiveInstallDir = normalizeInstallDir(opts.installDir ?? managedInstallDir ?? installDir);
const mode = typeof config?.mode === 'string' ? config.mode : 'socket';
Expand Down Expand Up @@ -386,7 +387,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {})
}
for (const dir of pluginDirs) {
const manifest = readPluginManifest(dir);
if (!manifest?.id || !knownPluginIds.has(manifest.id)) continue;
if (!manifest?.id) continue;
const trust = classifyPluginTrust(manifest, pluginCatalogById.get(manifest.id));
if (dangerousPluginIds.has(manifest.id)) {
add(
Expand All @@ -398,6 +399,7 @@ export async function doctorCommand(gamePath?: string, opts: DoctorOptions = {})
`Remove before shipping with \`feather plugin remove ${manifest.id} --dir ${projectDirArg} --install-dir ${installDirArg} --yes\`.`,
);
} else {
if (!knownPluginIds.has(manifest.id)) continue;
add(checks, 'Plugins', `Plugin ${manifest.id} trust`, trust === 'bundled-opt-in' ? 'info' : 'pass', pluginTrustLabel(trust));
}
}
Expand Down
15 changes: 12 additions & 3 deletions cli/src/lib/build/vendor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,18 +445,27 @@ async function installDesktopRuntime(target: 'windows' | 'macos' | 'linux' | 'st
const result = spawnSync(loveAppImage, ['--appimage-extract'], { cwd: targetPath, encoding: 'utf8' });
if (!result.error) {
if (result.status === 0 && existsSync(join(squashfsRoot, 'bin', 'love'))) return;
throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim());
const offset = findSquashFsOffset(loveAppImage);
if (offset < 0) {
throw new Error((result.stderr || result.stdout || 'Failed to extract LÖVE AppImage.').trim());
}
tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset);
return;
}

const code = (result.error as NodeJS.ErrnoException).code;
if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES') {
const offset = findSquashFsOffset(loveAppImage);
if (code !== 'ENOEXEC' && code !== 'ENOENT' && code !== 'EACCES' && offset < 0) {
throw new Error(`Failed to extract LÖVE AppImage: ${result.error.message}`);
}

// Not on Linux — try unsquashfs with explicit SquashFS offset (squashfs-tools >= 4.4).
// The macOS port doesn't auto-detect the offset, so we scan for the magic bytes ourselves.
tryExtractAppImageWithUnsquashfs(loveAppImage, squashfsRoot, targetPath, offset);
}

function tryExtractAppImageWithUnsquashfs(loveAppImage: string, squashfsRoot: string, targetPath: string, offset: number): void {
if (existsSync(squashfsRoot)) rmSync(squashfsRoot, { recursive: true });
const offset = findSquashFsOffset(loveAppImage);
const unsquashArgs = offset >= 0
? ['-offset', String(offset), '-d', squashfsRoot, loveAppImage]
: ['-d', squashfsRoot, loveAppImage];
Expand Down
25 changes: 23 additions & 2 deletions cli/test/commands/doctor.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,7 @@ test('doctor --json reports dangerous bundled plugin trust', () => {
writeGame(dir);
writeMinimalRuntime(dir);
writeFileSync(join(dir, 'feather.config.lua'), 'return { appId = "feather-app-test-1234567890" }\n');
const result = run(['plugin', 'install', 'console', '--local-src', LOCAL_SRC, '--dir', dir]);
assert.equal(result.exitCode, 0, outputOf(result));
writeLocalPluginSource(join(dir, 'feather'), 'console', { name: 'Console' });

const parsed = parseDoctorJson(dir);
const labels = new Map(parsed.checks.map((check) => [check.label, check]));
Expand Down Expand Up @@ -105,6 +104,28 @@ return {
assert.equal(labels.has('Plugin shader-graph'), false);
});

test('doctor --json treats managed = cli as bundled without legacy metadata comments', () => {
const dir = makeTmp();
writeGame(dir);
writeFileSync(
join(dir, 'feather.config.lua'),
`return {
appId = "feather-app-test-1234567890",
managed = "cli",
include = { "console", "shader-graph" },
}
`,
);

const parsed = parseDoctorJson(dir);
const labels = new Map(parsed.checks.map((check) => [check.label, check]));
assert.equal(labels.get('Embedded Feather runtime').severity, 'info');
assert.equal(labels.get('CLI-managed plugins').severity, 'pass');
assert.equal(labels.get('CLI-managed plugins').detail, '2 included from bundled runtime');
assert.equal(labels.has('Plugin console'), false);
assert.equal(labels.has('Plugin shader-graph'), false);
});

test('doctor --json reports malformed plugin manifests with recovery text', () => {
const dir = makeTmp();
writeGame(dir);
Expand Down
12 changes: 12 additions & 0 deletions docs/standalone-showcase.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Standalone Showcase

The standalone showcase is a browser-hosted version of Feather's visual authoring
tools. It lets you try the Shader Graph and Particle System Playground without
starting the desktop app or connecting a local LÖVE project.

[Open the standalone showcase](https://feather-showcase.up.railway.app/)

Use it to experiment with shader nodes, preview generated GLSL, tune particle
emitters, and see how the tools feel before wiring them into a running game. The
showcase runs separately from the documentation site so its LÖVE preview can use
the browser isolation headers required by love.js.
56 changes: 56 additions & 0 deletions e2e/showcase.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from '@playwright/test';

test('standalone showcase loads the landing page and tools', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: /browser-native authoring tools/i })).toBeVisible();

await page.locator('header').getByRole('button', { name: /^shader graph$/i }).click();
await expect(page.getByRole('heading', { name: 'Shader Graph' })).toBeVisible();
await expect(page.getByText('GLSL Output')).toBeVisible();
await expect(page.frameLocator('iframe[title="Shader Preview"]').locator('canvas')).toBeVisible();

const nodesBeforeInsert = await page.locator('.react-flow__node').count();
await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 300, y: 220 } });
await expect(page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes')).toBeVisible();
await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('time');
await page.getByTestId('shader-node-picker').getByRole('button', { name: /^time input$/i }).click();
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 1);
await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 420, y: 220 } });
await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float');
await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float input$/i }).click();
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 2);
await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 260, y: 320 } });
await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('float parameter');
await page.getByTestId('shader-node-picker').getByRole('button', { name: /^float parameter input$/i }).click();
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 3);
await expect(page.getByText(/extern number u_param_/i)).toBeVisible();
await page.getByTestId('shader-canvas').click({ button: 'right', position: { x: 380, y: 320 } });
await page.getByTestId('shader-node-picker').getByPlaceholder('Search nodes').fill('color parameter');
await page.getByTestId('shader-node-picker').getByRole('button', { name: /^color parameter input$/i }).click();
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 4);
await expect(page.getByText(/extern vec4 u_param_/i)).toBeVisible();
await page.keyboard.press('Control+C');
await page.keyboard.press('Control+V');
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 5);
await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1);
await page.keyboard.press('Control+D');
await expect(page.locator('.react-flow__node')).toHaveCount(nodesBeforeInsert + 6);
await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(2);
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('Unlink this node?');
await dialog.accept();
});
await page.getByRole('button', { name: /unlink linked node/i }).first().click();
await expect(page.getByRole('button', { name: /unlink linked node/i })).toHaveCount(1);

const nodesBeforePreset = await page.locator('.react-flow__node').count();
await page.getByText('Insert preset').click();
await page.getByRole('option', { name: /texture pass/i }).click();
await expect.poll(() => page.locator('.react-flow__node').count()).toBeGreaterThan(nodesBeforePreset);

await page.locator('header').getByRole('button', { name: /particle playground/i }).click();
await expect(page.getByRole('heading', { name: 'Particles Playground' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Emitter Properties' })).toBeVisible();
await expect(page.getByText('Rate')).toBeVisible();
await expect(page.frameLocator('iframe[title="Particle Preview"]').locator('canvas')).toBeVisible();
});
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ nav:
- Assets: assets.md
- Particle System Playground: particle-system-playground.md
- Shader Graph: shader-graph.md
- Standalone Showcase: standalone-showcase.md
- Time Travel: time-travel.md
- Session Replay: session-replay.md
- Recommendations: recommendations.md
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:cli:e2e": "npm run cli:build && node --test cli/test/commands/*.test.mjs",
"test:lua:e2e": "node scripts/lua-e2e.mjs",
"test:app:e2e": "playwright test",
"test:showcase:e2e": "playwright test -c playwright.showcase.config.ts",
"test:tauri:e2e": "cargo test --manifest-path src-tauri/Cargo.toml ws_server",
"extension:prepare": "npm run cli:build && npm run bundle:lua --workspace=cli && node vscode-extension/scripts/prepare.mjs",
"extension:build": "npm run extension:prepare && tsc -p vscode-extension/tsconfig.json",
Expand All @@ -29,6 +30,11 @@
"lua": "sh ./scripts/watch-lua.sh",
"web": "vite",
"build": "tsc && vite build",
"showcase:dev": "vite --config vite.showcase.config.ts",
"showcase:build": "tsc --noEmit -p tsconfig.json --composite false && vite build --config vite.showcase.config.ts && node scripts/prepare-showcase-lovejs.mjs && node scripts/build-showcase-love.mjs",
"showcase:prepare-lovejs": "node scripts/prepare-showcase-lovejs.mjs",
"showcase:preview": "vite preview --config vite.showcase.config.ts",
"showcase:start": "node scripts/serve-showcase.mjs",
"preview": "vite preview",
"typecheck:web": "tsc --noEmit -p tsconfig.json --composite false",
"typecheck:lua": "luacheck src-lua",
Expand Down
1 change: 1 addition & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
testIgnore: /showcase\.spec\.ts/,
timeout: 30_000,
expect: {
timeout: 5_000,
Expand Down
31 changes: 31 additions & 0 deletions playwright.showcase.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
testDir: './e2e',
testMatch: /showcase\.spec\.ts/,
timeout: 30_000,
expect: {
timeout: 5_000,
},
fullyParallel: true,
// @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled
reporter: process?.env.CI ? [['github'], ['list']] : 'list',
use: {
baseURL: 'http://127.0.0.1:4174',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npm run showcase:build && npm run showcase:preview -- --host 127.0.0.1',
url: 'http://127.0.0.1:4174',
// @ts-expect-error process is only defined in Node.js environment, and this config file is not bundled
reuseExistingServer: !process?.env.CI,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
12 changes: 12 additions & 0 deletions public/showcase-lovejs/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Feather love.js Preview</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<script src="./player.js?g=showcase.love&v=11.5"></script>
</body>
</html>
Loading
Loading