From c7241377dc71e2fbbe5e9d076b6b5407af50c601 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 5 Nov 2026 23:31:27 +0000 Subject: [PATCH] feat: add themes (.orskin) category with live color swatches and preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenROM now supports UI themes as `.orskin` palette files, so the storefront gains a first-class Themes category alongside the executable extensions. A theme is an ordinary `plugins` row with `category = 'Themes'`; no schema change was needed. The columns that normally describe conversion tooling carry the payload instead: `routes_json` holds the colour palette as an array of hex strings, and `platforms_json` is `["All Platforms"]` because a theme is not OS-specific. The storefront reads the palette positionally as Background, Surface, Accent and Text. Palette values come from D1, so they are untrusted input. `src/lib/theme.ts` validates every colour against `#rgb`/`#rgba`/`#rrggbb`/`#rrggbbaa` before it can reach a `style` attribute and drops anything else, so a crafted palette cannot break out of the attribute. `contrastText` picks a readable foreground by relative luminance so the preview stays legible on any palette. - Storefront: the Themes filter appears with a 🎨 label; a theme card swaps the conversion-route chips for a live swatch bar (up to 5 pills with a glow), and its actions become "Copy URI" (`openrom://theme/`) and "Download .orskin". - Detail page: themes get a Live Preview mock OpenROM window painted with the theme's exact colours (with ROMeo), a Colour Palette Inspector listing Background/Surface/Accent/Text with 1-click copy for each hex, an "Apply in OpenROM" deep link and a "Download .orskin" button. The manifest panel labels the array `palette` rather than `routes` for a theme. - Seed: migration 0006 registers the verified launch theme Cyberpunk Neon 1.0.0 by M5 Dev, and `public/themes/cyberpunk-neon.orskin` ships the matching palette (the migration records its real SHA-256). The download URL is root-relative so it works on production, preview deployments and local wrangler alike. - tests/theme.test.ts covers hex validation including injection payloads, the role mapping, contrast selection and the deep-link/file-name helpers. Co-authored-by: openhands --- migrations/0006_cyberpunk_neon_theme.sql | 79 +++++ public/themes/cyberpunk-neon.orskin | 19 ++ src/components/Card.astro | 38 ++- src/components/CategoryFilter.astro | 4 +- src/components/Icon.astro | 2 + src/lib/site.ts | 14 + src/lib/theme.ts | 96 ++++++ src/pages/plugin/[id].astro | 376 +++++++++++++++++++---- tests/theme.test.ts | 113 +++++++ 9 files changed, 670 insertions(+), 71 deletions(-) create mode 100644 migrations/0006_cyberpunk_neon_theme.sql create mode 100644 public/themes/cyberpunk-neon.orskin create mode 100644 src/lib/theme.ts create mode 100644 tests/theme.test.ts diff --git a/migrations/0006_cyberpunk_neon_theme.sql b/migrations/0006_cyberpunk_neon_theme.sql new file mode 100644 index 0000000..9bc5309 --- /dev/null +++ b/migrations/0006_cyberpunk_neon_theme.sql @@ -0,0 +1,79 @@ +-- Migration 0006 — register the first official theme: Cyberpunk Neon 1.0.0. +-- +-- A theme is an ordinary `plugins` row with `category = 'Themes'`; there is no +-- separate table. The columns that normally describe conversion tooling carry the +-- theme's payload instead: +-- +-- routes_json -> the colour palette, an array of hex strings. The storefront +-- reads it positionally (Background, Surface, Accent, Text) +-- and validates every value before it reaches a `style`. +-- platforms_json -> ["All Platforms"], because a theme is not OS-specific. +-- +-- The file it points at ships with this repository at public/themes/cyberpunk-neon.orskin, +-- and the `sha256` below is the digest of that exact file. The download URL is +-- root-relative on purpose: the file is served by this same site, so a relative +-- Location works on the production domain, on every Cloudflare Pages preview +-- deployment, and in local `wrangler pages dev` without hardcoding a hostname. +-- +-- Re-running is safe and never resets a live download counter: the upsert updates +-- every column *except* `downloads`. +-- +-- `publisher_id` is what the storefront joins on (see PLUGIN_SOURCE in +-- src/lib/db.ts). The M5 Dev publisher row is created by migration 0005, so +-- INSERT OR IGNORE here is a no-op when that has already run. + +INSERT OR IGNORE INTO publishers (id, name, owner_github_id, verified) VALUES + ('m5dev', 'M5 Dev', '193546948', 1); + +INSERT INTO plugins + (id, name, version, min_core_version, publisher_id, publisher_name, publisher_verified, + category, description, readme_markdown, routes_json, platforms_json, + download_url, sha256, icon, downloads) +VALUES + ( + 'cyberpunk-neon', + 'Cyberpunk Neon', + '1.0.0', + '3.9.0', + 'm5dev', + 'M5 Dev', + 1, + 'Themes', + 'Official high-contrast glowing neon cyan and hot pink retro-futuristic theme for OpenROM.', + '### Cyberpunk Neon + +A high-contrast, glow-forward skin for OpenROM. Deep near-black panels, a lifted +slate surface, and hot pink / cyan accents that carry the retro-futuristic look +without washing out long sessions. + +- **Background** — near-black for low glare on OLED and CRT-adjacent setups +- **Surface** — lifted slate so panels and cards stay legible +- **Accent** — hot pink primary with a cyan highlight for focus rings and links +- **Text** — pure white on dark for maximum contrast + +#### Applying + +Apply straight from the store with the **Apply in OpenROM** deep link, or download +the `.orskin` file and import it from *Settings → Appearance → Import…*.', + '["#0d0e15", "#161926", "#e94560", "#00f0ff", "#ffffff"]', + '["All Platforms"]', + '/themes/cyberpunk-neon.orskin', + 'ebe9e4056af600bfb6a2337b3114523d17924acb5196ff60bb1e5d0f0aa1d77a', + 'palette', + 0 + ) +ON CONFLICT (id) DO UPDATE SET + name = excluded.name, + version = excluded.version, + min_core_version = excluded.min_core_version, + publisher_id = excluded.publisher_id, + publisher_name = excluded.publisher_name, + publisher_verified = excluded.publisher_verified, + category = excluded.category, + description = excluded.description, + readme_markdown = excluded.readme_markdown, + routes_json = excluded.routes_json, + platforms_json = excluded.platforms_json, + download_url = excluded.download_url, + sha256 = excluded.sha256, + icon = excluded.icon; diff --git a/public/themes/cyberpunk-neon.orskin b/public/themes/cyberpunk-neon.orskin new file mode 100644 index 0000000..dab38a3 --- /dev/null +++ b/public/themes/cyberpunk-neon.orskin @@ -0,0 +1,19 @@ +{ + "format": "openrom.theme", + "version": 1, + "id": "cyberpunk-neon", + "name": "Cyberpunk Neon", + "author": "M5 Dev", + "description": "Official high-contrast glowing neon cyan and hot pink retro-futuristic theme for OpenROM.", + "palette": { + "background": "#0d0e15", + "surface": "#161926", + "accent": "#e94560", + "text": "#ffffff" + }, + "swatches": ["#0d0e15", "#161926", "#e94560", "#00f0ff", "#ffffff"], + "highlights": { + "primary": "#00f0ff", + "secondary": "#e94560" + } +} diff --git a/src/components/Card.astro b/src/components/Card.astro index d18b0b8..4de06b0 100644 --- a/src/components/Card.astro +++ b/src/components/Card.astro @@ -1,6 +1,7 @@ --- import Icon from './Icon.astro'; -import { PLATFORM_ICONS } from '../lib/site'; +import { PLATFORM_ICONS, categoryLabel } from '../lib/site'; +import { isTheme, themeApplyUri, themeSwatches, orskinFileName } from '../lib/theme'; import type { Plugin } from '../lib/db'; interface Props { @@ -22,6 +23,12 @@ const search = [ const published = Boolean(plugin.sha256); const cli = `openrom-core --install-plugin ${plugin.id}`; + +const theme = isTheme(plugin.category); +const swatches = theme ? themeSwatches(plugin.routes) : []; +const buttonLabel = theme ? 'Copy URI' : 'Copy install command'; +const buttonValue = theme ? themeApplyUri(plugin.id) : cli; +const downloadLabel = theme ? 'Download .orskin' : 'Get'; ---
- {plugin.category} + {categoryLabel(plugin.category)}

{plugin.description}

{ - plugin.routes.length > 0 && ( + theme && swatches.length > 0 ? ( +
+ {swatches.map((hex) => ( + + ))} + {swatches.length} colours +
+ ) : plugin.routes.length > 0 ? (
{plugin.routes.slice(0, 3).map((route) => ( @@ -93,7 +115,7 @@ const cli = `openrom-core --install-plugin ${plugin.id}`; ))}
- ) + ) : null }
@@ -116,9 +138,9 @@ const cli = `openrom-core --install-plugin ${plugin.id}`;
@@ -167,19 +192,21 @@ const body = dbError
{published ? ( - - Install in OpenROM + + {theme ? 'Apply in OpenROM' : 'Install in OpenROM'} ) : ( - - Bundle not published yet + + {theme ? 'Theme not published yet' : 'Bundle not published yet'} )} @@ -189,28 +216,40 @@ const body = dbError class="inline-flex h-11 items-center gap-2 rounded-lg border border-zinc-800 px-5 text-sm font-medium text-zinc-200 transition hover:border-zinc-700 hover:text-white" > - Download .orplug + Download {theme ? '.orskin' : '.orplug'} )}
{ published && (

- One-click install needs OpenROM Core installed. If nothing happens, use{' '} - - the .orplug download - {' '} - or the CLI command above. + {theme ? ( + <> + Applying a theme needs OpenROM installed. If nothing happens, use{' '} + + the .orskin download + {' '} + and open it from the app. + + ) : ( + <> + One-click install needs OpenROM Core installed. If nothing happens, use{' '} + + the .orplug download + {' '} + or the CLI command above. + + )}

) } @@ -241,11 +280,175 @@ const body = dbError
+ { + theme && ( + <> + {/* Mini App Mockup Preview */} +
+

+ Live Preview +

+

+ A mock OpenROM window painted with this theme's exact colours. +

+ +
+ {/* Title bar */} +
+
+ + {/* Body */} +
+ ROMeo, the OpenROM mascot, rendered over the theme colours +
+

+ Super Preserved Adventure +

+

+ Disc 1 of 2 · Verified dump +

+
+ + â–¶ Play + + + Library + +
+
+
+
+
+ + {/* Colour Palette Inspector */} +
+

+ Colour Palette Inspector +

+ {palette.length > 0 ? ( +
    + {palette.map((entry) => ( +
  • +
  • + ))} +
+ ) : ( +

+ This theme has not published a colour palette yet. +

+ )} + + {swatches.length > 0 && ( +
+

Raw palette

+
+ {swatches.map((hex) => ( + + ))} +
+
+ )} +
+ + ) + } +

