pixel-daylight-switch-gsvegetable 2.2.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,139 @@
1
+ # Framework integration recipes
2
+
3
+ The core package has no React or Vue runtime dependency. Use the same HTML root and mount/unmount lifecycle from your framework. The component owns its internal canvas and interaction elements; your framework owns the surrounding container.
4
+
5
+ ## React
6
+
7
+ Install the core package:
8
+
9
+ ```bash
10
+ npm install pixel-daylight-switch-gsvegetable
11
+ ```
12
+
13
+ ```jsx
14
+ import { useEffect, useRef } from 'react';
15
+ import { mount, unmount } from 'pixel-daylight-switch-gsvegetable';
16
+
17
+ export function DaylightSwitch({ scene = 'countryside', weather = 'default' }) {
18
+ const rootRef = useRef(null);
19
+
20
+ useEffect(() => {
21
+ const root = rootRef.current;
22
+ if (!root) return undefined;
23
+
24
+ mount(root);
25
+ return () => unmount(root);
26
+ }, [scene, weather]);
27
+
28
+ return (
29
+ <div style={{ width: 'min(100%, 800px)', marginInline: 'auto' }}>
30
+ <div
31
+ ref={rootRef}
32
+ data-ld-part="toggle-daylight"
33
+ data-scene={scene}
34
+ data-weather={weather}
35
+ />
36
+ </div>
37
+ );
38
+ }
39
+ ```
40
+
41
+ The effect cleans up the previous instance before applying changed scene or weather attributes, so the renderer always reads the latest configuration.
42
+
43
+ ## Vue 3
44
+
45
+ ```vue
46
+ <script setup>
47
+ import { onMounted, onBeforeUnmount, ref } from 'vue';
48
+ import { mount, unmount } from 'pixel-daylight-switch-gsvegetable';
49
+
50
+ const root = ref(null);
51
+ onMounted(() => mount(root.value));
52
+ onBeforeUnmount(() => {
53
+ if (root.value) unmount(root.value);
54
+ });
55
+ </script>
56
+
57
+ <template>
58
+ <div class="daylight-switch-wrap">
59
+ <div
60
+ ref="root"
61
+ data-ld-part="toggle-daylight"
62
+ data-scene="japanese-street"
63
+ data-weather="default"
64
+ />
65
+ </div>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .daylight-switch-wrap {
70
+ width: min(100%, 800px);
71
+ margin-inline: auto;
72
+ }
73
+ </style>
74
+ ```
75
+
76
+ ## Plain HTML / CDN
77
+
78
+ For one default switch with no build step, use the one-line embed:
79
+
80
+ ```html
81
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
82
+ ```
83
+
84
+ Set supported options on the script tag when you want a particular scene or weather:
85
+
86
+ ```html
87
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js" data-scene="seaside" data-weather="snow" data-start-night="true"></script>
88
+ ```
89
+
90
+ For custom configuration, place a root element where you want it and load the runtime:
91
+
92
+ ```html
93
+ <div style="width:min(100%, 800px);margin-inline:auto">
94
+ <div
95
+ data-ld-part="toggle-daylight"
96
+ data-scene="seaside"
97
+ data-weather="snow"
98
+ data-start-night="true"
99
+ ></div>
100
+ </div>
101
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
102
+ ```
103
+
104
+ The runtime injects component-scoped styles. Do not import `style.css` just to use the component; that file also contains styles for the repository's standalone demo page.
105
+
106
+ ## Community scene plugins
107
+
108
+ Register a JSON scene manifest before mounting a root. The package entry point exports `registerScene`, `validateScene`, `getScene` and `listScenes` alongside `mount` and `unmount`.
109
+
110
+ ```js
111
+ import PixelDaylightSwitch, { registerScene } from 'pixel-daylight-switch-gsvegetable';
112
+ import scene from './my-scene.json';
113
+
114
+ const unregister = registerScene(scene);
115
+ const root = document.querySelector('[data-ld-part="toggle-daylight"]');
116
+ root.dataset.scene = scene.id;
117
+ PixelDaylightSwitch.mount(root);
118
+
119
+ // Before removing a dynamically registered scene:
120
+ PixelDaylightSwitch.unmount(root);
121
+ unregister();
122
+ ```
123
+
124
+ For CDN usage, load `scene-plugin.js` before `app.js`, then call `PixelDaylightSwitchScenePlugins.registerScene(manifest)` before mounting. The embed loader loads both scripts in the correct order for built-in scenes; custom manifests still need to be registered by the host page.
125
+
126
+ Manifest v1 is data-only: a bounded pixel matrix, named palettes and a 24-hour palette timeline. See [the schema](schemas/scene-manifest.v1.schema.json) and [the example](examples/scenes/sakura-evening.json). Do not place executable code, remote script URLs or untrusted HTML in a manifest.
127
+
128
+ ## Lifecycle and events
129
+
130
+ - Call `mount(root)` after the root is attached to the DOM.
131
+ - Call `unmount(root)` before the framework removes the root.
132
+ - Mounting the same root repeatedly is safe; it is idempotent.
133
+ - Listen for `daylight:change` on the root to synchronize application state. The event's `detail.night` property is a boolean.
134
+ - The root must be allowed to measure its width. A width of `100%` inside a parent with a real layout width is the usual choice.
135
+ - The component keeps its intrinsic 1.6439 aspect ratio and scales to the available width.
136
+
137
+ ## Configuration
138
+
139
+ See [README.md](README.md) for the complete list of `data-*` attributes, CSS custom properties, rendering behavior and supported scene identifiers.
package/README.ja.md CHANGED
@@ -23,6 +23,7 @@ Pixel Daylight Switch は、ドラッグ操作で昼・夕暮れ・夜を切り
23
23
  | コンポーネントを見る | [Preview](https://gsvegetable.github.io/pixel-daylight-switch/preview.html) |
24
24
  | Web サイトに埋め込む | [embed.js](embed.js) |
25
25
  | JavaScript プロジェクトで使う | [npm](https://www.npmjs.com/package/pixel-daylight-switch-gsvegetable) |
26
+ | React / Vue の導入例 | [フレームワーク統合ガイド](INTEGRATIONS.md) |
26
27
  | 実装を読む | [app.js](app.js) · [style.css](style.css) |
27
28
  | モバイルビルド | [ビルド情報](builds/README.md) · [GitHub Actions](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/mobile-build.yml) |
28
29
  | テスト結果 | [CI ワークフロー](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/ci.yml) |
@@ -61,7 +62,13 @@ HTML にマウント要素を配置し、ランタイムを読み込みます。
61
62
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
62
63
  ```
63
64
 
64
- ローダーがコンテナ、スタイル、ランタイムを自動挿入します。現在は既定設定のインスタンスを 1 つ生成し、最大表示サイズを固定します。レイアウトや属性を細かく制御する場合は、上記のマウント要素方式を使用してください。
65
+ script タグにシーンや天候を直接指定することもできます。
66
+
67
+ ```html
68
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js" data-scene="seaside" data-weather="snow" data-start-night="true"></script>
69
+ ```
70
+
71
+ レスポンシブなインスタンスを 1 つ作成してランタイムを読み込みます。スタイルはコンポーネント内に限定され、ホストページの `body` や `main` などのグローバル要素を変更しません。script タグに `data-scene`、`data-weather`、`data-start-night` などを直接指定できます。複数インスタンスや配置の細かな制御には、上記のマウント方式を使用してください。
65
72
 
66
73
  ## npm からインストール
67
74
 
@@ -89,6 +96,41 @@ const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable');
89
96
 
90
97
  ESM と CommonJS の両方を提供します。SSR 環境でもインポートできますが、`mount()` はブラウザー上で対象要素が存在してから呼び出してください。
91
98
 
99
+ ## コミュニティシーンプラグイン(v3.0.0)
100
+
101
+ 3.0.0 では JSON Manifest によるカスタムピクセルワールドの登録に対応しました。初めての方はピクセルマップをそのまま使い、昼夜パレットの色だけを変更できます。上級者はピクセルマップと時間パレットを自由に設計できます。プラグインは宣言型データのみを受け取り、レンダラー内で第三者 JavaScript を実行しません。
102
+
103
+ **ブラウザーで使用:**
104
+
105
+ ```html
106
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/scene-plugin.js"></script>
107
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
108
+ <div data-ld-part="toggle-daylight" data-scene="sakura-evening"></div>
109
+ <script>
110
+ PixelDaylightSwitch.registerScene(sceneManifest);
111
+ </script>
112
+ ```
113
+
114
+ コンポーネントをマウントする前に Manifest を登録してください。npm の場合:
115
+
116
+ ```js
117
+ import PixelDaylightSwitch, { registerScene } from 'pixel-daylight-switch-gsvegetable';
118
+ import myScene from './my-scene.json';
119
+
120
+ const unregister = registerScene(myScene);
121
+ const root = document.querySelector('[data-ld-part="toggle-daylight"]');
122
+ root.dataset.scene = myScene.id;
123
+ PixelDaylightSwitch.mount(root);
124
+ ```
125
+
126
+ Manifest v1 はシーン ID、作者・ライセンス、ピクセルキャンバス、文字ベースのピクセルマップ、複数の昼夜パレット、24 時間のタイムラインで構成されます。キャンバスは最大 192 × 112 ピクセルです。すべてのピクセル記号に各パレットの色を定義する必要があります。時間ノード間の色はレンダラーが補間します。
127
+
128
+ - [プラグイン開発ガイド](SCENE_PLUGINS.md)
129
+ - [Manifest v1 JSON Schema](schemas/scene-manifest.v1.schema.json)
130
+ - [Sakura Evening サンプル](examples/scenes/sakura-evening.json)
131
+
132
+ v1 は外部画像/Sprite、レイヤー式 Tile Map、独自アニメーショントラックにはまだ対応していません。これらは任意コードの実行を許可せず、将来の独立した Schema 拡張として追加します。5 つの組み込みシーンは従来の描画・アニメーション実装を維持します。
133
+
92
134
  ## 設定属性
93
135
 
94
136
  すべて任意の設定で、マウント要素に指定します。省略時または無効な値の場合はデフォルト値が使われます。
@@ -176,7 +218,7 @@ Capacitor 8 アプリには、田園の家、日本の街並み、モダンな
176
218
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
177
219
  ```
178
220
 
179
- スタイルとデフォルトシーンのスイッチを自動挿入します。シーンや天気のカスタマイズ、複数インスタンス、配置の制御には、上記の npm API と `data-scene` / `data-weather` 属性を使用してください。
221
+ レスポンシブなデフォルトシーンのスイッチを自動作成してランタイムを読み込みます。デモページ用スタイルは挿入せず、サイトのグローバルスタイルも変更しません。シーンや天気のカスタマイズ、複数インスタンス、配置の制御には、上記の npm API と `data-scene` / `data-weather` 属性を使用してください。
180
222
 
181
223
  ### 独自の Android / iOS Capacitor アプリへの統合
182
224
 
package/README.md CHANGED
@@ -23,6 +23,8 @@ Pixel Daylight Switch is a draggable day/night UI component. Move its glass cont
23
23
  | Inspect the component | [Preview page](https://gsvegetable.github.io/pixel-daylight-switch/preview.html) |
24
24
  | Add it to a website with one script | [Embed loader source](embed.js) |
25
25
  | Use it in a JavaScript project | [npm package](https://www.npmjs.com/package/pixel-daylight-switch-gsvegetable) |
26
+ | Framework integration recipes | [React and Vue examples](INTEGRATIONS.md) |
27
+ | Scene plugin authoring guide | [Scene plugin guide](SCENE_PLUGINS.md) |
26
28
  | Read implementation details | [Runtime](app.js) · [Styles](style.css) |
27
29
  | Download mobile builds | [Build notes and limitations](builds/README.md) · [GitHub Actions artifacts](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/mobile-build.yml) |
28
30
  | Check tests | [CI workflow](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/ci.yml) |
@@ -61,7 +63,13 @@ For a self-inserting embed, add this script where you want the component to appe
61
63
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
62
64
  ```
63
65
 
64
- The loader inserts its own container, stylesheet and runtime. It currently creates one default instance with a fixed maximum display size; use the mount-point approach above when you need precise layout or custom attributes.
66
+ Configure the generated instance directly on the script tag:
67
+
68
+ ```html
69
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js" data-scene="seaside" data-weather="snow" data-start-night="true"></script>
70
+ ```
71
+
72
+ The loader inserts one responsive instance and loads only the component runtime. Its styles are isolated to the component and do not reset or modify your page's `body`, `main`, or other global elements. You can set `data-scene`, `data-weather`, `data-start-night` and the other documented `data-*` options directly on the script tag. For multiple instances or exact placement, use the mount-point approach above.
65
73
 
66
74
  ## Install from npm
67
75
 
@@ -89,6 +97,53 @@ const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable');
89
97
 
90
98
  The package exposes ESM and CommonJS entry points. Importing is safe in an SSR environment; call `mount()` only in a browser after the target element exists. The package's published file list is defined in [package.json](package.json).
91
99
 
100
+ ## Community scene plugins (v3.0.0)
101
+
102
+ Version 3 introduces a data-driven scene plugin API. Community scenes use a JSON manifest; they do not execute third-party JavaScript inside the renderer.
103
+
104
+ ### Register a scene
105
+
106
+ Load the registry before the renderer when using script tags:
107
+
108
+ ```html
109
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/scene-plugin.js"></script>
110
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
111
+ <div data-ld-part="toggle-daylight" data-scene="sakura-evening"></div>
112
+ ```
113
+
114
+ Register a manifest before mounting the component:
115
+
116
+ ```js
117
+ const unregister = PixelDaylightSwitchScenePlugins.registerScene(sceneManifest);
118
+ PixelDaylightSwitch.mount(document.querySelector('[data-ld-part="toggle-daylight"]'));
119
+ ```
120
+
121
+ With npm:
122
+
123
+ ```js
124
+ import PixelDaylightSwitch, { registerScene } from 'pixel-daylight-switch-gsvegetable';
125
+ import sakura from './sakura-evening.json';
126
+
127
+ registerScene(sakura);
128
+ PixelDaylightSwitch.mount(document.querySelector('[data-ld-part="toggle-daylight"]'));
129
+ ```
130
+
131
+ ### Manifest v1
132
+
133
+ A manifest contains a fixed-size character pixel map, named color palettes and a 24-hour palette timeline. The renderer interpolates colors between timeline stops while keeping the same scene geometry.
134
+
135
+ - Canvas limit: 192 × 112 pixels.
136
+ - Pixel rows must match the declared width and height.
137
+ - Every symbol used by the pixel map must have a color in every palette.
138
+ - Colors use `#RGB` or `#RRGGBB`.
139
+ - Timeline stops are strictly increasing and range from 0 to 24 hours.
140
+ - Built-in scene IDs are reserved; community IDs must be unique.
141
+ - The runtime accepts declarative data only. Manifest data is validated and copied on registration; it cannot run scripts.
142
+
143
+ See the [v1 JSON Schema](schemas/scene-manifest.v1.schema.json) and [Sakura Evening example](examples/scenes/sakura-evening.json). The public API is `registerScene(manifest)`, `validateScene(manifest)`, `getScene(id)`, and `listScenes()`. The returned unregister function removes that registration.
144
+
145
+ The first manifest release focuses on palette-driven pixel worlds. Sprite assets, layered tile maps and declarative animation tracks are planned as compatible schema extensions rather than arbitrary executable plugin code.
146
+
92
147
  ## Configuration
93
148
 
94
149
  Set optional attributes on the mount element. Defaults apply when an attribute is omitted or invalid.
@@ -176,7 +231,7 @@ For one basic day/night switch, no package installation is required. Paste this
176
231
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
177
232
  ```
178
233
 
179
- The loader injects its styles and one default-scene switch. For custom scenes, weather, multiple instances or full control over placement, use the npm API above and its `data-scene` / `data-weather` attributes.
234
+ The loader creates a responsive default-scene switch and loads the runtime. It does not inject the demo page stylesheet or alter your site's global styles. For custom scenes, weather, multiple instances or full control over placement, use the npm API above and its `data-scene` / `data-weather` attributes.
180
235
 
181
236
  ### Integrate with your own Android / iOS Capacitor app
182
237
 
package/README.zh-CN.md CHANGED
@@ -23,6 +23,7 @@ Pixel Daylight Switch 是一个可拖动的昼夜切换 UI 组件。拖动玻璃
23
23
  | 查看组件预览 | [Preview 页面](https://gsvegetable.github.io/pixel-daylight-switch/preview.html) |
24
24
  | 用一个脚本嵌入网站 | [嵌入加载器源码](embed.js) |
25
25
  | 在 JavaScript 项目中使用 | [npm 包](https://www.npmjs.com/package/pixel-daylight-switch-gsvegetable) |
26
+ | React / Vue 接入示例 | [框架集成指南](INTEGRATIONS.md) |
26
27
  | 阅读核心实现 | [运行时 app.js](app.js) · [样式 style.css](style.css) |
27
28
  | 获取移动端构建 | [构建说明与限制](builds/README.md) · [GitHub Actions 构建任务](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/mobile-build.yml) |
28
29
  | 查看自动化测试 | [CI 工作流](https://github.com/GSvegetable/pixel-daylight-switch/actions/workflows/ci.yml) |
@@ -61,7 +62,13 @@ Pixel Daylight Switch 是一个可拖动的昼夜切换 UI 组件。拖动玻璃
61
62
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
62
63
  ```
63
64
 
64
- 加载器会自动插入容器、样式和运行脚本。目前它创建一个默认实例,并使用固定的最大展示尺寸;如果需要精确控制布局或配置属性,请使用上面的挂载节点方式。
65
+ 也可以直接在 script 标签上配置场景和天气:
66
+
67
+ ```html
68
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js" data-scene="seaside" data-weather="snow" data-start-night="true"></script>
69
+ ```
70
+
71
+ 加载器会自动创建响应式实例并加载运行时。组件样式仅作用于组件本身,不会重置或修改宿主网页的 `body`、`main` 等全局元素。你也可以直接在 script 标签上设置 `data-scene`、`data-weather`、`data-start-night` 等参数。若要创建多个实例或精确控制位置,请使用上面的挂载节点方式。
65
72
 
66
73
  ## 通过 npm 安装
67
74
 
@@ -89,6 +96,41 @@ const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable');
89
96
 
90
97
  npm 包提供 ESM 和 CommonJS 入口,可在 SSR 环境中安全导入;只有在浏览器中且目标元素已经存在时,才能调用 `mount()`。npm 发布文件清单以 [package.json](package.json) 为准。
91
98
 
99
+ ## 社区场景插件(v3.0.0)
100
+
101
+ 3.0.0 开始支持通过 JSON Manifest 注册自定义像素世界。普通创作者可以保留像素矩阵、只修改昼夜色板;进阶创作者可以自己设计像素矩阵和时间色板。插件只提供声明式数据,不会在渲染器中执行第三方 JavaScript。
102
+
103
+ **浏览器使用:**
104
+
105
+ ```html
106
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/scene-plugin.js"></script>
107
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
108
+ <div data-ld-part="toggle-daylight" data-scene="sakura-evening"></div>
109
+ <script>
110
+ PixelDaylightSwitch.registerScene(sceneManifest);
111
+ </script>
112
+ ```
113
+
114
+ 请在组件挂载前注册 Manifest。使用 npm 时:
115
+
116
+ ```js
117
+ import PixelDaylightSwitch, { registerScene } from 'pixel-daylight-switch-gsvegetable';
118
+ import myScene from './my-scene.json';
119
+
120
+ const unregister = registerScene(myScene);
121
+ const root = document.querySelector('[data-ld-part="toggle-daylight"]');
122
+ root.dataset.scene = myScene.id;
123
+ PixelDaylightSwitch.mount(root);
124
+ ```
125
+
126
+ Manifest v1 包含场景 ID、作者与许可证、像素画布、字符像素矩阵、多个昼夜色板和 24 小时时间节点。画布最大为 192 × 112;每个像素符号都必须在每套色板中定义颜色。渲染器会在相邻时间节点之间插值颜色。
127
+
128
+ - [完整插件开发指南](SCENE_PLUGINS.md)
129
+ - [Manifest v1 JSON Schema](schemas/scene-manifest.v1.schema.json)
130
+ - [樱花黄昏示例](examples/scenes/sakura-evening.json)
131
+
132
+ v1 暂不支持外部图片/Sprite、分层 Tile Map 和自定义动画轨道;这些能力会通过后续独立版本的 Schema 扩展,不会开放任意代码执行。五个内置场景仍保留原有绘制与动态逻辑。
133
+
92
134
  ## 配置参数
93
135
 
94
136
  所有参数均为可选项,写在挂载元素上。省略参数或参数值无效时使用默认值。
@@ -176,7 +218,7 @@ Capacitor 移动端现已提供完整的场景控制界面:可在乡间小屋
176
218
  <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
177
219
  ```
178
220
 
179
- 这是一键式基础嵌入,当前展示单个默认场景开关;如果需要自定义场景、天气或多个实例,请使用上面的 npm API(`data-scene`、`data-weather` 等参数)。
221
+ 这是一键式基础嵌入,会创建响应式的默认场景开关;不会注入演示页面样式,也不会修改网站全局样式。如果需要自定义场景、天气或多个实例,请使用上面的 npm API(`data-scene`、`data-weather` 等参数)。
180
222
 
181
223
  ### 集成到自己的 Android / iOS Capacitor 工程
182
224
 
@@ -0,0 +1,78 @@
1
+ # Scene Plugin Guide
2
+
3
+ Pixel Daylight Switch 3.0.0 introduces a declarative community-scene format. A scene is JSON data, not an executable JavaScript plugin. The renderer validates the manifest and draws the resulting pixel world through the existing refractive-glass pipeline.
4
+
5
+ ## Two ways to create a scene
6
+
7
+ **Palette creator:** Start with `examples/scenes/sakura-evening.json`, keep its pixel rows, and change the colors under `palettes`. This is the quickest way to create a distinct atmosphere.
8
+
9
+ **Pixel-world creator:** Edit `canvas` and `pixels` to design your own geometry, then define a color for every symbol in each palette. Each character represents one pixel; rows must all have the declared width.
10
+
11
+ ## Register with npm
12
+
13
+ ```bash
14
+ npm install pixel-daylight-switch-gsvegetable
15
+ ```
16
+
17
+ ```js
18
+ import PixelDaylightSwitch, { registerScene } from 'pixel-daylight-switch-gsvegetable';
19
+ import myScene from './my-scene.json';
20
+
21
+ const unregister = registerScene(myScene);
22
+ const root = document.querySelector('[data-ld-part="toggle-daylight"]');
23
+ root.dataset.scene = myScene.id;
24
+ PixelDaylightSwitch.mount(root);
25
+
26
+ // Call when this scene is no longer needed:
27
+ unregister();
28
+ ```
29
+
30
+ The scene must be registered before its root is mounted. Registering a scene returns an idempotent unregister function.
31
+
32
+ ## Register in a browser
33
+
34
+ Load the registry before the renderer:
35
+
36
+ ```html
37
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/scene-plugin.js"></script>
38
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
39
+ <div data-ld-part="toggle-daylight" data-scene="sakura-evening"></div>
40
+ <script>
41
+ PixelDaylightSwitch.registerScene(sceneManifest);
42
+ </script>
43
+ ```
44
+
45
+ If the runtime auto-mounts on `DOMContentLoaded`, register the manifest before that event. For deterministic ordering, load both scripts with `defer` and register from a later deferred script, or register before loading `app.js`.
46
+
47
+ The one-line `embed.js` loader loads the registry before the renderer, but it does not fetch or execute arbitrary remote scene scripts. Register your manifest in your own page before the component mounts.
48
+
49
+ ## Manifest v1
50
+
51
+ Required fields:
52
+
53
+ - `schemaVersion`: currently `1`.
54
+ - `id`: unique lowercase ID, 3–64 characters. Built-in scene IDs are reserved.
55
+ - `name`, `author`, `license`: scene metadata.
56
+ - `canvas`: width 1–192 and height 1–112.
57
+ - `pixels`: one printable-ASCII string per row; every row must match the declared width.
58
+ - `palettes`: one or more named maps from single characters to `#RGB` or `#RRGGBB`.
59
+ - `timeCycle`: strictly increasing time stops, beginning at hour 0 and ending at hour 24. Each stop references a defined palette.
60
+
61
+ Every symbol used in `pixels` must have a color in every palette. The renderer interpolates the RGB values between adjacent time stops, including across midnight. Keep the first and last palette compatible to avoid a visible midnight jump.
62
+
63
+ The authoritative machine-readable contract is [schemas/scene-manifest.v1.schema.json](schemas/scene-manifest.v1.schema.json). A complete sample is [examples/scenes/sakura-evening.json](examples/scenes/sakura-evening.json).
64
+
65
+ ## API
66
+
67
+ - `registerScene(manifest)`: validate, snapshot and register a scene; returns an unregister function.
68
+ - `validateScene(manifest)`: return `{ valid, errors }` without registering.
69
+ - `getScene(id)`: return a registered, deeply frozen manifest or `null`.
70
+ - `listScenes()`: return a new array of registered manifests.
71
+
72
+ Registration rejects duplicate IDs, built-in IDs, malformed dimensions, invalid colors, missing palette symbols, unsupported manifest fields and invalid timelines. Registered manifests are copied and deeply frozen so later edits to the original object cannot silently alter a running scene.
73
+
74
+ ## Scope and safety
75
+
76
+ Manifest v1 supports palette-driven pixel maps. It intentionally does not execute plugin JavaScript, evaluate expressions, or load arbitrary code. External image/sprite assets, layered tile maps and custom animation tracks are not part of v1; they should be introduced through a separately versioned schema extension with explicit asset limits and lifecycle rules.
77
+
78
+ All five built-in scenes continue to use their original hand-authored painters and scene-specific live behavior. Community scenes use the data-driven renderer and do not replace or mutate built-in scene definitions.
package/app.js CHANGED
@@ -915,12 +915,60 @@ function mount(root) {
915
915
  }
916
916
  };
917
917
 
918
- const scene = ['countryside', 'japanese-street', 'apartment', 'seaside', 'mountain-cabin'].includes(dataset.scene) ? dataset.scene : 'countryside';
918
+ const scenePlugins = typeof window !== 'undefined' ? window.PixelDaylightSwitchScenePlugins : null;
919
+ const pluginScene = scenePlugins && typeof scenePlugins.getScene === 'function' ? scenePlugins.getScene(dataset.scene) : null;
920
+ const builtInScene = ['countryside', 'japanese-street', 'apartment', 'seaside', 'mountain-cabin'].includes(dataset.scene);
921
+ const scene = builtInScene ? dataset.scene : (pluginScene ? dataset.scene : 'countryside');
919
922
  const LIVE = { seaside: liveSeaside, 'mountain-cabin': liveCabin, 'japanese-street': liveStreet, apartment: liveApartment };
920
923
  const drawSceneLive = (h, dt) => { const fn = LIVE[scene]; if (fn) fn(h, dt); };
924
+ const parsePluginColor = (value) => {
925
+ const hex = value.slice(1);
926
+ const full = hex.length === 3 ? hex.split('').map((v) => v + v).join('') : hex;
927
+ return [parseInt(full.slice(0, 2), 16), parseInt(full.slice(2, 4), 16), parseInt(full.slice(4, 6), 16)];
928
+ };
929
+ const pluginPaletteRgb = pluginScene ? Object.fromEntries(Object.entries(pluginScene.palettes).map(([name, palette]) => {
930
+ const colors = new Array(128);
931
+ Object.keys(palette).forEach((symbol) => { colors[symbol.charCodeAt(0)] = parsePluginColor(palette[symbol]); });
932
+ return [name, colors];
933
+ })) : null;
934
+ const renderPluginWorld = (hour) => {
935
+ const stops = pluginScene.timeCycle;
936
+ let left = stops[0]; let right = stops[stops.length - 1]; let sampleHour = hour;
937
+ if (hour < stops[0].at) sampleHour += 24;
938
+ for (let i = 0; i < stops.length - 1; i++) {
939
+ const a = stops[i]; const b = stops[i + 1];
940
+ if (sampleHour >= a.at && sampleHour <= b.at) { left = a; right = b; break; }
941
+ }
942
+ if (sampleHour > stops[stops.length - 1].at) { left = stops[stops.length - 1]; right = stops[0]; right = { at: right.at + 24, palette: right.palette }; }
943
+ const span = Math.max(0.0001, right.at - left.at);
944
+ const t = clamp((sampleHour - left.at) / span);
945
+ const aPalette = pluginPaletteRgb[left.palette];
946
+ const bPalette = pluginPaletteRgb[right.palette];
947
+ const width = pluginScene.canvas.width; const height = pluginScene.canvas.height;
948
+ const scale = Math.min(bw / width, bh / height);
949
+ const drawW = Math.max(1, Math.round(width * scale)); const drawH = Math.max(1, Math.round(height * scale));
950
+ const offsetX = Math.floor((bw - drawW) / 2); const offsetY = Math.floor((bh - drawH) / 2);
951
+ const background = pluginScene.pixels[0].charCodeAt(0);
952
+ for (let py = 0; py < bh; py++) {
953
+ const insideY = py >= offsetY && py < offsetY + drawH;
954
+ const sy = insideY ? clamp(Math.floor(((py - offsetY) / drawH) * height), 0, height - 1) : 0;
955
+ const row = pluginScene.pixels[sy];
956
+ for (let px = 0; px < bw; px++) {
957
+ const inside = insideY && px >= offsetX && px < offsetX + drawW;
958
+ const sx = inside ? clamp(Math.floor(((px - offsetX) / drawW) * width), 0, width - 1) : 0;
959
+ const symbol = inside ? row.charCodeAt(sx) : background;
960
+ const ca = aPalette[symbol]; const cb = bPalette[symbol];
961
+ const r = Math.round(mix(ca[0], cb[0], t));
962
+ const g = Math.round(mix(ca[1], cb[1], t));
963
+ const b = Math.round(mix(ca[2], cb[2], t));
964
+ pixels[py * bw + px] = (255 << 24) | (b << 16) | (g << 8) | r;
965
+ }
966
+ }
967
+ };
921
968
 
922
969
 
923
970
  const buildBase = () => {
971
+ if (pluginScene) { matBuf.fill(0); baseBuf.set(matBuf); skyKeys = []; return; }
924
972
  matBuf.fill(0); if (scene === 'countryside') { paintGround(); paintFence(); paintBush(22, 78, 16, 8, 61); paintBush(176, 80, 14, 7, 62); paintBush(-6, 81, 11, 6, 63); paintBush(204, 81, 10, 5, 64); paintTrunk(); paintHouse(); paintFlowers(96, 126, 85, 92, 26, 91); paintFlowers(14, 38, 86, 96, 16, 92); paintFlowers(150, 205, 90, 106, 24, 93); paintFlowers(-14, 14, 88, 104, 12, 95); paintFlowers(40, 62, 90, 104, 12, 94); buildCanopy(); } else { canopy = []; if (scene === 'japanese-street') paintJapaneseStreet(); else if (scene === 'apartment') paintApartment(); else if (scene === 'seaside') paintSeaside(); else paintMountainCabin(); } baseBuf.set(matBuf);
925
973
  const r = seeded(808); stars = [];
926
974
  for (let i = 0; i < 95; i++) { stars.push({ x: Math.floor(r() * (CORE_W + 40)) - 20, y: Math.floor(r() * 66), seed: r(), big: r() < 0.14 }); }
@@ -1034,7 +1082,9 @@ function mount(root) {
1034
1082
  let keyA = 0; let keyB = 1; let keyP = 0;
1035
1083
  const pickKeys = (h) => { for (let i = 0; i < KEYS.length - 1; i++) { if (h >= KEYS[i][0] && h < KEYS[i + 1][0]) { keyA = i; keyB = i + 1; keyP = clamp(((h - KEYS[i][0]) / (KEYS[i + 1][0] - KEYS[i][0]) - 0.32) / 0.36); return; } } keyA = 0; keyB = 1; keyP = 0; };
1036
1084
  const renderWorld = (clock, dt, hurry) => {
1037
- const h = hourOf(clock); if (!calm) anim += dt; matBuf.set(baseBuf); varBuf.fill(0); emitList.length = 0;
1085
+ const h = hourOf(clock);
1086
+ if (pluginScene) { renderPluginWorld(h); return; }
1087
+ if (!calm) anim += dt; matBuf.set(baseBuf); varBuf.fill(0); emitList.length = 0;
1038
1088
  for (const c of sources) { c.w = 0; c.x = 0; c.y = 0; }
1039
1089
  lampFlick = calm ? 1 : 0.95 + 0.05 * Math.sin(anim * 13) * Math.sin(anim * 5.3);
1040
1090
  if (!calm) { birds.next -= dt; if (birds.t >= 0) { birds.t += dt; if (birds.t > 9) birds.t = -1; } else if (birds.next <= 0) { birds.t = 0; birds.next = 12 + Math.random() * 14; birds.y = 10 + Math.random() * 24; birds.dir = Math.random() < 0.5 ? 1 : -1; } shoot.next -= dt; if (shoot.t >= 0) { shoot.t += dt; if (shoot.t > 0.6) shoot.t = -1; } else if (shoot.next <= 0) { shoot.t = 0; shoot.next = 9 + Math.random() * 16; shoot.x = 10 + Math.random() * 130; shoot.y = 2 + Math.random() * 20; } }
@@ -1326,7 +1376,14 @@ function unmountOne(root) {
1326
1376
  }
1327
1377
  function mountAll() { document.querySelectorAll(SELECTOR).forEach(mountOne); }
1328
1378
  const api = { mount: mountOne, unmount: unmountOne };
1329
- if (typeof document !== 'undefined') {
1379
+ if (typeof window !== 'undefined' && window.PixelDaylightSwitchScenePlugins) {
1380
+ const sceneApi = window.PixelDaylightSwitchScenePlugins;
1381
+ api.registerScene = sceneApi.registerScene;
1382
+ api.validateScene = sceneApi.validateScene;
1383
+ api.getScene = sceneApi.getScene;
1384
+ api.listScenes = sceneApi.listScenes;
1385
+ }
1386
+ if (typeof document !== 'undefined' && !(typeof window !== 'undefined' && window.__PDS_DEFER_AUTO_MOUNT__)) {
1330
1387
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll, { once: true });
1331
1388
  else mountAll();
1332
1389
  }
package/browser.mjs ADDED
@@ -0,0 +1,20 @@
1
+ import './scene-plugin.js';
2
+
3
+ // Browser ESM consumers need to register their scene before mounting.
4
+ // Suppress the classic script's automatic DOM scan until the caller invokes mount().
5
+ globalThis.__PDS_DEFER_AUTO_MOUNT__ = true;
6
+ try {
7
+ await import('./app.js');
8
+ } finally {
9
+ delete globalThis.__PDS_DEFER_AUTO_MOUNT__;
10
+ }
11
+
12
+ const api = globalThis.PixelDaylightSwitch;
13
+ const scenes = globalThis.PixelDaylightSwitchScenePlugins;
14
+ export const mount = api.mount;
15
+ export const unmount = api.unmount;
16
+ export const registerScene = scenes.registerScene;
17
+ export const validateScene = scenes.validateScene;
18
+ export const getScene = scenes.getScene;
19
+ export const listScenes = scenes.listScenes;
20
+ export default api;
@@ -0,0 +1,38 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "id": "sakura-evening",
4
+ "name": "Sakura Evening",
5
+ "author": "Community Artist",
6
+ "license": "CC-BY-4.0",
7
+ "canvas": { "width": 16, "height": 8 },
8
+ "pixels": [
9
+ "................",
10
+ "......ccc.......",
11
+ ".....ccccc......",
12
+ "...tt..s..tt....",
13
+ "..ttttsss.tttt..",
14
+ "......sss.......",
15
+ "gggggggggggggggg",
16
+ "gggggggggggggggg"
17
+ ],
18
+ "palettes": {
19
+ "day": {
20
+ ".": "#9bd8f5", "c": "#f4a7c8", "t": "#8a5a72", "s": "#f6d6df", "g": "#78ad72"
21
+ },
22
+ "dusk": {
23
+ ".": "#ef9b83", "c": "#dc83b4", "t": "#563e69", "s": "#f0c0cf", "g": "#526f62"
24
+ },
25
+ "night": {
26
+ ".": "#171d3b", "c": "#9879b5", "t": "#292744", "s": "#c3a4c8", "g": "#283f45"
27
+ }
28
+ },
29
+ "timeCycle": [
30
+ { "at": 0, "palette": "night" },
31
+ { "at": 5.5, "palette": "night" },
32
+ { "at": 7, "palette": "day" },
33
+ { "at": 16.5, "palette": "day" },
34
+ { "at": 19, "palette": "dusk" },
35
+ { "at": 21, "palette": "night" },
36
+ { "at": 24, "palette": "night" }
37
+ ]
38
+ }
package/index.cjs CHANGED
@@ -1,4 +1,11 @@
1
1
  'use strict';
2
2
 
3
- // The browser runtime is inert during SSR and exposes mount/unmount when a DOM exists.
4
- module.exports = require('./app.js');
3
+ // Load the declarative scene registry before the browser runtime so registered
4
+ // community scenes are available when mount() initializes a root.
5
+ const scenes = require('./scene-plugin.js');
6
+ const api = require('./app.js');
7
+ api.registerScene = scenes.registerScene;
8
+ api.validateScene = scenes.validateScene;
9
+ api.getScene = scenes.getScene;
10
+ api.listScenes = scenes.listScenes;
11
+ module.exports = api;
package/index.d.ts ADDED
@@ -0,0 +1,52 @@
1
+ export interface ScenePalette {
2
+ [symbol: string]: `#${string}`;
3
+ }
4
+
5
+ export interface SceneTimeStop {
6
+ /** Local scene time in hours, from 0 through 24. */
7
+ at: number;
8
+ /** Key in SceneManifest.palettes. */
9
+ palette: string;
10
+ }
11
+
12
+ export interface SceneManifest {
13
+ schemaVersion: 1;
14
+ /** Unique lowercase community scene identifier (3-64 characters). */
15
+ id: string;
16
+ name: string;
17
+ author: string;
18
+ /** SPDX identifier or a clearly stated license label. */
19
+ license: string;
20
+ canvas: { width: number; height: number };
21
+ /** One printable-ASCII character per pixel; rows match canvas dimensions. */
22
+ pixels: string[];
23
+ /** Each palette maps every used pixel symbol to a #RGB or #RRGGBB color. */
24
+ palettes: Record<string, ScenePalette>;
25
+ /** Strictly increasing palette stops; must begin at 0 and end at 24. */
26
+ timeCycle: SceneTimeStop[];
27
+ }
28
+
29
+ export interface SceneValidationResult {
30
+ valid: boolean;
31
+ errors: string[];
32
+ }
33
+
34
+ export type UnregisterScene = () => boolean;
35
+
36
+ export interface PixelDaylightSwitchAPI {
37
+ mount(root: Element | null): (() => void) | null;
38
+ unmount(root: Element | null): void;
39
+ registerScene(manifest: SceneManifest): UnregisterScene;
40
+ validateScene(manifest: unknown): SceneValidationResult;
41
+ getScene(id: string): SceneManifest | null;
42
+ listScenes(): SceneManifest[];
43
+ }
44
+
45
+ declare const PixelDaylightSwitch: PixelDaylightSwitchAPI;
46
+ export declare const mount: PixelDaylightSwitchAPI['mount'];
47
+ export declare const unmount: PixelDaylightSwitchAPI['unmount'];
48
+ export declare const registerScene: PixelDaylightSwitchAPI['registerScene'];
49
+ export declare const validateScene: PixelDaylightSwitchAPI['validateScene'];
50
+ export declare const getScene: PixelDaylightSwitchAPI['getScene'];
51
+ export declare const listScenes: PixelDaylightSwitchAPI['listScenes'];
52
+ export default PixelDaylightSwitch;
package/index.mjs CHANGED
@@ -2,4 +2,8 @@ import api from './index.cjs';
2
2
 
3
3
  export const mount = api.mount;
4
4
  export const unmount = api.unmount;
5
+ export const registerScene = api.registerScene;
6
+ export const validateScene = api.validateScene;
7
+ export const getScene = api.getScene;
8
+ export const listScenes = api.listScenes;
5
9
  export default api;
package/llms.txt CHANGED
@@ -1,118 +1,111 @@
1
1
  # Pixel Daylight Switch
2
2
 
3
- > A dependency-free, draggable pixel-art day/night switch. A tiny animated world lives inside a refractive glass lens.
3
+ > A dependency-free, draggable pixel-art day/night UI component. Five living pixel-art environments sit inside a refractive glass lens, with continuous day-to-night color, lighting and animation transitions.
4
4
 
5
- ## Quick Start
5
+ ## Zero-setup browser embed
6
6
 
7
- ### CDN
7
+ Paste one line where the component should appear:
8
8
 
9
9
  ```html
10
- <div data-ld-part="toggle-daylight"></div>
11
- <script src="https://cdn.jsdelivr.net/npm/pixel-daylight-switch-gsvegetable@1.3.0/app.js"></script>
10
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/embed.js"></script>
12
11
  ```
13
12
 
14
- ### NPM
13
+ The loader creates a responsive instance and loads the runtime. Component styles are scoped; the loader does not inject the demo stylesheet or reset the host page's body, main, footer, typography or layout. Set supported `data-*` options (including `data-scene` and `data-weather`) on the script tag to configure this instance. Use the mount API for multiple instances or custom placement.
15
14
 
16
- - Install: `npm install pixel-daylight-switch-gsvegetable`
17
- - ESM: `import PixelDaylightSwitch from 'pixel-daylight-switch-gsvegetable'`
18
- - Named imports: `import { mount, unmount } from 'pixel-daylight-switch-gsvegetable'`
19
- - CommonJS: `const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable')`
20
- - The runtime auto-mounts matching elements present when the script is evaluated.
21
- - Call `mount(element)` for elements created later. Call `unmount(element)` before removing a mounted element.
22
- - SSR-safe to import: DOM work only runs when `document` exists.
15
+ ## Direct browser usage
23
16
 
24
- ## Markup
25
-
26
- - Mount selector: `[data-ld-part="toggle-daylight"]`
27
- - The component creates its canvas, interaction button, and live status element if they are missing.
28
- - Multiple independent instances are supported.
29
- - The root element should have a measurable width. Its aspect ratio is 1.6439.
30
-
31
- ## Data Attributes
17
+ ```html
18
+ <div style="width:min(100%, 800px);margin-inline:auto">
19
+ <div data-ld-part="toggle-daylight" data-scene="japanese-street"></div>
20
+ </div>
21
+ <script src="https://gsvegetable.github.io/pixel-daylight-switch/app.js"></script>
22
+ ```
32
23
 
33
- All attributes are optional unless stated. Numeric values that are invalid use the default. Numeric values with documented ranges are clamped to those ranges.
24
+ No stylesheet import is required. The runtime injects component-scoped CSS and automatically mounts matching elements. The host/container must have a measurable width. The component preserves its 1.6439 aspect ratio and scales to its available width.
34
25
 
35
- | Attribute | Default | Accepted values / range | Effect |
36
- | --- | --- | --- | --- |
37
- | `data-ld-part` | required | `toggle-daylight` | Identifies a component root for automatic mounting. |
38
- | `data-label` | localized label | Any text | Accessible label for the switch button. |
39
- | `data-speed` | `9.5` | Positive number | Base motion response speed. |
40
- | `data-refraction` | `1.24` | Positive number | Refraction strength of the glass lens. |
41
- | `data-bezel` | `0.046` | Positive number | Glass bezel thickness ratio. |
42
- | `data-start-night` | `false` | `true` / `false` | Start in night mode when exactly `true`. |
43
- | `data-friction` | `0.6` | `0.1–0.9` | Release inertia damping. Lower values retain more glide. |
44
- | `data-bounce` | `3.2` | `0–12` | Edge rebound impulse. |
45
- | `data-dispersion` | `0.05` | `0–0.25` | RGB chromatic dispersion at the lens edge. |
46
- | `data-shine` | `0.44` | `0–1.5` | Daylight lens highlight intensity. |
47
- | `data-time-speed` | `1.0` | `0–8` | Pixel-world simulation speed; zero freezes world time. |
48
- | `data-weather` | `default` | `default`, `snow` | Selects the weather effect. Unknown values use `default`. |
49
- | `data-scene` | `countryside` | `countryside`, `japanese-street`, `apartment`, `seaside`, `mountain-cabin` | Selects the pixel environment. Unknown values use `countryside`. |
26
+ ## npm
50
27
 
51
- ## CSS Custom Properties
28
+ ```bash
29
+ npm install pixel-daylight-switch-gsvegetable
30
+ ```
52
31
 
53
- Set these on the component root. Values shown are the runtime fallbacks.
32
+ ```js
33
+ import PixelDaylightSwitch, { mount, unmount } from 'pixel-daylight-switch-gsvegetable';
54
34
 
55
- | Variable | Default | Effect |
56
- | --- | --- | --- |
57
- | `--ld-bg` | `#f5ecd9` | Component background palette input. |
58
- | `--ld-surface` | `#8c9a62` | Surface and environment palette input. |
59
- | `--ld-fg` | `#1b2146` | Foreground palette input. |
60
- | `--ld-muted` | `#7f97b3` | Muted palette input. |
61
- | `--ld-accent` | `#efa02c` | Focus and accent color. |
62
- | `--ld-radius` | `9999px` | Interaction button corner radius. |
63
- | `--ld-font` | `system-ui, sans-serif` | Component font family. |
64
- | `--ld-space` | `8px` | Focus outline offset scale. |
65
- | `--ld-shadow` | `0 8px 24px rgb(27 33 70 / .2)` | Reserved theme token; current renderer does not consume it. |
66
- | `--ld-motion` | `200ms` | Focus outline transition duration. |
67
- | `--night` | Runtime: `0–1` | Read-only live night intensity; 0 is day, 1 is night. |
35
+ const root = document.querySelector('[data-ld-part="toggle-daylight"]');
36
+ mount(root);
37
+ // Before removing the root:
38
+ unmount(root);
39
+ ```
68
40
 
69
- ## JavaScript API
41
+ CommonJS: `const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable')`.
70
42
 
71
- ### `PixelDaylightSwitch.mount(element)`
43
+ The package is safe to import during SSR; mount only after the target DOM element exists. `mount(element)` is idempotent and returns a cleanup function on success (or null on failure). `unmount(element)` stops rendering and removes observers/listeners; it does not remove the DOM root or forcibly invalidate the canvas context.
72
44
 
73
- - Mounts one component root element.
74
- - Returns a cleanup function when mounting succeeds; returns `null` if mounting fails.
75
- - Idempotent: mounting the same element twice does not create duplicate instances.
76
- - Example: `const destroy = PixelDaylightSwitch.mount(element)`.
45
+ Framework lifecycle examples are in [INTEGRATIONS.md](INTEGRATIONS.md). Scene plugin authoring, manifest constraints and API examples are in [SCENE_PLUGINS.md](SCENE_PLUGINS.md).
77
46
 
78
- ### `PixelDaylightSwitch.unmount(element)`
47
+ ## Community scene plugins (v3.0.0)
79
48
 
80
- - Stops rendering, removes observers and event listeners, and releases the WebGL context for that element.
81
- - Call before removing the element from the document.
82
- - Does not remove the root element itself.
49
+ The optional `scene-plugin.js` registry exposes `registerScene(manifest)`, `validateScene(manifest)`, `getScene(id)` and `listScenes()`. Load the registry before `app.js` in a browser, or import `registerScene` from the npm package. Register a scene before mounting its root. Manifest v1 uses a 1–192 by 1–112 printable-ASCII pixel map, up to 8 named palettes, and a 24-hour timeline that starts at 0 and ends at 24. Every used pixel symbol must have a valid `#RGB` or `#RRGGBB` color in every palette. Data is copied and deeply frozen on registration. Arbitrary plugin JavaScript is never executed. External image/sprite assets, layered tile maps and custom animation tracks are not supported in v1; they are reserved for a future schema extension. See [SCENE_PLUGINS.md](SCENE_PLUGINS.md), the [v1 JSON Schema](schemas/scene-manifest.v1.schema.json), and the [Sakura example](examples/scenes/sakura-evening.json).
83
50
 
84
- ### ESM and CommonJS
51
+ ## Scenes and weather
85
52
 
86
- ```js
87
- import PixelDaylightSwitch, { mount, unmount } from 'pixel-daylight-switch-gsvegetable';
53
+ | Attribute | Default | Accepted values |
54
+ | --- | --- | --- |
55
+ | `data-scene` | `countryside` | `countryside`, `japanese-street`, `apartment`, `seaside`, `mountain-cabin` |
56
+ | `data-weather` | `default` | `default`, `snow` |
57
+ | `data-start-night` | `false` | `true`, `false` |
88
58
 
89
- // Or CommonJS:
90
- // const PixelDaylightSwitch = require('pixel-daylight-switch-gsvegetable');
59
+ All five scenes are authored environments, not static background images. They use time-of-day palette keys and scene-specific lighting/animation: for example, the seaside lighthouse sweeps a beam across the bay, the apartment changes window and room lighting and animates its cat/resident, and the mountain cabin includes snow/pines and aurora behavior. Scene details continue to evolve as the clock moves between dawn, day, dusk and night.
91
60
 
92
- const root = document.querySelector('[data-ld-part="toggle-daylight"]');
93
- mount(root);
94
- // Later:
95
- unmount(root);
96
- ```
61
+ ## Visual and motion parameters
97
62
 
98
- ## Event
63
+ | Attribute | Default | Accepted values / range |
64
+ | --- | --- | --- |
65
+ | `data-speed` | `9.5` | Positive number |
66
+ | `data-refraction` | `1.24` | Positive number |
67
+ | `data-bezel` | `0.046` | Positive number |
68
+ | `data-friction` | `0.6` | `0.1–0.9` |
69
+ | `data-bounce` | `3.2` | `0–12` |
70
+ | `data-dispersion` | `0.05` | `0–0.25` |
71
+ | `data-shine` | `0.44` | `0–1.5` |
72
+ | `data-time-speed` | `1` | `0–8`; zero freezes world time |
73
+
74
+ Invalid values fall back to defaults; documented numeric ranges are clamped.
75
+
76
+ ## JavaScript API and events
77
+
78
+ - `mount(element)`: mount one root; idempotent.
79
+ - `unmount(element)`: stop rendering and clean up observers and event listeners.
80
+ - The runtime auto-mounts existing `[data-ld-part="toggle-daylight"]` elements.
81
+ - Use `mount(element)` for elements created later.
82
+ - Listen for `daylight:change` on the root. `event.detail.night` is a boolean and the event fires when the committed day/night state changes, not every animation frame.
83
+ - The root exposes transition progress through CSS custom property `--night` (0 day, 1 night).
84
+
85
+ ## Rendering, accessibility and performance
86
+
87
+ - WebGL2 renders the refractive glass; Canvas 2D is the fallback.
88
+ - Keyboard interaction, switch semantics, accessible labels and live status are provided.
89
+ - Respects `prefers-reduced-motion`.
90
+ - Rendering pauses while the page is hidden or the component is outside the viewport.
91
+ - Multiple independent instances are supported.
99
92
 
100
- ### `daylight:change`
93
+ ## Links
101
94
 
102
- - Dispatched on the component root when the switch reaches a changed day/night state.
103
- - Bubbles and is composed.
104
- - `event.detail.night` is a boolean.
95
+ - [Live demo](https://gsvegetable.github.io/pixel-daylight-switch/)
96
+ - [Interactive Playground](https://gsvegetable.github.io/pixel-daylight-switch/playground.html)
97
+ - [Preview](https://gsvegetable.github.io/pixel-daylight-switch/preview.html)
98
+ - [Integration recipes](INTEGRATIONS.md)
99
+ - [English README](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja.md)
100
+ - [Changelog](CHANGELOG.md)
105
101
 
106
- ```js
107
- document.addEventListener('daylight:change', (event) => {
108
- const isNight = event.detail.night;
109
- document.documentElement.classList.toggle('dark', isNight);
110
- });
111
- ```
112
102
 
113
- ## Rendering and Compatibility
103
+ ## Scene plugins (v3.0.0)
114
104
 
115
- - WebGL2 renders the refractive glass; Canvas 2D is used as a fallback.
116
- - Respects `prefers-reduced-motion`.
117
- - Pauses unnecessary rendering when the page is hidden or the component is offscreen.
118
- - No runtime dependencies.
105
+ - Public API: registerScene(manifest), validateScene(manifest), getScene(id), listScenes().
106
+ - Manifest schema: schemas/scene-manifest.v1.schema.json.
107
+ - Example: examples/scenes/sakura-evening.json.
108
+ - Manifest v1: printable-ASCII pixel matrix (up to 192x112), named palettes, 24-hour palette interpolation.
109
+ - Scene registration is data-only; arbitrary plugin JavaScript is not executed.
110
+ - Browser script order: scene-plugin.js, register the manifest, then app.js. The one-line embed loader loads the registry before the renderer.
111
+ - Existing built-in scenes and their custom live animation code remain unchanged.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pixel-daylight-switch-gsvegetable",
3
- "version": "2.2.0",
4
- "description": "A draggable pixel-art day/night switch with a living world inside a refractive glass lens.",
3
+ "version": "3.0.0",
4
+ "description": "A draggable pixel-art day/night switch with a community-extensible scene plugin system.",
5
5
  "keywords": [
6
6
  "pixel-art",
7
7
  "day-night",
@@ -13,7 +13,11 @@
13
13
  "vanilla-js",
14
14
  "capacitor",
15
15
  "android",
16
- "ios"
16
+ "ios",
17
+ "scene-plugin",
18
+ "pixel-world",
19
+ "scene-manifest",
20
+ "pixel-art-scenes"
17
21
  ],
18
22
  "author": "GSvegetable",
19
23
  "license": "Unlicense",
@@ -27,14 +31,20 @@
27
31
  },
28
32
  "main": "./index.cjs",
29
33
  "module": "./index.mjs",
30
- "browser": "./app.js",
34
+ "browser": "./browser.mjs",
31
35
  "exports": {
32
36
  ".": {
37
+ "types": "./index.d.ts",
38
+ "browser": "./browser.mjs",
33
39
  "import": "./index.mjs",
34
40
  "require": "./index.cjs"
35
41
  },
36
42
  "./app.js": "./app.js",
37
- "./package.json": "./package.json"
43
+ "./package.json": "./package.json",
44
+ "./scene-plugin": {
45
+ "import": "./scene-plugin.js",
46
+ "require": "./scene-plugin.js"
47
+ }
38
48
  },
39
49
  "files": [
40
50
  "app.js",
@@ -42,7 +52,14 @@
42
52
  "index.mjs",
43
53
  "README.md",
44
54
  "LICENSE",
45
- "llms.txt"
55
+ "llms.txt",
56
+ "INTEGRATIONS.md",
57
+ "SCENE_PLUGINS.md",
58
+ "scene-plugin.js",
59
+ "schemas",
60
+ "examples",
61
+ "browser.mjs",
62
+ "index.d.ts"
46
63
  ],
47
64
  "sideEffects": [
48
65
  "./app.js"
@@ -54,7 +71,7 @@
54
71
  "access": "public"
55
72
  },
56
73
  "scripts": {
57
- "test": "node tests/package-smoke.cjs && node tests/mobile-smoke.cjs",
74
+ "test": "node tests/package-smoke.cjs && node tests/scene-plugin-smoke.cjs && node tests/mobile-smoke.cjs && node tests/embed-smoke.cjs",
58
75
  "test:browser": "playwright test",
59
76
  "mobile:prepare": "node scripts/prepare-mobile.cjs",
60
77
  "mobile:android": "npm run mobile:prepare && cap sync android && node scripts/set-android-version.cjs android/app/build.gradle",
@@ -69,5 +86,6 @@
69
86
  "@capacitor/core": "8.6.0",
70
87
  "@capacitor/android": "8.6.0",
71
88
  "@capacitor/ios": "8.6.0"
72
- }
89
+ },
90
+ "types": "./index.d.ts"
73
91
  }
@@ -0,0 +1,127 @@
1
+ (function (root, factory) {
2
+ const api = root && root.PixelDaylightSwitchScenePlugins ? root.PixelDaylightSwitchScenePlugins : factory();
3
+ if (typeof module === 'object' && module.exports) module.exports = api;
4
+ if (root) {
5
+ root.PixelDaylightSwitchScenePlugins = api;
6
+ root.PixelDaylightSwitchScenes = api;
7
+ }
8
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
9
+ 'use strict';
10
+ const registry = new Map();
11
+ const MAX_WIDTH = 192, MAX_HEIGHT = 112, MAX_PALETTES = 8, MAX_SYMBOLS = 64, MAX_TIME_STOPS = 48;
12
+ const ID_RE = /^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$/;
13
+ const COLOR_RE = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
14
+ const SYMBOL_RE = /^[\x20-\x7E]$/;
15
+ const BUILT_INS = new Set(['countryside', 'japanese-street', 'apartment', 'seaside', 'mountain-cabin']);
16
+ const isPlainObject = value => {
17
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
18
+ const prototype = Object.getPrototypeOf(value);
19
+ return prototype === Object.prototype || prototype === null;
20
+ };
21
+ const unknownKeys = (value, allowed, path, errors) => {
22
+ if (isPlainObject(value)) Object.keys(value).forEach(key => { if (!allowed.has(key)) errors.push(path + ' contains unsupported property "' + key + '".'); });
23
+ };
24
+ function validateScene(input) {
25
+ const errors = [];
26
+ if (!isPlainObject(input)) return { valid:false, errors:['Scene manifest must be a plain object.'] };
27
+ unknownKeys(input, new Set(['schemaVersion','id','name','author','license','canvas','pixels','palettes','timeCycle']), 'manifest', errors);
28
+ if (input.schemaVersion !== 1) errors.push('schemaVersion must be 1.');
29
+ if (typeof input.id !== 'string' || !ID_RE.test(input.id)) errors.push('id must be 3-64 lowercase letters, numbers, dots, underscores or hyphens.');
30
+ if (BUILT_INS.has(input.id)) errors.push('Built-in scene IDs are reserved.');
31
+ if (typeof input.name !== 'string' || !input.name.trim() || input.name.length > 80) errors.push('name must be a non-empty string of at most 80 characters.');
32
+ if (typeof input.author !== 'string' || !input.author.trim() || input.author.length > 80) errors.push('author must be a non-empty string of at most 80 characters.');
33
+ if (typeof input.license !== 'string' || !input.license.trim() || input.license.length > 64) errors.push('license must be a non-empty SPDX identifier or license label.');
34
+ const canvas = input.canvas;
35
+ if (!isPlainObject(canvas)) errors.push('canvas must be an object.');
36
+ else unknownKeys(canvas, new Set(['width','height']), 'canvas', errors);
37
+ const width = canvas && canvas.width, height = canvas && canvas.height;
38
+ if (!Number.isInteger(width) || width < 1 || width > MAX_WIDTH) errors.push('canvas.width must be an integer from 1 to 192.');
39
+ if (!Number.isInteger(height) || height < 1 || height > MAX_HEIGHT) errors.push('canvas.height must be an integer from 1 to 112.');
40
+ if (!Array.isArray(input.pixels) || input.pixels.length === 0) errors.push('pixels must be a non-empty array of strings.');
41
+ else {
42
+ if (input.pixels.length > MAX_HEIGHT) errors.push('pixels cannot contain more than 112 rows.');
43
+ if (Number.isInteger(height) && input.pixels.length !== height) errors.push('pixels must contain exactly canvas.height rows.');
44
+ input.pixels.forEach((row,index) => {
45
+ if (typeof row !== 'string' || !Number.isInteger(width) || row.length !== width) errors.push('pixels[' + index + '] must be a string exactly canvas.width characters long.');
46
+ else if (!Array.from(row).every(symbol => SYMBOL_RE.test(symbol))) errors.push('pixels[' + index + '] may contain printable ASCII characters only.');
47
+ });
48
+ }
49
+ const palettes = input.palettes;
50
+ if (!isPlainObject(palettes)) errors.push('palettes must be an object of named color maps.');
51
+ else {
52
+ const names = Object.keys(palettes);
53
+ if (!names.length) errors.push('palettes must define at least one palette.');
54
+ if (names.length > MAX_PALETTES) errors.push('palettes cannot contain more than 8 palettes.');
55
+ names.forEach(name => {
56
+ const palette = palettes[name];
57
+ if (!isPlainObject(palette)) errors.push('palette "' + name + '" must be a color map.');
58
+ else {
59
+ const symbols = Object.keys(palette);
60
+ if (symbols.length > MAX_SYMBOLS) errors.push('palette "' + name + '" cannot contain more than 64 symbols.');
61
+ symbols.forEach(symbol => {
62
+ if (!SYMBOL_RE.test(symbol)) errors.push('Palette keys must be one printable ASCII character.');
63
+ if (typeof palette[symbol] !== 'string' || !COLOR_RE.test(palette[symbol])) errors.push('Palette color for "' + symbol + '" must be #RGB or #RRGGBB.');
64
+ });
65
+ }
66
+ });
67
+ }
68
+ const timeCycle = input.timeCycle;
69
+ if (!Array.isArray(timeCycle) || timeCycle.length < 2) errors.push('timeCycle must contain at least two time stops.');
70
+ else {
71
+ if (timeCycle.length > MAX_TIME_STOPS) errors.push('timeCycle may contain at most 48 stops.');
72
+ let previous = -1;
73
+ timeCycle.forEach((stop,index) => {
74
+ if (!isPlainObject(stop)) { errors.push('timeCycle[' + index + '] must be an object.'); return; }
75
+ unknownKeys(stop, new Set(['at','palette']), 'timeCycle[' + index + ']', errors);
76
+ if (!Number.isFinite(stop.at) || stop.at < 0 || stop.at > 24 || stop.at <= previous) errors.push('timeCycle[' + index + '].at must be strictly increasing from 0 to 24.');
77
+ else previous = stop.at;
78
+ if (typeof stop.palette !== 'string' || !isPlainObject(palettes) || !Object.prototype.hasOwnProperty.call(palettes,stop.palette)) errors.push('timeCycle[' + index + '] references an unknown palette.');
79
+ });
80
+ if (timeCycle[0] && timeCycle[0].at !== 0) errors.push('timeCycle must start at hour 0.');
81
+ if (timeCycle[timeCycle.length-1] && timeCycle[timeCycle.length-1].at !== 24) errors.push('timeCycle must end at hour 24.');
82
+ }
83
+ if (Array.isArray(input.pixels) && isPlainObject(palettes)) {
84
+ const symbols = new Set();
85
+ input.pixels.forEach(row => { if (typeof row === 'string') for (const symbol of row) symbols.add(symbol); });
86
+ if (symbols.size > MAX_SYMBOLS) errors.push('pixels cannot use more than 64 unique symbols.');
87
+ Object.keys(palettes).forEach(name => {
88
+ const palette = palettes[name];
89
+ if (!isPlainObject(palette)) return;
90
+ symbols.forEach(symbol => { if (!Object.prototype.hasOwnProperty.call(palette,symbol)) errors.push('Palette "' + name + '" is missing pixel symbol "' + symbol + '".'); });
91
+ });
92
+ }
93
+ return { valid:errors.length === 0, errors };
94
+ }
95
+ function deepFreeze(value) {
96
+ if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
97
+ Object.keys(value).forEach(key => deepFreeze(value[key]));
98
+ return Object.freeze(value);
99
+ }
100
+ function registerScene(manifest) {
101
+ const result = validateScene(manifest);
102
+ if (!result.valid) {
103
+ const error = new Error('Invalid Pixel Daylight Switch scene: ' + result.errors.join(' '));
104
+ error.validationErrors = result.errors;
105
+ throw error;
106
+ }
107
+ if (registry.has(manifest.id)) throw new Error('A scene with id "' + manifest.id + '" is already registered.');
108
+ let snapshot;
109
+ try { snapshot = JSON.parse(JSON.stringify(manifest)); }
110
+ catch (cause) {
111
+ const error = new Error('Scene manifest must be JSON-serializable.');
112
+ error.cause = cause;
113
+ throw error;
114
+ }
115
+ const frozen = deepFreeze(snapshot);
116
+ registry.set(frozen.id, frozen);
117
+ let active = true;
118
+ return function unregisterScene() {
119
+ if (!active) return false;
120
+ active = false;
121
+ return registry.delete(frozen.id);
122
+ };
123
+ }
124
+ const getScene = id => registry.get(id) || null;
125
+ const listScenes = () => Array.from(registry.values());
126
+ return Object.freeze({ registerScene, validateScene, getScene, listScenes, schemaVersion:1 });
127
+ });
@@ -0,0 +1,117 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://gsvegetable.github.io/pixel-daylight-switch/schemas/scene-manifest.v1.schema.json",
4
+ "title": "Pixel Daylight Switch Scene Manifest v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "required": [
8
+ "schemaVersion",
9
+ "id",
10
+ "name",
11
+ "author",
12
+ "license",
13
+ "canvas",
14
+ "pixels",
15
+ "palettes",
16
+ "timeCycle"
17
+ ],
18
+ "properties": {
19
+ "schemaVersion": {
20
+ "const": 1
21
+ },
22
+ "id": {
23
+ "type": "string",
24
+ "minLength": 3,
25
+ "maxLength": 64,
26
+ "pattern": "^[a-z0-9][a-z0-9._-]{1,62}[a-z0-9]$"
27
+ },
28
+ "name": {
29
+ "type": "string",
30
+ "minLength": 1,
31
+ "maxLength": 80
32
+ },
33
+ "author": {
34
+ "type": "string",
35
+ "minLength": 1,
36
+ "maxLength": 80
37
+ },
38
+ "license": {
39
+ "type": "string",
40
+ "minLength": 1,
41
+ "maxLength": 64
42
+ },
43
+ "canvas": {
44
+ "type": "object",
45
+ "additionalProperties": false,
46
+ "required": [
47
+ "width",
48
+ "height"
49
+ ],
50
+ "properties": {
51
+ "width": {
52
+ "type": "integer",
53
+ "minimum": 1,
54
+ "maximum": 192
55
+ },
56
+ "height": {
57
+ "type": "integer",
58
+ "minimum": 1,
59
+ "maximum": 112
60
+ }
61
+ }
62
+ },
63
+ "pixels": {
64
+ "type": "array",
65
+ "minItems": 1,
66
+ "maxItems": 112,
67
+ "items": {
68
+ "type": "string",
69
+ "minLength": 1,
70
+ "maxLength": 192,
71
+ "pattern": "^[\\x20-\\x7E]+$"
72
+ }
73
+ },
74
+ "palettes": {
75
+ "type": "object",
76
+ "minProperties": 1,
77
+ "maxProperties": 8,
78
+ "additionalProperties": {
79
+ "type": "object",
80
+ "maxProperties": 64,
81
+ "propertyNames": {
82
+ "type": "string",
83
+ "minLength": 1,
84
+ "maxLength": 1,
85
+ "pattern": "^[\\u0020-\\u007E]$"
86
+ },
87
+ "additionalProperties": {
88
+ "type": "string",
89
+ "pattern": "^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
90
+ }
91
+ }
92
+ },
93
+ "timeCycle": {
94
+ "type": "array",
95
+ "minItems": 2,
96
+ "items": {
97
+ "type": "object",
98
+ "additionalProperties": false,
99
+ "required": [
100
+ "at",
101
+ "palette"
102
+ ],
103
+ "properties": {
104
+ "at": {
105
+ "type": "number",
106
+ "minimum": 0,
107
+ "maximum": 24
108
+ },
109
+ "palette": {
110
+ "type": "string"
111
+ }
112
+ }
113
+ },
114
+ "maxItems": 48
115
+ }
116
+ }
117
+ }