diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 9030888..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "frontend-design@claude-plugins-official": true - } -} diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 93748c6..3bb5949 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Pages +name: Check & deploy on: push: @@ -25,25 +25,20 @@ jobs: cache: 'npm' - name: Install dependencies - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - - name: Lint - run: npm run lint - - - name: Typecheck - run: npm run typecheck - - - name: Typecheck browser tests - run: npm run typecheck:e2e + run: | + npm ci + npx playwright install --with-deps chromium - name: Test - run: npm test + run: | + npm run lint:check + npm run typecheck + npm run typecheck:e2e + npm test - - name: Browser tests - run: npm run test:e2e + - name: Test E2E + run: | + npm run test:e2e - name: Upload Playwright report if: failure() @@ -59,4 +54,4 @@ jobs: if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | apt update && apt install -y rsync - rsync -a --delete dist/ /pages/fleeting-garden + rsync -a --delete dist/ /pages/fleeting diff --git a/.gitignore b/.gitignore index 916a63e..0f59a68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,3 @@ -# Dependency directory node_modules -modules/ -ts-node--*/ -rss.xml - dist -playwright-report test-results - -# Logs -logs -*.log -npm-debug.log* - -# Runtime data -pids -*.pid -*.seed -*.ssh -*.ppk -v8-compile-cache-0/ -Thumbs.db - -# node-waf configuration -.lock-wscript - -# Compiled binary addons (http://nodejs.org/api/addons.html) -build/Release -bin -ts-node - -# Personal Scripts -*.bat -*.ssh -*.sh -!system.min.js - -# Editors -.vscode -.markdownlint.json - -# Build Files -temp -*.js -*.map -!webpack.* diff --git a/README.md b/README.md index 1a7466c..ec6f5d7 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ Fleeting Garden is a single-player WebGPU drawing garden. Pick a vibe palette, draw persistent coloured paths, spawn agents from those strokes, erase locally, -and export the scene as a 4K wallpaper. +and export the scene as an internal render buffer snapshot. Check out the [agent logic](./src/pipelines/agents/agent.wgsl). ## Testing - `npm test` runs the Vitest unit suite. -- `npm run test:e2e` builds the production bundle and runs the Playwright Chromium - smoke test. +- `npm run test:e2e` runs the Playwright Chromium smoke test. The Playwright + config builds the production bundle before serving it. - `npx playwright install chromium` installs the local browser binary when needed. diff --git a/assets/fonts/comfortaa-v40-latin-regular.woff b/assets/fonts/comfortaa-v40-latin-regular.woff deleted file mode 100644 index c54393b..0000000 Binary files a/assets/fonts/comfortaa-v40-latin-regular.woff and /dev/null differ diff --git a/assets/fonts/comfortaa-v40-latin-regular.woff2 b/assets/fonts/comfortaa-v40-latin-regular.woff2 deleted file mode 100644 index bc4da8b..0000000 Binary files a/assets/fonts/comfortaa-v40-latin-regular.woff2 and /dev/null differ diff --git a/assets/fonts/open-sans-v34-latin-regular.woff b/assets/fonts/open-sans-v34-latin-regular.woff deleted file mode 100644 index b083626..0000000 Binary files a/assets/fonts/open-sans-v34-latin-regular.woff and /dev/null differ diff --git a/assets/icons/brush.svg b/assets/icons/brush.svg deleted file mode 100644 index c3a203e..0000000 --- a/assets/icons/brush.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/assets/icons/download.svg b/assets/icons/download.svg index f880e05..423bc7d 100644 --- a/assets/icons/download.svg +++ b/assets/icons/download.svg @@ -2,7 +2,7 @@ + diff --git a/assets/icons/maximize.svg b/assets/icons/maximize.svg index 9c8558e..7239da3 100644 --- a/assets/icons/maximize.svg +++ b/assets/icons/maximize.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/minimize.svg b/assets/icons/minimize.svg index 05d54a8..2ffe1f7 100644 --- a/assets/icons/minimize.svg +++ b/assets/icons/minimize.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/restart.svg b/assets/icons/restart.svg index f87e22b..a58d2a6 100644 --- a/assets/icons/restart.svg +++ b/assets/icons/restart.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg index 176c63c..ba7478f 100644 --- a/assets/icons/settings.svg +++ b/assets/icons/settings.svg @@ -1,4 +1,4 @@ - + diff --git a/assets/icons/sound.svg b/assets/icons/sound.svg index 78dbb2b..d440a9c 100644 --- a/assets/icons/sound.svg +++ b/assets/icons/sound.svg @@ -1,3 +1,3 @@ - - + + diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index f0381bf..19f0efd 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -1,6 +1,33 @@ import { expect, test, type Page } from '@playwright/test'; -type WebGpuFailureMode = 'adapter-null' | 'adapter-rejects' | 'device-rejects'; +const canvasName = 'Interactive generative garden canvas'; + +const isLocalUrl = (url: string) => { + const { hostname } = new URL(url); + return hostname === '127.0.0.1' || hostname === 'localhost'; +}; + +const collectLocalBrowserFailures = (page: Page) => { + const failures: Array = []; + + page.on('requestfailed', (request) => { + if (!isLocalUrl(request.url())) { + return; + } + + const failure = request.failure(); + failures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); + }); + page.on('response', (response) => { + if (response.status() < 400 || !isLocalUrl(response.url())) { + return; + } + + failures.push(`${response.status()} ${response.url()}`); + }); + + return failures; +}; const disableWebGpu = async (page: Page) => { await page.addInitScript(() => { @@ -11,299 +38,190 @@ const disableWebGpu = async (page: Page) => { }); }; -const emulateWebGpuFailure = async (page: Page, mode: WebGpuFailureMode) => { - await page.addInitScript((failureMode) => { - const limits = { - maxBufferSize: 256 * 1024 * 1024, - maxComputeWorkgroupsPerDimension: 65_535, - maxStorageBufferBindingSize: 128 * 1024 * 1024, - }; - const adapter = { - features: new Set(), - info: { - architecture: 'test', - description: 'Playwright fake adapter', - device: 'test-device', - isFallbackAdapter: false, - subgroupMaxSize: 0, - subgroupMinSize: 0, - vendor: 'test-vendor', - }, - limits, - requestDevice: async () => { - if (failureMode === 'device-rejects') { - throw new Error('Playwright fake device failure'); - } - - return {}; - }, - }; - - Object.defineProperty(navigator, 'gpu', { - configurable: true, - value: { - getPreferredCanvasFormat: () => 'rgba8unorm', - requestAdapter: async () => { - if (failureMode === 'adapter-null') { - return null; - } - - if (failureMode === 'adapter-rejects') { - throw new Error('Playwright fake adapter failure'); - } - - return adapter; - }, - }, - }); - }, mode); -}; - -const getFirstSwatchColor = (page: Page) => - page - .locator('.color-swatch') - .first() - .evaluate((element) => getComputedStyle(element).backgroundColor); - -const getGardenBackground = (page: Page) => - page.evaluate(() => - document.documentElement.style.getPropertyValue('--garden-background').trim() - ); - -test('loads the app shell and WebGPU fallback in Chromium', async ({ page }) => { - await disableWebGpu(page); - - await page.goto('/'); - - await expect(page).toHaveTitle('Fleeting Garden'); - await expect( - page.getByRole('img', { name: 'Interactive generative garden canvas' }) - ).toBeVisible(); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); - - await page.getByRole('button', { name: 'About' }).click(); - await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); -}); - -test('keeps fallback controls interactive and accessible', async ({ page }) => { - await disableWebGpu(page); - - await page.goto('/'); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - - const aboutButton = page.getByRole('button', { name: 'About' }); - const aboutPanel = page.locator('#info-panel'); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); - await aboutButton.click(); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'true'); - await expect(aboutPanel).toHaveAttribute('aria-hidden', 'false'); - await expect(aboutPanel).not.toHaveAttribute('inert', ''); - await expect(page.getByRole('heading', { name: 'Fleeting Garden' })).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(aboutButton).toHaveAttribute('aria-expanded', 'false'); - await expect(aboutPanel).toHaveAttribute('aria-hidden', 'true'); - await expect(aboutPanel).toHaveAttribute('inert', ''); - - const settingsButton = page.getByRole('button', { name: 'Show config overlay' }); - await settingsButton.click(); - await expect(page.getByRole('button', { name: 'Hide config overlay' })).toHaveAttribute( - 'aria-expanded', - 'true' - ); - await expect(page.locator('.config-pane')).toBeVisible(); - - const soundButton = page.locator('button.sound'); - await expect(soundButton).toHaveAttribute('aria-pressed', 'false'); - await soundButton.click(); - await expect(soundButton).toHaveAttribute('aria-pressed', 'true'); - await expect(soundButton).toHaveAttribute('aria-label', 'Unmute audio'); - await page.reload(); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.locator('button.sound')).toHaveAttribute('aria-pressed', 'true'); - - const initialSwatchColor = await getFirstSwatchColor(page); - const initialBackground = await getGardenBackground(page); - await page.getByRole('button', { name: 'Next vibe' }).click(); - await expect.poll(() => getFirstSwatchColor(page)).not.toBe(initialSwatchColor); - await expect.poll(() => getGardenBackground(page)).not.toBe(initialBackground); - - await page.getByRole('button', { name: 'Draw colour 2' }).click(); - await expect(page.locator('.color-swatch').nth(1)).toHaveClass(/active/); - await expect(page.locator('.color-swatch').first()).not.toHaveClass(/active/); - - const mirrorSlider = page.locator('.mirror-segment-slider'); - await mirrorSlider.evaluate((input) => { - const slider = input as HTMLInputElement; - slider.value = '3'; - slider.dispatchEvent(new Event('input', { bubbles: true })); - }); - await expect(page.locator('.mirror-segment-control')).toHaveAttribute( - 'title', - '3 thirds' - ); - await expect(page.locator('.mirror-segment-control')).toHaveClass(/active/); -}); - -( - [ - { - expectedCode: 'webgpu-adapter-unavailable', - expectedMessage: - 'WebGPU is available, but this browser could not provide a compatible GPU adapter.', - mode: 'adapter-null', - }, - { - expectedCode: 'webgpu-adapter-unavailable', - expectedMessage: 'Could not request a WebGPU adapter.', - mode: 'adapter-rejects', - }, - { - expectedCode: 'webgpu-device-unavailable', - expectedMessage: 'Could not create a WebGPU device for this adapter.', - mode: 'device-rejects', - }, - ] satisfies Array<{ - expectedCode: string; - expectedMessage: string; - mode: WebGpuFailureMode; - }> -).forEach(({ expectedCode, expectedMessage, mode }) => { - test(`reports ${mode} startup failures without leaving the shell loading`, async ({ - page, - }) => { - await emulateWebGpuFailure(page, mode); - - await page.goto('/'); - - await expect(page.locator('body')).not.toHaveClass(/is-loading/); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.getByRole('alert')).toContainText(expectedMessage); - await expect(page.getByRole('alert')).toContainText(expectedCode); - }); -}); - -test('serves the production bundle without missing browser assets', async ({ page }) => { - const browserFailures: Array = []; - - page.on('requestfailed', (request) => { - const failure = request.failure(); - browserFailures.push(`${request.method()} ${request.url()} ${failure?.errorText}`); - }); - page.on('response', (response) => { - if (response.status() >= 400) { - browserFailures.push(`${response.status()} ${response.url()}`); +test('starts the WebGPU garden and accepts drawing input', async ({ page }) => { + const browserFailures = collectLocalBrowserFailures(page); + const consoleErrors: Array = []; + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); } }); - await disableWebGpu(page); - await page.goto('/'); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await page.addInitScript((expectedCanvasName) => { + const captureState = { count: 0 }; + Object.defineProperty(window, '__fleetingGardenPointerCaptures', { + configurable: true, + value: captureState, + }); + const originalSetPointerCapture = Element.prototype.setPointerCapture; + Element.prototype.setPointerCapture = function setPointerCapture(pointerId) { + if ( + this instanceof HTMLCanvasElement && + this.getAttribute('aria-label') === expectedCanvasName + ) { + captureState.count += 1; + } + + return originalSetPointerCapture.call(this, pointerId); + }; + }, canvasName); + + await page.goto('/'); + const startButton = page.getByRole('button', { name: 'Start' }); + await expect(startButton).toBeVisible(); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await startButton.click(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, + }); + + await expect(page.getByRole('alert')).toHaveCount(0); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + + const canvas = page.getByRole('img', { name: canvasName }); + await expect(canvas).toBeVisible(); + const canvasSize = await canvas.evaluate((element) => { + const canvasElement = element as HTMLCanvasElement; + return { + height: canvasElement.height, + width: canvasElement.width, + }; + }); + expect(canvasSize.width).toBeGreaterThan(0); + expect(canvasSize.height).toBeGreaterThan(0); + + const box = await canvas.boundingBox(); + expect(box).not.toBeNull(); + if (!box) { + return; + } + + await page.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.5); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.8, box.y + box.height * 0.5, { + steps: 16, + }); + await page.mouse.up(); + + await expect + .poll(() => + page.evaluate( + () => + ( + window as unknown as { + __fleetingGardenPointerCaptures?: { count: number }; + } + ).__fleetingGardenPointerCaptures?.count ?? 0 + ) + ) + .toBeGreaterThan(0); + + expect(consoleErrors).toEqual([]); expect(browserFailures).toEqual([]); }); -[ - { height: 720, name: 'desktop', width: 1280 }, - { height: 844, name: 'mobile', width: 390 }, -].forEach(({ height, name, width }) => { - test(`keeps the fallback shell usable on ${name}`, async ({ page }) => { - await page.setViewportSize({ height, width }); - await disableWebGpu(page); +test('shows a clear fallback when WebGPU is unavailable', async ({ page }) => { + const browserFailures = collectLocalBrowserFailures(page); - await page.goto('/'); - await expect(page.locator('body')).not.toHaveClass(/is-loading/); + await disableWebGpu(page); + await page.goto('/'); - const canvasBox = await page - .getByRole('img', { name: 'Interactive generative garden canvas' }) - .boundingBox(); - expect(canvasBox?.width).toBeGreaterThan(0); - expect(canvasBox?.height).toBeGreaterThan(0); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await expect(page.getByRole('button', { name: 'About' })).toBeVisible(); - await expect(page.getByRole('alert')).toContainText('Fleeting Garden needs WebGPU'); + await expect(page).toHaveTitle('Fleeting Garden'); + await expect(page.getByRole('img', { name: canvasName })).toBeVisible(); + await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); - const aboutButtonReceivesPointer = await page - .getByRole('button', { name: 'About' }) - .evaluate((button) => { - const rect = button.getBoundingClientRect(); - const target = document.elementFromPoint( - rect.left + rect.width / 2, - rect.top + rect.height / 2 - ); - - return button === target || button.contains(target); - }); - - expect(aboutButtonReceivesPointer).toBe(true); - }); + const fallback = page.getByRole('alert'); + await expect(fallback).toContainText('Fleeting Garden needs WebGPU'); + await expect(fallback).toContainText('webgpu-unsupported'); + expect(browserFailures).toEqual([]); }); -test('hides the bottom dock after the cursor leaves fullscreen controls', async ({ +test('keeps audio focus outlines scoped to the active control', async ({ page }) => { + await disableWebGpu(page); + await page.goto('/'); + await expect(page.locator('body')).not.toHaveClass(/is-loading/); + + const audioControl = page.locator('.audio-control'); + const soundButton = page.locator('button.sound'); + const volumeSlider = page.locator('.volume-slider'); + + await soundButton.click(); + await expect(audioControl).toHaveCSS('outline-style', 'none'); + await expect(soundButton).toHaveCSS('outline-style', 'none'); + + await page.mouse.click(10, 10); + for (let tabIndex = 0; tabIndex < 12; tabIndex += 1) { + await page.keyboard.press('Tab'); + const activeClass = await page.evaluate(() => + String(document.activeElement?.className ?? '') + ); + if (activeClass.includes('sound')) { + break; + } + } + + await expect(soundButton).toBeFocused(); + await expect(soundButton).toHaveCSS('outline-style', 'solid'); + await expect(soundButton).toHaveCSS('outline-offset', '-4px'); + + await page.keyboard.press('Tab'); + await expect(volumeSlider).toBeFocused(); + await expect(volumeSlider).toHaveCSS('outline-style', 'solid'); + await expect(volumeSlider).toHaveCSS('outline-offset', '-4px'); +}); + +test('keeps the config overlay scrollable and dismissible on mobile', async ({ page, }) => { - await disableWebGpu(page); - + await page.setViewportSize({ width: 390, height: 640 }); await page.goto('/'); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - await page.getByRole('button', { name: 'Enter fullscreen' }).click(); - await expect - .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) - .toBe(true); - - await page.mouse.move(640, 120); - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } + const startButton = page.getByRole('button', { name: 'Start' }); + await expect(startButton).toBeEnabled({ timeout: 30_000 }); + await startButton.click(); + await expect(page.locator('body')).not.toHaveClass(/is-loading/, { + timeout: 30_000, }); - await expect(page.locator('aside.control-dock')).toHaveClass(/menu-hidden/, { - timeout: 6000, - }); - await expect(page.locator('.garden-controls')).not.toBeVisible(); - await expect - .poll(() => - page - .locator('aside.control-dock') - .evaluate((dock) => dock.getBoundingClientRect().top >= window.innerHeight) - ) - .toBe(true); + const settingsButton = page.locator('button.settings'); + await settingsButton.click(); - await page.mouse.move(640, 700); - await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); - await expect(page.locator('.garden-controls')).toBeVisible(); + const pane = page.locator('.config-pane'); + const closeButton = page.locator('.config-pane-close'); + await expect(pane).toBeVisible(); + await expect(closeButton).toBeVisible(); + + const paneMetrics = await pane.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + return { + bottom: rect.bottom, + clientHeight: element.clientHeight, + overflowY: style.overflowY, + scrollHeight: element.scrollHeight, + top: rect.top, + viewportHeight: window.innerHeight, + viewportWidth: window.innerWidth, + width: rect.width, + }; + }); + + expect(paneMetrics.top).toBeGreaterThanOrEqual(0); + expect(paneMetrics.bottom).toBeLessThanOrEqual(paneMetrics.viewportHeight); + expect(Math.round(paneMetrics.width)).toBe(Math.round(paneMetrics.viewportWidth * 0.8)); + expect(paneMetrics.scrollHeight).toBeGreaterThan(paneMetrics.clientHeight); + expect(['auto', 'scroll']).toContain(paneMetrics.overflowY); + + await pane.evaluate((element) => { + element.scrollTop = element.scrollHeight; + }); await expect - .poll(() => - page - .locator('aside.control-dock') - .evaluate((dock) => dock.getBoundingClientRect().bottom <= window.innerHeight) - ) - .toBe(true); -}); - -test('keeps the bottom dock visible in mobile fullscreen', async ({ page }) => { - await page.setViewportSize({ height: 844, width: 390 }); - await disableWebGpu(page); - - await page.goto('/'); - await expect(page.getByRole('toolbar', { name: 'Garden toolbar' })).toBeVisible(); - - await page.getByRole('button', { name: 'Enter fullscreen' }).click(); - await expect - .poll(() => page.evaluate(() => Boolean(document.fullscreenElement))) - .toBe(true); - - await page.mouse.move(195, 120); - await page.evaluate(() => { - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - }); - await page.waitForTimeout(5200); - - await expect(page.locator('aside.control-dock')).not.toHaveClass(/menu-hidden/); - await expect(page.getByRole('button', { name: 'Show config overlay' })).toBeVisible(); + .poll(() => pane.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(0); + + await closeButton.click(); + await expect(pane).toBeHidden(); + await expect(settingsButton).toHaveAttribute('aria-expanded', 'false'); }); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..db8b127 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js'; +import prettierConfig from 'eslint-config-prettier'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['node_modules/**', 'dist/**', 'public/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + prettierConfig, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.browser, + }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/ban-ts-comment': 'error', + 'prefer-const': 'error', + }, + } +); diff --git a/index.html b/index.html index 1957196..fadc28f 100644 --- a/index.html +++ b/index.html @@ -7,25 +7,64 @@ content="width=device-width,initial-scale=1,viewport-fit=cover" /> + + + + + + + - - - - + + + + + + - - - - + + + + + + + + + + + + + + Fleeting Garden @@ -41,28 +80,31 @@

Fleeting Garden is a pointer-driven WebGPU drawing canvas. Drag or touch the scene - to paint coloured paths, then use the toolbar to change colours, erase, adjust the - config overlay, export, restart, or open more information. + to paint coloured paths, then use the toolbar to change colours, erase, export, + adjust the config overlay, restart, or open more information.

+
-
- + + + +