- Supported Routes + {theme ? 'How it applies' : 'Supported Routes'}

- {plugin.routes.length > 0 ? ( + {theme ? ( +
    +
  1. + 1. + + Download the .orskin file, or + open the deep link below directly from OpenROM. + +
  2. +
  3. + 2. + + OpenROM reads the palette and repaints its interface immediately — no restart. + +
  4. +
  5. + 3. + Switch back to any other theme from Settings → Appearance. +
  6. +
+ ) : plugin.routes.length > 0 ? (
{plugin.routes.map((route) => ( @@ -261,50 +464,101 @@ const body = dbError

- Installation + {theme ? 'Apply this theme' : 'Installation'}

-
-

Command line

-

- Install with OpenROM Core. This fetches the bundle and verifies its SHA-256 - before installing. -

-
- {cli} - -
-
+ {theme ? ( + <> +
+

Deep link

+

+ Apply the theme straight from the app. OpenROM fetches the palette and + activates it without a restart. +

+
+ + {applyUri} + + +
+
-
-

Desktop app

-
    -
  1. - 1. - - Download the .orplug bundle - above. - -
  2. -
  3. - 2. - - Drag the file onto the OpenROM window, or use Install from file…. - -
  4. -
  5. - 3. - OpenROM checks the checksum and installs it automatically. -
  6. -
-
+
+

Or install from file

+
    +
  1. + 1. + + Download the .orskin file + above. + +
  2. +
  3. + 2. + + Drag it onto the OpenROM window, or use{' '} + Settings → Appearance → Import…. + +
  4. +
  5. + 3. + OpenROM validates the palette and applies it right away. +
  6. +
+
+ + ) : ( + <> +
+

Command line

+

+ Install with OpenROM Core. This fetches the bundle and verifies its SHA-256 + before installing. +

+
+ {cli} + +
+
+ +
+

Desktop app

+
    +
  1. + 1. + + Download the .orplug bundle + above. + +
  2. +
  3. + 2. + + Drag the file onto the OpenROM window, or use Install from file…. + +
  4. +
  5. + 3. + OpenROM checks the checksum and installs it automatically. +
  6. +
+
+ + )}
diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..166bd14 --- /dev/null +++ b/tests/theme.test.ts @@ -0,0 +1,113 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + THEME_CATEGORY, + THEME_PLATFORM, + isHexColor, + isTheme, + themePalette, + themeSwatches, + contrastText, + themeApplyUri, + orskinFileName, +} from '../src/lib/theme.ts'; + +const PALETTE = ['#0d0e15', '#161926', '#e94560', '#00f0ff', '#ffffff']; + +test('isTheme matches only the Themes category', () => { + assert.equal(isTheme(THEME_CATEGORY), true); + assert.equal(isTheme('Themes '), false); + assert.equal(isTheme('Nintendo'), false); + assert.equal(isTheme(null), false); + assert.equal(isTheme(undefined), false); +}); + +test('isHexColor accepts 3/4/6/8-digit hex in either case', () => { + for (const value of ['#fff', '#FFFF', '#0d0e15', '#0D0E15FF', ' #abc123 ']) { + assert.equal(isHexColor(value), true, `${value} should be valid`); + } +}); + +test('isHexColor rejects anything that could escape a style attribute', () => { + const attacks = [ + 'red', + 'rgb(1,2,3)', + '#12', + '#12345', + '#1234567', + '#gggggg', + '#fff;background:url(evil)', + 'url(javascript:alert(1))', + 'expression(alert(1))', + '#fff" onload="alert(1)', + '#fff;}body{display:none', + 'var(--x)', + '', + ]; + for (const value of attacks) { + assert.equal(isHexColor(value), false, `${value} must be rejected`); + } + assert.equal(isHexColor(123 as unknown as string), false); + assert.equal(isHexColor(null as unknown as string), false); +}); + +test('themePalette maps the launch palette onto named roles', () => { + const palette = themePalette(PALETTE); + assert.deepEqual(palette, [ + { role: 'Background', hex: '#0d0e15' }, + { role: 'Surface', hex: '#161926' }, + { role: 'Accent', hex: '#e94560' }, + { role: 'Text', hex: '#ffffff' }, + ]); +}); + +test('themePalette drops invalid entries so nothing unsafe reaches a template', () => { + const palette = themePalette(['#0d0e15', 'red; background:url(x)', '#e94560', 'nope', '#ffffff']); + assert.deepEqual( + palette.map((entry) => entry.hex), + ['#0d0e15', '#e94560', '#ffffff', '#ffffff'], + ); +}); + +test('themePalette returns empty for a palette with no valid colours', () => { + assert.deepEqual(themePalette(['not-a-colour', 'rgb(0,0,0)']), []); + assert.deepEqual(themePalette([]), []); + assert.deepEqual(themePalette(null), []); + assert.deepEqual(themePalette(undefined), []); +}); + +test('themePalette repeats the last colour when the palette is short', () => { + const palette = themePalette(['#111111', '#222222']); + assert.deepEqual( + palette.map((entry) => entry.hex), + ['#111111', '#222222', '#222222', '#222222'], + ); +}); + +test('themeSwatches filters invalid colours and caps the count', () => { + assert.deepEqual(themeSwatches(PALETTE), PALETTE); + assert.deepEqual(themeSwatches(PALETTE, 2), ['#0d0e15', '#161926']); + assert.deepEqual(themeSwatches(['#abc', 'evil', '#def']), ['#abc', '#def']); + assert.deepEqual(themeSwatches(null), []); +}); + +test('contrastText picks a readable foreground for light and dark backgrounds', () => { + assert.equal(contrastText('#ffffff'), '#0b0b0f'); + assert.equal(contrastText('#0d0e15'), '#ffffff'); + assert.equal(contrastText('#00f0ff'), '#0b0b0f'); + assert.equal(contrastText('#e94560'), '#ffffff'); +}); + +test('contrastText falls back to white for an invalid colour', () => { + assert.equal(contrastText('url(evil)'), '#ffffff'); +}); + +test('themeApplyUri and orskinFileName build the deep link and file name', () => { + assert.equal(themeApplyUri('cyberpunk-neon'), 'openrom://theme/cyberpunk-neon'); + assert.equal(orskinFileName('cyberpunk-neon'), 'cyberpunk-neon.orskin'); +}); + +test('THEME_PLATFORM is the OS-agnostic label the migration uses', () => { + assert.equal(THEME_PLATFORM, 'All Platforms'); +});