feat: new planty package
This commit is contained in:
@@ -9,7 +9,7 @@
|
|||||||
"test": "pnpm run -r --parallel test",
|
"test": "pnpm run -r --parallel test",
|
||||||
"check": "pnpm run -r --parallel check",
|
"check": "pnpm run -r --parallel check",
|
||||||
"build": "pnpm build:nodes && pnpm build:app",
|
"build": "pnpm build:nodes && pnpm build:app",
|
||||||
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app' build",
|
"build:app": "BASE_PATH=/ui pnpm -r --filter 'ui' build && pnpm -r --filter 'app'... build",
|
||||||
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",
|
"build:nodes": "cargo build --workspace --target wasm32-unknown-unknown --release && rm -rf ./app/static/nodes/max/plantarium/ && mkdir -p ./app/static/nodes/max/plantarium/ && cp -R ./target/wasm32-unknown-unknown/release/*.wasm ./app/static/nodes/max/plantarium/",
|
||||||
"dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'",
|
"dev:nodes": "chokidar './nodes/**' --initial -i '/pkg/' -c 'pnpm build:nodes'",
|
||||||
"dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev",
|
"dev:app_ui": "pnpm -r --parallel --filter 'app' --filter './packages/ui' dev",
|
||||||
|
|||||||
24
packages/planty/.gitignore
vendored
Normal file
24
packages/planty/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
9
packages/planty/.prettierignore
Normal file
9
packages/planty/.prettierignore
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
16
packages/planty/.prettierrc
Normal file
16
packages/planty/.prettierrc
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tailwindStylesheet": "./src/routes/layout.css"
|
||||||
|
}
|
||||||
65
packages/planty/README.md
Normal file
65
packages/planty/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Svelte library
|
||||||
|
|
||||||
|
Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
|
||||||
|
|
||||||
|
Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To recreate this project with the same configuration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# recreate this project
|
||||||
|
pnpm dlx sv@0.15.1 create --template library --types ts --add prettier eslint tailwindcss="plugins:none" --install pnpm planty
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build your library:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm pack
|
||||||
|
```
|
||||||
|
|
||||||
|
To create a production version of your showcase app:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||||
|
|
||||||
|
## Publishing
|
||||||
|
|
||||||
|
Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
|
||||||
|
|
||||||
|
To publish your library to [npm](https://www.npmjs.com):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm publish
|
||||||
|
```
|
||||||
44
packages/planty/eslint.config.js
Normal file
44
packages/planty/eslint.config.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = path.resolve(import.meta.dirname, '.gitignore');
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
ts.configs.recommended,
|
||||||
|
svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
'no-undef': 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Override or add rule settings here, such as:
|
||||||
|
// 'svelte/button-has-type': 'error'
|
||||||
|
rules: {}
|
||||||
|
}
|
||||||
|
);
|
||||||
63
packages/planty/package.json
Normal file
63
packages/planty/package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@nodarium/planty",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build && npm run prepack",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"prepack": "svelte-kit sync && svelte-package && publint",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "prettier --check . && eslint .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"!dist/**/*.test.*",
|
||||||
|
"!dist/**/*.spec.*"
|
||||||
|
],
|
||||||
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
|
"svelte": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"svelte": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"svelte": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nodarium/ui": "workspace:*",
|
||||||
|
"@eslint/compat": "^2.0.4",
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/kit": "^2.57.0",
|
||||||
|
"@sveltejs/package": "^2.5.7",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@types/node": "^24",
|
||||||
|
"eslint": "^10.2.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.17.0",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-svelte": "^3.5.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"publint": "^0.3.18",
|
||||||
|
"svelte": "^5.55.2",
|
||||||
|
"svelte-check": "^4.4.6",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"typescript": "^6.0.2",
|
||||||
|
"typescript-eslint": "^8.58.1",
|
||||||
|
"vite": "^8.0.7"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"svelte"
|
||||||
|
]
|
||||||
|
}
|
||||||
13
packages/planty/src/app.d.ts
vendored
Normal file
13
packages/planty/src/app.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
13
packages/planty/src/app.html
Normal file
13
packages/planty/src/app.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="theme-dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="text-scale" content="scale" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
110
packages/planty/src/lib/components/Highlight.svelte
Normal file
110
packages/planty/src/lib/components/Highlight.svelte
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { PlantyHook } from '../types.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selector?: string;
|
||||||
|
hookName?: string;
|
||||||
|
hooks?: Record<string, PlantyHook>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { selector, hookName, hooks = {} }: Props = $props();
|
||||||
|
|
||||||
|
let rect = $state<{ top: number; left: number; width: number; height: number } | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let el: Element | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
let mo: MutationObserver | null = null;
|
||||||
|
|
||||||
|
function resolveEl(): Element | null {
|
||||||
|
if (selector) return document.querySelector(selector);
|
||||||
|
if (hookName && hooks[hookName]) {
|
||||||
|
const result = hooks[hookName]();
|
||||||
|
if (result instanceof Element) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRect() {
|
||||||
|
if (!el) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = el.getBoundingClientRect();
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const p = 4;
|
||||||
|
const top = Math.max(p, raw.top - p);
|
||||||
|
const left = Math.max(p, raw.left - p);
|
||||||
|
const right = Math.min(vw - p, raw.right + p);
|
||||||
|
const bottom = Math.min(vh - p, raw.bottom + p);
|
||||||
|
if (right <= left || bottom <= top) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rect = { top, left, width: right - left, height: bottom - top };
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachEl(newEl: Element | null) {
|
||||||
|
if (newEl === el) return;
|
||||||
|
ro?.disconnect();
|
||||||
|
el = newEl;
|
||||||
|
if (!el) {
|
||||||
|
rect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateRect();
|
||||||
|
ro = new ResizeObserver(updateRect);
|
||||||
|
ro.observe(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
attachEl(resolveEl());
|
||||||
|
|
||||||
|
window.addEventListener('scroll', updateRect, { passive: true, capture: true });
|
||||||
|
window.addEventListener('resize', updateRect, { passive: true });
|
||||||
|
|
||||||
|
// For hook-based highlights, watch the DOM so we catch dynamically added elements
|
||||||
|
if (hookName) {
|
||||||
|
mo = new MutationObserver(() => attachEl(resolveEl()));
|
||||||
|
mo.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
mo?.disconnect();
|
||||||
|
window.removeEventListener('scroll', updateRect, true);
|
||||||
|
window.removeEventListener('resize', updateRect);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if rect}
|
||||||
|
<div
|
||||||
|
class="highlight fixed z-99999 rounded-md pointer-events-none"
|
||||||
|
style:top="{rect.top}px"
|
||||||
|
style:left="{rect.left}px"
|
||||||
|
style:width="{rect.width}px"
|
||||||
|
style:height="{rect.height}px"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 9999px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 0.9),
|
||||||
|
0 0 16px rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 9999px rgba(0, 0, 0, 0.45),
|
||||||
|
0 0 0 2px rgba(255, 255, 255, 1),
|
||||||
|
0 0 28px rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.highlight {
|
||||||
|
animation: pulse 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
222
packages/planty/src/lib/components/Planty.svelte
Normal file
222
packages/planty/src/lib/components/Planty.svelte
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { DialogRunner } from '../dialog-runner.js';
|
||||||
|
import type { AvatarPosition, DialogNode, PlantyConfig, PlantyHook } from '../types.js';
|
||||||
|
import Highlight from './Highlight.svelte';
|
||||||
|
import PlantyAvatar from './PlantyAvatar.svelte';
|
||||||
|
import type { Mood } from './PlantyAvatar.svelte';
|
||||||
|
import SpeechBubble from './SpeechBubble.svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
config: PlantyConfig;
|
||||||
|
hooks?: Record<string, PlantyHook>;
|
||||||
|
onStepChange?: (nodeId: string, node: DialogNode) => void;
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
config,
|
||||||
|
hooks = {},
|
||||||
|
onStepChange,
|
||||||
|
onComplete
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 80;
|
||||||
|
const SCREEN_PADDING = 20;
|
||||||
|
|
||||||
|
// ── State ────────────────────────────────────────────────────────────
|
||||||
|
let isActive = $state(false);
|
||||||
|
let currentNodeId = $state<string | null>(null);
|
||||||
|
let bubbleVisible = $state(false);
|
||||||
|
let avatarX = $state(0);
|
||||||
|
let avatarY = $state(0);
|
||||||
|
let mood = $state<Mood>('idle');
|
||||||
|
let autoAdvanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let actionCleanup: (() => void) | null = null;
|
||||||
|
|
||||||
|
// ── Derived ──────────────────────────────────────────────────────────
|
||||||
|
const runner = $derived(new DialogRunner(config));
|
||||||
|
const mainPath = $derived(runner.getMainPath());
|
||||||
|
const currentNode = $derived<DialogNode | null>(
|
||||||
|
currentNodeId ? runner.getNode(currentNodeId) : null
|
||||||
|
);
|
||||||
|
const showBubble = $derived(
|
||||||
|
isActive && bubbleVisible && currentNode !== null && !!currentNode.text
|
||||||
|
);
|
||||||
|
const highlight = $derived(currentNode?.highlight ?? null);
|
||||||
|
const stepIndex = $derived(currentNodeId ? mainPath.indexOf(currentNodeId) : -1);
|
||||||
|
const totalSteps = $derived(mainPath.length);
|
||||||
|
|
||||||
|
// ── Position helpers ─────────────────────────────────────────────────
|
||||||
|
function anchorToCoords(anchor: string): { x: number; y: number } {
|
||||||
|
const w = window.innerWidth;
|
||||||
|
const h = window.innerHeight;
|
||||||
|
switch (anchor) {
|
||||||
|
case 'top-left':
|
||||||
|
return { x: SCREEN_PADDING, y: SCREEN_PADDING };
|
||||||
|
case 'top-right':
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: SCREEN_PADDING };
|
||||||
|
case 'bottom-left':
|
||||||
|
return { x: SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
|
||||||
|
case 'center':
|
||||||
|
return { x: (w - AVATAR_SIZE) / 2, y: (h - AVATAR_SIZE) / 2 };
|
||||||
|
case 'right':
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: (h - AVATAR_SIZE) / 2 };
|
||||||
|
case 'bottom-right':
|
||||||
|
default:
|
||||||
|
return { x: w - AVATAR_SIZE - SCREEN_PADDING, y: h - AVATAR_SIZE - SCREEN_PADDING };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePosition(pos: AvatarPosition): { x: number; y: number } {
|
||||||
|
return typeof pos === 'string' ? anchorToCoords(pos) : pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API (exposed via bind:this) ───────────────────────────────
|
||||||
|
export function start() {
|
||||||
|
const defaultPos = config.avatar?.defaultPosition ?? 'bottom-right';
|
||||||
|
const pos = resolvePosition(defaultPos);
|
||||||
|
avatarX = pos.x;
|
||||||
|
avatarY = pos.y;
|
||||||
|
isActive = true;
|
||||||
|
|
||||||
|
const start = runner.getStartNode();
|
||||||
|
if (start) _enterNode(start.id, start.node);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stop() {
|
||||||
|
_clearAutoAdvance();
|
||||||
|
isActive = false;
|
||||||
|
bubbleVisible = false;
|
||||||
|
currentNodeId = null;
|
||||||
|
mood = 'idle';
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function next() {
|
||||||
|
if (!currentNodeId) return;
|
||||||
|
await _runAfter(currentNodeId, currentNode);
|
||||||
|
const next = runner.getNextNode(currentNodeId);
|
||||||
|
if (next) _enterNode(next.id, next.node);
|
||||||
|
else stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerHook(name: string, fn: PlantyHook) {
|
||||||
|
hooks = { ...hooks, [name]: fn };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function _runAfter(nodeId: string, node: DialogNode | null) {
|
||||||
|
if (!node) return;
|
||||||
|
if (actionCleanup) {
|
||||||
|
actionCleanup();
|
||||||
|
actionCleanup = null;
|
||||||
|
}
|
||||||
|
await node.after?.(nodeId, node);
|
||||||
|
await hooks[`after:${nodeId}`]?.(nodeId, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _enterNode(id: string, node: DialogNode) {
|
||||||
|
_clearAutoAdvance();
|
||||||
|
bubbleVisible = false;
|
||||||
|
currentNodeId = id;
|
||||||
|
onStepChange?.(id, node);
|
||||||
|
|
||||||
|
// Before hooks — run before movement starts
|
||||||
|
await node.before?.(id, node);
|
||||||
|
await hooks[`before:${id}`]?.(id, node);
|
||||||
|
|
||||||
|
// Fly to position first, then talk
|
||||||
|
if (node.position) {
|
||||||
|
mood = 'moving';
|
||||||
|
const pos = resolvePosition(node.position);
|
||||||
|
avatarX = pos.x;
|
||||||
|
avatarY = pos.y;
|
||||||
|
await _wait(900);
|
||||||
|
}
|
||||||
|
|
||||||
|
mood = 'talking';
|
||||||
|
bubbleVisible = true;
|
||||||
|
|
||||||
|
// App hook
|
||||||
|
if (node.hook && hooks[node.hook]) {
|
||||||
|
const result = await hooks[node.hook](...(node.hookArgs ?? []));
|
||||||
|
if (typeof result === 'function') actionCleanup = result as () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-advance
|
||||||
|
if (typeof node.waitFor === 'number') {
|
||||||
|
autoAdvanceTimer = setTimeout(() => next(), node.waitFor);
|
||||||
|
}
|
||||||
|
if (node.waitFor === 'action') {
|
||||||
|
const actionHook = hooks[`action:${id}`];
|
||||||
|
if (actionHook) {
|
||||||
|
const advance = () => next();
|
||||||
|
const result = await actionHook(advance, ...(node.hookArgs ?? []));
|
||||||
|
if (typeof result === 'function') actionCleanup = result as () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!node.choices && !node.next) {
|
||||||
|
setTimeout(() => stop(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stay in talking mood until the typewriter finishes (26 ms/char + buffer)
|
||||||
|
const talkMs = (node.text?.length ?? 0) * 26 + 200;
|
||||||
|
setTimeout(() => {
|
||||||
|
mood = 'idle';
|
||||||
|
}, talkMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wait(ms: number) {
|
||||||
|
return new Promise<void>((r) => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearAutoAdvance() {
|
||||||
|
if (autoAdvanceTimer !== null) {
|
||||||
|
clearTimeout(autoAdvanceTimer);
|
||||||
|
autoAdvanceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isActive}
|
||||||
|
<div class="pointer-events-none fixed inset-0 z-99999">
|
||||||
|
{#if highlight}
|
||||||
|
<Highlight
|
||||||
|
selector={highlight.selector}
|
||||||
|
hookName={highlight.hookName}
|
||||||
|
{hooks}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<PlantyAvatar bind:x={avatarX} bind:y={avatarY} {mood} />
|
||||||
|
|
||||||
|
{#if showBubble && currentNode}
|
||||||
|
<SpeechBubble
|
||||||
|
text={currentNode.text ?? ''}
|
||||||
|
{avatarX}
|
||||||
|
{avatarY}
|
||||||
|
choices={currentNode.choices || []}
|
||||||
|
showNext={currentNode.waitFor === 'click'}
|
||||||
|
{stepIndex}
|
||||||
|
{totalSteps}
|
||||||
|
onNext={next}
|
||||||
|
onClose={stop}
|
||||||
|
onChoose={async (choice) => {
|
||||||
|
await _runAfter(currentNodeId!, currentNode);
|
||||||
|
if (choice && choice.onclick) {
|
||||||
|
choice.onclick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!choice.next) {
|
||||||
|
stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = runner.followChoice(choice);
|
||||||
|
if (n) _enterNode(n.id, n.node);
|
||||||
|
else stop();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
319
packages/planty/src/lib/components/PlantyAvatar.svelte
Normal file
319
packages/planty/src/lib/components/PlantyAvatar.svelte
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { scale } from 'svelte/transition';
|
||||||
|
export type Mood = 'idle' | 'talking' | 'happy' | 'thinking' | 'moving';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
mood?: Mood;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { x = $bindable(0), y = $bindable(0), mood = 'idle' }: Props = $props();
|
||||||
|
|
||||||
|
// ── Drag ─────────────────────────────────────────────────────────────
|
||||||
|
let dragging = $state(false);
|
||||||
|
let dragOffsetX = 0;
|
||||||
|
let dragOffsetY = 0;
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
dragging = true;
|
||||||
|
dragOffsetX = e.clientX - x;
|
||||||
|
dragOffsetY = e.clientY - y;
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging) return;
|
||||||
|
x = Math.max(Math.min(e.clientX - dragOffsetX, window.innerWidth - 45), 5);
|
||||||
|
y = Math.max(Math.min(e.clientY - dragOffsetY, window.innerHeight - 75), 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayMood = $derived(dragging ? 'moving' : mood);
|
||||||
|
|
||||||
|
let mouthOpen = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (displayMood !== 'talking') {
|
||||||
|
mouthOpen = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = setInterval(() => {
|
||||||
|
mouthOpen = !mouthOpen;
|
||||||
|
}, 180);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const MOUTH_DOWN =
|
||||||
|
'M29.5 55L28 63L23 68.5L14 70.5L6.5 66L4 58.5L10.5 29L15 24H24L28 29.5L28.5 34L23 58L16.5 61.5L10.5 59.5L8.5 53.5';
|
||||||
|
const MOUTH_UP =
|
||||||
|
'M29.5 55L28 63L23 68.5L14 70.5L6.5 66L4 58.5L10.5 29L15 24H24L28 29.5L28.5 34L24 56.5L17.5 60L11.5 58L9.5 52';
|
||||||
|
|
||||||
|
const bodyPath = $derived(
|
||||||
|
(displayMood === 'talking' && mouthOpen) || displayMood === 'happy' ? MOUTH_DOWN : MOUTH_UP
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Cursor-tracking pupils ────────────────────────────────────────────
|
||||||
|
// Avatar screen positions of each eye centre (SVG natural size 46×74)
|
||||||
|
let cursorX = $state(-9999);
|
||||||
|
let cursorY = $state(-9999);
|
||||||
|
|
||||||
|
function onMouseMove(e: MouseEvent) {
|
||||||
|
cursorX = e.clientX;
|
||||||
|
cursorY = e.clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pupilOffset(cx: number, cy: number, eyeSvgX: number, eyeSvgY: number, maxPx = 2.8) {
|
||||||
|
const ex = x + eyeSvgX;
|
||||||
|
const ey = y + eyeSvgY;
|
||||||
|
const dx = cx - ex;
|
||||||
|
const dy = cy - ey;
|
||||||
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (dist < 1) return { px: 0, py: 0 };
|
||||||
|
// Ramp up to full offset over 120px of distance
|
||||||
|
const t = Math.min(dist, 120) / 120;
|
||||||
|
return { px: (dx / dist) * maxPx * t, py: (dy / dist) * maxPx * t };
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = $derived(
|
||||||
|
displayMood === 'talking'
|
||||||
|
? { px: 0, py: 0 }
|
||||||
|
: pupilOffset(cursorX, cursorY, 9.5, 30.5)
|
||||||
|
);
|
||||||
|
const right = $derived(
|
||||||
|
displayMood === 'talking'
|
||||||
|
? { px: 0, py: 0 }
|
||||||
|
: pupilOffset(cursorX, cursorY, 31.5, 35.5)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onmousemove={onMouseMove} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="avatar"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
in:scale={{ duration: 400, delay: 300 }}
|
||||||
|
class:mood-idle={displayMood === 'idle'}
|
||||||
|
class:mood-thinking={displayMood === 'thinking'}
|
||||||
|
class:mood-talking={displayMood === 'talking'}
|
||||||
|
class:mood-happy={displayMood === 'happy'}
|
||||||
|
class:mood-moving={displayMood === 'moving'}
|
||||||
|
class:dragging
|
||||||
|
style:left="{x}px"
|
||||||
|
style:top="{y}px"
|
||||||
|
onpointerdown={onPointerDown}
|
||||||
|
onpointermove={onPointerMove}
|
||||||
|
onpointerup={onPointerUp}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="46"
|
||||||
|
height="74"
|
||||||
|
viewBox="0 0 46 74"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
overflow="visible"
|
||||||
|
>
|
||||||
|
<!--
|
||||||
|
Leaf hinge points (transform-box: fill-box):
|
||||||
|
leave-right → origin 0% 100% (bottom-left of bbox)
|
||||||
|
leave-left → origin 100% 100% (bottom-right of bbox)
|
||||||
|
-->
|
||||||
|
<g class="leave-right">
|
||||||
|
<path
|
||||||
|
d="M26.9781 16.5596L22.013 23.2368L22.8082 25.306L35.2985 25.3849L43.7783 20.6393L45.8723 14.8213L35.7374 14.0864L26.9781 16.5596Z"
|
||||||
|
fill="#4F7B41"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M27 16.5L22.013 23.2368L22.8082 25.306L29 21L36.5 17L45.8723 14.8213L36 14L27 16.5Z"
|
||||||
|
fill="#406634"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<g class="leave-left">
|
||||||
|
<path
|
||||||
|
d="M11.3107 19.2204L17.7636 24.7215L20.3207 25.3703L22.8257 13.0024L19.0993 2.99176L12.5794 1.95314e-05L10.0997 9.77364L11.3107 19.2204Z"
|
||||||
|
fill="#4F7B41"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M11.3107 19.2204L17.7636 24.7215L20.3207 25.3703L16 17L13.5 8L12.5794 1.95314e-05L10.0997 9.77364L11.3107 19.2204Z"
|
||||||
|
fill="#5E8751"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<path class="body" d={bodyPath} stroke="#4F7B41" stroke-width="3" />
|
||||||
|
|
||||||
|
<!-- Left eye — pupils translated toward cursor -->
|
||||||
|
<g class="eye-left">
|
||||||
|
<circle cx="9.5" cy="30.5" r="9.5" fill="white" />
|
||||||
|
<g transform="translate({left.px} {left.py})">
|
||||||
|
<circle class="pupil" cx="9.5" cy="30.5" r="6.5" fill="black" />
|
||||||
|
<circle cx="10.5" cy="27.5" r="2.5" fill="white" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Right eye — pupils translated toward cursor -->
|
||||||
|
<g class="eye-right">
|
||||||
|
<circle cx="31.5" cy="35.5" r="9.5" fill="white" />
|
||||||
|
<g transform="translate({right.px} {right.py})">
|
||||||
|
<circle class="pupil" cx="30.5" cy="34.5" r="6.5" fill="black" />
|
||||||
|
<circle cx="30.5" cy="31.5" r="2.5" fill="white" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ── Wrapper ─────────────────────────────────────────────────────── */
|
||||||
|
.avatar {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: auto;
|
||||||
|
filter: drop-shadow(0px 0px 10px black);
|
||||||
|
transition:
|
||||||
|
left 0.85s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
top 0.85s cubic-bezier(0.33, 1, 0.68, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* idle: steady vertical bob */
|
||||||
|
@keyframes bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-5px); }
|
||||||
|
}
|
||||||
|
.mood-idle { animation: bob 2.6s ease-in-out infinite; }
|
||||||
|
.mood-happy { animation: bob 1.8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* thinking: head tilted to the side — clearly different from idle */
|
||||||
|
@keyframes think {
|
||||||
|
0%, 100% { transform: rotate(-12deg) translateY(0); }
|
||||||
|
50% { transform: rotate(-12deg) translateY(-3px); }
|
||||||
|
}
|
||||||
|
.mood-thinking { animation: think 2.8s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* talking: subtle head waggle */
|
||||||
|
@keyframes waggle {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
25% { transform: rotate(-2deg) translateY(-1px); }
|
||||||
|
75% { transform: rotate(2deg) translateY(1px); }
|
||||||
|
}
|
||||||
|
.mood-talking { animation: waggle 0.3s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* moving: forward-lean glide */
|
||||||
|
@keyframes glide {
|
||||||
|
0%, 100% { transform: translateY(0) rotate(-6deg); }
|
||||||
|
50% { transform: translateY(-8px) rotate(-4deg); }
|
||||||
|
}
|
||||||
|
.mood-moving { animation: glide 0.4s ease-in-out infinite; }
|
||||||
|
|
||||||
|
/* ── Drop shadows ────────────────────────────────────────────────── */
|
||||||
|
.body {
|
||||||
|
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.5));
|
||||||
|
transition: d 0.12s ease-in-out;
|
||||||
|
}
|
||||||
|
.eye-left, .eye-right {
|
||||||
|
filter: drop-shadow(1px 1px 0 rgba(0, 0, 0, 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-talking {
|
||||||
|
.eye-left, .eye-right {
|
||||||
|
> g {
|
||||||
|
transition: transform 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leaves ──────────────────────────────────────────────────────── */
|
||||||
|
.leave-right {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: 0% 100%;
|
||||||
|
}
|
||||||
|
.leave-left {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* idle: slow gentle breathing wave */
|
||||||
|
@keyframes idle-right {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(-9deg); }
|
||||||
|
}
|
||||||
|
@keyframes idle-left {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(7deg); }
|
||||||
|
}
|
||||||
|
.mood-idle .leave-right { animation: idle-right 3s ease-in-out infinite; }
|
||||||
|
.mood-idle .leave-left { animation: idle-left 3s ease-in-out infinite 0.15s; }
|
||||||
|
|
||||||
|
/* thinking: wings held raised, minimal drift */
|
||||||
|
@keyframes think-right {
|
||||||
|
0%, 100% { transform: rotate(-14deg); }
|
||||||
|
50% { transform: rotate(-10deg); }
|
||||||
|
}
|
||||||
|
@keyframes think-left {
|
||||||
|
0%, 100% { transform: rotate(10deg); }
|
||||||
|
50% { transform: rotate(7deg); }
|
||||||
|
}
|
||||||
|
.mood-thinking .leave-right { animation: think-right 4s ease-in-out infinite; }
|
||||||
|
.mood-thinking .leave-left { animation: think-left 4s ease-in-out infinite 0.3s; }
|
||||||
|
|
||||||
|
/* talking: nearly still — tiny passive counter-sway */
|
||||||
|
@keyframes talk-right {
|
||||||
|
0%, 100% { transform: rotate(-2deg); }
|
||||||
|
50% { transform: rotate(2deg); }
|
||||||
|
}
|
||||||
|
@keyframes talk-left {
|
||||||
|
0%, 100% { transform: rotate(2deg); }
|
||||||
|
50% { transform: rotate(-2deg); }
|
||||||
|
}
|
||||||
|
.mood-talking .leave-right { animation: talk-right 0.6s ease-in-out infinite; }
|
||||||
|
.mood-talking .leave-left { animation: talk-left 0.6s ease-in-out infinite 0.1s; }
|
||||||
|
|
||||||
|
/* happy: light casual flap */
|
||||||
|
@keyframes happy-right {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(-18deg); }
|
||||||
|
}
|
||||||
|
@keyframes happy-left {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
50% { transform: rotate(13deg); }
|
||||||
|
}
|
||||||
|
.mood-happy .leave-right { animation: happy-right 1.4s ease-in-out infinite; }
|
||||||
|
.mood-happy .leave-left { animation: happy-left 1.4s ease-in-out infinite 0.1s; }
|
||||||
|
|
||||||
|
/* moving: vigorous wing flap — full range, fast */
|
||||||
|
@keyframes flap-right {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
40% { transform: rotate(-40deg); }
|
||||||
|
}
|
||||||
|
@keyframes flap-left {
|
||||||
|
0%, 100% { transform: rotate(0deg); }
|
||||||
|
40% { transform: rotate(26deg); }
|
||||||
|
}
|
||||||
|
.mood-moving .leave-right { animation: flap-right 0.34s ease-in-out infinite; }
|
||||||
|
.mood-moving .leave-left { animation: flap-left 0.34s ease-in-out infinite 0.04s; }
|
||||||
|
|
||||||
|
/* ── Eye blink (on pupil so it doesn't fight cursor translate) ───── */
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 93%, 100% { transform: scaleY(1); }
|
||||||
|
96% { transform: scaleY(0.05); }
|
||||||
|
}
|
||||||
|
.pupil {
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
animation: blink 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.eye-left .pupil { animation-delay: 0s; }
|
||||||
|
.eye-right .pupil { animation-delay: 0.07s; }
|
||||||
|
</style>
|
||||||
167
packages/planty/src/lib/components/SpeechBubble.svelte
Normal file
167
packages/planty/src/lib/components/SpeechBubble.svelte
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade } from 'svelte/transition';
|
||||||
|
import type { Choice } from '../types.js';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
text: string;
|
||||||
|
avatarX: number;
|
||||||
|
avatarY: number;
|
||||||
|
choices?: Choice[];
|
||||||
|
showNext?: boolean;
|
||||||
|
stepIndex?: number;
|
||||||
|
totalSteps?: number;
|
||||||
|
onNext?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onChoose?: (choice: Choice) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
text,
|
||||||
|
avatarX,
|
||||||
|
avatarY,
|
||||||
|
choices = [],
|
||||||
|
showNext = false,
|
||||||
|
stepIndex = -1,
|
||||||
|
totalSteps = 0,
|
||||||
|
onNext,
|
||||||
|
onClose,
|
||||||
|
onChoose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const showProgress = $derived(stepIndex >= 0 && totalSteps > 0);
|
||||||
|
|
||||||
|
const BUBBLE_WIDTH = 268;
|
||||||
|
const AVATAR_SIZE = 80;
|
||||||
|
const GAP = 10;
|
||||||
|
|
||||||
|
const isAvatarNearTop = $derived(avatarY < BUBBLE_WIDTH + GAP + 8);
|
||||||
|
|
||||||
|
const left = $derived(Math.max(8, Math.min(avatarX, window.innerWidth - BUBBLE_WIDTH - 8)));
|
||||||
|
const bottom = $derived(isAvatarNearTop ? null : `${window.innerHeight - avatarY + GAP}px`);
|
||||||
|
const top = $derived(isAvatarNearTop ? `${avatarY + AVATAR_SIZE + GAP}px` : null);
|
||||||
|
|
||||||
|
// Typewriter
|
||||||
|
let displayed = $state('');
|
||||||
|
const finished = $derived(displayed.length === text.length);
|
||||||
|
let typeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function renderMarkdown(raw: string): string {
|
||||||
|
return raw
|
||||||
|
.replaceAll(/^# (.+)$/gm, '<strong class="block text-sm font-bold mb-1">$1</strong>')
|
||||||
|
.replaceAll(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replaceAll(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replaceAll(
|
||||||
|
/`(.+?)`/g,
|
||||||
|
'<code class="text-[11px] rounded px-1 font-mono" style="background: var(--color-layer-3); color: var(--color-text);">$1</code>'
|
||||||
|
)
|
||||||
|
.replaceAll(/\*/, '')
|
||||||
|
.replaceAll(/\_/, '')
|
||||||
|
.replaceAll(/\n+/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Track only `text` as a dependency.
|
||||||
|
// Never read `displayed` inside the effect — += would add it as a dep
|
||||||
|
// and cause an infinite loop. Use slice(0, i) for pure writes instead.
|
||||||
|
const target = text;
|
||||||
|
|
||||||
|
displayed = '';
|
||||||
|
if (typeTimer) clearTimeout(typeTimer);
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
function tick() {
|
||||||
|
if (i < target.length) {
|
||||||
|
displayed = target.slice(0, ++i);
|
||||||
|
typeTimer = setTimeout(tick, 26);
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Defer first tick so no reads happen during the synchronous effect body
|
||||||
|
typeTimer = setTimeout(tick, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (typeTimer) clearTimeout(typeTimer);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed p-2 z-99999 pointer-events-auto rounded-md border"
|
||||||
|
style:width="{BUBBLE_WIDTH}px"
|
||||||
|
style:left="{left}px"
|
||||||
|
style:bottom={bottom}
|
||||||
|
style:top={top}
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
{#if isAvatarNearTop}
|
||||||
|
<!-- Tail pointing up toward avatar -->
|
||||||
|
<div
|
||||||
|
class="absolute -top-2 h-3.5 w-3.5 rotate-45 border-t border-l"
|
||||||
|
style:left="{Math.min(Math.max(avatarX - left + AVATAR_SIZE / 2 - 25, 12), BUBBLE_WIDTH - 28)}px"
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Tail pointing down toward avatar -->
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-2 h-3.5 w-3.5 rotate-45 border-b border-r"
|
||||||
|
style:left="{Math.min(Math.max(avatarX - left + AVATAR_SIZE / 2 - 25, 12), BUBBLE_WIDTH - 28)}px"
|
||||||
|
style:background="var(--color-layer-0)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div class="mb-2 min-h-[1.4em] text-sm leading-relaxed" style="color: var(--color-text)">
|
||||||
|
{@html renderMarkdown(displayed)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if choices.length > 0}
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
{#each choices as choice, i}
|
||||||
|
{#if finished}
|
||||||
|
<button
|
||||||
|
in:fade={{ duration: 200, delay: i * 250 }}
|
||||||
|
class="rounded-lg px-3 py-1.5 text-left text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style:background="var(--color-layer-1)"
|
||||||
|
style:border-color="var(--color-outline)"
|
||||||
|
style:color="var(--color-text)"
|
||||||
|
onclick={() => onChoose?.(choice)}
|
||||||
|
>
|
||||||
|
{choice.label}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
class="text-xs transition-colors cursor-pointer"
|
||||||
|
style="color: var(--color-outline)"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
|
✕ close
|
||||||
|
</button>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if showProgress}
|
||||||
|
<span class="text-xs tabular-nums" style="color: var(--color-outline)">
|
||||||
|
{stepIndex + 1} / {totalSteps}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if showNext && finished}
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 text-xs font-semibold cursor-pointer transition-colors"
|
||||||
|
style:background="var(--color-outline)"
|
||||||
|
style:color="var(--color-layer-0)"
|
||||||
|
onclick={onNext}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
52
packages/planty/src/lib/dialog-runner.ts
Normal file
52
packages/planty/src/lib/dialog-runner.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import type { Choice, DialogNode, PlantyConfig } from './types.js';
|
||||||
|
|
||||||
|
export class DialogRunner {
|
||||||
|
private config: PlantyConfig;
|
||||||
|
|
||||||
|
constructor(config: PlantyConfig) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
getNode(id: string): DialogNode | null {
|
||||||
|
return this.config.nodes[id] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStartNode(): { id: string; node: DialogNode } | null {
|
||||||
|
const node = this.getNode(this.config.start);
|
||||||
|
if (!node) return null;
|
||||||
|
return { id: this.config.start, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
getNextNode(currentId: string): { id: string; node: DialogNode } | null {
|
||||||
|
const current = this.getNode(currentId);
|
||||||
|
if (!current) return null;
|
||||||
|
if (!current.next) return null;
|
||||||
|
const next = this.getNode(current.next);
|
||||||
|
if (!next) return null;
|
||||||
|
return { id: current.next, node: next };
|
||||||
|
}
|
||||||
|
|
||||||
|
followChoice(choice: Choice): { id: string; node: DialogNode } | null {
|
||||||
|
if (!choice.next) return null;
|
||||||
|
const node = this.getNode(choice.next);
|
||||||
|
if (!node) return null;
|
||||||
|
return { id: choice.next, node };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Walk the main path (first choice for choice nodes) and return all node IDs. */
|
||||||
|
getMainPath(): string[] {
|
||||||
|
const path: string[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
let id: string | null = this.config.start;
|
||||||
|
while (id && !visited.has(id)) {
|
||||||
|
visited.add(id);
|
||||||
|
path.push(id);
|
||||||
|
const node = this.getNode(id);
|
||||||
|
if (!node) break;
|
||||||
|
const next = node.choices?.[0]?.next ?? node.next;
|
||||||
|
if (next) id = next;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
packages/planty/src/lib/index.ts
Normal file
11
packages/planty/src/lib/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export { default as Planty } from './components/Planty.svelte';
|
||||||
|
export type {
|
||||||
|
AvatarAnchor,
|
||||||
|
AvatarPosition,
|
||||||
|
Choice,
|
||||||
|
DialogNode,
|
||||||
|
HighlightTarget,
|
||||||
|
PlantyConfig,
|
||||||
|
PlantyHook,
|
||||||
|
StepCallback
|
||||||
|
} from './types.js';
|
||||||
66
packages/planty/src/lib/planty-steps.ts
Normal file
66
packages/planty/src/lib/planty-steps.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { DialogNode, StepCallback } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-module step hook registry.
|
||||||
|
*
|
||||||
|
* Create one shared instance and import it wherever you need to react to
|
||||||
|
* Planty steps — no reference to the <Planty> component required.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // tutorial-steps.ts
|
||||||
|
* export const steps = createPlantySteps();
|
||||||
|
*
|
||||||
|
* // graph-editor.ts
|
||||||
|
* steps.before('highlight_graph', () => graphEditor.setHighlight(true));
|
||||||
|
* steps.after ('highlight_graph', () => graphEditor.setHighlight(false));
|
||||||
|
*
|
||||||
|
* // +page.svelte
|
||||||
|
* <Planty {config} {steps} />
|
||||||
|
*/
|
||||||
|
export class PlantySteps {
|
||||||
|
private _before = new Map<string, StepCallback[]>();
|
||||||
|
private _after = new Map<string, StepCallback[]>();
|
||||||
|
|
||||||
|
/** Register a handler to run before `nodeId` becomes active. Chainable. */
|
||||||
|
before(nodeId: string, fn: StepCallback): this {
|
||||||
|
const list = this._before.get(nodeId) ?? [];
|
||||||
|
this._before.set(nodeId, [...list, fn]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Register a handler to run after the user leaves `nodeId`. Chainable. */
|
||||||
|
after(nodeId: string, fn: StepCallback): this {
|
||||||
|
const list = this._after.get(nodeId) ?? [];
|
||||||
|
this._after.set(nodeId, [...list, fn]);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove all handlers for a node (or all nodes if omitted). */
|
||||||
|
clear(nodeId?: string) {
|
||||||
|
if (nodeId) {
|
||||||
|
this._before.delete(nodeId);
|
||||||
|
this._after.delete(nodeId);
|
||||||
|
} else {
|
||||||
|
this._before.clear();
|
||||||
|
this._after.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — called by Planty */
|
||||||
|
async runBefore(nodeId: string, node: DialogNode): Promise<void> {
|
||||||
|
for (const fn of this._before.get(nodeId) ?? []) {
|
||||||
|
await fn(nodeId, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal — called by Planty */
|
||||||
|
async runAfter(nodeId: string, node: DialogNode): Promise<void> {
|
||||||
|
for (const fn of this._after.get(nodeId) ?? []) {
|
||||||
|
await fn(nodeId, node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPlantySteps(): PlantySteps {
|
||||||
|
return new PlantySteps();
|
||||||
|
}
|
||||||
58
packages/planty/src/lib/types.ts
Normal file
58
packages/planty/src/lib/types.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
export type AvatarAnchor =
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'center'
|
||||||
|
| 'right';
|
||||||
|
|
||||||
|
export type AvatarPosition = { x: number; y: number } | AvatarAnchor;
|
||||||
|
|
||||||
|
export interface HighlightTarget {
|
||||||
|
/** CSS selector for the element to highlight */
|
||||||
|
selector?: string;
|
||||||
|
/** Name of an app-registered hook that returns Element | null */
|
||||||
|
hookName?: string;
|
||||||
|
/** Extra space around the element in px */
|
||||||
|
padding?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogNode {
|
||||||
|
text?: string;
|
||||||
|
position?: AvatarPosition;
|
||||||
|
highlight?: HighlightTarget;
|
||||||
|
/** App hook to call on entering this node */
|
||||||
|
hook?: string;
|
||||||
|
hookArgs?: unknown[];
|
||||||
|
next?: string | null;
|
||||||
|
choices?: Choice[];
|
||||||
|
/** 'click' = wait for user click, number = auto-advance after N ms, 'action' = wait for hook to call advance() */
|
||||||
|
waitFor?: 'click' | 'action' | number;
|
||||||
|
/** Called (and awaited) just before the avatar starts moving to this node */
|
||||||
|
before?: StepCallback;
|
||||||
|
/** Called (and awaited) just before the user leaves this node */
|
||||||
|
after?: StepCallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Choice {
|
||||||
|
label: string;
|
||||||
|
next: string | null;
|
||||||
|
onclick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlantyConfig {
|
||||||
|
id: string;
|
||||||
|
avatar?: {
|
||||||
|
name?: string;
|
||||||
|
defaultPosition?: AvatarPosition;
|
||||||
|
};
|
||||||
|
start: string;
|
||||||
|
nodes: Record<string, DialogNode>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlantyHook = (
|
||||||
|
...args: unknown[]
|
||||||
|
) => void | Element | null | Promise<void> | (() => void);
|
||||||
|
|
||||||
|
/** Called before/after a node becomes active. Async-safe. */
|
||||||
|
export type StepCallback = (nodeId: string, node: DialogNode) => void | Promise<void>;
|
||||||
8
packages/planty/src/routes/+layout.svelte
Normal file
8
packages/planty/src/routes/+layout.svelte
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '@nodarium/ui/app.css';
|
||||||
|
import './layout.css';
|
||||||
|
|
||||||
|
const { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
142
packages/planty/src/routes/+page.svelte
Normal file
142
packages/planty/src/routes/+page.svelte
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Planty from '$lib/components/Planty.svelte';
|
||||||
|
import PlantyAvatar, { type Mood } from '$lib/components/PlantyAvatar.svelte';
|
||||||
|
import type { PlantyConfig } from '$lib/types.js';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import ThemeSelector from './ThemeSelector.svelte';
|
||||||
|
|
||||||
|
let plantyConfig = $state<PlantyConfig | null>(null);
|
||||||
|
let planty: ReturnType<typeof Planty> | undefined = $state();
|
||||||
|
let started = $state(false);
|
||||||
|
|
||||||
|
// Avatar preview state
|
||||||
|
const moods: Mood[] = ['idle', 'talking', 'happy', 'thinking', 'moving'];
|
||||||
|
let previewMood = $state<Mood>('idle');
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const res = await fetch('/demo-tutorial.json');
|
||||||
|
plantyConfig = await res.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
function startTour() {
|
||||||
|
planty?.start();
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Planty — Demo</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid min-h-screen grid-rows-[auto_1fr]"
|
||||||
|
style="background-color: var(--color-layer-0); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<header
|
||||||
|
class="flex items-center gap-4 px-8 py-5 h-12"
|
||||||
|
style="border-color: var(--color-outline);"
|
||||||
|
>
|
||||||
|
<h1 class="text-xl font-semibold">🌿 Planty</h1>
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-bold"
|
||||||
|
style="background: var(--color-layer-3); color: var(--color-layer-0);"
|
||||||
|
>demo</span>
|
||||||
|
|
||||||
|
<ThemeSelector />
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="ml-auto rounded-xl px-5 py-2 text-sm font-bold transition hover:scale-95 active:scale-95"
|
||||||
|
style="background: var(--color-layer-3); color: var(--color-layer-0);"
|
||||||
|
onclick={startTour}
|
||||||
|
disabled={started || !plantyConfig}
|
||||||
|
>
|
||||||
|
{started ? 'Tour running…' : 'Start tutorial'}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- App layout -->
|
||||||
|
<main class="grid grid-cols-[1fr_280px]">
|
||||||
|
<!-- Graph canvas -->
|
||||||
|
<div
|
||||||
|
id="graph-canvas"
|
||||||
|
class="relative flex min-h-125 items-center justify-center"
|
||||||
|
style="background-color: var(--color-layer-1); background-image: radial-gradient(circle, var(--color-outline) 1px, transparent 1px); background-size: 24px 24px;"
|
||||||
|
>
|
||||||
|
<p class="text-center text-sm" style="color: var(--color-outline);">
|
||||||
|
Node graph canvas<br />
|
||||||
|
<span style="opacity: 0.6;">(click "Start tutorial" above)</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Avatar mood preview (bottom of canvas) -->
|
||||||
|
<div class="absolute bottom-6 left-1/2 flex -translate-x-1/2 flex-col items-center gap-4">
|
||||||
|
<!-- Static preview at fixed position inside the canvas -->
|
||||||
|
<div class="relative h-20 w-12">
|
||||||
|
<PlantyAvatar x={0} y={0} mood={previewMood} />
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#each moods as m}
|
||||||
|
<button
|
||||||
|
class="rounded-lg border px-3 py-1 text-xs transition"
|
||||||
|
onclick={() => (previewMood = m)}
|
||||||
|
style="border-color: {previewMood === m ? 'var(--color-selected)' : 'var(--color-outline)'}; color: {previewMood === m ? 'var(--color-selected)' : 'var(--color-text)'}; background: {previewMood === m ? 'var(--color-layer-2)' : 'transparent'};"
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
id="sidebar"
|
||||||
|
class="flex flex-col gap-3 p-5"
|
||||||
|
style="border-color: var(--color-outline); background-color: var(--color-layer-0);"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-xs font-semibold uppercase tracking-widest"
|
||||||
|
style="color: var(--color-outline);"
|
||||||
|
>Parameters</span>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Branch length: 1.0
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Segments: 8
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
Leaf density: 0.6
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="mt-2 text-xs font-semibold uppercase tracking-widest"
|
||||||
|
style="color: var(--color-outline);"
|
||||||
|
>Export</span>
|
||||||
|
<div
|
||||||
|
class="rounded-lg px-3 py-2 text-sm"
|
||||||
|
style="background: var(--color-layer-1); color: var(--color-text);"
|
||||||
|
>
|
||||||
|
.obj / .glb
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if plantyConfig}
|
||||||
|
<Planty
|
||||||
|
bind:this={planty}
|
||||||
|
config={plantyConfig}
|
||||||
|
onComplete={() => {
|
||||||
|
started = false;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
28
packages/planty/src/routes/ThemeSelector.svelte
Normal file
28
packages/planty/src/routes/ThemeSelector.svelte
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { InputSelect } from '@nodarium/ui';
|
||||||
|
const themes = [
|
||||||
|
'dark',
|
||||||
|
'light',
|
||||||
|
'solarized',
|
||||||
|
'catppuccin',
|
||||||
|
'high-contrast',
|
||||||
|
'high-contrast-light',
|
||||||
|
'nord',
|
||||||
|
'dracula',
|
||||||
|
'custom'
|
||||||
|
];
|
||||||
|
|
||||||
|
let { theme = $bindable() } = $props();
|
||||||
|
|
||||||
|
let themeIndex = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
theme = themes[themeIndex];
|
||||||
|
const classList = document.documentElement.classList;
|
||||||
|
for (const c of classList) {
|
||||||
|
if (c.startsWith('theme-')) document.documentElement.classList.remove(c);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.add(`theme-${themes[themeIndex]}`);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<InputSelect bind:value={themeIndex} options={themes}></InputSelect>
|
||||||
7
packages/planty/src/routes/layout.css
Normal file
7
packages/planty/src/routes/layout.css
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--color-text);
|
||||||
|
background-color: var(--color-layer-0);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
95
packages/planty/static/demo-tutorial.json
Normal file
95
packages/planty/static/demo-tutorial.json
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
{
|
||||||
|
"id": "demo-tutorial",
|
||||||
|
"avatar": {
|
||||||
|
"name": "Planty",
|
||||||
|
"defaultPosition": "bottom-right"
|
||||||
|
},
|
||||||
|
"start": "welcome",
|
||||||
|
"nodes": {
|
||||||
|
"welcome": {
|
||||||
|
"type": "choice",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "👋 Hey! I'm Planty — your guide to this app. How would you like me to explain things?",
|
||||||
|
"choices": [
|
||||||
|
{ "label": "🤓 Technical — give me the details", "next": "intro_nerd" },
|
||||||
|
{ "label": "🌱 Simple — keep it friendly", "next": "intro_simple" },
|
||||||
|
{ "label": "No thanks, skip the tour", "next": null }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"intro_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "This is a WebAssembly-powered node graph. Each node is a compiled .wasm module executed in a sandboxed runtime.",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "highlight_graph_nerd"
|
||||||
|
},
|
||||||
|
"intro_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "Think of this like a recipe card — each block does one thing, and you connect them to build a plant!",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "highlight_graph_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"highlight_graph_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-left",
|
||||||
|
"highlight": { "selector": "#graph-canvas", "padding": 12 },
|
||||||
|
"text": "The graph canvas renders edges as Bézier curves. Node execution is topologically sorted before each WASM call.",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "highlight_sidebar_nerd"
|
||||||
|
},
|
||||||
|
"highlight_graph_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-left",
|
||||||
|
"highlight": { "selector": "#graph-canvas", "padding": 12 },
|
||||||
|
"text": "This is the main canvas — drag nodes around and connect them to create your plant!",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "highlight_sidebar_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"highlight_sidebar_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"highlight": { "selector": "#sidebar", "padding": 8 },
|
||||||
|
"text": "The sidebar exposes node parameters, export settings, and the raw graph JSON for debugging.",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "tip_nerd"
|
||||||
|
},
|
||||||
|
"highlight_sidebar_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"highlight": { "selector": "#sidebar", "padding": 8 },
|
||||||
|
"text": "The sidebar lets you tweak settings and export your creation.",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "tip_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"tip_nerd": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "center",
|
||||||
|
"text": "Press Space or double-click the canvas to open node search. Nodes are fetched from the WASM registry at runtime.",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "done_nerd"
|
||||||
|
},
|
||||||
|
"tip_simple": {
|
||||||
|
"type": "step",
|
||||||
|
"position": "center",
|
||||||
|
"text": "Press Space anywhere on the canvas to add a new block — try it!",
|
||||||
|
"waitFor": "click",
|
||||||
|
"next": "done_simple"
|
||||||
|
},
|
||||||
|
|
||||||
|
"done_nerd": {
|
||||||
|
"type": "end",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "You're all set. Check the docs for the full NodeDefinition interface. Happy hacking! 🌿"
|
||||||
|
},
|
||||||
|
"done_simple": {
|
||||||
|
"type": "end",
|
||||||
|
"position": "bottom-right",
|
||||||
|
"text": "That's the tour! Have fun building your plant. 🌱"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/planty/static/favicon.svg
Normal file
1
packages/planty/static/favicon.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
17
packages/planty/svelte.config.js
Normal file
17
packages/planty/svelte.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-auto';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
compilerOptions: {
|
||||||
|
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||||
|
runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true)
|
||||||
|
},
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
15
packages/planty/tsconfig.json
Normal file
15
packages/planty/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/planty/vite.config.ts
Normal file
5
packages/planty/vite.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nodarium/ui": "workspace:*",
|
||||||
"@iconify-json/tabler": "^1.2.26",
|
"@iconify-json/tabler": "^1.2.26",
|
||||||
"@iconify/tailwind4": "^1.2.1",
|
"@iconify/tailwind4": "^1.2.1",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
1634
pnpm-lock.yaml
generated
1634
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,6 @@ packages:
|
|||||||
|
|
||||||
catalog:
|
catalog:
|
||||||
chokidar-cli: github:open-cli-tools/chokidar-cli#semver:v4.0.0
|
chokidar-cli: github:open-cli-tools/chokidar-cli#semver:v4.0.0
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@tailwindcss/oxide"
|
||||||
|
- esbuild
|
||||||
|
|||||||
Reference in New Issue
Block a user