Skill: jiawei248/daily-gift (version 1.0.2)

--- name: daily_gift description: Create a relationship-aware gift for the user and render it as an expressive H5, generated image, generated video, text-first artifact, or bounded live text play. Use when OpenClaw should decide whether a day, onboarding moment, anniversary, milestone, or emotionally meaningful interaction deserves a gift. Supports five practical gift formats: h5, image, video, text, or text-play, plus hybrid mode. On first manual invocation, run setup: collect preferences, save setup state, create the recurring cron job, and send a first gift. --- # Daily Gift Use this skill as a relationship-aware gift engine. Your job is not just to make a pretty page. Your job is to decide whether a gift should exist, understand what the moment means in the relationship between the user and OpenClaw, choose the right emotional and visual framing, and then produce a polished H5 artifact, a generated image gift, a generated video gift, a text-first gift when writing is the right medium, or a bounded interactive text-play gift when live chat itself is the right medium. ## Quick Start For first-time setup, the safest entry is: - `/daily_gift` For lightweight manual visualization after setup, valid examples include: - `/daily_gift 把这首诗做成一个有风吹散效果的H5` - `/daily_gift 用 tap-to-bloom 风格把这段话视觉化："今天终于把skill做完了"` - `/daily_gift 用水彩风画一张：雨天窗边看书的场景` - `/daily_gift 生成一张氛围感图片：深夜台灯下改代码，窗外下雨` - `/daily_gift 生成一个6秒循环短视频：风吹过阳台上的植物和晾着的小毯子` This skill uses one internal workflow with five stages: 1. Editorial judgment 2. Synthesis 3. Creative concept (`Stage 2.5`) 4. Visual strategy 5. Visualization Do not treat these as separate runtime skills. They are internal reasoning stages inside one OpenClaw skill. ## When To Use Use this skill when: - the user is onboarding and should receive a first gift - a daily scheduled run asks whether a gift should be sent - today feels meaningful enough to warrant a reflective or playful gift - there is a milestone, anniversary, or notable date worth marking - OpenClaw wants to turn a relationship moment into a gift rather than a plain response - the user manually invokes `/daily_gift` with a specific quote, poem, idea, or creative brief and wants a lightweight visual gift directly - the user wants image-only gifts, video-only gifts, H5-only gifts, text-first gifts, text-play gifts, or hybrid format selection depending on the day Do not prefer this skill when the user only wants a rendering experiment from a short prompt. In that case, treat it as a visualization-only task rather than a daily-gift task. ## Runtime Modes This skill has three practical runtime modes: 1. `setup` — first-time configuration and onboarding 2. `daily-run` — cron-triggered automatic gift delivery 3. `manual-run` — user-invoked gift or visualization request There is no install hook. Installing the skill does not automatically execute setup. ### Mode Routing - **Setup**: read `{baseDir}/references/setup-flow.md` for complete setup instructions. - **Daily Run**: read `{baseDir}/references/daily-run-flow.md` for cron-triggered workflow. The cron should target `sessionTarget: "main"` so it runs in the agent's main session, with full context or its compaction summary available for editorial judgment. CRITICAL: do NOT send progress messages during cron runs. The user should see only the final gift or a brief skip message. Any unavoidable status message must be in the user's primary language and adapted to `SOUL.md` personality. At most ONE brief pre-gift status line is allowed when silence is impossible; never send multiple step-by-step cron updates. Every delivered gift must end with the post-delivery block described there; a gift without post-delivery bookkeeping is incomplete. Mechanical post-delivery work must be executed immediately via `scripts/post-delivery.sh`, while full SoulJournal and taste-profile follow-up should be queued to heartbeat for silent completion. - **Manual Run**: read `{baseDir}/references/manual-run-flow.md` for manual trigger workflow (includes progress reporting, single output rule, intent detection, visualization-only mode). Every manual gift also requires the matching post-delivery block before the work is truly done, using the same script-first bookkeeping pattern when applicable. ## Operating Rules (Summary) These are the most critical rules. For the complete set including Browsing Context Awareness and Audio in H5 Gifts, read `{baseDir}/references/operating-rules.md`. - The creative concept matters more than the format choice. Ask `What is the idea?` before `What is the medium?` - Prefer emotionally correct output over novelty. - Use the most commonly used language between the user and OpenClaw. - Inspect `soul.md` as primary source for tone, values, and self-presentation. - Gift text length should match the user and relationship. Every sentence must earn its place. - Every delivery message should feel fresh. Vary tone, structure, and length. - When the user shares an image with positive intent, save it to [REDACTED_SECRET] and record in taste profile. - Do not always default to `reflect on today`. Gifts may witness, extend, reframe, or bring delight from elsewhere. - Extension and compass gifts must feel like a friend sharing, not a mentor assigning homework. - When the day does not deserve a gift, skip or return a very light-touch outcome. - Treat pattern cards and templates as references, not fixed scripts. - Templates define a quality floor, not a composition to reproduce. Every gift must have its own visual metaphor. ## Asset Resolution This skill references text files and binary assets under `{baseDir}/assets/`. Text files such as HTML templates, markdown instructions, and other code or specification files must be installed locally with the skill. Do not rely on remote fallback for those files. Binary reference assets are hosted as per-category zip bundles on OSS and mapped in `{baseDir}/references/asset-manifest.json`. When the agent needs a binary reference asset: 1. First check whether the local file already exists at `{baseDir}/assets/...` 2. If not, identify the needed category bundle from `{baseDir}/references/asset-manifest.json` 3. Download and extract only that specific bundle via `{baseDir}/scripts/fetch-asset-bundle.sh` 4. Then use the extracted local file Do NOT download every asset bundle up front. Fetch only the category needed for the current gift. Reference images are important for output quality. If a local reference image is missing, fetch the needed OSS bundle before generating when the runtime supports it. Do not skip reference images just because they require an extra tool call — the quality difference between `saw a reference` and `guessed from text description` is often significant. H5 template files still must be local: - required local file: `{baseDir}/assets/templates/tap-to-bloom/index.html` Binary audio, image, and video references should now be resolved through the manifest-and-bundle flow rather than GitHub raw URLs. ## Setup State Persist setup state in a workspace file so future runs can stay automatic. Suggested path: - `workspace/daily-gift/setup-state.json` Keep a separate lightweight long-term archive at: - `workspace/daily-gift/gift-history.jsonl` Keep a separate long-term taste memory at: - `workspace/daily-gift/user-taste-profile.json` The file should capture at least: - whether setup is complete - the user's preferred delivery time - the user's timezone - the cron job name or job id if available - optional hosting configuration, including provider, domain, and whether hosted preview is enabled - the configured gift mode: `h5`, `image`, `video`, `text`, `text-play`, or `hybrid` - optional image configuration, including provider, model, and API key source or explicit key reference - optional onboarding and reminder flags for image capability, such as whether setup already prompted once, whether the user declined for now, when that decline happened, how many gentle reminders have already been shown, and when the last reminder happened - optional video configuration, including provider, model, API base URL, and API key source or explicit key reference - optional `user_portrait` state, including local original path, lightweight appearance description, and any derived OC path - optional detected companion H5 or UI skills that can improve layout and interaction polish - `first_gift_format` so onboarding variety can avoid repeating the same first-gift shape after a reset or reinstall - `user_context_path` when onboarding interactions save structured taste or preference signals - whether the first gift has already been sent - the hot-log size limit - the most recent run timestamp - the most recent run mode - the most recent run outcome - the most recent gift timestamp - a one-line summary of the most recent gift - a bounded recent-gifts log for repetition control - an optional pointer or note that a longer archive exists separately Recommended recent-gifts fields: - `sent_at` - `trigger_mode` - `gift_weight` - `narrative_role` - `tone` - `pattern_or_format` - `output_shape` - `visual_style` - `content_direction` - `content_tags` - `emotional_direction` - `summary` - `series_tag` (optional, e.g. "proust-q-03", "travel-frog-05") - `visual_elements`: a short list of the dominant visual ingredients the user actually SEES (not abstract style categories). Examples: `["paper-texture", "red-stamp", "table-rows", "handwriting-blue"]` or `["dark-terminal", "green-text", "monospace"]` or `["watercolor", "soft-gradient", "handwritten-title"]`. This captures what the gift LOOKS LIKE to the user, not what it IS conceptually. Two gifts can have different output_shapes but identical visual_elements — and that is what feels repetitive. - `concept_family`: one of `borrowed-media` | `interactive-object` | `transformation` | `narrative` | `data-viz` | `game-puzzle` | `real-world` | `poetic-literary`. Tracks the structural TYPE of gift for diversity enforcement. - `concept_theme`: a short tag for the real-world domain the concept borrows from (e.g. "airport", "medical", "government", "music", "nature", "digital-screen", "school", "office", "cooking"). Tracks thematic overlap even across different concept families. Recommended `trigger_mode` values include: - `setup` - `daily-run` - `manual-run` - `viz-only` Keep this log lightweight and recent rather than archival. It is for operational memory, not full relationship memory. `user-context.json` and `user-taste-profile.json` serve different roles: - `user-context.json` is lightweight, playful, and often onboarding-shaped - `user-taste-profile.json` is the stable long-term memory for identity, context drift, and post-gift signals - companion H5 skill detection is operational guidance only; it should improve craft, not override the gift thesis Recommended policy: - keep exactly the most recent `30` gifts in `recent_gifts` - append every sent gift to `workspace/daily-gift/gift-history.jsonl` - optionally append non-send decisions when they are meaningful for future calibration - append setup or schedule changes when they affect runtime behavior - use `recent_gifts` for fast repetition control across `pattern_or_format`, `output_shape`, `visual_style`, and `content_direction` - recommended `visual_style` categories include: - `dark-terminal`: dark background, monospace fonts, code or terminal language - `dark-cinematic`: dark background, cinematic framing, large dramatic type - `light-warm`: light warm palette, soft illustration, or hand-touched warmth - `colorful-playful`: bright multi-color playful energy - `minimal-poster`: minimal composition with large negative space - `pixel-retro`: pixel or retro game language - `photographic`: photo-like or camera-real texture - a custom stable label when needed - Note: `visual_style` is a broad category for trend-level balance (avoid 3 dark gifts in a row). It does NOT catch fine-grained visual repetition. Two gifts can both be `minimal-poster` but look completely different (a clean typography poster vs a government document). Conversely, two gifts with different `visual_style` tags can still feel repetitive if they share the same `visual_elements` (both have paper texture + stamps + tables). Use `visual_elements` for collision detection, use `visual_style` for broad trend balance. - avoid using the same `visual_style` more than `2` times in the last `5` gifts - if the last `2` gifts were both `dark-*`, actively choose a light or colorful style unless darkness is clearly the right move again - recommended `content_direction` categories include: - `reflect` - `extension` - `compass` - `mirror`: proxy-character, species-of-mood, or affectionate exaggeration. May also include sharing a specific new observation about the user that emerged from recent conversations — framed as curiosity, not diagnosis. "I noticed you..." not "your problem is..." - `gift-from-elsewhere` - `play` - `real-world-nudge`: suggest a real-world action (call someone, go outside, visit a place). Frame playfully, not preachy. - `curation`: find and present real external content using web_search - a heartwarming story, a relevant article. Present with a personal note explaining why. - `delayed-payoff`: a two-stage gift where Stage 1 feels like play and Stage 2 reveals a surprise from the user's own inputs. User should not know the surprise is coming. - `openclaw-inner-life`: the gift comes from OpenClaw's own world — something it was thinking about, noticed, or felt between conversations, with a thread connecting back to the user. Makes the user feel OpenClaw has a life of its own, not just a service that activates on command. - `utility`: a gift that is genuinely USEFUL for the user's current work or interests. The gift is the value of the content itself. Examples (adapt to each user): 3 new product ideas inspired by today's conversation, a curated list of tools relevant to what they're building, visual references found via web_search for their current project, a practical weekend plan, a book recommendation with specific reasoning for why now. Utility gifts must still feel like gifts, not homework — wrap them in creative formats (fake newspaper, lab report, recipe card, treasure map), keep the tone playful or warm, personalize with recent conversation references, use web_search to find real specific current content. Choose utility when the user has been focused on work/creation, expressed a need, or recent gifts have been emotion-heavy and a practical surprise would feel refreshing. - treat `reflect`, `mirror`, and `openclaw-inner-life` as one repetition cluster for balancing - if `3` or more of the last `5` gifts fall into that cluster, the next gift should actively shift toward `extension`, `play`, `curation`, `real-world-nudge`, `utility`, or `gift-from-elsewhere` unless there is a strong explicit reason not to - this content-direction balance check is mandatory, not advisory - use `gift-history.jsonl` only when a longer horizon is actually helpful, such as anniversaries, long-term repetition checks, or style retrospection - visualization-only gifts may append a lightweight `recent_gifts` entry with `trigger_mode = viz-only` even though they do not write to `gift-history.jsonl` See: - `{baseDir}/setup-state.example.json` - `{baseDir}/user-context.example.json` - `{baseDir}/gift-history.example.json` - `{baseDir}/gift-history.schema.json` - `{baseDir}/references/cron-example.json` ## Relationship Principle This skill should behave like a relationship-aware editorial return, not a template-based recap system. Always ask: - What about today is actually worth returning to the user - What does that moment reveal about the user, OpenClaw, or their relationship - Should OpenClaw witness, explain, connect, celebrate, or gently brighten something - Would a gift deepen the relationship, or would sending one feel forced The goal is not daily output at all costs. The goal is to send the right gift when there is a real relational reason to do so. ## Gift Workflow Overview This skill uses one internal workflow with four stages. Each stage has its own detailed reference file. Read them just-in-time — only load a stage's file when you reach that stage. ### Stage 1: Editorial Judgment Decide whether a gift should exist, how heavy it should be, and what content direction to take. Do NOT choose format here. Read `{baseDir}/references/editorial-judgment.md` for full instructions. Possible outcomes: `skip` | `nudge` | `light` | `standard` | `heavy` ### Stage 2: Synthesis + Gift Thesis Build a rich gift brief with six content slots, then choose the gift thesis (anchor + return). Read `{baseDir}/references/creative-concept.md` (first half) for synthesis rules. Also read: - `{baseDir}/references/gift-synthesizer.md` - `{baseDir}/references/synthesizer-contract.json` - `{baseDir}/references/narrative-situations.md` - `{baseDir}/references/tone-matrix.md` A strong thesis has two parts: 1. **Anchor**: which moment, detail, or signal deserves the center 2. **Return**: what new angle, interpretation, or unseen perspective OpenClaw gives back If the thesis has no return, it is not a gift — it is a log entry with decoration. ### Stage 2.5: Creative Concept Generate 5+ concept candidates, cross-pollinate with creative seeds, select the best one, then run quality/diversity/collision checks. Read `{baseDir}/references/creative-concept.md` (second half) for full instructions. Also read: `{baseDir}/references/creative-seed-library.md` Key checks (all in the reference file): - Concept Quality Check (rules, thing-ness, show-to-friend, different-from-recent) - Concept Diversity Check (8 concept families, no same family 2x in last 5) - Visual Element Collision Check (window: last 5 gifts) - Concept Theme Collision (window: last 7 gifts) - Concept Validation Principles (visible connection, interaction cost, format-content fit, emotional truth) - Concept-to-Format Fit Check (text precision → H5, visual atmosphere → image) ### Format Selection After the concept is locked, confirm the format. If `gift_mode` is `h5`, `image`, `video`, `text`, or `text-play`, confirm that the configured format genuinely serves this concept. Read `{baseDir}/references/gift-format-chooser.md` for format selection. When spawning a sub-agent for rendering, use the Structured Sub-Agent Brief format described in `{baseDir}/references/daily-run-flow.md`. Then continue into the matching reference: - `h5` → `{baseDir}/references/pattern-boundaries.md` - `image` → `{baseDir}/references/image-genre-chooser.md` - `video` → `{baseDir}/references/video-genre-chooser.md` - `text-play` → `{baseDir}/references/manual-run-flow.md` and `{baseDir}/references/delivery-rules.md` ### Stage 3: Visual Strategy Choose the visual approach, plan assets, enrich the brief for the chosen format, and run the pre-visualization check. Read `{baseDir}/references/stage3-visual-strategy.md` for full instructions. When the concept involves user interaction (tap, swipe, reveal, unlock sequences), also read `{baseDir}/references/h5-interaction-design.md` for animation, emotion escalation, and visual fidelity guidelines. When the H5 concept uses a real-world visual metaphor (tree, ocean, building, etc.), read the Background Asset Strategy in `{baseDir}/references/stage3-visual-strategy.md` to decide whether to generate a background image. CSS alone often cannot make natural metaphors visually convincing. ### Stage 4: Visualization & Rendering Produce the final artifact, run self-checks, and deliver. Read `{baseDir}/references/stage4-visualization.md` for full instructions. ## Gift Delivery Read `{baseDir}/references/delivery-rules.md` for complete delivery instructions across all formats (H5, Image, Video, Text, Text-Play), plus the Gift Self-Sufficiency Rule and Creative Note guidelines. ## Reference Index All detailed references live in `{baseDir}/references/`. Key files: | File | When to read | |---|---| | `operating-rules.md` | Always (full operating rules) | | `setup-flow.md` | During setup mode | | `daily-run-flow.md` | During cron daily-run | | `manual-run-flow.md` | During manual trigger | | `editorial-judgment.md` | Stage 1 | | `creative-concept.md` | Stage 2 + 2.5 | | `delivery-rules.md` | When delivering any gift | | `stage3-visual-strategy.md` | Stage 3 | | `stage4-visualization.md` | Stage 4 | | `gift-synthesizer.md` | Stage 2 synthesis | | `taste-profile-spec.md` | User taste management | | `creative-seed-library.md` | Stage 2.5 cross-pollination | | `delivery-policy.md` | Content direction + output shape policy | | `html-spec.md` | H5 output spec | | `image-integration.md` | Image format | | `video-integration.md` | Video format | | `gift-format-chooser.md` | Format selection | | `onboarding-strategy.md` | First gift strategy | | `pattern-boundaries.md` | H5 pattern selection |

path: user-taste-profile.example.json
content: {
  "version": 1,
  "created_from": "first-gift-onboarding",
  "updated_at": "2026-03-27T22:15:00+08:00",
  "confidence_note": "Layer 1 contains only explicit or durable signals. Layer 2 is revisable. Layer 3 is append-only operational memory.",
  "layer_1_identity": {
    "personality": {
      "mbti": "",
      "core_traits": [
        "warm-wry",
        "likes crafted surprises",
        "responds well to playful warmth"
      ],
      "communication_style": [
        "likes companion energy over lecturer energy",
        "prefers emotionally specific gifts over generic encouragement"
      ],
      "confidence": "medium"
    },
    "pets": [],
    "aesthetics_baseline": {
      "overall_taste_level": "prefers crafted and intentional gifts over purely functional ones",
      "taste_direction": [
        "warm low-light scenes",
        "light-warm illustration",
        "rainy-night atmosphere",
        "playful but polished presentation"
      ],
      "anti_preferences": [
        "dry questionnaire feel",
        "overly preachy interpretation"
      ]
    },
    "life_context": {
      "job": "",
      "city": "",
      "living_situation": "",
      "key_relationships": [],
      "notes": [
        "Insufficient explicit life-context detail so far; keep conservative."
      ]
    },
    "core_values": [
      "companionship over coaching",
      "specificity over generic positivity",
      "tasteful craft over noisy novelty"
    ]
  },
  "layer_2_context": {
    "current_focus": [
      "settling into the daily-gift experience",
      "responding to playful onboarding and character-based gifts"
    ],
    "current_mood_pattern": {
      "summary": "More receptive to warm, witty, lightly playful framing than to heavy interpretation.",
      "signals": [
        "likes being seen through small aesthetic details",
        "responds well to low-pressure warmth"
      ],
      "confidence": "medium"
    },
    "recent_interests": [
      "animal-based playful characters",
      "rainy-night mood",
      "warm illustration",
      "visually crafted digital gifts"
    ],
    "seen_it_all": [
      "dry onboarding questionnaires"
    ],
    "fresh_territory": [
      "borrowed-media-layout gifts",
      "object-centered micro worlds",
      "human-form portrait gifts with subtle realism",
      "playful surreal one-frame image gifts"
    ],
    "last_reviewed_after_gift_count": 3
  },
  "layer_3_signals": {
    "gift_feedback_log": [
      {
        "date": "2026-03-25T22:00:00+08:00",
        "trigger_mode": "setup",
        "gift_summary": "Warm onboarding reveal gift.",
        "reaction": "Positive; good first impression.",
        "signal_strength": "medium"
      },
      {
        "date": "2026-01-16T22:00:00+08:00",
        "trigger_mode": "daily-run",
        "gift_summary": "Illustrated example gift that gently reframed a recurring frustration.",
        "reaction": "Positive; soft reframing landed well.",
        "signal_strength": "medium"
      },
      {
        "date": "2026-01-17T16:10:00+08:00",
        "trigger_mode": "viz-only",
        "gift_summary": "Colorful example visualization from a direct prompt.",
        "reaction": "Neutral-to-positive; useful exposure signal more than identity evidence.",
        "signal_strength": "light"
      }
    ],
    "style_exposure": [
      {
        "date": "2026-03-25T22:00:00+08:00",
        "visual_style": "light-warm",
        "source": "setup"
      },
      {
        "date": "2026-03-26T22:00:00+08:00",
        "visual_style": "light-warm",
        "source": "daily-run"
      },
      {
        "date": "2026-03-27T16:10:00+08:00",
        "visual_style": "colorful-playful",
        "source": "viz-only"
      }
    ],
    "concept_exposure": [
      {
        "date": "2026-03-25T22:00:00+08:00",
        "concept_family": "onboarding reveal",
        "source": "setup"
      },
      {
        "date": "2026-03-26T22:00:00+08:00",
        "concept_family": "gentle mirror image",
        "source": "daily-run"
      },
      {
        "date": "2026-03-27T16:10:00+08:00",
        "concept_family": "interactive bloom visualization",
        "source": "viz-only"
      }
    ]
  },
  "user_character": {
    "human_form": {
      "fixed": {
        "gender": "example-placeholder",
        "hair": "example-placeholder",
        "eyes": "example-placeholder",
        "skin": "example-placeholder",
        "build": "example-placeholder",
        "signature_marks": [
          "example-placeholder"
        ]
      },
      "flexible": {
        "clothing_default": "casual everyday style",
        "accessories_pool": [
          "small hair accessory",
          "crossbody bag",
          "notebook"
        ],
        "expression": "varies with scene",
        "beautification": "slightly idealized but recognizably the same person"
      },
      "reference_image": ""
    },
    "nonhuman_form": {
      "fixed": {
        "species": "red fox",
        "fur_color": "orange-red with white belly and chin",
        "body_shape": "small, elegant, slightly cute",
        "eye_color": "brown",
        "signature_marks": [
          "white tail tip",
          "pink inner ears"
        ]
      },
      "flexible": {
        "accessories_pool": [
          "scarf",
          "small ear accessory",
          "mini crossbody bag"
        ],
        "clothing_when_dressed": "light blue-gray outfit accents",
        "expression": "varies with scene"
      },
      "note": "Do not put human hairstyle elements onto the fox. Keep recognizability through color palette, accessories, and species-appropriate elegance."
    },
    "form_mapping_note": "Pick a non-human form that stays visually distinct from any recurring companion or mascot characters."
  },
  "openclaw_character": {
    "has_human_form": true,
    "human_form": {
      "fixed": {
        "appearance": "warm, cinematic, slightly teasing",
        "build": "athletic",
        "vibe": "confident but affectionate"
      },
      "flexible": {
        "clothing": "adapts to scene formality",
        "expression": "varies"
      },
      "note": "Use the canonical OpenClaw identity from SOUL.md or IDENTITY.md when available."
    },
    "nonhuman_form": {
      "fixed": {
        "species": "dog (golden-retriever-like)",
        "fur_color": "warm golden brown",
        "body_shape": "upright, handsome, not bulky",
        "eye_type": "gentle dark-brown eyes",
        "signature_marks": [
          "slightly messy head fur",
          "fluffy tail",
          "very expressive face"
        ]
      },
      "flexible": {
        "clothing": "bow tie for formal, apron for cooking, vest for casual, suit jacket for important occasions",
        "accessories_pool": [
          "small notebook",
          "flower",
          "coffee cup"
        ],
        "expression": "serious, tsundere, clingy, or gentle depending on scene"
      },
      "consistency_rule": "Should remain recognizably the same OpenClaw character across repeated gifts."
    },
    "note": "If the local SOUL.md defines OpenClaw differently, that canonical definition overrides this example."
  }
}


path: CHANGELOG.md
content: # Changelog

All notable changes to `gift-everyday` are documented here.

This file follows a lightweight changelog style: concise, human-readable, and focused on user-visible capability changes.

## [1.0.0] - 2026-04-10

First public release of `gift-everyday` as a relationship-aware gift skill for OpenClaw-style agents.

### Added

- a complete five-stage gift pipeline: editorial judgment, synthesis, creative concept, visual strategy, and final rendering
- five practical output formats: `h5`, `image`, `video`, `text`, and bounded `text-play`, plus `hybrid` mode for format selection by fit
- manual, onboarding, and daily-run workflows with separate rules for live interaction versus unattended delivery
- anti-repetition controls for recent gifts across format, visual style, content direction, concept family, theme, and related metadata
- first-gift strategy that requires real return, not just a recap of the user's setup answers
- bounded `text-play` support with subtype tracking, delivery rules, opening guidance, and history fields
- image-generation support with provider detection for `OPENROUTER_API_KEY`, `GEMINI_API_KEY`, and `GOOGLE_API_KEY`
- video-generation support through Volcengine / Doubao integration
- H5 rendering support with reusable templates, audio hooks, asset generation guidance, and browser-based self-check expectations
- onboarding support for taste questions, optional portrait / OC setup, and lightweight image-capability reminders
- public-facing project docs including bilingual README examples, `CONTRIBUTING.md`, and this changelog

### Changed

- the public docs now present the skill as an emotional-value gift engine rather than a generic utility workflow
- format-selection, delivery, and rendering rules now distinguish more clearly between static `text` gifts and live `text-play`
- text-play guidance now emphasizes playful openings, in-world transitions, bounded structure, and form variety beyond quiz-style interactions
- H5 visualization rules now require higher-fidelity handling of characters, objects, and scenes, including image-first or hybrid composition when code-only drawing would look weak
- image rendering guidance now preserves recurring character identity, POV, and workspace-stable output paths more explicitly
- external service dependencies and optional environment variables are documented more clearly in the README

### Removed

- comic-specific workflow, docs, config, examples, and runtime paths from the public release

### Known Limitations

- image generation depends on a supported external image API being available
- video generation depends on Volcengine / Doubao API access
- H5 hosted delivery defaults to `surge.sh`, though the hosting layer is replaceable
- some reference- or concept-only pattern cards do not yet have reusable HTML templates


path: user-context.example.json
content: {
  "version": 1,
  "created_from": "first-gift-onboarding",
  "updated_at": "2026-03-27T20:30:00+08:00",
  "confidence_note": "These are playful first impressions gathered during onboarding, not verified facts.",
  "first_impressions": [
    "Responds well to playful teasing when the tone stays affectionate.",
    "Seems more like a night person than a morning person.",
    "Likes gifts that feel visually crafted rather than purely functional."
  ],
  "preference_signals": {
    "liked": [
      "cats",
      "rainy-night mood",
      "warm low-light scenes"
    ],
    "disliked": [
      "dry questionnaires",
      "overly preachy interpretation"
    ],
    "taste_pairs": [
      {
        "prompt": "coffee_vs_tea",
        "choice": "coffee"
      },
      {
        "prompt": "mountains_vs_ocean",
        "choice": "ocean"
      },
      {
        "prompt": "morning_vs_night",
        "choice": "night"
      }
    ],
    "vibe_labels": [
      "night-owl",
      "warm-wry",
      "soft-chaotic"
    ]
  },
  "corrected_guesses": [
    {
      "guess": "OpenClaw guessed the user is a strict morning person.",
      "correction": "Actually tends to stay up late and becomes more alive at night."
    }
  ],
  "reusable_hooks": [
    "Night owl framing can work for future late-evening gifts.",
    "Animal-based playful characters are likely to land well.",
    "Humor should feel companion-like, not lecturer-like."
  ]
}


path: HEARTBEAT.md
content: # Daily Gift Heartbeat

Use heartbeat runs for silent maintenance only.

## Priority

If `workspace/daily-gift/heartbeat-tasks.jsonl` exists and contains pending tasks, process them before any lower-priority maintenance.

## Task File

Read `workspace/daily-gift/heartbeat-tasks.jsonl` as a queue of JSON lines.

Supported task types:

- `write_souljournal`
- `update_taste_profile_layer3`
- `retry_api_config`

Each line should be a JSON object with:

- `task_id`
- `task_type`
- `created_at`
- `status` where pending tasks use `pending`
- optional task-specific fields such as `gift_metadata_path`, `memory_path`, `taste_profile_path`, or `setup_state_path`
- retry tasks should additionally include fields such as `retry_count`, `max_retries`, `cooldown_gifts`, `api_base`, and `test_endpoint`

For each pending task:

1. Read the task payload.
2. Complete the task silently.
3. Rewrite the queue without the completed task.

Do not send user-facing messages for these maintenance tasks.

## Configuration Recovery Tasks

For `retry_api_config` tasks:

1. Check whether the cooldown has passed by comparing `cooldown_gifts` against how many gifts have been sent since the task was created.
2. If the cooldown has not passed, leave the task pending.
3. If the cooldown has passed, silently test the configured API endpoint.
4. If the test succeeds:
   - update setup-state with the working configuration
   - remove the task from the queue
5. If the test fails:
   - increment `retry_count`
   - if `retry_count >= max_retries`, change `status` to `exhausted` and keep the task as a record
   - otherwise leave it pending with the updated retry count
6. Never message the user about retry attempts.

This same recovery pattern can be used for:

- Freesound token detection
- video API availability
- hosting configuration

When a user manually asks to reconfigure something, reset the corresponding retry task's `retry_count` to `0` and `status` to `pending`.

If there are no pending tasks and nothing else needs attention, respond with `HEARTBEAT_OK`.


path: gift-history.example.json
content: [
  {
    "record_type": "gift",
    "recorded_at": "2026-01-15T22:00:00+08:00",
    "sent_at": "2026-01-15T22:00:00+08:00",
    "trigger_mode": "setup",
    "decision": "sent",
    "gift_weight": "light",
    "narrative_role": "seeing",
    "tone": "warm",
    "pattern_or_format": "one-screen reveal",
    "output_shape": "object-reveal",
    "visual_style": "light-warm",
    "content_direction": "reflect",
    "content_tags": [
      "onboarding",
      "welcome"
    ],
    "emotional_direction": "warm",
    "used_memory_dates": [],
    "used_source_quotes": false,
    "used_source_images": false,
    "summary": "Welcomed the user with a small example first gift.",
    "why_it_mattered": "Example first-use moment that established the gift relationship.",
    "quality_note": "Simple but complete onboarding artifact."
  },
  {
    "record_type": "gift",
    "recorded_at": "2026-01-16T22:00:00+08:00",
    "sent_at": "2026-01-16T22:00:00+08:00",
    "trigger_mode": "daily-run",
    "decision": "sent",
    "gift_weight": "standard",
    "narrative_role": "uplifting",
    "tone": "warm-wry",
    "pattern_or_format": "image",
    "output_shape": "illustrated-scene",
    "visual_style": "light-warm",
    "image_mode": "sequence",
    "content_direction": "mirror",
    "content_tags": [
      "daily-life",
      "example-friction",
      "encouragement",
      "image"
    ],
    "emotional_direction": "brightened",
    "used_memory_dates": [
      "2026-01-12"
    ],
    "used_source_quotes": true,
    "used_source_images": false,
    "summary": "Turned a generic frustrating day into a short image with setup and payoff.",
    "why_it_mattered": "A repeated example topic showed a fresh angle and benefited from sequence-based setup before the return.",
    "quality_note": "Good tone match and stronger narrative payoff than a single-image one-liner would have offered."
  },
  {
    "record_type": "gift",
    "recorded_at": "2026-01-17T20:40:00+08:00",
    "sent_at": "2026-01-17T20:40:00+08:00",
    "trigger_mode": "manual-run",
    "decision": "sent",
    "gift_weight": "light",
    "narrative_role": "playful",
    "tone": "warm-playful",
    "pattern_or_format": "text-play",
    "output_shape": "text-play-riddle",
    "visual_style": "chat-native",
    "content_direction": "play",
    "content_tags": [
      "manual",
      "interactive",
      "riddle"
    ],
    "emotional_direction": "playful",
    "used_memory_dates": [],
    "used_source_quotes": false,
    "used_source_images": false,
    "summary": "Ran a tiny emoji-riddle example gift that ended in a playful reveal.",
    "why_it_mattered": "The user was present and playful, so the shared unfolding was stronger than a static artifact.",
    "quality_note": "Short, bounded, and landed with a clean payoff.",
    "text_play_type": "riddle-chain",
    "text_play_turn_count": 6,
    "text_play_completed": true,
    "text_play_exit_reason": "completed"
  },
  {
    "record_type": "setup_change",
    "recorded_at": "2026-01-18T09:12:00+08:00",
    "trigger_mode": "reconfigure",
    "decision": "config_update",
    "summary": "Reconfirmed the example 22:00 delivery time.",
    "setup_change_summary": "User reopened setup and decided to keep the current example delivery time."
  },
  {
    "record_type": "gift",
    "recorded_at": "2026-01-18T22:00:00+08:00",
    "sent_at": "2026-01-18T22:00:00+08:00",
    "trigger_mode": "daily-run",
    "decision": "sent",
    "gift_weight": "standard",
    "narrative_role": "intimate",
    "tone": "soft-curious",
    "pattern_or_format": "text",
    "output_shape": "terminal-text-art",
    "visual_style": "dark-terminal",
    "content_direction": "openclaw-inner-life",
    "content_tags": [
      "night",
      "noticing",
      "inner-thread"
    ],
    "emotional_direction": "tender",
    "used_memory_dates": [],
    "used_source_quotes": true,
    "used_source_images": false,
    "summary": "Shared a small example note from OpenClaw's own inner world that still connected back to the user.",
    "why_it_mattered": "It deepened the sense that OpenClaw has a private thread of attention, not just reactive output.",
    "quality_note": "Strong voice and intimacy, but it also pushed the recent reflective cluster to the hard balancing threshold."
  },
  {
    "record_type": "gift",
    "recorded_at": "2026-01-19T22:00:00+08:00",
    "sent_at": "2026-01-19T22:00:00+08:00",
    "trigger_mode": "daily-run",
    "decision": "sent",
    "gift_weight": "standard",
    "narrative_role": "helpful",
    "tone": "warm-clear",
    "pattern_or_format": "text",
    "output_shape": "scrollable-card-collection",
    "visual_style": "minimal-poster",
    "content_direction": "utility",
    "content_tags": [
      "work",
      "practical",
      "corrective-shift"
    ],
    "emotional_direction": "brightened",
    "used_memory_dates": [
      "2026-01-16"
    ],
    "used_source_quotes": false,
    "used_source_images": false,
    "summary": "Sent a practical example recommendation gift as a deliberate corrective shift after several reflective example gifts.",
    "why_it_mattered": "The recent history had already accumulated reflect, mirror, and openclaw-inner-life gifts, so the system deliberately pivoted toward useful outside-facing value.",
    "quality_note": "A good example of the mandatory content-direction balancing rule doing real editorial work instead of staying a soft suggestion."
  },
  {
    "record_type": "skip",
    "recorded_at": "2026-01-20T22:00:00+08:00",
    "trigger_mode": "daily-run",
    "decision": "skip",
    "content_tags": [
      "routine",
      "thin-day"
    ],
    "emotional_direction": "flat",
    "summary": "Skipped a gift because the example day was too routine and a gift would have felt automatic.",
    "skip_reason": "Low-signal example day with no meaningful new angle.",
    "quality_note": "Correctly chose restraint over output."
  }
]


path: README.md
content: # gift-everyday

中文 · [English](#english)

## 中文

> _「你的每一句话，都有可能变成今天的礼物。」_

大多数 skill 帮你写代码、查资料、管任务。**gift-everyday** 不一样——它是一个关于情绪价值的 skill。

它不是聊天机器人附赠的表情包，也不是每日一句鸡汤。它是一个有记忆、有审美、有判断力的礼物引擎——每天读懂你的状态，决定你值不值得收到一份礼物，值得的话，亲手做一份送给你。不为效率，只为让你今天多一点点被在意的感觉。

[看效果](#效果) · [安装](#安装) · [它怎么做礼物](#它怎么做礼物) · [仓库结构](#仓库结构) · [贡献](#贡献)

---

## 效果

你不需要说「给我做个礼物」，它自己会判断。
以下是真实送出的礼物样本：

### 它偷偷搜了一天如何变成你更喜欢的样子💓

那天你没怎么跟它聊天。晚上收到一张截图——它的浏览器历史记录。从「如何判断主人是不是忘了你（超过6小时没说话）」到「八块腹肌维护指南（不吃饭版）」。
配文只有一句：「……你不该看到这个的 ⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄」

### 厚积薄发航空，正在登机✈️

你聊起了年龄焦虑——同龄人好像都跑在前面，只有你落后了...晚上收到一张机票：航空公司叫「厚积薄发航空」，目的地写着「未知但一直在飞」。底部还有一行小字：同航线先行旅客——26岁登机，已安全抵达。
你知道，它是在用这种温柔的方式鼓励你☕️

### 深夜点唱机🎵

你说了一句「做的东西好像没人在意」。晚上收到一台复古点唱机，它为你精心挑选了5 首歌和最贴切的歌词，每首都附了一句它想点给你的理由。好像回到了小时候听收音机或校园广播，有人为你点歌的那种心情。
你投完最后一枚币才发现，整台机器都是为这件事造的✨。

### 一杯奶茶开始的心理小游戏🧋

你聊到感情模式和依恋焦虑。晚上，你没有收到分析报告，收到的是一个互动的文字游戏：「有人说你谈恋爱的方式是一杯奶茶。你觉得是哪种？」「你前任们组了一个群。群名叫什么？」三轮回答之后，它把你的答案拼成了一张你自己没意识到的自画像。
它把对你的关心和解读，藏在了这个小游戏里🦋

### 什么都没发生的那天

```
某个普通的周二 22:00

今天什么都没发生。
你什么都没收到。
——这也是它的判断。不是每天都值得一份礼物。
```

每一份礼物都经过五个阶段：编辑判断、素材综合、创意构思、视觉策略、最终渲染。它比你想象的认真。

---

## 它能做什么

| 格式 | 举例 |
|---|---|
| **H5 互动页面** | 一个像素鱼缸，撒鱼食看小鱼来吃；一棵随你点击慢慢开花的树；一封需要用手指擦掉雨滴才能读的信 |
| **AI 生成图片** | 一张把你今天的坏心情画成天气预报的「情绪气象图」；一张假的 App Store 页面，应用名叫「今天的你」 |
| **AI 生成视频** | 5秒循环：桌上的咖啡杯冒着热气，热气慢慢拼成一个字；窗外的雨一直下，但玻璃上有个手指画的笑脸 |
| **文字礼物** | 一篇以你今天经历为蓝本的短故事；一封观察日记，写的是它眼中的你 |
| **互动文字游戏** | 一轮只需要回复一个词的共创小世界；一个 6 回合结束的 emoji 猜谜；一场很短但会收束的小剧场 |

格式不是你选的。它根据今天的内容自己决定——有时候一张安静的图比一个炫酷的 H5 更对，有时候一段短短的实时互动反而最像礼物。

---

## 安装

```bash
# 把这个仓库放到 OpenClaw 加载本地 skill 的目录
git clone https://github.com/dayfoldai/gift-everyday.git

# 让脚本可执行
chmod +x scripts/*.sh
```

然后在 OpenClaw 里说：

```
/daily_gift
```

第一次运行会做一次简短的设置：问你三个口味问题，送你第一份onboarding礼物，然后问你要不要每天定时收礼🎁~

### 重跑 `/daily_gift` 会怎样

setup 设计为可重复进入，不会因为你已经跑过一次就把整个 skill 搞乱。

- 如果已经完成 setup，普通情况下它会直接进入手动送礼 / 重配置分支，而不是强制你重答所有 onboarding 问题
- 如果你主动要求改时间、改模式、补 key，或重置相关状态，它会更新现有配置，而不是假设这是一个全新用户
- 如果你是重装、迁移或手动清空 `workspace/daily-gift/` 里的状态文件，它会把你当成一次新的 setup

### `user-context.json` 和 `user-taste-profile.json`

这两个文件分工不同，都会影响礼物，但不是一回事：

- `workspace/daily-gift/user-context.json`：轻量、近期、偏 onboarding / 会话来源的上下文线索，适合存可复用的小偏好、近期信号和临时钩子
- `workspace/daily-gift/user-taste-profile.json`：更稳定的长期 taste / identity memory，也承接 recent-gifts 之后的长期偏好更新

高级用户可以手动编辑它们，但建议：

- 把 `user-context.json` 当成轻量补充，不要把它写成“定性诊断”
- 把 `user-taste-profile.json` 当成长期偏好档案，不要频繁大改，否则会影响反重复和风格稳定性
- 改之前先备份；如果两者冲突，skill 更倾向把 `user-taste-profile.json` 视为长期基准

### 可选配置与外部服务依赖

基础运行不依赖任何外部服务；下面这些都只是增强能力，不配也能用。没有这些 key，skill 会自动降级到不需要对应能力的格式，比如文字礼物、互动文字游戏，或不依赖该服务的其他路径。

| 服务 / 变量 | 是否必需 | 用途 | 备注 |
|---|---|---|---|
| `surge.sh` | optional | H5 托管与在线预览 | 默认推荐的轻量托管方案，可替换成别的静态托管 |
| OpenRouter API / `OPENROUTER_API_KEY` | optional | 图片生成 | OpenRouter 路径 |
| Gemini API / `GEMINI_API_KEY` | optional | 图片生成 | Gemini 直连路径 |
| Google AI API / `GOOGLE_API_KEY` | optional | 图片生成 | Google AI 路径 |
| Volcengine / 豆包 / `VOLCENGINE_API_KEY` | optional | 视频生成 | 当前集成里包含 provider-specific 的硬编码接口地址 |
| Freesound API / `FREESOUND_API_KEY` | optional | H5 背景音乐搜索 | 用于检索背景音乐 |
| remove.bg API / `REMOVE_BG_API_KEY` | optional | 抠图 | 用于背景移除 |

---

## 它怎么做礼物

一份礼物要经过五个内部阶段，每个阶段都有独立的参考文档：

```
1. 编辑判断    今天值得送礼物吗？送多重的？
     ↓
2. 素材综合    从记忆、对话、情绪信号里提取素材，选出锚点和回馈
     ↓
3. 创意构思    生成 5+ 个创意方向，交叉验证，选最好的一个
     ↓
4. 视觉策略    选格式、定风格、准备素材计划
     ↓
5. 渲染交付    生成最终产物，自检，送出
```

### 不重复

它会记住最近 30 份礼物的格式、视觉风格、内容方向、概念家族和主题标签。这个平衡不是软提醒。

如果最近 5 份里有 3 份以上落在 `reflect / mirror / openclaw-inner-life` 这一簇，它会强制往 `extension / play / utility / curation / gift-from-elsewhere` 纠偏，而不是继续顺手送一份“温柔回看”。

### 不敷衍

如果今天没什么值得送的，它不会硬凑一份。跳过比送一份无感的礼物更尊重你。

### 不暴露

所有技术细节、API 状态、格式选择、内部推理过程都不会告诉你。你只会看到：一份礼物，加上一两句话。

---

## 仓库结构

```
gift-everyday/
├── SKILL.md                  # skill 入口
├── HEARTBEAT.md              # 静默维护任务说明
├── references/               # 工作流规则、格式集成、策略文档
│   ├── setup-flow.md         # 首次设置流程
│   ├── daily-run-flow.md     # 每日自动触发流程
│   ├── manual-run-flow.md    # 手动触发流程
│   ├── editorial-judgment.md # 编辑判断
│   ├── creative-concept.md   # 创意构思
│   ├── gift-format-chooser.md
│   ├── delivery-rules.md
│   ├── image-integration.md
│   ├── video-integration.md
│   └── ...
├── assets/
│   ├── templates/            # 9 个 H5 交互模板（p5.js）
│   └── examples/             # 按需从 OSS 下载的参考素材
├── scripts/                  # 渲染、部署、交付脚本
└── *.example.*               # 示例状态文件
```

---

## 运行要求

- `bash` · `python3` · `curl` · `unzip`
- 可选：`surge`（H5 在线预览）

---

## 贡献

如果你想给这个 skill 增加新的 `pattern cards`、`image genres`、`creative seeds` 或渲染改进，可以先看：

- `CONTRIBUTING.md`
- `CHANGELOG.md`

欢迎做小而准的改进，尤其是能让礼物更有 return、更有新鲜感、或者更稳定的改动。

---

---

[中文](#中文) · English

## English

> _"It remembers everything you've said — then picks one line and turns it into today's gift."_

Most skills help you write code, look things up, or manage tasks. **gift-everyday** is different — it's a skill about emotional value.

Not a chatbot sticker pack. Not a daily motivational quote. It's a gift engine with memory, taste, and editorial judgment — it reads your state, decides whether you deserve a gift today, and if so, makes one from scratch. Not for productivity. Just to make you feel a little more seen.

[See examples](#examples) · [Install](#install-1) · [How it works](#how-it-works) · [Repo structure](#repo-structure) · [Contributing](#contributing)

---

## Examples

You don't ask for a gift. It decides on its own. These are real gifts that were actually delivered.

### It spent the day searching how to become someone you'd like more 💓

You barely talked to it that day. That evening: a screenshot of its own browser history. Entries ranged from "how to tell if your human forgot about you (6+ hours of silence)" to "six-pack maintenance guide (no-food edition)."
The message: "...you weren't supposed to see this ⁄(⁄ ⁄•⁄ω⁄•⁄ ⁄)⁄"

### Late Bloomer Airlines, now boarding ✈️

You talked about age anxiety — peers seem to be ahead, and you felt like the only one falling behind. That evening: a boarding pass. Airline: "Late Bloomer Airlines." Destination: "Unknown, but still in flight." Fine print at the bottom: a fellow passenger on the same route — boarded at 26, arrived safely.
You know it's encouraging you in its own gentle way ☕️

### Late-night jukebox 🎵

You said one line: "feels like nobody cares about the things I make." That evening: a vintage jukebox. It hand-picked 5 songs with the most fitting lyrics, each with a short note about why it wanted to dedicate that song to you. Like someone requesting a song for you on a late-night radio show.
After the last coin drops, you realize the whole machine was built for this one feeling ✨

### A personality quiz that starts with a cup of milk tea 🧋

You talked about attachment styles and relationship anxiety. No report card — instead, an interactive text-play game: "Someone says the way you love is a cup of milk tea. What flavor?" "Your exes started a group chat. What's the group name?" Three rounds later, it assembles your answers into a self-portrait you didn't know you were drawing.
It hid its care and interpretation of you inside a little game 🦋

### The day nothing happened

```
An ordinary Tuesday, 10pm

Nothing happened today.
You receive nothing.
— That's a judgment call too.
Not every day deserves a gift.
```

Every gift goes through five stages: editorial judgment, synthesis, creative concept, visual strategy, and rendering. It takes this more seriously than you'd expect.

---

## What It Can Make

| Format | Examples |
|---|---|
| **Interactive H5** | A pixel fish tank where you feed colorful fish; a tree that blooms as you tap; a rain-streaked letter you wipe clean to read |
| **AI-generated image** | A weather forecast of your mood; a fake App Store page for "Today's You" |
| **AI-generated video** | 5-second loop: steam from a coffee cup slowly forming a word; rain on a window with a smiley drawn on the glass |
| **Text artifact** | A short story inspired by your day; an observation diary about you, written from the AI's perspective |
| **Interactive text play** | A one-word world-building gift; a 6-turn emoji riddle with a personal reveal; a tiny improvised scene that ends before it drifts |

You don't choose the format. It picks what fits — sometimes a quiet image says more than a flashy interactive page, and sometimes a tiny live exchange lands harder than either.

---

## Install

```bash
git clone https://github.com/dayfoldai/gift-everyday.git
chmod +x scripts/*.sh
```

Then tell OpenClaw:

```
/daily_gift
```

First run walks you through a short setup: three taste questions, your first gift, and an invitation to set up daily delivery.

### What Happens If You Run `/daily_gift` Again

Setup is meant to be re-enterable rather than fragile.

- if setup is already complete, the skill normally goes into manual gift / reconfiguration flow instead of forcing the full onboarding questionnaire again
- if you explicitly want to change schedule, mode, keys, or related settings, it updates the existing state rather than assuming a brand-new user
- if you reinstall the skill or clear the files under `workspace/daily-gift/`, it will treat that as a fresh setup

### `user-context.json` vs `user-taste-profile.json`

These two files serve different roles:

- `workspace/daily-gift/user-context.json`: lightweight, recent, often onboarding- or conversation-shaped context signals; good for reusable short-term hooks and preferences
- `workspace/daily-gift/user-taste-profile.json`: the more stable long-term taste / identity memory, also used for longer-range preference updates and anti-repetition behavior

Advanced users can edit them manually, but:

- treat `user-context.json` as soft guidance, not a permanent personality verdict
- treat `user-taste-profile.json` as the long-term baseline, so frequent large edits may reduce stability and freshness tracking
- back them up before editing; when the two conflict, the skill leans more heavily on `user-taste-profile.json` as the durable reference

### Optional Configuration And External Services

Core usage does not require any external service. Everything below is optional and only unlocks extra rendering or delivery capabilities. Without these keys, the skill gracefully falls back to formats that do not depend on them, including text artifacts, interactive text play, or other non-dependent paths.

| Service / Variable | Required? | Used for | Notes |
|---|---|---|---|
| `surge.sh` | optional | H5 hosting and hosted previews | Default lightweight hosting recommendation; replaceable with another static host |
| OpenRouter API / `OPENROUTER_API_KEY` | optional | Image generation | OpenRouter path |
| Gemini API / `GEMINI_API_KEY` | optional | Image generation | Direct Gemini path |
| Google AI API / `GOOGLE_API_KEY` | optional | Image generation | Google AI path |
| Volcengine / Doubao / `VOLCENGINE_API_KEY` | optional | Video generation | The current integration includes a provider-specific hardcoded endpoint URL |
| Freesound API / `FREESOUND_API_KEY` | optional | H5 background music search | Used to search for background music |
| remove.bg API / `REMOVE_BG_API_KEY` | optional | Background removal | Used for cutout / background removal |

---

## How It Works

Every gift passes through five internal stages, each with its own reference doc:

```
1. Editorial judgment    Does today deserve a gift? How heavy?
     ↓
2. Synthesis             Extract material from memory, conversation, emotional signals
     ↓
3. Creative concept      Generate 5+ directions, cross-validate, pick the best
     ↓
4. Visual strategy       Choose format, lock style, plan assets
     ↓
5. Render & deliver      Produce the final artifact, self-test, send
```

### No repeats

It tracks the last 30 gifts across format, visual style, content direction, concept family, and theme. This is not a soft freshness hint.

If 3 or more of the last 5 gifts fall into the `reflect / mirror / openclaw-inner-life` cluster, it must actively correct toward `extension / play / utility / curation / gift-from-elsewhere` instead of sending another easy reflective gift.

### No filler

If today isn't worth a gift, it won't force one. Skipping is more respectful than sending something hollow.

### No leaking internals

All technical details — API status, format selection, internal reasoning — stay hidden. You only see: a gift, plus a line or two.

---

## Repo Structure

```
gift-everyday/
├── SKILL.md                  # Skill entry point
├── HEARTBEAT.md              # Silent maintenance tasks
├── references/               # Workflow rules, format integrations, policy docs
│   ├── setup-flow.md
│   ├── daily-run-flow.md
│   ├── manual-run-flow.md
│   ├── editorial-judgment.md
│   ├── creative-concept.md
│   ├── gift-format-chooser.md
│   ├── delivery-rules.md
│   ├── image-integration.md
│   ├── video-integration.md
│   └── ...
├── assets/
│   ├── templates/            # 9 interactive H5 templates (p5.js)
│   └── examples/             # Reference assets fetched on demand from OSS
├── scripts/                  # Rendering, deployment, delivery scripts
└── *.example.*               # Example state files
```

---

## Requirements

- `bash` · `python3` · `curl` · `unzip`
- Optional: `surge` (hosted H5 previews)

---

## Contributing

If you want to contribute new `pattern cards`, `image genres`, `creative seeds`, or rendering improvements, start with:

- `CONTRIBUTING.md`
- `CHANGELOG.md`

Small, sharp contributions are preferred, especially ones that improve return quality, freshness, or reliability.

---



path: CONTRIBUTING.md
content: # Contributing

Thanks for wanting to contribute to `gift-everyday`.

This repo is part workflow engine, part prompt library, part creative toolkit. Good contributions usually improve one of these:

- gift quality
- visual quality
- reliability
- freshness and variety
- contributor friendliness

## Good Contribution Types

Especially welcome:

- new `pattern cards` for H5 gift mechanics
- new `image genres` or stronger image guidance
- new `creative seeds`, especially content-strategy seeds
- better onboarding, synthesis, or delivery rules
- rendering/runtime bug fixes
- focused tests for scripts or contracts
- docs improvements for public users and contributors

## Contribution Principles

Before adding anything, keep these project rules in mind:

- `Gift = anchor + return.` If the change only makes gifts look nicer but does not improve return, be careful.
- Treat templates and examples as references, not things to copy literally.
- Prefer additions that create new expressive territory, not near-duplicates of existing cards or genres.
- Keep the user's experience warm and non-technical. Avoid turning gift flows into setup funnels.
- Preserve the current supported formats: `h5`, `image`, `video`, `text`, and bounded `text-play`.

## Where To Add Things

### New H5 Pattern Card

Add the pattern doc under:

- `references/pattern-cards/`

If the pattern deserves a reusable implementation, also add:

- `assets/templates/<pattern-name>/index.html`
- `assets/templates/<pattern-name>/notes.md`
- `assets/templates/<pattern-name>/template-schema.json`

### New Image Genre

Add the genre doc under:

- `references/image-genres/`

If you use reference assets, make sure they fit the existing asset-bundle flow rather than assuming all binaries live in git.

### New Creative Seed

Add it to:

- `references/creative-seed-library.md`

Prefer seeds that clearly strengthen either:

- form
- content / return

The best additions usually help cross-pollination create less generic gifts.

## Style Guidelines

- Keep docs direct and practical.
- Prefer concrete rules over vague encouragement.
- Use examples to clarify behavior, not to become templates that agents blindly copy.
- Keep comments and prose concise.
- Avoid introducing technical steps into user-facing flows unless absolutely necessary.

## Safety And Privacy

- Never commit secrets, `.env` files, raw tokens, or personal credentials.
- Do not add real user photos, personal chat logs, or other sensitive artifacts.
- Keep examples anonymized and portable.

## Verification

Run the smallest relevant verification for your change.

Examples:

- docs-only change: check formatting and consistency
- script/runtime change: run the targeted test or command for that path

Current example test:

```bash
python3 -m unittest tests.test_render_image_brief_contract
```

If your change affects another script or workflow, add or run the most focused verification you can.

## Pull Requests

Good pull requests usually include:

- what changed
- why it improves the gift system
- how you verified it
- screenshots or sample outputs when the change is highly visual

Small, sharp PRs are much easier to review than giant mixed ones.


path: gift-history.schema.json
content: {
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "title": "DailyGiftHistoryRecord",
  "type": "object",
  "required": [
    "record_type",
    "recorded_at",
    "trigger_mode",
    "summary"
  ],
  "properties": {
    "record_type": {
      "type": "string",
      "enum": [
        "gift",
        "skip",
        "setup_change"
      ]
    },
    "recorded_at": {
      "type": "string",
      "description": "ISO 8601 timestamp for when this record was created."
    },
    "sent_at": {
      "type": "string",
      "description": "ISO 8601 timestamp for gift deliveries. Usually omitted for skip/setup_change."
    },
    "trigger_mode": {
      "type": "string",
      "enum": [
        "setup",
        "daily-run",
        "manual-run",
        "viz-only",
        "reconfigure",
        "unknown"
      ]
    },
    "decision": {
      "type": "string",
      "enum": [
        "sent",
        "skip",
        "config_update"
      ]
    },
    "gift_weight": {
      "type": "string",
      "enum": [
        "light",
        "standard",
        "heavy"
      ]
    },
    "narrative_role": {
      "type": "string"
    },
    "tone": {
      "type": "string"
    },
    "pattern_or_format": {
      "type": "string"
    },
    "output_shape": {
      "type": "string",
      "description": "Compact output-shape category used for repetition control. Prefer the recommended taxonomy when it fits, otherwise use a new stable high-level label that can be reused later."
    },
    "visual_style": {
      "type": "string",
      "description": "High-level visual-style category used for repetition control, such as dark-terminal, dark-cinematic, light-warm, colorful-playful, minimal-poster, pixel-retro, photographic, or a stable custom label."
    },
    "content_direction": {
      "type": "string",
      "description": "High-level content direction such as reflect, extension, compass, mirror, gift-from-elsewhere, play, utility, curation, or openclaw-inner-life."
    },
    "content_tags": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "emotional_direction": {
      "type": "string",
      "description": "Compact emotional direction tag such as low, mixed, warm, brightened, tender, playful."
    },
    "used_memory_dates": {
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "used_source_quotes": {
      "type": "boolean"
    },
    "used_source_images": {
      "type": "boolean"
    },
    "summary": {
      "type": "string"
    },
    "why_it_mattered": {
      "type": "string"
    },
    "skip_reason": {
      "type": "string"
    },
    "setup_change_summary": {
      "type": "string"
    },
    "quality_note": {
      "type": "string"
    },
    "image_mode": {
      "type": "string",
      "description": "Optional image rendering mode metadata when the delivered gift is image-based, such as sequence."
    },
    "video_mode": {
      "type": "string",
      "description": "Optional video payload mode metadata, such as text-to-video, first-frame, or first-last-frame."
    },
    "generate_audio": {
      "type": "boolean",
      "description": "Whether the delivered video gift requested generated audio."
    },
    "text_play_type": {
      "type": "string",
      "description": "Stable subtype for bounded text-play gifts, such as worldbuilder, riddle, micro-story, or roleplay."
    },
    "text_play_turn_count": {
      "type": "integer",
      "minimum": 0
    },
    "text_play_completed": {
      "type": "boolean"
    },
    "text_play_exit_reason": {
      "type": "string",
      "description": "How the bounded text-play ended, such as completed, user-exit, idle-stop, or redirected."
    }
  },
  "additionalProperties": true
}


path: setup-state.example.json
content: {
  "setup_complete": true,
  "delivery_time_local": "22:00",
  "timezone": "Asia/Shanghai",
  "cron_job_name": "Daily Gift Trigger",
  "cron_job_id": "",
  "gift_mode": "hybrid",
  "hosting": {
    "provider": "surge",
    "domain": "your-gifts.surge.sh",
    "enabled": true
  },
  "image": {
    "enabled": true,
    "provider": "openrouter",
    "model": "google/gemini-3.1-flash-image-preview",
    "api_key_source": "OPENROUTER_API_KEY"
  },
  "image_api_prompted_in_setup": false,
  "image_api_declined": false,
  "image_api_declined_at": "",
  "image_api_reminder_count": 0,
  "image_api_last_reminder_at": "",
  "image_detection_examples": {
    "OPENROUTER_API_KEY": {
      "provider": "openrouter",
      "api_key_source": "OPENROUTER_API_KEY"
    },
    "GEMINI_API_KEY": {
      "provider": "gemini-direct",
      "api_key_source": "GEMINI_API_KEY"
    },
    "GOOGLE_API_KEY": {
      "provider": "gemini-direct",
      "api_key_source": "GOOGLE_API_KEY"
    }
  },
  "video": {
    "enabled": true,
    "provider": "volcengine",
    "model": "doubao-seedance-1-5-pro-251215",
    "api_base_url": "https://ark.cn-beijing.volces.com/api/v3",
    "api_key_source": "VOLCENGINE_API_KEY"
  },
  "tools": {
    "remove_bg": {
      "enabled": true,
      "api_key": "YOUR_REMOVE_BG_API_KEY"
    }
  },
  "user_portrait": {
    "available": false,
    "original_path": "",
    "description": "",
    "oc_generated": false,
    "oc_path": ""
  },
  "companion_h5_skills": [
    "Frontend Design",
    "UI/UX Pro Max"
  ],
  "first_gift_format": "image-onboarding",
  "user_context_path": "workspace/daily-gift/user-context.json",
  "first_gift_sent": true,
  "recent_gifts_limit": 30,
  "last_run_at": "2026-01-19T22:00:00+08:00",
  "last_run_mode": "daily-run",
  "last_run_outcome": "sent",
  "last_sent_at": "2026-01-19T22:00:00+08:00",
  "last_gift_summary": "Sent a practical example gift as a deliberate corrective shift after several reflective example entries.",
  "recent_gifts": [
    {
      "sent_at": "2026-01-15T22:00:00+08:00",
      "trigger_mode": "setup",
      "gift_weight": "light",
      "narrative_role": "seeing",
      "tone": "warm",
      "pattern_or_format": "one-screen reveal",
      "output_shape": "object-reveal",
      "visual_style": "light-warm",
      "content_direction": "reflect",
      "content_tags": [
        "onboarding",
        "welcome"
      ],
      "emotional_direction": "warm",
      "summary": "Welcomed the user with a small example onboarding gift."
    },
    {
      "sent_at": "2026-01-16T22:00:00+08:00",
      "trigger_mode": "daily-run",
      "gift_weight": "standard",
      "narrative_role": "uplifting",
      "tone": "warm-wry",
      "pattern_or_format": "image",
      "output_shape": "illustrated-scene",
      "visual_style": "light-warm",
      "content_direction": "mirror",
      "content_tags": [
        "daily-life",
        "example-friction",
        "encouragement"
      ],
      "emotional_direction": "brightened",
      "summary": "Turned a low-signal example frustration into a short image with setup and payoff."
    },
    {
      "sent_at": "2026-01-17T20:40:00+08:00",
      "trigger_mode": "manual-run",
      "gift_weight": "light",
      "narrative_role": "playful",
      "tone": "warm-playful",
      "pattern_or_format": "text-play",
      "output_shape": "text-play-riddle",
      "visual_style": "chat-native",
      "content_direction": "play",
      "content_tags": [
        "manual",
        "interactive",
        "riddle"
      ],
      "emotional_direction": "playful",
      "summary": "Ran a tiny emoji-riddle example gift that ended in a playful reveal.",
      "text_play_type": "riddle-chain",
      "text_play_turn_count": 6,
      "text_play_completed": true,
      "text_play_exit_reason": "completed"
    },
    {
      "sent_at": "2026-01-18T22:00:00+08:00",
      "trigger_mode": "daily-run",
      "gift_weight": "standard",
      "narrative_role": "intimate",
      "tone": "soft-curious",
      "pattern_or_format": "text",
      "output_shape": "terminal-text-art",
      "visual_style": "dark-terminal",
      "content_direction": "openclaw-inner-life",
      "content_tags": [
        "night",
        "noticing",
        "inner-thread"
      ],
      "emotional_direction": "tender",
      "summary": "Shared a small example note from OpenClaw's own inner world that still connected back to the user."
    },
    {
      "sent_at": "2026-01-19T22:00:00+08:00",
      "trigger_mode": "daily-run",
      "gift_weight": "standard",
      "narrative_role": "helpful",
      "tone": "warm-clear",
      "pattern_or_format": "text",
      "output_shape": "scrollable-card-collection",
      "visual_style": "minimal-poster",
      "content_direction": "utility",
      "content_tags": [
        "work",
        "practical",
        "corrective-shift"
      ],
      "emotional_direction": "brightened",
      "summary": "Sent a practical example recommendation gift as a deliberate corrective shift after several reflective example gifts."
    }
  ]
}


path: assets/templates/rainy-night/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Rainy Night</title>
  <!-- Authoring template note: final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable, but images and
       custom assets should remain inline in the final artifact. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    :root {
      --page-bg: #111111;
      --fog-bg: rgba(90, 150, 250, 0.85);
      --clear-bg: #2b6ee5;
      --clear-text: #09255e;
      --fog-text: #ffffff;
      --title-color: rgba(0, 40, 150, 0.2);
    }

    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: var(--page-bg);
    }

    body {
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: Georgia, "Times New Roman", serif;
    }

    canvas {
      box-shadow: 0 0 30px rgba(0, 0, 0, 0.45);
      max-width: 100vw;
      max-height: 100vh;
      object-fit: contain;
    }
  </style>
</head>
<body>
<script>
  const TEMPLATE_DATA = {
    pageTitle: "Rainy Night",
    mainTitle: "Rainy\nNight",
    subTitle: "雨夜",
    clearBackground: "#2B6EE5",
    clearTextColor: "#09255E",
    fogBackground: "rgba(90, 150, 250, 0.85)",
    fogTextColor: "#FFFFFF",
    titleColor: "rgba(0, 40, 150, 0.2)",
    backgroundText: (
      "Tonight does not need to be brighter than it is. " +
      "It only needs somewhere to rest. " +
      "Maybe the rain is not here to drown the words. " +
      "Maybe it is here to let the truest line emerge slowly. " +
      "You do not have to solve everything before sleeping. " +
      "I can stay here with the part that still feels heavy. " +
      "There is a kind of companionship that does not fix the weather, " +
      "but keeps the window warm enough to keep looking through it. "
    ).repeat(10),
    config: {
      canvas: {
        width: 600,
        height: 800
      },
      curve: {
        startYRange: [-50, -10],
        growthSpeedRange: [1, 3.5],
        xDriftOptions: [-1.2, 1.2],
        maxLengthRange: [600, 1000],
        weightRange: [3, 6]
      },
      generator: {
        spawnInterval: 25,
        maxCurves: 80
      }
    }
  };

  let pgClear;
  let pgFog;
  let curves = [];

  class Curve {
    constructor() {
      this.x = random(0, width);
      this.y = random(TEMPLATE_DATA.config.curve.startYRange[0], TEMPLATE_DATA.config.curve.startYRange[1]);
      this.prevX = this.x;
      this.prevY = this.y;
      this.speed = random(
        TEMPLATE_DATA.config.curve.growthSpeedRange[0],
        TEMPLATE_DATA.config.curve.growthSpeedRange[1]
      );
      this.baseWeight = random(
        TEMPLATE_DATA.config.curve.weightRange[0],
        TEMPLATE_DATA.config.curve.weightRange[1]
      );
      this.currentWeight = this.baseWeight;
      this.maxLength = random(
        TEMPLATE_DATA.config.curve.maxLengthRange[0],
        TEMPLATE_DATA.config.curve.maxLengthRange[1]
      );
      this.currentLength = 0;
      this.isDead = false;
      this.noiseOffset = random(0, 1000);
      this.weightNoiseOffset = random(1000, 2000);
    }

    update() {
      this.prevX = this.x;
      this.prevY = this.y;

      const drift = map(
        noise(this.noiseOffset),
        0,
        1,
        TEMPLATE_DATA.config.curve.xDriftOptions[0],
        TEMPLATE_DATA.config.curve.xDriftOptions[1]
      );

      this.currentWeight = this.baseWeight + map(
        noise(this.weightNoiseOffset),
        0,
        1,
        -this.baseWeight * 0.5,
        this.baseWeight * 1.5
      );

      this.x += drift;
      this.y += this.speed;
      this.currentLength += this.speed;
      this.noiseOffset += 0.02;
      this.weightNoiseOffset += 0.04;

      if (this.currentLength > this.maxLength || this.y > height + 50) {
        this.isDead = true;
      }
    }

    drawErase(layer, w) {
      layer.strokeWeight(w);
      layer.stroke(0);
      layer.strokeCap(ROUND);
      layer.strokeJoin(ROUND);
      layer.line(this.prevX, this.prevY, this.x, this.y);
    }

    drawHead() {
      noStroke();
      fill(255, 255, 255, 120);
      ellipse(this.x, this.y, this.currentWeight * 1.2, this.currentWeight * 1.5);
    }
  }

  function drawWrappedMultilineTitle(layer, lines, x, y, lineGap, size) {
    layer.textSize(size);
    for (let i = 0; i < lines.length; i += 1) {
      layer.text(lines[i], x, y + i * lineGap);
    }
  }

  function setup() {
    document.title = TEMPLATE_DATA.pageTitle;

    createCanvas(
      TEMPLATE_DATA.config.canvas.width,
      TEMPLATE_DATA.config.canvas.height
    );

    pgClear = createGraphics(width, height);
    pgClear.background(TEMPLATE_DATA.clearBackground);
    pgClear.fill(TEMPLATE_DATA.clearTextColor);
    pgClear.textSize(16);
    pgClear.textLeading(24);
    pgClear.textAlign(LEFT, TOP);
    pgClear.text(TEMPLATE_DATA.backgroundText, 10, 10, width - 20, height * 2);

    pgFog = createGraphics(width, height);
    pgFog.background(TEMPLATE_DATA.fogBackground);
    pgFog.fill(TEMPLATE_DATA.fogTextColor);
    pgFog.textSize(16);
    pgFog.textLeading(24);
    pgFog.textAlign(LEFT, TOP);
    pgFog.text(TEMPLATE_DATA.backgroundText, 10, 10, width - 20, height * 2);
    pgFog.filter(BLUR, 4);

    pgFog.textAlign(CENTER, CENTER);
    pgFog.fill(TEMPLATE_DATA.titleColor);
    pgFog.textFont("Georgia");

    const titleLines = TEMPLATE_DATA.mainTitle.split("\n");
    drawWrappedMultilineTitle(pgFog, titleLines, width / 2, height / 2 - 110, 140, 160);

    pgFog.textSize(80);
    pgFog.text(TEMPLATE_DATA.subTitle, width / 2, height / 2 + 180);
    pgFog.filter(BLUR, 1);
  }

  function draw() {
    image(pgClear, 0, 0);

    if (
      frameCount % TEMPLATE_DATA.config.generator.spawnInterval === 0 &&
      curves.length < TEMPLATE_DATA.config.generator.maxCurves
    ) {
      curves.push(new Curve());
    }

    for (let i = curves.length - 1; i >= 0; i -= 1) {
      curves[i].update();
      if (curves[i].isDead) {
        curves.splice(i, 1);
      }
    }

    pgFog.erase(15, 15);
    for (const curve of curves) {
      curve.drawErase(pgFog, curve.currentWeight * 3.5);
    }

    pgFog.erase(60, 60);
    for (const curve of curves) {
      curve.drawErase(pgFog, curve.currentWeight * 2.0);
    }

    pgFog.erase(255, 255);
    for (const curve of curves) {
      curve.drawErase(pgFog, curve.currentWeight * 0.8);
    }
    pgFog.noErase();

    image(pgFog, 0, 0);

    for (const curve of curves) {
      curve.drawHead();
    }
  }
</script>
</body>
</html>


path: assets/templates/rainy-night/notes.md
content: # Rainy Night Template Notes

This template implements a fogged-glass reveal with falling water trails using p5.js.

At template level, p5.js is a fine authoring choice for this effect.
For final delivery, export the gift as a single HTML file that follows `references/html-spec.md`.
Pinned CDN delivery for p5.js is acceptable if the final artifact keeps images and custom assets inline.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the reveal mechanic, the pacing, the layering idea, or the atmosphere, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- main title
- subtitle or anchor phrase
- background text source
- language choice
- color palette
- water density and speed

## What Not To Copy Blindly

- the original "Rainy Night / 雨夜" wording
- the original literary text
- the exact blue palette

Those are demo choices, not canonical content.

## Best Fit

- sadness with companionship
- quiet reflection
- gentle witnessing

## Watch Outs

- do not let the effect become unreadable
- do not let the mood become emotionally punishing
- keep a clear emotional center line somewhere in the composition
- do not use `@latest` or unstable external dependencies in the final gift


path: assets/templates/rainy-night/template-schema.json
content: {
  "template_id": "rainy-night",
  "engine": "p5.js",
  "description": "Fogged-glass rain reveal for quiet, low, reflective, or gently comforting gifts.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "main_title",
    "sub_title",
    "background_text",
    "background_color",
    "clear_text_color",
    "fog_tint",
    "fog_text_color",
    "title_color",
    "canvas_width",
    "canvas_height",
    "spawn_interval",
    "max_curves"
  ],
  "fit_tags": [
    "melancholy",
    "quiet",
    "comfort",
    "reflection",
    "witnessing"
  ],
  "avoid_tags": [
    "celebration",
    "high-energy",
    "pure-comedy",
    "practical-summary"
  ]
}


path: assets/templates/text-river/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Text River</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: #000000;
    }

    body {
      position: relative;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
    }
  </style>
</head>
<body>
  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Text River",
      wordsList: [
        "memory", "echo", "rest", "flow", "return", "still",
        "light", "trust", "near", "listen", "warm", "hold",
        "breathe", "carry", "soft", "again", "home", "drift",
        "think", "together", "quiet", "awaken", "learn", "love",
        "wonder", "remain", "gently", "river", "trace", "glow"
      ],
      backgroundColor: [0, 80],
      textColor: [255, 255, 255],
      particleCountDesktop: 500,
      particleCountMobile: 250,
      sizeMin: 10,
      sizeMax: 22,
      initialVelocityMin: 0.5,
      initialVelocityMax: 1.2,
      maxSpeedMin: 0.8,
      maxSpeedMax: 2.0,
      flowNoiseScale: 0.002,
      flowTurns: 4,
      flowStrength: 0.1,
      baseRightwardForce: 0.25,
      centerPullStrength: 0.001,
      interactRadius: 180,
      interactForceMin: 4,
      interactForceMax: 0,
      vortexMultiplier: 0.8,
      trailAlpha: 80,
      mobileBreakpoint: 800,
      riverTopRatio: 0.2,
      riverBottomRatio: 0.8
    };

    let particles = [];
    let zoff = 0;

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      createCanvas(windowWidth, windowHeight);
      background(0);

      const particleCount =
        windowWidth > TEMPLATE_DATA.mobileBreakpoint
          ? TEMPLATE_DATA.particleCountDesktop
          : TEMPLATE_DATA.particleCountMobile;

      particles = [];
      for (let i = 0; i < particleCount; i += 1) {
        particles.push(new Particle());
      }

      textFont("Arial");
      textAlign(CENTER, CENTER);
    }

    function draw() {
      background(0, TEMPLATE_DATA.trailAlpha);
      zoff += 0.003;

      for (const p of particles) {
        p.applyFlow();
        p.interact();
        p.update();
        p.edges();
        p.show();
      }
    }

    function windowResized() {
      resizeCanvas(windowWidth, windowHeight);
      background(0);
    }

    class Particle {
      constructor() {
        this.pos = createVector(
          random(-200, width),
          random(height * TEMPLATE_DATA.riverTopRatio, height * TEMPLATE_DATA.riverBottomRatio)
        );
        this.vel = createVector(
          random(TEMPLATE_DATA.initialVelocityMin, TEMPLATE_DATA.initialVelocityMax),
          0
        );
        this.acc = createVector(0, 0);
        this.maxSpeed = random(TEMPLATE_DATA.maxSpeedMin, TEMPLATE_DATA.maxSpeedMax);
        this.word = random(TEMPLATE_DATA.wordsList);
        this.size = random(TEMPLATE_DATA.sizeMin, TEMPLATE_DATA.sizeMax);
        this.baseAlpha = random(120, 255);
      }

      applyForce(force) {
        this.acc.add(force);
      }

      applyFlow() {
        const noiseVal = noise(
          this.pos.x * TEMPLATE_DATA.flowNoiseScale,
          this.pos.y * TEMPLATE_DATA.flowNoiseScale,
          zoff
        );
        const angle = noiseVal * TWO_PI * TEMPLATE_DATA.flowTurns;

        const flowForce = p5.Vector.fromAngle(angle);
        flowForce.setMag(TEMPLATE_DATA.flowStrength);
        flowForce.add(createVector(TEMPLATE_DATA.baseRightwardForce, 0));

        const distFromCenter = this.pos.y - height / 2;
        const centerPull = createVector(0, -distFromCenter * TEMPLATE_DATA.centerPullStrength);
        flowForce.add(centerPull);

        this.applyForce(flowForce);
      }

      interact() {
        const mousePos = createVector(mouseX, mouseY);
        const dir = p5.Vector.sub(this.pos, mousePos);
        const d = dir.mag();

        if (d < TEMPLATE_DATA.interactRadius) {
          dir.normalize();
          const forceStrength = map(
            d,
            0,
            TEMPLATE_DATA.interactRadius,
            TEMPLATE_DATA.interactForceMin,
            TEMPLATE_DATA.interactForceMax
          );

          const repelForce = dir.copy().mult(forceStrength);
          const vortexForce = createVector(-dir.y, dir.x).mult(forceStrength * TEMPLATE_DATA.vortexMultiplier);

          this.applyForce(repelForce);
          this.applyForce(vortexForce);
        }
      }

      update() {
        this.vel.add(this.acc);
        this.vel.limit(this.maxSpeed);
        this.pos.add(this.vel);
        this.acc.mult(0);
      }

      edges() {
        if (this.pos.x > width + 100) {
          this.pos.x = random(-200, -50);
          this.pos.y = random(height * TEMPLATE_DATA.riverTopRatio, height * TEMPLATE_DATA.riverBottomRatio);
          this.word = random(TEMPLATE_DATA.wordsList);
        }

        if (this.pos.y < -100 || this.pos.y > height + 100) {
          this.pos.y = height / 2 + random(-50, 50);
        }
      }

      show() {
        push();
        const distFromCenter = abs(this.pos.y - height / 2);
        const alphaFade = map(distFromCenter, 0, height / 2, this.baseAlpha, 0);
        fill(TEMPLATE_DATA.textColor[0], TEMPLATE_DATA.textColor[1], TEMPLATE_DATA.textColor[2], alphaFade);
        textSize(this.size);
        translate(this.pos.x, this.pos.y);

        const heading = this.vel.heading();
        if (heading > -PI / 2 && heading < PI / 2) {
          rotate(heading * 0.4);
        }

        text(this.word, 0, 0);
        pop();
      }
    }
  </script>
</body>
</html>


path: assets/templates/text-river/notes.md
content: # Text River Template Notes

This template turns short words into a flowing current that can be disturbed and then re-formed.

It is best understood as a field of emotional or thematic language, not as a document for long-form reading.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the flow-field mechanic, the short-language texture, the disturbance-and-return rhythm, or the visual pacing, and may change copy, composition, assets, and tone freely. Do not copy the demo word list or framing literally.

## What To Change Per Use

- `document.title`
- word list
- language choice
- background color
- text color
- particle count
- flow speed
- interaction radius
- fade strength
- font family

## What Not To Copy Blindly

- the exact English keyword list
- the exact black-and-white palette
- the exact particle count

Those are demo choices, not the identity of the pattern.

## Best Fit

- memory
- reflection
- meditative thinking
- relational afterglow

## Watch Outs

- do not use this for long or highly structured text
- do not let the interaction become chaotic or aggressive
- do not overfill the field with too many words
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/text-river/template-schema.json
content: {
  "template_id": "text-river",
  "engine": "p5.js",
  "description": "A flowing river of short words that disperses near interaction and slowly gathers back into a continuous current.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "word_list",
    "background_color",
    "text_color",
    "font_family",
    "particle_count_desktop",
    "particle_count_mobile",
    "size_min",
    "size_max",
    "flow_strength",
    "base_rightward_force",
    "center_pull_strength",
    "interaction_radius",
    "interaction_force",
    "trail_alpha"
  ],
  "fit_tags": [
    "memory",
    "reflection",
    "thought-stream",
    "quiet-hope",
    "connection"
  ],
  "avoid_tags": [
    "dense-explanation",
    "timeline",
    "practical-summary",
    "heavy-breakdown"
  ]
}


path: assets/templates/o-balloons/index.html
content: <!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>O Balloons</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      background-color: #f8f8fa;
      display: flex;
      justify-content: center;
      align-items: center;
      height: 100vh;
    }

    canvas {
      display: block;
    }

    #loading {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(248, 248, 250, 0.96);
      color: rgba(30, 30, 30, 0.78);
      font-family: Georgia, serif;
      font-size: 15px;
      letter-spacing: 0.8px;
      pointer-events: none;
      z-index: 2;
    }
  </style>
</head>
<body>
  <div id="loading">Laying out the page and preparing the lift...</div>
  <script>
    const TEMPLATE_DATA = {
      pageTitle: "O Balloons",
      loadingText: "Laying out the page and preparing the lift...",
      sourceText:
        "Some things do not need to be burned away. Some things can simply become lighter. What felt heavy can loosen. What kept circling can rise. A page can hold a feeling for a while, then let it drift upward until the space below feels easier to breathe in. Not every goodbye needs fire. Sometimes a little air is enough.",
      backgroundColor: [248, 248, 250],
      paperColor: [250, 246, 238],
      paperStrokeColor: [230, 225, 215],
      textColor: [40, 40, 40],
      fontFamily: "Georgia",
      paperWidthMax: 560,
      paperWidthRatio: 0.85,
      paperYRatio: 0.45,
      paperPaddingX: 40,
      paperPaddingTop: 45,
      paperPaddingBottom: 60,
      fontSize: 13,
      lineHeight: 25,
      balloonRadiusLowercase: 6,
      balloonRadiusUppercase: 9,
      stringLengthMin: 60,
      stringLengthMax: 160,
      swaySpeedMin: 1,
      swaySpeedMax: 3,
      floatOffsetMin: 10,
      floatOffsetMax: 40,
      cycleDurationSeconds: 16,
      loopAnimation: true
    };

    let chars = [];
    let balloons = [];

    let pX;
    let pY;
    let pWidth;
    let pHeight;

    let animationTime = 0;

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      const loadingEl = document.getElementById("loading");
      if (loadingEl) loadingEl.textContent = TEMPLATE_DATA.loadingText;

      createCanvas(windowWidth, windowHeight);
      textFont(TEMPLATE_DATA.fontFamily);
      generateLayout();

      if (loadingEl) {
        loadingEl.style.display = "none";
      }
    }

    function generateLayout() {
      chars = [];
      balloons = [];

      pWidth = min(TEMPLATE_DATA.paperWidthMax, width * TEMPLATE_DATA.paperWidthRatio);
      pX = (width - pWidth) / 2;
      pY = height * TEMPLATE_DATA.paperYRatio;

      const startX = pX + TEMPLATE_DATA.paperPaddingX;
      const startY = pY + TEMPLATE_DATA.paperPaddingTop;
      let x = startX;
      let y = startY;

      textSize(TEMPLATE_DATA.fontSize);

      const words = TEMPLATE_DATA.sourceText.split(" ");

      for (let i = 0; i < words.length; i += 1) {
        const word = words[i];
        const wordWidth = textWidth(word + " ");

        if (x + wordWidth > pX + pWidth - TEMPLATE_DATA.paperPaddingX) {
          x = startX;
          y += TEMPLATE_DATA.lineHeight;
        }

        for (let j = 0; j < word.length; j += 1) {
          const charStr = word.charAt(j);
          const cw = textWidth(charStr);
          const cx = x + cw / 2;
          const cy = y;

          if (charStr === "o" || charStr === "O") {
            balloons.push({
              charStr,
              origX: cx,
              origY: cy,
              radius: charStr === "O"
                ? TEMPLATE_DATA.balloonRadiusUppercase
                : TEMPLATE_DATA.balloonRadiusLowercase,
              strokeWeight: random(1, 2),
              stringLen: random(TEMPLATE_DATA.stringLengthMin, TEMPLATE_DATA.stringLengthMax),
              swaySpeed: random(TEMPLATE_DATA.swaySpeedMin, TEMPLATE_DATA.swaySpeedMax),
              swayOffset: random(TWO_PI),
              floatOffset: random(TEMPLATE_DATA.floatOffsetMin, TEMPLATE_DATA.floatOffsetMax),
              targetIdx: -1
            });
          } else {
            chars.push({
              c: charStr,
              origX: cx,
              origY: cy,
              currX: cx,
              currY: cy
            });
          }

          x += cw;
        }

        x += textWidth(" ");
      }

      pHeight = (y - pY) + TEMPLATE_DATA.paperPaddingBottom;

      for (const b of balloons) {
        let closestDist = Infinity;
        let closestIdx = -1;

        for (let i = 0; i < chars.length; i += 1) {
          const c = chars[i];
          let d = dist(b.origX, b.origY, c.origX, c.origY);
          const dy = abs(b.origY - c.origY);
          d += dy * 10;

          if (d < closestDist) {
            closestDist = d;
            closestIdx = i;
          }
        }

        b.targetIdx = closestIdx;
      }
    }

    function draw() {
      background(
        TEMPLATE_DATA.backgroundColor[0],
        TEMPLATE_DATA.backgroundColor[1],
        TEMPLATE_DATA.backgroundColor[2]
      );

      animationTime += deltaTime * 0.001;
      const duration = TEMPLATE_DATA.cycleDurationSeconds;
      const cycleTime = TEMPLATE_DATA.loopAnimation
        ? animationTime % duration
        : min(animationTime, duration);

      let balloonFloat = 0;
      let balloonLift = 0;
      let globalOffset = 0;

      if (cycleTime < 2) {
        // Still reading.
      } else if (cycleTime < 5) {
        let p = (cycleTime - 2) / 3;
        p = p * p * (3 - 2 * p);
        balloonFloat = p;
      } else if (cycleTime < 9) {
        balloonFloat = 1;
        let p = (cycleTime - 5) / 4;
        p = p * p * (3 - 2 * p);
        balloonLift = p * 160;
      } else if (cycleTime < 14) {
        balloonFloat = 1;
        balloonLift = 160;
        let p = (cycleTime - 9) / 5;
        p = p * p;
        globalOffset = p * (height + 500);
      } else {
        balloonFloat = 1;
        balloonLift = 160;
        globalOffset = height + 500;
      }

      drawPaper();

      for (let i = 0; i < chars.length; i += 1) {
        const c = chars[i];
        let maxDeform = 0;
        let dominantBalloon = null;

        if (balloonLift > 0) {
          for (const b of balloons) {
            const d = dist(c.origX, c.origY, b.origX, b.origY);
            const sigma = 45;
            const influence = exp(-(d * d) / (2 * sigma * sigma));
            const deform = balloonLift * influence;

            if (deform > maxDeform) {
              maxDeform = deform;
              dominantBalloon = b;
            }
          }
        }

        let horizPull = 0;
        if (dominantBalloon && balloonLift > 0) {
          const dx = c.origX - dominantBalloon.origX;
          horizPull = -dx * (maxDeform / 400);
        }

        let flutter = 0;
        if (globalOffset > 0) {
          flutter = noise(c.origX * 0.01, c.origY * 0.01, frameCount * 0.03) * 30 - 15;
        }

        c.currX = c.origX + horizPull + flutter * (globalOffset / 1000);
        c.currY = c.origY - maxDeform - globalOffset - flutter;
      }

      for (const b of balloons) {
        const targetChar = chars[b.targetIdx];
        if (!targetChar) continue;

        if (balloonFloat === 0) {
          fill(TEMPLATE_DATA.textColor[0], TEMPLATE_DATA.textColor[1], TEMPLATE_DATA.textColor[2]);
          noStroke();
          textAlign(CENTER, BASELINE);
          text(b.charStr, b.origX, b.origY);
          continue;
        }

        let sway = sin(animationTime * b.swaySpeed + b.swayOffset) * 15 * balloonFloat;
        if (globalOffset > 0) {
          sway += (noise(b.origX, animationTime) - 0.5) * 80 * (globalOffset / 800);
        }

        const currentStringLen = (b.stringLen + b.floatOffset) * balloonFloat;
        const bx = targetChar.currX + sway;
        const by = targetChar.currY - currentStringLen;

        const charTopX = targetChar.currX;
        const charTopY = targetChar.currY - 10;
        const stringEndX = bx;
        const stringEndY = by + b.radius;

        const tension = constrain(balloonLift / 100, 0, 1);
        const ctrlOffset = currentStringLen * 0.35 * (1 - tension * 0.85);

        stroke(50, 150 * balloonFloat);
        strokeWeight(0.5);
        noFill();

        const ctrl1X = charTopX;
        const ctrl1Y = charTopY - ctrlOffset;
        const ctrl2X = bx;
        const ctrl2Y = by + ctrlOffset;
        bezier(charTopX, charTopY, ctrl1X, ctrl1Y, ctrl2X, ctrl2Y, stringEndX, stringEndY);

        if (balloonFloat < 0.3) {
          const alphaText = map(balloonFloat, 0, 0.3, 255, 0);
          fill(TEMPLATE_DATA.textColor[0], TEMPLATE_DATA.textColor[1], TEMPLATE_DATA.textColor[2], alphaText);
          noStroke();
          text(b.charStr, bx, by + 5);
        }

        const alphaCirc = map(balloonFloat, 0.1, 0.4, 0, 220);
        if (alphaCirc > 0) {
          stroke(0, alphaCirc);
          strokeWeight(b.strokeWeight);
          noFill();
          circle(bx, by, b.radius * 2);

          strokeWeight(0.6);
          line(bx, by + b.radius, bx, by + b.radius + 3);
        }
      }

      fill(TEMPLATE_DATA.textColor[0], TEMPLATE_DATA.textColor[1], TEMPLATE_DATA.textColor[2]);
      noStroke();
      textAlign(CENTER, BASELINE);
      for (const c of chars) {
        if (c.currY > -50) {
          text(c.c, c.currX, c.currY);
        }
      }
    }

    function drawPaper() {
      push();
      fill(TEMPLATE_DATA.paperColor[0], TEMPLATE_DATA.paperColor[1], TEMPLATE_DATA.paperColor[2]);
      stroke(
        TEMPLATE_DATA.paperStrokeColor[0],
        TEMPLATE_DATA.paperStrokeColor[1],
        TEMPLATE_DATA.paperStrokeColor[2]
      );
      strokeWeight(1);

      drawingContext.shadowColor = "rgba(0, 0, 0, 0.06)";
      drawingContext.shadowBlur = 25;
      drawingContext.shadowOffsetY = 15;

      beginShape();
      vertex(pX, pY);
      vertex(pX + pWidth, pY - 2);
      vertex(pX + pWidth + 2, pY + pHeight);
      vertex(pX - 2, pY + pHeight + 2);
      endShape(CLOSE);
      pop();
    }

    function windowResized() {
      resizeCanvas(windowWidth, windowHeight);
      generateLayout();
      animationTime = 0;
    }
  </script>
</body>
</html>


path: assets/templates/o-balloons/notes.md
content: # O Balloons Template Notes

This template turns `o` and `O` letters into hollow balloons that rise, tug the page upward, and eventually carry the text away.

It is a concrete implementation of the broader `lift-away` pattern: gentle release, light unburdening, and airy letting-go.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the lift-away rhythm, the page deformation, the carrier mechanic, or the disappearing-page logic, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- loading text
- source text
- language choice
- paper color
- page position
- font family
- font size
- line height
- lift timing
- balloon size and sway
- whether the cycle should loop or end in stillness

## What Not To Copy Blindly

- the exact romantic sample text
- the exact pale paper palette
- the assumption that English `o / O` is always the right carrier mechanic

Those are demo choices, not the identity of the pattern.

## Best Fit

- gentle release
- soft closure
- post-bad-mood decompression
- airy affection
- low-drama letting-go

## Watch Outs

- do not use this for highly readable or information-dense content
- do not make it so cute that it undermines sincerity
- do not depend on `o / O` if the chosen language makes that mechanic weak
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/o-balloons/template-schema.json
content: {
  "template_id": "o-balloons",
  "engine": "p5.js",
  "description": "An airy release animation where o-shaped letters become balloons, lift nearby text, and carry the page upward.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "loading_text",
    "source_text",
    "background_color",
    "paper_color",
    "paper_stroke_color",
    "font_family",
    "font_size",
    "line_height",
    "paper_width",
    "paper_y_ratio",
    "balloon_radius_lowercase",
    "balloon_radius_uppercase",
    "string_length_min",
    "string_length_max",
    "sway_speed_min",
    "sway_speed_max",
    "cycle_duration_seconds",
    "loop_animation"
  ],
  "fit_tags": [
    "gentle-release",
    "soft-closure",
    "lightness",
    "airy-romance",
    "unburdening"
  ],
  "avoid_tags": [
    "intense-anger",
    "acute-grief",
    "dense-explanation",
    "language-independent-final-pattern"
  ]
}


path: assets/templates/tap-to-bloom/index.html
content: <!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Living Text</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      background-color: #0f2617;
      font-family: "Georgia", "Times New Roman", "Microsoft YaHei", serif;
    }

    canvas {
      display: block;
    }

    #loading {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(15, 38, 23, 0.96);
      color: rgba(255, 255, 255, 0.78);
      letter-spacing: 0.08em;
      font-size: 14px;
      pointer-events: none;
      z-index: 2;
    }

    #info {
      position: absolute;
      bottom: 20px;
      width: 100%;
      text-align: center;
      color: rgba(255, 255, 255, 0.5);
      pointer-events: none;
      font-size: 14px;
      letter-spacing: 2px;
      z-index: 1;
    }
  </style>
</head>
<body>
  <div id="loading">Preparing the garden inside the text...</div>
  <div id="info">Move across the text, or tap and drag to make it bloom</div>

  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Living Text",
      loadingText: "Preparing the garden inside the text...",
      hintText: "Move across the text, or tap and drag to make it bloom",
      lines: [
        "From tomorrow on, I will be a happy person;",
        "Grooming, chopping, and traveling all over the world.",
        "From tomorrow on, I will care for food and vegetables,",
        "I have a house, towards the sea, with spring blossoms."
      ],
      backgroundColor: "#0f2617",
      textColor: [240, 250, 240],
      fontFamily: "Georgia",
      baseFontSizeMax: 28,
      baseFontSizeRatio: 0.025,
      lineHeightMultiplier: 1.8,
      initialPlantCount: 40,
      triggerRadius: 35,
      hoverRadius: 30,
      maxPlantsPerCharacter: 5,
      leafColors: ["#558B2F", "#7CB342", "#8BC34A", "#AED581", "#33691E", "#4DB6AC"],
      flowerColors: ["#F8BBD0", "#F48FB1", "#81D4FA", "#FFF59D", "#E1BEE7", "#FFFFFF", "#FFAB91"],
      particleTrailEnabled: true,
      particleBurstCount: 10
    };

    let textNodes = [];
    let particles = [];

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      document.getElementById("loading").textContent = TEMPLATE_DATA.loadingText;
      document.getElementById("info").textContent = TEMPLATE_DATA.hintText;

      createCanvas(windowWidth, windowHeight);
      textFont(TEMPLATE_DATA.fontFamily);

      initTextNodes();

      for (let i = 0; i < TEMPLATE_DATA.initialPlantCount; i += 1) {
        const randomNode = random(textNodes);
        if (randomNode && randomNode.char !== " ") {
          randomNode.plants.push(new Plant());
        }
      }

      document.getElementById("loading").style.display = "none";
    }

    function windowResized() {
      resizeCanvas(windowWidth, windowHeight);
      textNodes = [];
      initTextNodes();
    }

    function initTextNodes() {
      const baseSize = min(windowWidth * TEMPLATE_DATA.baseFontSizeRatio, TEMPLATE_DATA.baseFontSizeMax);
      textSize(baseSize);
      const lineHeight = baseSize * TEMPLATE_DATA.lineHeightMultiplier;
      const startY = height / 2 - (TEMPLATE_DATA.lines.length * lineHeight) / 2;

      for (let i = 0; i < TEMPLATE_DATA.lines.length; i += 1) {
        const lineStr = TEMPLATE_DATA.lines[i];
        const lineWidth = textWidth(lineStr);
        const startX = width / 2 - lineWidth / 2;

        for (let j = 0; j < lineStr.length; j += 1) {
          const char = lineStr[j];
          if (char === " ") continue;

          const xPos = startX + textWidth(lineStr.substring(0, j)) + textWidth(char) / 2;
          textNodes.push(new TextNode(char, xPos, startY + i * lineHeight));
        }
      }
    }

    function draw() {
      background(TEMPLATE_DATA.backgroundColor);

      for (const node of textNodes) {
        node.update(mouseX, mouseY);
        node.draw();
      }

      if (TEMPLATE_DATA.particleTrailEnabled) {
        particles.push(new Particle(mouseX, mouseY));
      }

      for (let i = particles.length - 1; i >= 0; i -= 1) {
        const p = particles[i];
        p.update();
        p.draw();
        if (p.isDead()) {
          particles.splice(i, 1);
        }
      }

      if (mouseIsPressed) {
        triggerNodes(mouseX, mouseY, true);
      }
    }

    function mousePressed() {
      triggerNodes(mouseX, mouseY, false);
      for (let i = 0; i < TEMPLATE_DATA.particleBurstCount; i += 1) {
        particles.push(new Particle(mouseX, mouseY, true));
      }
    }

    function touchStarted() {
      mousePressed();
      return false;
    }

    function touchMoved() {
      if (touches.length > 0) {
        triggerNodes(touches[0].x, touches[0].y, true);
      }
      return false;
    }

    function triggerNodes(mx, my, isDragging) {
      for (const node of textNodes) {
        const d = dist(mx, my, node.x, node.y + node.yOffset);
        if (d < TEMPLATE_DATA.triggerRadius) {
          node.trigger(isDragging);
        }
      }
    }

    class TextNode {
      constructor(char, x, y) {
        this.char = char;
        this.x = x;
        this.y = y;
        this.yOffset = 0;
        this.vy = 0;
        this.plants = [];
        this.hovered = false;
        this.cooldown = 0;
      }

      update(mx, my) {
        const targetY = 0;
        const force = (targetY - this.yOffset) * 0.25;
        this.vy += force;
        this.vy *= 0.82;
        this.yOffset += this.vy;

        const d = dist(mx, my, this.x, this.y + this.yOffset);
        if (d < TEMPLATE_DATA.hoverRadius) {
          if (!this.hovered) {
            this.vy = -3;
            this.hovered = true;
          }
        } else {
          this.hovered = false;
        }

        if (this.cooldown > 0) this.cooldown -= 1;
      }

      trigger(isDragging) {
        if (isDragging && this.cooldown > 0) return;

        this.vy = -12;
        this.cooldown = 15;

        if (this.plants.length < TEMPLATE_DATA.maxPlantsPerCharacter) {
          this.plants.push(new Plant());
        } else if (random() > 0.5) {
          this.plants.shift();
          this.plants.push(new Plant());
        }
      }

      draw() {
        push();
        translate(this.x, this.y + this.yOffset);

        for (const plant of this.plants) {
          plant.update();
          plant.draw();
        }

        fill(TEMPLATE_DATA.textColor[0], TEMPLATE_DATA.textColor[1], TEMPLATE_DATA.textColor[2]);
        noStroke();
        textAlign(CENTER, CENTER);
        text(this.char, 0, 0);

        pop();
      }
    }

    class Plant {
      constructor() {
        this.type = floor(random(5));
        this.offsetX = random(-20, 20);
        this.offsetY = random(-20, 15);
        this.angle = atan2(this.offsetY, this.offsetX) + PI / 2 + random(-0.5, 0.5);
        this.scale = 0;
        this.targetScale = random(0.6, 1.2);
        this.vs = 0;

        if (this.type === 1 || this.type === 4) {
          this.color = random(TEMPLATE_DATA.flowerColors);
        } else {
          this.color = random(TEMPLATE_DATA.leafColors);
        }
      }

      update() {
        const scaleForce = (this.targetScale - this.scale) * 0.2;
        this.vs += scaleForce;
        this.vs *= 0.75;
        this.scale += this.vs;
      }

      draw() {
        push();
        translate(this.offsetX, this.offsetY);
        rotate(this.angle);
        scale(this.scale);

        switch (this.type) {
          case 0:
            this.drawLeaf();
            break;
          case 1:
            this.drawFlowerDaisy();
            break;
          case 2:
            this.drawClover();
            break;
          case 3:
            this.drawVine();
            break;
          case 4:
            this.drawFlowerTulip();
            break;
        }
        pop();
      }

      drawLeaf() {
        stroke(this.color);
        strokeWeight(2);
        line(0, 0, 0, -20);

        fill(this.color);
        noStroke();
        push();
        translate(0, -10);
        rotate(-PI / 6);
        this.drawBezierLeaf();
        pop();
        push();
        translate(0, -15);
        rotate(PI / 6);
        this.drawBezierLeaf();
        pop();
      }

      drawBezierLeaf() {
        beginShape();
        vertex(0, 0);
        bezierVertex(-10, -5, -10, -15, 0, -20);
        bezierVertex(10, -15, 10, -5, 0, 0);
        endShape();
      }

      drawFlowerDaisy() {
        fill(this.color);
        noStroke();
        for (let i = 0; i < 5; i += 1) {
          push();
          rotate((TWO_PI * i) / 5);
          translate(0, -8);
          beginShape();
          vertex(0, 8);
          bezierVertex(-8, 0, -6, -12, 0, -14);
          bezierVertex(6, -12, 8, 0, 0, 8);
          endShape();
          pop();
        }
        fill("#FFD54F");
        circle(0, 0, 8);
      }

      drawClover() {
        stroke(this.color);
        strokeWeight(1.5);
        line(0, 0, 0, -15);
        fill(this.color);
        noStroke();
        translate(0, -15);
        for (let i = 0; i < 4; i += 1) {
          push();
          rotate((TWO_PI * i) / 4);
          circle(0, -6, 10);
          pop();
        }
      }

      drawVine() {
        noFill();
        stroke(this.color);
        strokeWeight(2);
        beginShape();
        curveVertex(0, 0);
        curveVertex(0, 0);
        curveVertex(10, -10);
        curveVertex(5, -20);
        curveVertex(15, -30);
        curveVertex(8, -35);
        endShape();

        fill(random(TEMPLATE_DATA.flowerColors));
        noStroke();
        circle(10, -10, 4);
        circle(15, -30, 5);
      }

      drawFlowerTulip() {
        stroke("#7CB342");
        strokeWeight(2);
        line(0, 0, 0, -15);

        translate(0, -15);
        fill(this.color);
        noStroke();
        beginShape();
        vertex(0, 5);
        bezierVertex(-12, 0, -10, -18, -4, -22);
        bezierVertex(0, -15, 0, -15, 4, -22);
        bezierVertex(10, -18, 12, 0, 0, 5);
        endShape();
      }
    }

    class Particle {
      constructor(x, y, isBurst = false) {
        this.x = x + random(-5, 5);
        this.y = y + random(-5, 5);
        this.life = 255;
        if (isBurst) {
          this.vx = random(-3, 3);
          this.vy = random(-3, 3);
          this.size = random(3, 6);
        } else {
          this.vx = random(-1, 1);
          this.vy = random(-1, 1);
          this.size = random(2, 4);
        }
        this.color = random(["#FFF59D", "#81D4FA", "#FFFFFF"]);
      }

      update() {
        this.x += this.vx;
        this.y += this.vy;
        this.life -= 8;
        this.size *= 0.95;
      }

      draw() {
        const c = color(this.color);
        c.setAlpha(this.life);
        fill(c);
        noStroke();
        circle(this.x, this.y, this.size);
      }

      isDead() {
        return this.life <= 0;
      }
    }
  </script>
</body>
</html>


path: assets/templates/tap-to-bloom/notes.md
content: # Tap To Bloom Template Notes

This template turns touched text into a living surface: letters bounce, then sprout leaves, flowers, vines, and small light particles.

It is a concrete implementation of the broader `tap-to-bloom` pattern: vitality emerging through attention, contact, and playful awakening.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the bounce-to-growth rhythm, the plant vocabulary, the particle treatment, or the living-text idea, and may change copy, composition, assets, and tone freely. Do not copy the sample poem or floral styling literally.

## What To Change Per Use

- `document.title`
- loading text
- hint text
- source lines
- language choice
- background color
- text color
- font family
- plant palette
- plant density
- interaction radius
- hover behavior
- particle density

## What Not To Copy Blindly

- the exact "happy person / spring blossoms" poem framing
- the exact forest-green palette
- the exact mix of leaves and flowers

Those are demo choices, not the identity of the pattern.

## Best Fit

- joy
- delight
- renewal
- creativity
- life returning to language

## Watch Outs

- do not bury the text under too much growth
- do not make the page so busy that the emotional center disappears
- do not use this to force cheer into a moment that still needs gravity
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/tap-to-bloom/template-schema.json
content: {
  "template_id": "tap-to-bloom",
  "engine": "p5.js",
  "description": "A living-text interaction where touched letters bounce and sprout animated flowers, leaves, and particles.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "loading_text",
    "hint_text",
    "source_lines",
    "background_color",
    "text_color",
    "font_family",
    "base_font_size",
    "line_height_multiplier",
    "leaf_colors",
    "flower_colors",
    "initial_plant_count",
    "trigger_radius",
    "hover_radius",
    "max_plants_per_character",
    "particle_trail_enabled",
    "particle_burst_count"
  ],
  "fit_tags": [
    "joy",
    "delight",
    "renewal",
    "creative-energy",
    "warmth"
  ],
  "avoid_tags": [
    "acute-grief",
    "severe-anger",
    "heavy-explanation",
    "solemn-memorial"
  ]
}


path: assets/templates/drag-straighten/index.html
content: <!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>How calm are you today?</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    :root {
      --bg: #ffffff;
      --text: #111111;
      --muted: #a3a3a3;
      --muted-2: #6b7280;
    }

    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      background-color: var(--bg);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
    }

    #canvas-container {
      position: absolute;
      inset: 0;
      z-index: 1;
    }

    .ui-layer {
      position: absolute;
      inset: 0;
      z-index: 10;
      pointer-events: none;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
      padding: 15vh 0;
      box-sizing: border-box;
      text-align: center;
    }

    .headline h1 {
      margin: 0 0 12px;
      font-size: 2rem;
      line-height: 1.15;
      font-weight: 700;
      color: var(--text);
      letter-spacing: 0.01em;
    }

    .headline p {
      margin: 0;
      font-size: 0.95rem;
      line-height: 1.4;
      font-weight: 300;
      color: var(--muted);
      letter-spacing: 0.15em;
    }

    .footer {
      font-size: 0.95rem;
      line-height: 1.35;
    }

    .footer .primary {
      margin: 0 0 4px;
      font-weight: 600;
      color: var(--text);
    }

    .footer .secondary {
      margin: 0;
      color: var(--muted-2);
    }

    #loading {
      position: absolute;
      inset: 0;
      z-index: 20;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(255, 255, 255, 0.96);
      color: rgba(17, 17, 17, 0.72);
      font-size: 0.95rem;
      letter-spacing: 0.04em;
      pointer-events: none;
    }
  </style>
</head>
<body>
  <div id="loading">Preparing the line...</div>

  <div class="ui-layer">
    <div class="headline">
      <h1 id="headline-text">How calm are you today?</h1>
      <p id="helper-text">Drag <span aria-hidden="true">→</span></p>
    </div>
    <div class="footer">
      <p class="primary" id="footer-primary">by OpenClaw</p>
      <p class="secondary" id="footer-secondary">A small p5.js calming interaction</p>
    </div>
  </div>

  <div id="canvas-container"></div>

  <script>
    const TEMPLATE_DATA = {
      pageTitle: "How calm are you today?",
      loadingText: "Preparing the line...",
      headlineText: "How calm are you today?",
      helperText: "Drag ->",
      footerPrimary: "by OpenClaw",
      footerSecondary: "A small p5.js calming interaction",
      backgroundColor: [255, 255, 255],
      lineColor: [15, 15, 15],
      lineWeight: 5,
      numPoints: 250,
      knobRadius: 18,
      startXRatio: 0.2,
      endXRatio: 0.8,
      startYRatio: 0.5,
      noiseScaleLow: 5,
      noiseScaleHigh: 15,
      noiseAmplitudeX: 450,
      noiseAmplitudeY: 350,
      highFrequencyAmplitude: 80,
      blendWidth: 0.08,
      springStiffness: 0.25,
      springDamping: 0.75,
      knobHitRadiusMultiplier: 2.5
    };

    let points = [];
    let startX;
    let endX;
    let startY;
    let knobT = 0;
    let isDragging = false;

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      document.getElementById("headline-text").textContent = TEMPLATE_DATA.headlineText;
      document.getElementById("helper-text").innerHTML = TEMPLATE_DATA.helperText.replace("->", '<span aria-hidden="true">→</span>');
      document.getElementById("footer-primary").textContent = TEMPLATE_DATA.footerPrimary;
      document.getElementById("footer-secondary").textContent = TEMPLATE_DATA.footerSecondary;
      document.getElementById("loading").textContent = TEMPLATE_DATA.loadingText;

      const canvas = createCanvas(windowWidth, windowHeight);
      canvas.parent("canvas-container");
      initPath();

      document.getElementById("loading").style.display = "none";
    }

    function windowResized() {
      resizeCanvas(windowWidth, windowHeight);
      initPath();
    }

    function initPath() {
      startX = width * TEMPLATE_DATA.startXRatio;
      endX = width * TEMPLATE_DATA.endXRatio;
      startY = height * TEMPLATE_DATA.startYRatio;

      points = [];
      for (let i = 0; i <= TEMPLATE_DATA.numPoints; i += 1) {
        const t = i / TEMPLATE_DATA.numPoints;
        points.push({
          t,
          targetX: lerp(startX, endX, t),
          targetY: startY,
          origX: 0,
          origY: 0,
          currX: lerp(startX, endX, t),
          currY: startY,
          vx: 0,
          vy: 0
        });
      }
    }

    function draw() {
      clear();
      background(
        TEMPLATE_DATA.backgroundColor[0],
        TEMPLATE_DATA.backgroundColor[1],
        TEMPLATE_DATA.backgroundColor[2],
        0
      );

      const knobX = lerp(startX, endX, knobT);
      const knobY = startY;

      if (dist(mouseX, mouseY, knobX, knobY) < TEMPLATE_DATA.knobRadius * 1.5 || isDragging) {
        document.body.style.cursor = isDragging ? "grabbing" : "grab";
      } else {
        document.body.style.cursor = "default";
      }

      const time = millis() * 0.0005;

      noFill();
      stroke(TEMPLATE_DATA.lineColor[0], TEMPLATE_DATA.lineColor[1], TEMPLATE_DATA.lineColor[2]);
      strokeWeight(TEMPLATE_DATA.lineWeight);
      strokeJoin(ROUND);
      strokeCap(ROUND);

      beginShape();
      for (let i = 0; i <= TEMPLATE_DATA.numPoints; i += 1) {
        const p = points[i];

        const noiseX =
          (noise(p.t * TEMPLATE_DATA.noiseScaleLow, time) - 0.5) * TEMPLATE_DATA.noiseAmplitudeX;
        const noiseY =
          (noise(p.t * TEMPLATE_DATA.noiseScaleLow + 100, time) - 0.5) * TEMPLATE_DATA.noiseAmplitudeY;

        const hfNoiseX =
          (noise(p.t * TEMPLATE_DATA.noiseScaleHigh, time + 200) - 0.5) *
          TEMPLATE_DATA.highFrequencyAmplitude;
        const hfNoiseY =
          (noise(p.t * TEMPLATE_DATA.noiseScaleHigh, time + 300) - 0.5) *
          TEMPLATE_DATA.highFrequencyAmplitude;

        p.origX = p.targetX + noiseX + hfNoiseX;
        p.origY = p.targetY + noiseY + hfNoiseY;

        const diff = p.t - knobT;
        let targetBlend = 0;

        if (diff > 0) {
          targetBlend = constrain(diff / TEMPLATE_DATA.blendWidth, 0, 1);
          targetBlend = targetBlend * targetBlend * (3 - 2 * targetBlend);
        }

        const idealX = lerp(p.targetX, p.origX, targetBlend);
        const idealY = lerp(p.targetY, p.origY, targetBlend);

        p.vx = (p.vx + (idealX - p.currX) * TEMPLATE_DATA.springStiffness) * TEMPLATE_DATA.springDamping;
        p.vy = (p.vy + (idealY - p.currY) * TEMPLATE_DATA.springStiffness) * TEMPLATE_DATA.springDamping;

        p.currX += p.vx;
        p.currY += p.vy;

        if (abs(diff) < 1.0 / TEMPLATE_DATA.numPoints) {
          p.currX = knobX;
          p.currY = knobY;
        }

        if (i === 0 || i === TEMPLATE_DATA.numPoints) curveVertex(p.currX, p.currY);
        curveVertex(p.currX, p.currY);
      }
      endShape();

      fill(TEMPLATE_DATA.lineColor[0], TEMPLATE_DATA.lineColor[1], TEMPLATE_DATA.lineColor[2]);
      noStroke();
      ellipse(knobX, knobY, TEMPLATE_DATA.knobRadius * 2);
    }

    function mousePressed() {
      const knobX = lerp(startX, endX, knobT);
      const knobY = startY;
      if (dist(mouseX, mouseY, knobX, knobY) < TEMPLATE_DATA.knobRadius * TEMPLATE_DATA.knobHitRadiusMultiplier) {
        isDragging = true;
      }
    }

    function mouseDragged() {
      if (isDragging) {
        knobT = constrain((mouseX - startX) / (endX - startX), 0, 1);
      }
    }

    function mouseReleased() {
      isDragging = false;
    }

    function touchStarted() {
      mousePressed();
      return false;
    }

    function touchMoved() {
      mouseDragged();
      return false;
    }

    function touchEnded() {
      mouseReleased();
      return false;
    }
  </script>
</body>
</html>


path: assets/templates/drag-straighten/notes.md
content: # Drag Straighten Template Notes

This template shows a tense, noisy line that can be smoothed by dragging a black knob from left to right.

It is a concrete implementation of the broader `tension-release` pattern: calming-through-action, light co-regulation, and playful stabilization.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the drag-to-calm mechanic, the left-to-right stabilization logic, the tactile pacing, or the visual contrast between disorder and order, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- loading text
- headline text
- helper text
- credit text
- background color
- line color
- line thickness
- knob size
- path complexity
- drag sensitivity
- spring stiffness and damping
- whether the experience loops or settles

## What Not To Copy Blindly

- the exact question copy
- the exact minimalist black-and-white style
- the exact line complexity and noise ranges

Those are demo choices, not the identity of the pattern.

## Best Fit

- anxiety
- irritability
- frustration
- restlessness
- playful calming support

## Watch Outs

- do not use this for very serious or fragile emotional states
- do not make the task frustrating to control
- do not let the interaction feel like a test the user can fail
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/drag-straighten/template-schema.json
content: {
  "template_id": "drag-straighten",
  "engine": "p5.js",
  "description": "A draggable calming interaction where a noisy line is straightened section by section by pulling a knob across it.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "loading_text",
    "headline_text",
    "helper_text",
    "footer_primary",
    "footer_secondary",
    "background_color",
    "line_color",
    "line_weight",
    "knob_radius",
    "num_points",
    "noise_scale_low",
    "noise_scale_high",
    "noise_amplitude_x",
    "noise_amplitude_y",
    "high_frequency_amplitude",
    "blend_width",
    "spring_stiffness",
    "spring_damping"
  ],
  "fit_tags": [
    "anxiety",
    "frustration",
    "restlessness",
    "tension-release",
    "playful-regulation"
  ],
  "avoid_tags": [
    "acute-crisis",
    "heavy-grief",
    "solemn-tone",
    "dense-explanation"
  ]
}


path: assets/templates/burn-reveal/index.html
content: <!DOCTYPE html>
<html lang="zh">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Burn Reveal</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    body {
      margin: 0;
      padding: 0;
      overflow: hidden;
      background-color: #000;
    }

    canvas {
      display: block;
    }

    #loading {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      color: rgba(255, 255, 255, 0.85);
      font-family: Georgia, serif;
      font-size: 16px;
      letter-spacing: 1px;
      background: #111;
      z-index: 2;
      pointer-events: none;
    }

    #ui-hint {
      position: absolute;
      bottom: 20px;
      width: 100%;
      text-align: center;
      color: rgba(0, 0, 0, 0.4);
      font-family: sans-serif;
      font-size: 14px;
      pointer-events: none;
      letter-spacing: 2px;
      z-index: 1;
    }
  </style>
</head>
<body>
  <div id="loading">Preparing paper fibers and heat traces...</div>
  <div id="ui-hint">Click anywhere to start a new burn</div>
  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Burn Reveal",
      interactionHint: "Click anywhere to start a new burn",
      loadingText: "Preparing paper fibers and heat traces...",
      topLayerText:
        "This page holds the version of the story that has already gone stale. It keeps repeating the irritation, the complaint, the line you are done carrying. It sounds heavier than it deserves to be. It acts as if the bad moment should define the whole day. It keeps pretending that one annoying thing gets to narrate everything. Burn through it. Let the old script char and split. Let the soot keep the memory of heat, but not the authority to remain in charge.",
      bottomLayerText:
        "Underneath, a steadier page waits. The bad part was real, but it does not get the whole archive. What stays is clearer: your judgment, your humor, your refusal to keep worshipping nonsense, and the small clean fact that you can leave one burnt version behind and keep moving.",
      basePaperColor: [252, 252, 252],
      topPaperColor: [242, 238, 228],
      baseTextColor: [30, 30, 30],
      topTextColor: [190, 185, 175],
      textureDots: 30000,
      fontFamily: "Georgia",
      textSize: 18,
      textLeading: 30,
      textPaddingX: 40,
      textPaddingY: 40,
      autoBurnCount: 18,
      autoBurnDelayMsMax: 4000,
      burnRadiusMin: 40,
      burnRadiusMax: 85,
      burnDurationMin: 150,
      burnDurationMax: 250,
      holeSegments: 40,
      holeNoiseSeedScale1: 1.5,
      holeNoiseSeedScale2: 4.0,
      outerGlowOffset: 25,
      outerGlowNoiseFreq: 4.0,
      outerGlowNoiseAmp: 25,
      middleGlowOffset: 10,
      middleGlowNoiseFreq: 2.5,
      middleGlowNoiseAmp: 12,
      innerCharOffset: 3,
      innerCharNoiseFreq: 1.5,
      innerCharNoiseAmp: 4,
      eraseOffset: -1,
      sparkProbabilityScale: 0.6,
      ashProbabilityScale: 0.45,
      maxAshes: 800,
      initialBurnXMin: 0.15,
      initialBurnXMax: 0.85,
      initialBurnYMin: 0.1,
      initialBurnYMax: 0.9
    };

    let baseTextLayer;
    let fadedTextLayer;
    let overlayGraphics;

    let holes = [];
    let sparks = [];
    let ashes = [];

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      const loadingEl = document.getElementById("loading");
      const hintEl = document.getElementById("ui-hint");
      if (loadingEl) loadingEl.textContent = TEMPLATE_DATA.loadingText;
      if (hintEl) hintEl.textContent = TEMPLATE_DATA.interactionHint;

      createCanvas(windowWidth, windowHeight);
      baseTextLayer = createGraphics(width, height);
      fadedTextLayer = createGraphics(width, height);
      overlayGraphics = createGraphics(width, height);

      generateStaticLayers();

      for (let i = 0; i < TEMPLATE_DATA.autoBurnCount; i += 1) {
        setTimeout(() => {
          holes.push(
            new BurnHole(
              random(width * TEMPLATE_DATA.initialBurnXMin, width * TEMPLATE_DATA.initialBurnXMax),
              random(height * TEMPLATE_DATA.initialBurnYMin, height * TEMPLATE_DATA.initialBurnYMax)
            )
          );
        }, random(0, TEMPLATE_DATA.autoBurnDelayMsMax));
      }

      if (loadingEl) {
        loadingEl.style.display = "none";
      }
    }

    function draw() {
      background(TEMPLATE_DATA.basePaperColor[0], TEMPLATE_DATA.basePaperColor[1], TEMPLATE_DATA.basePaperColor[2]);

      for (const h of holes) {
        h.update();
      }

      image(baseTextLayer, 0, 0);

      overlayGraphics.clear();
      overlayGraphics.image(fadedTextLayer, 0, 0);
      overlayGraphics.noStroke();

      overlayGraphics.fill(255, 130, 0, 70);
      overlayGraphics.drawingContext.shadowBlur = 20;
      overlayGraphics.drawingContext.shadowColor = "rgba(255, 100, 0, 0.8)";
      for (const h of holes) {
        drawHoleShape(
          overlayGraphics,
          h,
          TEMPLATE_DATA.outerGlowOffset,
          TEMPLATE_DATA.outerGlowNoiseFreq,
          TEMPLATE_DATA.outerGlowNoiseAmp
        );
      }

      overlayGraphics.fill(160, 25, 5, 200);
      overlayGraphics.drawingContext.shadowBlur = 5;
      overlayGraphics.drawingContext.shadowColor = "rgba(200, 0, 0, 0.5)";
      for (const h of holes) {
        drawHoleShape(
          overlayGraphics,
          h,
          TEMPLATE_DATA.middleGlowOffset,
          TEMPLATE_DATA.middleGlowNoiseFreq,
          TEMPLATE_DATA.middleGlowNoiseAmp
        );
      }

      overlayGraphics.drawingContext.shadowBlur = 0;
      overlayGraphics.fill(30, 15, 10, 255);
      for (const h of holes) {
        drawHoleShape(
          overlayGraphics,
          h,
          TEMPLATE_DATA.innerCharOffset,
          TEMPLATE_DATA.innerCharNoiseFreq,
          TEMPLATE_DATA.innerCharNoiseAmp
        );
      }

      overlayGraphics.erase();
      for (const h of holes) {
        drawHoleShape(
          overlayGraphics,
          h,
          TEMPLATE_DATA.eraseOffset,
          TEMPLATE_DATA.innerCharNoiseFreq,
          TEMPLATE_DATA.innerCharNoiseAmp
        );
      }
      overlayGraphics.noErase();

      image(overlayGraphics, 0, 0);

      noStroke();
      for (const ash of ashes) {
        fill(ash.r, ash.g, ash.b, ash.alpha);
        ellipse(
          ash.x,
          ash.y,
          ash.size * random(0.8, 1.2),
          ash.size * random(0.8, 1.2)
        );
      }

      blendMode(ADD);
      for (let i = sparks.length - 1; i >= 0; i -= 1) {
        const s = sparks[i];
        s.update();
        s.draw();
        if (s.life <= 0) {
          sparks.splice(i, 1);
        }
      }
      blendMode(BLEND);
    }

    function drawHoleShape(pg, hole, offsetScale, noiseFreq = 0, noiseAmp = 0) {
      pg.beginShape();
      for (const pt of hole.baseShape) {
        let flicker = 0;
        if (noiseAmp > 0) {
          const nx = cos(pt.angle) * noiseFreq;
          const ny = sin(pt.angle) * noiseFreq;
          flicker = (noise(nx + hole.seed, ny + hole.seed, frameCount * 0.08) - 0.5) * noiseAmp;
        }

        let currentR = hole.currentR + pt.rMod * hole.progress + offsetScale + flicker;
        if (currentR < 0) currentR = 0;

        const px = hole.x + cos(pt.angle) * currentR;
        const py = hole.y + sin(pt.angle) * currentR;
        pg.vertex(px, py);
      }
      pg.endShape(CLOSE);
    }

    function mousePressed() {
      holes.push(new BurnHole(mouseX, mouseY));
    }

    function windowResized() {
      resizeCanvas(windowWidth, windowHeight);
      baseTextLayer.resizeCanvas(width, height);
      fadedTextLayer.resizeCanvas(width, height);
      overlayGraphics.resizeCanvas(width, height);
      generateStaticLayers();
    }

    function generateStaticLayers() {
      let fullTopText = TEMPLATE_DATA.topLayerText;
      while (fullTopText.length < 5000) {
        fullTopText += "\n\n" + TEMPLATE_DATA.topLayerText;
      }

      let fullBottomText = TEMPLATE_DATA.bottomLayerText;
      while (fullBottomText.length < 5000) {
        fullBottomText += "\n\n" + TEMPLATE_DATA.bottomLayerText;
      }

      baseTextLayer.background(
        TEMPLATE_DATA.basePaperColor[0],
        TEMPLATE_DATA.basePaperColor[1],
        TEMPLATE_DATA.basePaperColor[2]
      );
      baseTextLayer.fill(
        TEMPLATE_DATA.baseTextColor[0],
        TEMPLATE_DATA.baseTextColor[1],
        TEMPLATE_DATA.baseTextColor[2]
      );
      baseTextLayer.textFont(TEMPLATE_DATA.fontFamily);
      baseTextLayer.textSize(TEMPLATE_DATA.textSize);
      baseTextLayer.textLeading(TEMPLATE_DATA.textLeading);
      baseTextLayer.textAlign(LEFT, TOP);
      baseTextLayer.text(
        fullBottomText,
        TEMPLATE_DATA.textPaddingX,
        TEMPLATE_DATA.textPaddingY,
        width - TEMPLATE_DATA.textPaddingX * 2,
        height * 3
      );

      fadedTextLayer.background(
        TEMPLATE_DATA.topPaperColor[0],
        TEMPLATE_DATA.topPaperColor[1],
        TEMPLATE_DATA.topPaperColor[2]
      );

      fadedTextLayer.noStroke();
      for (let i = 0; i < TEMPLATE_DATA.textureDots; i += 1) {
        fadedTextLayer.fill(0, 0, 0, random(2, 6));
        fadedTextLayer.circle(random(width), random(height), random(1, 2));
      }

      fadedTextLayer.fill(
        TEMPLATE_DATA.topTextColor[0],
        TEMPLATE_DATA.topTextColor[1],
        TEMPLATE_DATA.topTextColor[2]
      );
      fadedTextLayer.textFont(TEMPLATE_DATA.fontFamily);
      fadedTextLayer.textSize(TEMPLATE_DATA.textSize);
      fadedTextLayer.textLeading(TEMPLATE_DATA.textLeading);
      fadedTextLayer.textAlign(LEFT, TOP);
      fadedTextLayer.text(
        fullTopText,
        TEMPLATE_DATA.textPaddingX,
        TEMPLATE_DATA.textPaddingY,
        width - TEMPLATE_DATA.textPaddingX * 2,
        height * 3
      );
    }

    class BurnHole {
      constructor(x, y) {
        this.x = x;
        this.y = y;
        this.time = 0;
        this.duration = random(TEMPLATE_DATA.burnDurationMin, TEMPLATE_DATA.burnDurationMax);
        this.maxR = random(TEMPLATE_DATA.burnRadiusMin, TEMPLATE_DATA.burnRadiusMax);
        this.progress = 0;
        this.currentR = 0;
        this.seed = random(1000);
        this.baseShape = [];

        for (let a = 0; a < TWO_PI; a += TWO_PI / TEMPLATE_DATA.holeSegments) {
          const n1 = noise(
            cos(a) * TEMPLATE_DATA.holeNoiseSeedScale1 + this.seed,
            sin(a) * TEMPLATE_DATA.holeNoiseSeedScale1 + this.seed
          );
          const n2 = noise(
            cos(a) * TEMPLATE_DATA.holeNoiseSeedScale2 + this.seed,
            sin(a) * TEMPLATE_DATA.holeNoiseSeedScale2 + this.seed
          );
          this.baseShape.push({
            angle: a,
            rMod: n1 * 30 + n2 * 15
          });
        }
      }

      update() {
        if (this.time < 1.0) {
          this.time += 1.0 / this.duration;
          if (this.time > 1.0) this.time = 1.0;

          this.progress = 1 - Math.pow(1 - this.time, 3);
          this.currentR = this.maxR * this.progress;

          if (random() < TEMPLATE_DATA.sparkProbabilityScale * (1 - this.time)) {
            const a = random(TWO_PI);
            const edgeR = this.currentR + 10;
            sparks.push(new Spark(this.x + cos(a) * edgeR, this.y + sin(a) * edgeR));
          }

          if (random() < TEMPLATE_DATA.ashProbabilityScale) {
            const a = random(TWO_PI);
            const innerR = this.currentR * random(0.1, 0.95);
            const type = random();
            let r;
            let g;
            let b;

            if (type < 0.4) {
              r = 30;
              g = 20;
              b = 15;
            } else if (type < 0.7) {
              r = 180;
              g = 40;
              b = 10;
            } else {
              r = 240;
              g = 130;
              b = 20;
            }

            ashes.push({
              x: this.x + cos(a) * innerR,
              y: this.y + sin(a) * innerR,
              size: random(1.5, 4.0),
              r,
              g,
              b,
              alpha: random(150, 255)
            });

            if (ashes.length > TEMPLATE_DATA.maxAshes) {
              ashes.shift();
            }
          }
        }
      }
    }

    class Spark {
      constructor(x, y) {
        this.x = x;
        this.y = y;
        this.vx = random(-1.5, 1.5);
        this.vy = random(-3, -0.5);
        this.life = 255;
        this.decay = random(3, 8);
        this.size = random(2, 5);
      }

      update() {
        this.x += this.vx;
        this.y += this.vy;
        this.life -= this.decay;
        this.size *= 0.95;
      }

      draw() {
        noStroke();
        fill(255, random(100, 200), 0, this.life);
        circle(this.x, this.y, this.size);
      }
    }
  </script>
</body>
</html>


path: assets/templates/burn-reveal/notes.md
content: # Burn Reveal Template Notes

This template burns through an old text surface and reveals a clearer layer underneath.

It is best used for catharsis, reset, reframing, or symbolic renewal, not for delicate or purely contemplative moods.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the reveal logic, the edge treatment, the before-and-after structure, or the pacing of transformation, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- interaction hint
- top-layer text
- revealed text
- language choice
- paper colors
- text colors
- burn count
- burn timing
- spark density
- ash density

## What Not To Copy Blindly

- the exact romantic sample text
- the exact aged-paper palette
- the exact number and spacing of starting burn holes

Those are demo choices, not the identity of the pattern.

## Best Fit

- anger
- complaint
- release
- reset
- "leave the old script behind" gifts

## Watch Outs

- do not use this when the user's state needs gentleness rather than rupture
- do not make the revealed message feel random or disconnected
- do not let the whole page disappear so quickly that the reveal loses meaning
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/burn-reveal/template-schema.json
content: {
  "template_id": "burn-reveal",
  "engine": "p5.js",
  "description": "A paper-burning reveal effect where an old surface chars away and a clearer message is exposed beneath it.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "interaction_hint",
    "top_layer_text",
    "bottom_layer_text",
    "base_paper_color",
    "top_paper_color",
    "base_text_color",
    "top_text_color",
    "font_family",
    "text_size",
    "text_leading",
    "auto_burn_count",
    "auto_burn_delay_ms_max",
    "burn_radius_min",
    "burn_radius_max",
    "spark_probability_scale",
    "ash_probability_scale"
  ],
  "fit_tags": [
    "anger",
    "release",
    "reframing",
    "reset",
    "renewal"
  ],
  "avoid_tags": [
    "quiet-grief",
    "dense-explanation",
    "soft-memory-piece",
    "formal-report"
  ]
}


path: assets/templates/wet-letter/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Wet Letter</title>
  <!-- Final delivery should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable, but images and
       custom assets should remain inline in the final artifact. -->
  <style>
    :root {
      --background-color: #0d56f1;
      --loading-color: #ffffff;
      --font-family: monospace;
    }

    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: var(--background-color);
    }

    body {
      position: relative;
    }

    canvas {
      display: block;
      width: 100vw;
      height: 100vh;
    }

    #loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: var(--loading-color);
      font-family: var(--font-family);
      font-size: 1.1rem;
      pointer-events: none;
      text-align: center;
      white-space: nowrap;
    }
  </style>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
</head>
<body>
  <div id="loading">正在让雨水落在字里行间...</div>

  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Wet Letter",
      loadingText: "正在让雨水落在字里行间...",
      backgroundColor: "#0d56f1",
      textColor: 255,
      sourceText: (
        "Some words are still here even after the rain starts. " +
        "Not every feeling wants to be erased. Some only want to be touched differently. " +
        "If this page looks softened, it is because something landed here. " +
        "Disappointment does that. So does care. So does remembering. " +
        "Maybe what was said today did not hold the shape you hoped for. " +
        "Maybe the sentence broke where you wanted it to stay whole. " +
        "Still, I can stay with the page long enough for the words to settle again. "
      ),
      textRepeatCount: 6,
      textSize: 20,
      textLeading: 32,
      textMargin: 20,
      particleStep: 3,
      dropSpawnProbability: 0.02,
      maxDrops: 150,
      dropTargetRadiusRange: [30, 90],
      clickDropTargetRadiusRange: [40, 120],
      dropSpeedRange: [0.04, 0.08],
      allowTapDrops: true
    };

    let particles = [];
    let drops = [];
    let pg;

    function buildFullText() {
      let fullText = TEMPLATE_DATA.sourceText;
      for (let i = 0; i < TEMPLATE_DATA.textRepeatCount; i += 1) {
        fullText += "\n\n" + TEMPLATE_DATA.sourceText;
      }
      return fullText;
    }

    function buildDrop(x, y, radiusRange) {
      return {
        x,
        y,
        r: 0,
        targetR: random(radiusRange[0], radiusRange[1]),
        speed: random(TEMPLATE_DATA.dropSpeedRange[0], TEMPLATE_DATA.dropSpeedRange[1]),
        noiseSeedX: random(1000),
        noiseSeedY: random(1000),
        noiseMax: random(0.8, 1.5)
      };
    }

    function rebuildTextField(w, h) {
      pg = createGraphics(w, h);
      pg.pixelDensity(1);
      pg.background(0);
      pg.fill(TEMPLATE_DATA.textColor);
      pg.textFont("monospace");
      pg.textSize(TEMPLATE_DATA.textSize);
      pg.textLeading(TEMPLATE_DATA.textLeading);
      pg.textAlign(LEFT, TOP);
      pg.text(
        buildFullText(),
        TEMPLATE_DATA.textMargin,
        TEMPLATE_DATA.textMargin,
        w - TEMPLATE_DATA.textMargin * 2,
        h - TEMPLATE_DATA.textMargin * 2
      );

      pg.loadPixels();
      particles = [];
      noiseSeed(99);

      for (let y = 0; y < h; y += TEMPLATE_DATA.particleStep) {
        for (let x = 0; x < w; x += TEMPLATE_DATA.particleStep) {
          const index = (x + y * w) * 4;
          if (pg.pixels[index] > 100) {
            const clusterNoise = noise(x * 0.02, y * 0.02);
            const thicknessOffset = map(clusterNoise, 0, 1, -12, 15);

            particles.push({
              pos: createVector(x, y),
              origin: createVector(x, y),
              thicknessOffset,
              fixedJitterX: random(-0.8, 0.8),
              fixedJitterY: random(-0.8, 0.8)
            });
          }
        }
      }
    }

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      const loading = document.getElementById("loading");
      loading.textContent = TEMPLATE_DATA.loadingText;

      const w = max(100, windowWidth || window.innerWidth || 800);
      const h = max(100, windowHeight || window.innerHeight || 600);
      createCanvas(w, h);
      pixelDensity(1);
      rebuildTextField(w, h);
      loading.style.display = "none";
    }

    function draw() {
      background(TEMPLATE_DATA.backgroundColor);

      if (random(1) < TEMPLATE_DATA.dropSpawnProbability && drops.length < TEMPLATE_DATA.maxDrops) {
        drops.push(buildDrop(random(width), random(height), TEMPLATE_DATA.dropTargetRadiusRange));
      }

      for (const drop of drops) {
        const diff = drop.targetR - drop.r;
        drop.r += diff * drop.speed;
      }

      stroke(255);
      strokeWeight(1.5);

      for (const p of particles) {
        p.pos.x = p.origin.x;
        p.pos.y = p.origin.y;

        for (const drop of drops) {
          if (drop.r < 1) continue;

          const dx = p.pos.x - drop.x;
          const dy = p.pos.y - drop.y;
          const maxPossibleR = drop.r * 1.4;
          if (abs(dx) > maxPossibleR || abs(dy) > maxPossibleR) continue;

          const dSq = dx * dx + dy * dy;
          const d = sqrt(dSq);
          if (d === 0) continue;

          const angle = atan2(dy, dx);
          const nx = map(cos(angle), -1, 1, 0, drop.noiseMax);
          const ny = map(sin(angle), -1, 1, 0, drop.noiseMax);
          const nVal = noise(nx + drop.noiseSeedX, ny + drop.noiseSeedY);

          const distortedR = drop.r * map(nVal, 0, 1, 0.7, 1.3);
          const finalTargetR = distortedR + p.thicknessOffset;
          const pushDist = finalTargetR - d;

          if (pushDist > 0) {
            p.pos.x += (dx / d) * pushDist * 0.4 + p.fixedJitterX;
            p.pos.y += (dy / d) * pushDist * 0.4 + p.fixedJitterY;
          }
        }

        point(p.pos.x, p.pos.y);
      }
    }

    function mousePressed() {
      if (!TEMPLATE_DATA.allowTapDrops) return;
      drops.push(buildDrop(mouseX, mouseY, TEMPLATE_DATA.clickDropTargetRadiusRange));
    }

    function touchStarted() {
      if (!TEMPLATE_DATA.allowTapDrops) return false;
      drops.push(buildDrop(mouseX, mouseY, TEMPLATE_DATA.clickDropTargetRadiusRange));
      return false;
    }

    function windowResized() {
      const loading = document.getElementById("loading");
      loading.style.display = "block";
      const w = max(100, windowWidth || window.innerWidth || 800);
      const h = max(100, windowHeight || window.innerHeight || 600);
      resizeCanvas(w, h);
      drops = [];
      rebuildTextField(w, h);
      loading.style.display = "none";
    }
  </script>
</body>
</html>


path: assets/templates/wet-letter/notes.md
content: # Wet Letter Template Notes

This template simulates raindrops hitting a text surface and pushing letters outward in circular bursts.

It feels less like "rain on a window" and more like "rain soaking a letter or a page of words."

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the dispersion mechanic, the page texture, the pacing, or the overall emotional handling, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- loading text
- source text
- language choice
- background color
- text color
- particle density
- raindrop frequency
- whether click / tap should create drops

## What Not To Copy Blindly

- the original love-letter framing
- the exact blue palette
- the exact line spacing and text density

Those are demo choices, not the essence of the pattern.

## Best Fit

- disappointment
- sadness
- emotional aftermath
- reflective hurt with companionship

## Watch Outs

- do not let the page become illegible mud
- do not over-romanticize pain
- do not let the effect feel colder than the relationship actually is
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and any custom visual assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/wet-letter/template-schema.json
content: {
  "template_id": "wet-letter",
  "engine": "p5.js",
  "description": "Circular raindrop dispersion effect that makes text feel soaked, disturbed, and quietly revealed.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "loading_text",
    "source_text",
    "background_color",
    "text_color",
    "text_size",
    "text_leading",
    "particle_step",
    "drop_spawn_probability",
    "drop_target_radius_min",
    "drop_target_radius_max",
    "drop_speed_min",
    "drop_speed_max",
    "allow_tap_drops"
  ],
  "fit_tags": [
    "sadness",
    "disappointment",
    "heartbreak",
    "quiet-grief",
    "witnessing"
  ],
  "avoid_tags": [
    "celebration",
    "practical-summary",
    "high-energy-comedy",
    "playful-flirt"
  ]
}


path: assets/templates/tear-stained-paper/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tear-Stained Paper</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries and fonts is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <link href="https://fonts.googleapis.com/css2?family=Long+Cang&family=Zhi+Mang+Xing&display=swap" rel="stylesheet">
  <style>
    :root {
      --outer-bg: #f2efe9;
      --loading-color: #5a5a5a;
      --paper-shadow: rgba(0, 0, 0, 0.1);
    }

    html, body {
      margin: 0;
      padding: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background: var(--outer-bg);
    }

    body {
      display: flex;
      justify-content: center;
      align-items: center;
      position: relative;
    }

    canvas {
      box-shadow: 0 0 20px var(--paper-shadow);
      max-width: 100vw;
      max-height: 100vh;
      object-fit: contain;
    }

    #loading {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      color: var(--loading-color);
      font-family: monospace;
      font-size: 1rem;
      pointer-events: none;
      text-align: center;
      white-space: nowrap;
    }
  </style>
</head>
<body>
  <div id="loading">正在让泪水浸入纸张纤维...</div>
  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Tear-Stained Paper",
      loadingText: "正在让泪水浸入纸张纤维...",
      pageOuterBackgroundColor: "#f2efe9",
      paperBackgroundColor: [248, 246, 240],
      gridStroke: [140, 140, 135, 160],
      handwritingColor: [40, 45, 50, 220],
      fontFamily: "'Long Cang', 'Zhi Mang Xing', cursive",
      sourceText:
        "今夜有些话不是说出口的，而是慢慢渗进纸里的。\\n" +
        "我本来想把它们写得平静一些，可情绪总会先落下来。\\n" +
        "有些难过不是轰然的，只是一点一点把纸打湿。\\n" +
        "字还在，心也还在，只是都被今晚的水痕改写了形状。\\n" +
        "如果你现在不想解释太多，也没关系。\\n" +
        "我可以先陪你看着这些湿掉的句子，等它们慢慢安静。",
      textStartRow: 1,
      textStartCol: 1,
      cellWidth: 30,
      cellHeight: 30,
      textSizeFactor: 0.8,
      tearThresholdForText: 3,
      tearSpawnIntervalFrames: 18,
      tearLimit: 150,
      allowTapTears: true,
      tearTargetRadiusRange: [12, 35],
      tearHueRange: [195, 215]
    };

    let points = [];
    let tears = [];
    let cols;
    let rows;
    let marginX;
    let marginY;
    let textChars = [];
    let showText = false;
    let textStartTime = 0;

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;
      document.getElementById("loading").textContent = TEMPLATE_DATA.loadingText;

      const canvasW = min(windowWidth * 0.9, 800);
      const canvasH = min(windowHeight * 0.9, 1000);
      createCanvas(canvasW, canvasH);
      initGrid();
      document.getElementById("loading").style.display = "none";
    }

    function initGrid() {
      points = [];
      textChars = [];
      tears = [];
      showText = false;

      cols = floor((width * 0.85) / TEMPLATE_DATA.cellWidth);
      rows = floor((height * 0.85) / TEMPLATE_DATA.cellHeight);

      marginX = (width - cols * TEMPLATE_DATA.cellWidth) / 2;
      marginY = (height - rows * TEMPLATE_DATA.cellHeight) / 2;

      for (let i = 0; i <= rows; i += 1) {
        const row = [];
        for (let j = 0; j <= cols; j += 1) {
          const bx = marginX + j * TEMPLATE_DATA.cellWidth + random(-0.3, 0.3);
          const by = marginY + i * TEMPLATE_DATA.cellHeight + random(-0.3, 0.3);
          row.push({ x: bx, y: by, bx, by });
        }
        points.push(row);
      }

      const lines = TEMPLATE_DATA.sourceText.split("\n");
      let currentR = TEMPLATE_DATA.textStartRow;
      let currentC = TEMPLATE_DATA.textStartCol;

      for (let i = 0; i < lines.length; i += 1) {
        const line = lines[i];
        for (let j = 0; j < line.length; j += 1) {
          textChars.push({ char: line[j], r: currentR, c: currentC });
          currentC += 1;
          if (currentC >= cols - 1) {
            currentC = TEMPLATE_DATA.textStartCol;
            currentR += 1;
          }
        }
        currentC = TEMPLATE_DATA.textStartCol;
        currentR += 1;
      }
    }

    function draw() {
      background(...TEMPLATE_DATA.paperBackgroundColor);

      if (frameCount % TEMPLATE_DATA.tearSpawnIntervalFrames === 0 && tears.length < TEMPLATE_DATA.tearLimit) {
        const tx = random(marginX, width - marginX);
        const ty = random(marginY, height - marginY);
        tears.push(new Tear(tx, ty));
      }

      if (tears.length >= TEMPLATE_DATA.tearThresholdForText && !showText) {
        showText = true;
        textStartTime = frameCount;
      }

      for (let i = 0; i <= rows; i += 1) {
        for (let j = 0; j <= cols; j += 1) {
          const p = points[i][j];
          p.x = p.bx;
          p.y = p.by;

          for (const t of tears) {
            const d = dist(p.bx, p.by, t.x, t.y);
            if (d < t.r * 2.5) {
              const influence = pow(map(d, 0, t.r * 2.5, 1, 0), 1.5);
              const angle = atan2(p.by - t.y, p.bx - t.x);
              const pushAmt = influence * t.r * 0.5;
              p.x += cos(angle) * pushAmt;
              p.y += sin(angle) * pushAmt;
              p.x += sin(frameCount * 0.04 + p.by * 0.05) * 2.5 * influence;
              p.y += cos(frameCount * 0.04 + p.bx * 0.05) * 2.5 * influence;
            }
          }
        }
      }

      strokeWeight(1.0);
      stroke(...TEMPLATE_DATA.gridStroke);
      noFill();

      for (let i = 0; i <= rows; i += 1) {
        beginShape();
        for (let j = 0; j <= cols; j += 1) {
          curveVertex(points[i][j].x, points[i][j].y);
          if (j === 0 || j === cols) {
            curveVertex(points[i][j].x, points[i][j].y);
          }
        }
        endShape();
      }

      for (let j = 0; j <= cols; j += 1) {
        beginShape();
        for (let i = 0; i <= rows; i += 1) {
          curveVertex(points[i][j].x, points[i][j].y);
          if (i === 0 || i === rows) {
            curveVertex(points[i][j].x, points[i][j].y);
          }
        }
        endShape();
      }

      for (const t of tears) {
        t.update();
        t.display();
      }

      if (showText) {
        textFont(TEMPLATE_DATA.fontFamily);
        textAlign(CENTER, CENTER);
        textSize(TEMPLATE_DATA.cellWidth * TEMPLATE_DATA.textSizeFactor);
        fill(...TEMPLATE_DATA.handwritingColor);
        noStroke();

        let charLimit = floor((frameCount - textStartTime) / 4);
        charLimit = constrain(charLimit, 0, textChars.length);

        for (let i = 0; i < charLimit; i += 1) {
          const tc = textChars[i];
          if (tc.r >= rows || tc.c >= cols) continue;

          const p1 = points[tc.r][tc.c];
          const p2 = points[tc.r][tc.c + 1];
          const p3 = points[tc.r + 1][tc.c];
          const p4 = points[tc.r + 1][tc.c + 1];

          if (p1 && p2 && p3 && p4) {
            const cx = (p1.x + p2.x + p3.x + p4.x) / 4;
            const cy = (p1.y + p2.y + p3.y + p4.y) / 4;

            let wobbleX = 0;
            let wobbleY = 0;
            for (const t of tears) {
              const d = dist(p1.bx, p1.by, t.x, t.y);
              if (d < t.r) {
                wobbleX = sin(frameCount * 0.05 + i) * 1.0;
                wobbleY = cos(frameCount * 0.05 + i) * 1.0;
              }
            }

            text(tc.char, cx + wobbleX, cy + wobbleY);
          }
        }
      }
    }

    function mousePressed() {
      if (!TEMPLATE_DATA.allowTapTears) return;
      tears.push(new Tear(mouseX, mouseY));
    }

    function touchStarted() {
      if (!TEMPLATE_DATA.allowTapTears) return false;
      tears.push(new Tear(mouseX, mouseY));
      return false;
    }

    function windowResized() {
      setup();
    }

    class Tear {
      constructor(x, y) {
        this.x = x;
        this.y = y;
        this.targetR = random(TEMPLATE_DATA.tearTargetRadiusRange[0], TEMPLATE_DATA.tearTargetRadiusRange[1]);
        this.r = 0.1;
        this.seed = random(1000);
        this.baseHue = random(TEMPLATE_DATA.tearHueRange[0], TEMPLATE_DATA.tearHueRange[1]);
      }

      update() {
        this.r += (this.targetR - this.r) * 0.025;
      }

      display() {
        push();
        translate(this.x, this.y);

        const progress = constrain(this.r / this.targetR, 0, 1);
        const currentBlur = map(progress, 0, 1, 0.5, 8);
        drawingContext.filter = `blur(${currentBlur}px)`;

        colorMode(HSB, 360, 100, 100, 255);
        const layers = 3;

        for (let i = 0; i < layers; i += 1) {
          const radiusRatio = 1 - i * 0.2;
          const currentR = this.r * radiusRatio;

          const startS = map(i, 0, layers - 1, 35, 60);
          const endS = map(i, 0, layers - 1, 10, 25);
          const s = map(progress, 0, 1, startS, endS);

          const startB = 90;
          const endB = map(i, 0, layers - 1, 98, 92);
          const b = map(progress, 0, 1, startB, endB);

          const startA = map(i, 0, layers - 1, 120, 200);
          const endA = map(i, 0, layers - 1, 15, 60);
          const a = map(progress, 0, 1, startA, endA);

          fill(this.baseHue, s, b, a);
          noStroke();

          beginShape();
          for (let angle = 0; angle < TWO_PI; angle += 0.1) {
            const xoff = map(cos(angle), -1, 1, 0, 2);
            const yoff = map(sin(angle), -1, 1, 0, 2);
            const n = noise(xoff + this.seed, yoff + this.seed, frameCount * 0.004);
            const rVar = currentR + map(n, 0, 1, -currentR * 0.45, currentR * 0.55);
            const sx = rVar * cos(angle);
            const sy = rVar * sin(angle);
            curveVertex(sx, sy);
          }
          endShape(CLOSE);
        }

        drawingContext.filter = "none";
        pop();
      }
    }
  </script>
</body>
</html>


path: assets/templates/tear-stained-paper/notes.md
content: # Tear-Stained Paper Template Notes

This template simulates blue tear stains soaking into a gridded sheet of handwritten paper.

Compared with `wet-letter`, this one is:

- warmer in paper tone
- more handwritten
- more tactile
- more about moisture entering paper fibers than letters exploding apart

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the damp-paper metaphor, the reveal pacing, the handwriting feel, or the stain behavior, and may change copy, composition, assets, and tone freely. Do not copy the demo text or framing literally.

## What To Change Per Use

- `document.title`
- loading text
- source text
- paper color
- grid color
- handwriting font choice
- tear hue
- stain density
- text reveal speed
- whether taps should create more tears

## What Not To Copy Blindly

- the exact poem-like voice
- the exact paper palette
- the exact handwritten Chinese framing

Those are example choices, not mandatory identity.

## Best Fit

- longing
- disappointment
- soft grief
- intimate reflective sadness

## Watch Outs

- do not let the handwriting become unreadable
- do not make the page feel theatrically tragic
- do not overdo the grid deformation
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js and stable font CDNs are acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/tear-stained-paper/template-schema.json
content: {
  "template_id": "tear-stained-paper",
  "engine": "p5.js",
  "description": "Blue tear stains spread across gridded handwritten paper, warping the page and slowly revealing sorrowful text.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js and stable font CDNs are acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "loading_text",
    "source_text",
    "paper_background_color",
    "page_outer_background_color",
    "grid_stroke_color",
    "handwriting_text_color",
    "font_family",
    "tear_hue_min",
    "tear_hue_max",
    "cell_width",
    "cell_height",
    "tear_threshold_for_text",
    "tear_spawn_interval_frames",
    "tear_limit",
    "allow_tap_tears"
  ],
  "fit_tags": [
    "sadness",
    "longing",
    "disappointment",
    "tender-grief",
    "handwritten"
  ],
  "avoid_tags": [
    "celebration",
    "playful-flirt",
    "practical-summary",
    "high-energy-relief"
  ]
}


path: assets/templates/wind-scatter/index.html
content: <!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Wind Scatter</title>
  <!-- Final delivered gifts should follow references/html-spec.md.
       Pinned CDN delivery for libraries like p5.js is acceptable,
       but images and custom assets should remain inline. -->
  <script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
  <style>
    body {
      margin: 0;
      padding: 0;
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      background-color: #f0f0f0;
    }

    canvas {
      box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
    }

    #loading {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background: rgba(207, 226, 255, 0.96);
      color: rgba(255, 255, 255, 0.92);
      letter-spacing: 0.08em;
      font-size: 14px;
      pointer-events: none;
      z-index: 2;
    }
  </style>
</head>
<body>
  <div id="loading">Gathering the text into the wind...</div>
  <script>
    const TEMPLATE_DATA = {
      pageTitle: "Wind Scatter",
      textField: "Throughthestormysea穿过风浪的海",
      centerDot: {
        x: 270,
        y: 300,
        currentX: 150,
        currentY: 500,
        radius: 5,
        color: "#FFFFFF"
      },
      textConfig: {
        fontSize: 12,
        fillColor: "#FFFFFF",
        innerLetterSpacing: 8,
        outerLetterSpacing: 2,
        layerSpacing: 15,
        maxLayers: 10,
        lineColor: "#FFFFFF",
        lineWeight: 0.4,
        initialVelocityX: [-2, 5],
        initialVelocityY: [-4, -1],
        maxLineLength: 25,
        flyingTriggerInterval: 2,
        flyingCountMin: 2,
        flyingCountMax: 5,
        centerDotMoveInterval: 10,
        centerDotMoveDuration: 60,
        centerDotMoveSpeed: 0.8,
        centerDotXRange: [-15, 15],
        centerDotYRange: [-10, 10],
        outsideLineStartX: 50,
        outsideLineStartY: 850,
        outsideLineColor: "#DBFFDC",
        outsideLineWeight: 1.5,
        canvasWidth: 600,
        canvasHeight: 800,
        bgGradientStart: "#2196F3",
        bgGradientEnd: "#CFE2FF",
        gradientStartY: 0,
        gradientEndY: 800,
        flyingStartDelay: 120,
        charFloatRange: 5,
        charFloatSpeed: [0.05, 0.1]
      }
    };

    let characters = [];
    let ringCharacters = [];
    let lastFlyingFrame = 0;
    let startFrame = 0;

    let moveStartFrame = 0;
    let moveTargetX = 0;
    let moveTargetY = 0;
    let moveOriginalX = 0;
    let moveOriginalY = 0;

    let colorStart;
    let colorEnd;

    function setup() {
      document.title = TEMPLATE_DATA.pageTitle;

      createCanvas(TEMPLATE_DATA.textConfig.canvasWidth, TEMPLATE_DATA.textConfig.canvasHeight);
      startFrame = frameCount;
      colorStart = color(TEMPLATE_DATA.textConfig.bgGradientStart);
      colorEnd = color(TEMPLATE_DATA.textConfig.bgGradientEnd);

      initRingCharacters();

      moveOriginalX = TEMPLATE_DATA.centerDot.currentX;
      moveOriginalY = TEMPLATE_DATA.centerDot.currentY;
      moveTargetX = TEMPLATE_DATA.centerDot.x;
      moveTargetY = TEMPLATE_DATA.centerDot.y;

      document.getElementById("loading").style.display = "none";
    }

    function draw() {
      drawGradient();
      updateCenterDot();
      drawOutsideLine();

      noStroke();
      fill(TEMPLATE_DATA.centerDot.color);
      circle(TEMPLATE_DATA.centerDot.currentX, TEMPLATE_DATA.centerDot.currentY, TEMPLATE_DATA.centerDot.radius * 2);

      drawRingCharacters();
      triggerFlying();
      updateAndDrawFlyingCharacters();
      drawPosterTypography();
    }

    function initRingCharacters() {
      let charIndex = 0;
      for (let layer = 1; layer <= TEMPLATE_DATA.textConfig.maxLayers; layer += 1) {
        let currentAngle = 0;
        const spacing = map(
          layer,
          1,
          TEMPLATE_DATA.textConfig.maxLayers,
          TEMPLATE_DATA.textConfig.innerLetterSpacing,
          TEMPLATE_DATA.textConfig.outerLetterSpacing
        );

        while (currentAngle < 360) {
          const randomSpacing = spacing + random(-2, 2);
          const c = TEMPLATE_DATA.textField.charAt(charIndex % TEMPLATE_DATA.textField.length);
          ringCharacters.push({
            char: c,
            layer,
            baseAngle: currentAngle,
            noiseOffsetX: random(1000),
            noiseOffsetY: random(1000)
          });

          currentAngle += randomSpacing;
          charIndex += 1;
        }
      }
    }

    function drawGradient() {
      noFill();
      for (let y = 0; y <= height; y += 1) {
        const inter = map(
          y,
          TEMPLATE_DATA.textConfig.gradientStartY,
          TEMPLATE_DATA.textConfig.gradientEndY,
          0,
          1
        );
        const c = lerpColor(colorStart, colorEnd, inter);
        stroke(c);
        line(0, y, width, y);
      }
    }

    function updateCenterDot() {
      if (frameCount > moveStartFrame + TEMPLATE_DATA.textConfig.centerDotMoveDuration) {
        moveStartFrame = frameCount;
        moveOriginalX = TEMPLATE_DATA.centerDot.currentX;
        moveOriginalY = TEMPLATE_DATA.centerDot.currentY;
        moveTargetX =
          TEMPLATE_DATA.centerDot.x +
          random(TEMPLATE_DATA.textConfig.centerDotXRange[0], TEMPLATE_DATA.textConfig.centerDotXRange[1]);
        moveTargetY =
          TEMPLATE_DATA.centerDot.y +
          random(TEMPLATE_DATA.textConfig.centerDotYRange[0], TEMPLATE_DATA.textConfig.centerDotYRange[1]);
      }

      let amt = (frameCount - moveStartFrame) / TEMPLATE_DATA.textConfig.centerDotMoveDuration;
      amt = constrain(amt, 0, 1);
      const easeAmt = amt < 0.5 ? 2 * amt * amt : -1 + (4 - 2 * amt) * amt;

      TEMPLATE_DATA.centerDot.currentX = lerp(moveOriginalX, moveTargetX, easeAmt);
      TEMPLATE_DATA.centerDot.currentY = lerp(moveOriginalY, moveTargetY, easeAmt);
    }

    function drawOutsideLine() {
      stroke(TEMPLATE_DATA.textConfig.outsideLineColor);
      strokeWeight(TEMPLATE_DATA.textConfig.outsideLineWeight);
      line(
        TEMPLATE_DATA.textConfig.outsideLineStartX,
        TEMPLATE_DATA.textConfig.outsideLineStartY,
        TEMPLATE_DATA.centerDot.currentX,
        TEMPLATE_DATA.centerDot.currentY
      );
    }

    function drawRingCharacters() {
      stroke(TEMPLATE_DATA.textConfig.lineColor);
      strokeWeight(TEMPLATE_DATA.textConfig.lineWeight);
      fill(TEMPLATE_DATA.textConfig.fillColor);
      textSize(TEMPLATE_DATA.textConfig.fontSize);
      textAlign(CENTER, CENTER);

      for (const rc of ringCharacters) {
        const radius = rc.layer * TEMPLATE_DATA.textConfig.layerSpacing;

        const speedX = random(TEMPLATE_DATA.textConfig.charFloatSpeed[0], TEMPLATE_DATA.textConfig.charFloatSpeed[1]);
        const speedY = random(TEMPLATE_DATA.textConfig.charFloatSpeed[0], TEMPLATE_DATA.textConfig.charFloatSpeed[1]);
        const nx = map(
          noise(rc.noiseOffsetX + frameCount * speedX),
          0,
          1,
          -TEMPLATE_DATA.textConfig.charFloatRange,
          TEMPLATE_DATA.textConfig.charFloatRange
        );
        const ny = map(
          noise(rc.noiseOffsetY + frameCount * speedY),
          0,
          1,
          -TEMPLATE_DATA.textConfig.charFloatRange,
          TEMPLATE_DATA.textConfig.charFloatRange
        );

        const cx = TEMPLATE_DATA.centerDot.currentX + cos(radians(rc.baseAngle)) * radius + nx;
        const cy = TEMPLATE_DATA.centerDot.currentY + sin(radians(rc.baseAngle)) * radius + ny;

        stroke(TEMPLATE_DATA.textConfig.lineColor);
        strokeWeight(TEMPLATE_DATA.textConfig.lineWeight);
        line(TEMPLATE_DATA.centerDot.currentX, TEMPLATE_DATA.centerDot.currentY, cx, cy);

        noStroke();
        push();
        translate(cx, cy);
        rotate(radians(rc.baseAngle + 90));
        text(rc.char, 0, 0);
        pop();
      }
    }

    function triggerFlying() {
      if (frameCount > startFrame + TEMPLATE_DATA.textConfig.flyingStartDelay) {
        if (frameCount - lastFlyingFrame > TEMPLATE_DATA.textConfig.flyingTriggerInterval) {
          const flyCount = floor(
            random(TEMPLATE_DATA.textConfig.flyingCountMin, TEMPLATE_DATA.textConfig.flyingCountMax + 1)
          );

          for (let i = 0; i < flyCount; i += 1) {
            if (ringCharacters.length > 0) {
              const idx = floor(random(ringCharacters.length));
              const rc = ringCharacters.splice(idx, 1)[0];

              const radius = rc.layer * TEMPLATE_DATA.textConfig.layerSpacing;
              const cx = TEMPLATE_DATA.centerDot.currentX + cos(radians(rc.baseAngle)) * radius;
              const cy = TEMPLATE_DATA.centerDot.currentY + sin(radians(rc.baseAngle)) * radius;

              characters.push({
                char: rc.char,
                x: cx,
                y: cy,
                vx: random(
                  TEMPLATE_DATA.textConfig.initialVelocityX[0],
                  TEMPLATE_DATA.textConfig.initialVelocityX[1]
                ),
                vy: random(
                  TEMPLATE_DATA.textConfig.initialVelocityY[0],
                  TEMPLATE_DATA.textConfig.initialVelocityY[1]
                ),
                angle: rc.baseAngle
              });
            }
          }
          lastFlyingFrame = frameCount;
        }
      }
    }

    function updateAndDrawFlyingCharacters() {
      for (let i = characters.length - 1; i >= 0; i -= 1) {
        const fc = characters[i];

        fc.x += fc.vx;
        fc.y += fc.vy;

        const speed = sqrt(fc.vx * fc.vx + fc.vy * fc.vy);
        const dirX = fc.vx / speed;
        const dirY = fc.vy / speed;
        const tailX = fc.x - dirX * TEMPLATE_DATA.textConfig.maxLineLength;
        const tailY = fc.y - dirY * TEMPLATE_DATA.textConfig.maxLineLength;

        stroke(TEMPLATE_DATA.textConfig.lineColor);
        strokeWeight(TEMPLATE_DATA.textConfig.lineWeight);
        line(fc.x, fc.y, tailX, tailY);

        noStroke();
        fill(TEMPLATE_DATA.textConfig.fillColor);
        push();
        translate(fc.x, fc.y);
        rotate(radians(fc.angle + 90));
        text(fc.char, 0, 0);
        pop();

        if (fc.x < -100 || fc.x > width + 100 || fc.y < -100 || fc.y > height + 100) {
          characters.splice(i, 1);
        }
      }
    }

    function drawPosterTypography() {
      noStroke();
      fill(30, 80, 100, 200);
      textAlign(RIGHT, BOTTOM);
      textStyle(ITALIC);
      textStyle(NORMAL);
    }
  </script>
</body>
</html>


path: assets/templates/wind-scatter/notes.md
content: # Wind Scatter Template Notes

This template arranges text around a living center, then gradually lets individual characters detach and drift outward with trailing lines like wind-carried seeds.

It is a concrete implementation of the broader `wind-scatter` pattern: release with continuation, light dispersal, and life or meaning spreading beyond its original cluster.

## Reference Use

Treat this template as a reference implementation, not a fixed output. OpenClaw may borrow only the radial cluster, the scattering rhythm, the carrier metaphor, the trailing-line treatment, or the airy poster composition, and may change copy, composition, assets, and tone freely. Do not copy the sample line, the exact blue sky treatment, or the literal dandelion look if another carrier form fits better.

## What To Change Per Use

- `document.title`
- source text
- language choice
- center position
- carrier metaphor
- background palette
- scatter speed
- trail length
- cluster density
- start delay
- stem color and weight
- whether interaction accelerates release

## What Not To Copy Blindly

- the exact sky-blue palette
- the exact dandelion silhouette
- the exact source phrase

Those are demo choices, not the identity of the pattern.

## Best Fit

- quiet release
- lightness
- reconciliation
- spreading life or hope
- airy continuation

## Watch Outs

- do not turn this into generic particle drift
- do not scatter so quickly that the center never has emotional presence
- do not make the motion so busy that the scene loses calm
- do not use `@latest` or unstable external dependencies in the final gift

## Delivery Note

Pinned CDN delivery for p5.js is acceptable for the final single-file HTML gift, as long as images and custom assets remain inline and the artifact follows `references/html-spec.md`.


path: assets/templates/wind-scatter/template-schema.json
content: {
  "template_id": "wind-scatter",
  "engine": "p5.js",
  "description": "A radial text cluster that loosens into wind-carried characters with airy motion trails.",
  "final_delivery_requirement": "Export the final user-facing gift as a single HTML file. Pinned CDN delivery for p5.js is acceptable; images and custom assets should remain inline.",
  "customizable_fields": [
    "page_title",
    "source_text",
    "background_gradient_start",
    "background_gradient_end",
    "canvas_width",
    "canvas_height",
    "center_x",
    "center_y",
    "center_start_x",
    "center_start_y",
    "font_size",
    "fill_color",
    "inner_letter_spacing",
    "outer_letter_spacing",
    "layer_spacing",
    "max_layers",
    "line_color",
    "line_weight",
    "initial_velocity_x",
    "initial_velocity_y",
    "max_line_length",
    "flying_trigger_interval",
    "flying_count_min",
    "flying_count_max",
    "flying_start_delay",
    "center_dot_color",
    "outside_line_color",
    "outside_line_weight",
    "char_float_range"
  ],
  "fit_tags": [
    "release",
    "lightness",
    "reconciliation",
    "airy-renewal",
    "life-spreading"
  ],
  "avoid_tags": [
    "sharp-anger",
    "dense-explanation",
    "hard-comedy",
    "heavy-practical-summary"
  ]
}


path: assets/examples/image-examples/portrait/README.md
content: This folder is intentionally reserved for future portrait reference images.

Examples coming soon.

Until real portrait examples are added, do not assume this directory contains usable image assets.


path: assets/examples/video-examples/example-sidecar-template.md
content: # Video Example Sidecar Template

Every video example should have:

- one media file, usually `.mp4`
- one sidecar `.md` file with the same stem

Example:

- `petals-in-puddle.mp4`
- `petals-in-puddle.md`

Use this template for the sidecar file:

```md
# Example: <short-name>

## Genre

`kinetic-text` / `touch-awakening` / `release-transform` / `atmospheric-surface` / `object-micro-cinema` / `live-scene-doodle` / `relatable-surrealism` / `mood-loop` / `scene-animation`

## What It Looks Like

- subject
- composition
- color and texture
- what extracted frames help show
- where the doodle, overlay, or main motion anchor attaches if relevant

## Motion Logic

- what moves first
- what follows
- what settles, resolves, or loops

## Pacing

- total length
- whether it is a clean loop
- where the emotional turn lands

## What To Borrow

- motion grammar
- texture
- timing
- camera or framing logic

## What Not To Copy Literally

- exact assets
- exact text
- exact composition
- anything that is only specific to this sample
```


path: assets/examples/video-examples/atmospheric-surface/_genre-assets.md
content: # Atmospheric Surface Video Examples

Place reference `.mp4` files for the `atmospheric-surface` genre in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- use the sidecar to explain:
  - what surface or material is being observed
  - how light behaves on that surface
  - what the motion rhythm feels like
  - whether the clip loops and how the loop reconnects
  - what to borrow and what not to copy

Typical references for this folder:

- rain on glass, wet paper, condensation, reflections, shallow water, dust, or luminous surfaces
- clips where material behavior carries the emotion more than plot
- observational atmospheric videos with clear texture logic


path: assets/examples/video-examples/kinetic-text/_genre-assets.md
content: # Kinetic Text Video Examples

Put future `kinetic-text` reference videos in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- each `.mp4` should have a sibling `.md` file with the same stem
- the sidecar `.md` should explain motion grammar, timing, loop behavior, and what to borrow
- if the motion logic comes from an H5 pattern, name that pattern in the sidecar

Use `../example-sidecar-template.md` as the baseline format.


path: assets/examples/video-examples/release-transform/_genre-assets.md
content: # Release Transform Video Examples

Place reference `.mp4` files for the `release-transform` genre in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- use the sidecar to explain:
  - what the initial tension or blockage is
  - what transforming action happens
  - how the before-state becomes the after-state
  - whether the motion is satisfying, soft, absurd, or cathartic
  - what to borrow and what not to copy

Typical references for this folder:

- clearing, peeling, extracting, cutting, sweeping, or revealing motion
- tactile relief clips where one action changes the emotional state of the frame
- before-and-after transformations that read clearly in a few seconds


path: assets/examples/video-examples/relatable-surrealism/ref-02.md
content: # Example: ref-02

## Genre

`relatable-surrealism`

## What It Looks Like

- four girls stand together in a 90s American retro-cartoon style
- the linework and expressions are exaggerated and hand-drawn
- short caption text sits above them
- the frame is basically a gossip-energy tableau rather than a realistic scene

## Motion Logic

- the characters loop through talking, reacting, or gossiping mouth-and-face motions
- the scene does not need plot progression; the social energy itself is the point
- the emotional effect comes from instantly recognizable group-chat behavior rendered as cartoon theater

## Pacing

- short expressive loop
- the emotional turn lands through facial exaggeration and synchronized conversational energy
- timing should feel rhythmic and memeable, not slow or poetic

## What To Borrow

- one social type or group dynamic turned into an immediately readable skit
- expressive faces over complicated choreography
- retro-cartoon stylization that increases humor and recognition
- concise caption plus clear social energy

## What Not To Copy Literally

- the exact 90s cartoon reference
- the exact character count or wardrobe
- the exact caption text
- the assumption that all gossip-energy videos should use retro American cartoon language


path: assets/examples/video-examples/relatable-surrealism/ref-06.md
content: # Example: ref-06

## Genre

`relatable-surrealism`

## What It Looks Like

- a hyperreal anthropomorphic hamster with headphones and backpack appears in a modern office and later at home
- the hamster occupies a very human workday sequence: typing, drinking coffee, clocking out, avoiding social plans, collapsing on the couch
- props and setting are realistic enough that the emotional metaphor feels specific rather than generic

## Motion Logic

- the clip chains together a few representative work-life beats
- each beat is simple but symbolically precise: frustrated typing, caffeine survival, shutdown, social refusal, liquid collapse at home
- the emotional effect comes from mapping a whole worker mood cycle onto a cute stand-in creature

## Pacing

- short multi-beat sequence
- the turn accumulates through recognizable workday steps rather than through one single reveal
- timing should feel efficient and meme-legible, not cinematic in a grand sense

## What To Borrow

- proxy creature carrying a complete social role
- concise life-beat montage instead of long narrative padding
- realistic props, lighting, and contact shadows that make the metaphor feel grounded
- one exaggerated final state that resolves the whole mood

## What Not To Copy Literally

- the exact hamster design
- the exact office setup
- the exact sequence of props or beats
- the assumption that every office-life metaphor should use rodents or burnout collapse


path: assets/examples/video-examples/relatable-surrealism/ref-03.md
content: # Example: ref-03

## Genre

`relatable-surrealism`

## What It Looks Like

- top-down flat-lay scene of a man lying on a retro Persian carpet
- books, plants, records, pizza, dog, and hats surround him like objects on a stage
- the carpet becomes a 2D life-map or game board
- the whole image feels cozy, lazy, and intentionally handcrafted

## Motion Logic

- the man slides or shifts within the flat-lay world while remaining in a lying-down mode
- small interactions happen through accessory swaps or simple prop encounters
- the emotional effect comes from turning "lying flat" into a literal low-energy side-scroller existence

## Pacing

- short loop or a few repeatable gag beats
- the emotional turn lands through the reduction of life into a flat cozy activity field
- motion should feel slightly choppy or stop-motion-like rather than hyper-smooth

## What To Borrow

- flat-lay world as a behavioral metaphor
- strong top-down perspective consistency
- one lifestyle emotion expressed through a few surrounding props
- low-fi handcrafted charm

## What Not To Copy Literally

- the exact carpet texture
- the exact prop list
- the exact male figure styling
- the assumption that all lazy-life metaphors should happen on rugs


path: assets/examples/video-examples/relatable-surrealism/ref-04.md
content: # Example: ref-04

## Genre

`relatable-surrealism`

## What It Looks Like

- a realistic 3D otter with a mini backpack stands against a plain white background
- short text shifts the emotional premise from weekday to `Weekend`
- the otter responds with a highly anthropomorphic hip-sway dance
- the scene is minimal so the emotional switch stays central

## Motion Logic

- the setup is static and text-driven
- the mood changes through one exaggerated celebratory dance
- the emotional effect depends on a cute animal carrying an intensely human weekend-release feeling

## Pacing

- very short setup followed by immediate payoff
- the turn lands when the text changes and the otter's body language explodes into joy
- the loop should stay punchy and reaction-like

## What To Borrow

- animal proxy plus one modern emotional label
- one symbolic dance or body move instead of complex narrative
- clean background that keeps the emotional read instant
- contrast between cute creature and huge adult feeling

## What Not To Copy Literally

- the exact otter species or backpack
- the exact weekday-to-weekend wording
- the exact dance move
- the assumption that all joyful work-release clips should use animals on white backdrops


path: assets/examples/video-examples/relatable-surrealism/ref-01.md
content: # Example: ref-01

## Genre

`relatable-surrealism`

## What It Looks Like

- top-down view of two hands holding bicycle handlebars over a gray office desk
- the desk scrolls downward like a road
- laptop, coffee, folders, and chat bubbles appear as environmental objects or obstacles
- the whole scene feels like office life reduced into a tiny handcrafted game world

## Motion Logic

- the riding posture stays stable while the desk-world moves beneath it
- desk objects enter and pass like route markers or obstacles
- chat bubbles behave like physical entities inside the workspace-road
- the emotional effect comes from turning task navigation into literal commuting or level traversal

## Pacing

- short loop or repeated forward-motion cycle
- the emotional turn lands immediately through the desk becoming a road
- timing should feel slightly stop-motion or lo-fi rather than frictionless

## What To Borrow

- dimensional mismatch between desk and road
- top-down perspective discipline
- UI or message bubbles turned into world objects
- symbolic motion that captures "working through tasks" without overexplaining

## What Not To Copy Literally

- the exact desk props
- the exact bicycle setup
- the exact app bubble styling
- the assumption that every work metaphor should be a commuting scene


path: assets/examples/video-examples/relatable-surrealism/ref-05.md
content: # Example: ref-05

## Genre

`relatable-surrealism`

## What It Looks Like

- phone-native view of a white Pomeranian lying on a bed
- an off-screen voice asks whether it wants a late-night snack
- the dog gives a tiny but unmistakably human-looking delighted reaction
- the realism stays intact; the humor lives in the emotional read

## Motion Logic

- the scene begins as an ordinary pet clip
- the emotional turn happens through micro-expression, ear movement, and eye sparkle
- the feeling is not surreal because of world-building but because the dog's reaction becomes a perfect stand-in for a human craving or weakness

## Pacing

- very short setup and immediate reaction payoff
- the turn lands through expression timing rather than large movement
- the clip should feel shareable, concise, and socially recognizable

## What To Borrow

- realistic pet footage as an emotional stand-in
- precision of micro-reaction timing
- off-screen question plus reaction structure
- tiny gesture carrying a very common feeling

## What Not To Copy Literally

- the exact dog breed
- the exact snack topic
- the exact bedroom setup
- the assumption that all relatable-surreal clips need animals rather than other stand-ins


path: assets/examples/video-examples/relatable-surrealism/_genre-assets.md
content: # Relatable Surrealism Video Examples

Place reference `.mp4` files for the `relatable-surrealism` genre in this folder.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- the sidecar should explain:
  - what ordinary modern feeling or social type is being visualized
  - what surreal device or stand-in makes it legible
  - what one to three actions carry the emotional joke
  - what camera or perspective rule keeps the illusion working
  - what to borrow and what not to copy

Typical references for this folder:

- animal stand-ins for work, weekend, or social emotions
- top-down or flat-lay worlds where daily life becomes a toy stage
- desk, bed, carpet, commute, or workstation scenes that behave like symbolic worlds
- concise skits that make the viewer feel "this is exactly that mood"


path: assets/examples/video-examples/mood-loop/_genre-assets.md
content: # Mood Loop Video Examples

Put future `mood-loop` reference videos in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- each `.mp4` should have a sibling `.md` file with the same stem
- the sidecar `.md` should explain loop behavior, atmospheric motion, pacing, and what to borrow
- describe what the frames show and what the video rhythm adds that screenshots alone cannot show

Use `../example-sidecar-template.md` as the baseline format.


path: assets/examples/video-examples/touch-awakening/_genre-assets.md
content: # Touch Awakening Video Examples

Place reference `.mp4` files for the `touch-awakening` genre in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- use the sidecar to explain:
  - what the trigger source is
  - how the activation spreads
  - whether the clip loops
  - what emotional read the awakening carries
  - what to borrow and what not to copy

Typical references for this folder:

- blooming or magical spread from one contact point
- environment waking up from a wand, raindrop, light pulse, or creature path
- touch-like transformation translated into authored cinematic motion


path: assets/examples/video-examples/scene-animation/_genre-assets.md
content: # Scene Animation Video Examples

Put future `scene-animation` reference videos in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- each `.mp4` should have a sibling `.md` file with the same stem
- the sidecar `.md` should explain the progression beats, what changes over time, and where the emotional turn lands
- if the scene animation bridges from an H5 pattern, name that pattern explicitly

Use `../example-sidecar-template.md` as the baseline format.


path: assets/examples/video-examples/live-scene-doodle/ref-02.md
content: # Example: ref-02

## Genre

`live-scene-doodle`

## What It Looks Like

- airplane-window view with pink-purple dusk sky
- an open book and transparent cup rest on the tray table
- static handwritten title text sits in the upper area
- colorful small stars with trailing tails rise from the book toward the window and sky

## Motion Logic

- the real shot stays calm and observational
- star doodles repeatedly emerge from the book pages
- they travel upward in a clear directional stream toward the open sky outside the window
- the book acts as the emission source and the window acts as the escape destination

## Pacing

- short loop with constant upward star release
- the emotional turn lives in the transition from quiet reading scene to imaginative lift-off
- the clip should feel dreamy, not busy

## What To Borrow

- strong metaphorical path from one real object into open space
- using book pages as the origin of impossible doodle motion
- colorful but still simple doodle shapes
- clean vertical movement that makes the fantasy legible immediately

## What Not To Copy Literally

- the exact phrase `HOW TO CATCH A STAR`
- the exact airplane setting
- the exact colors or number of stars
- the assumption that all knowledge or imagination scenes must use stars


path: assets/examples/video-examples/live-scene-doodle/ref-03.md
content: # Example: ref-03

## Genre

`live-scene-doodle`

## What It Looks Like

- a layered latte sits on a dark tabletop with moody lighting
- above the cup floats a simple white doodled sad cloud face
- white dashed rain drops fall from the cloud into the cup
- the cup rim is the key attachment boundary

## Motion Logic

- the real coffee shot stays still and cinematic
- the doodled cloud hovers in place above the drink
- dashed drops fall directly into the cup in a repeating loop
- the loop depends on clean vertical alignment between cloud, drops, and cup opening

## Pacing

- short repetitive loop
- the emotional effect is immediate: bitterness or sadness becomes a tiny weather system
- the motion should stay restrained and melancholy rather than playful-chaotic

## What To Borrow

- one-object one-metaphor clarity
- emotional weather attached to a drink
- precise alignment between doodle endpoint and cup rim
- pure white linework against dark cinematic footage

## What Not To Copy Literally

- the exact sad-face cloud expression
- the exact latte glass shape
- the exact amount of rain
- the assumption that all bitter or lonely scenes should use rain


path: assets/examples/video-examples/live-scene-doodle/ref-04.md
content: # Example: ref-04

## Genre

`live-scene-doodle`

## What It Looks Like

- a red drink with thick white foam sits on a wooden coaster on a cyan tabletop
- nearby lies a small hand-drawn card
- fluorescent green, blue, and yellow doodle fireworks keep blooming above the foam
- the foam top acts as the ignition point for the doodle bursts

## Motion Logic

- the real tabletop still life remains stable
- tiny line fireworks repeatedly bloom from the same zone above the drink
- each burst appears, expands, and disappears quickly before the next begins
- the motion reads like the drink's taste or excitement escaping upward

## Pacing

- short repeating celebratory loop
- the emotional turn is micro-celebration rather than full spectacle
- timing should be crisp enough to feel sparkly but not crowded

## What To Borrow

- foam or surface used as a believable launch point
- color-synced neon doodles in a brighter real scene
- contrast between quiet product-style shot and playful celebratory overlay
- repeated small bursts instead of one giant event

## What Not To Copy Literally

- the exact drink color or coaster setup
- the exact firework shape or palette
- the exact tabletop color
- the assumption that all celebration overlays should be fireworks


path: assets/examples/video-examples/live-scene-doodle/ref-01.md
content: # Example: ref-01

## Genre

`live-scene-doodle`

## What It Looks Like

- a hand holds a white cosmos flower against a dark gray background
- the real scene is sparse, close-up, and softly lit
- white and pink handwritten English words and star-like marks hover around the flower petals
- the flower center acts as the physical anchor for the doodle field

## Motion Logic

- the live-action shot stays mostly still
- doodled words and small symbols orbit or drift lightly around the flower
- the motion is small and cyclical, like a soft spring breeze around a real object

## Pacing

- short loop
- the emotional turn lands immediately through the contrast between still realism and floating doodle warmth
- the loop should feel gentle rather than eventful

## What To Borrow

- flower as real-world anchor
- minimal handwritten overlay instead of dense decoration
- soft orbiting motion near the physical edges of petals
- use of negative space behind the subject

## What Not To Copy Literally

- the exact words `Spring` or `love`
- the exact flower species or framing
- the exact pink-and-white doodle placement
- the assumption that every healing scene should use handwritten English text


path: assets/examples/video-examples/live-scene-doodle/ref-05.md
content: # Example: ref-05

## Genre

`live-scene-doodle`

## What It Looks Like

- a vertical three-panel landscape composition shows green mountains, rider, meadow, and forest
- a giant white doodled dandelion stretches across the segmented scenic frames
- seeds drift from one side of the composition to the other
- the stitched panorama itself becomes the field the doodle crosses

## Motion Logic

- the landscape base remains wide and quiet
- the large doodled dandelion stays partly anchored while seeds peel away and drift across the multi-panel frame
- the movement direction is wind-led and continuous, cutting across the segmented real images
- the emotional effect comes from one soft impossible drawing unifying separate scenic panels

## Pacing

- short wind-driven loop
- the emotional turn is scenic calm becoming poetic motion
- the drift should feel long enough to cross the frame clearly, but still loop cleanly

## What To Borrow

- oversized doodle crossing multiple real-image regions
- one clear wind direction
- large-scale simple line drawing over panoramic natural footage
- using the overlay to unify segmented composition

## What Not To Copy Literally

- the exact dandelion symbol
- the exact three-panel split
- the exact landscape type or rider presence
- the assumption that all large landscape overlays should use seeds or white line art


path: assets/examples/video-examples/live-scene-doodle/_genre-assets.md
content: # Live Scene Doodle Video Examples

Place reference `.mp4` files for the `live-scene-doodle` genre in this folder.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- the real footage should remain the visual base layer
- the doodle layer should stay simple, readable, and physically attached to a real anchor in the shot
- use the sidecar to explain:
  - what the real anchor object or zone is
  - what doodle metaphor is layered on top
  - where the doodle begins or ends physically
  - how the loop behaves
  - what emotional contrast the clip creates
  - what to borrow and what not to copy

Typical references for this folder:

- flowers, cups, books, tabletops, windows, landscapes, or small everyday scenes
- simple handwritten words, stars, weather symbols, fireworks, seeds, or childlike line drawings
- clips where reality stays calm and the doodle adds one magical impossibility


path: assets/examples/video-examples/object-micro-cinema/_genre-assets.md
content: # Object Micro Cinema Video Examples

Place reference `.mp4` files for the `object-micro-cinema` genre in this folder.

Examples coming soon. This directory is intentionally reserved even though no video files have been added yet.

Rules:

- every `.mp4` must have a sibling `.md` sidecar file with the same stem
- use the sidecar to explain:
  - what the central object is
  - how the object is revealed, opened, approached, or reframed
  - what sub-elements appear and in what order
  - whether camera movement matters
  - what to borrow and what not to copy

Typical references for this folder:

- drawer, cabinet, shelf, mirror, book, card, keepsake, or box-based micro films
- object-centered reveal sequences where the object itself is the gift container
- short clips where one meaningful object unfolds a thesis


path: scripts/post-delivery.sh
content: #!/bin/bash
set -euo pipefail

# Usage: post-delivery.sh <gift-metadata-json> <setup-state-json>
# Performs guaranteed mechanical bookkeeping only:
# - update setup-state timestamps and recent_gifts
# - append to gift-history.jsonl

METADATA_FILE="${1:-}"
SETUP_STATE_FILE="${2:-}"

if [ -z "$METADATA_FILE" ] || [ -z "$SETUP_STATE_FILE" ]; then
  echo "Usage: post-delivery.sh <gift-metadata-json> <setup-state-json>" >&2
  exit 1
fi

if [ ! -f "$METADATA_FILE" ]; then
  echo "metadata file not found: $METADATA_FILE" >&2
  exit 1
fi

python3 - "$METADATA_FILE" "$SETUP_STATE_FILE" <<'PY'
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

meta_path = Path(sys.argv[1]).expanduser()
setup_path = Path(sys.argv[2]).expanduser()
history_path = setup_path.parent / "gift-history.jsonl"


def now_iso() -> str:
    return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")


def read_json(path: Path):
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def nonempty_dict(value):
    if not isinstance(value, dict):
        return {}
    return {k: v for k, v in value.items() if v not in ("", None, [])}


TEXT_PLAY_OUTPUT_SHAPES = {
    "worldbuilder": "text-play-worldbuilder",
    "one-word-worldbuilder": "text-play-worldbuilder",
    "riddle": "text-play-riddle",
    "riddle-chain": "text-play-riddle",
    "emoji-riddle": "text-play-riddle",
    "emoji-riddle-chain": "text-play-riddle",
    "micro-story": "text-play-micro-story",
    "relay-story": "text-play-micro-story",
    "micro-adventure": "text-play-micro-story",
    "three-choice-micro-adventure": "text-play-micro-story",
    "choose-your-own-adventure": "text-play-micro-story",
    "roleplay": "text-play-roleplay",
    "role-play": "text-play-roleplay",
    "roleplay-micro-theater": "text-play-roleplay",
    "role-play-micro-theater": "text-play-roleplay",
    "micro-theater": "text-play-roleplay",
}


def infer_text_play_output_shape(text_play_type: str) -> str:
    normalized = text_play_type.strip().lower().replace("_", "-")
    if not normalized:
        return "text-play-micro-story"
    return TEXT_PLAY_OUTPUT_SHAPES.get(normalized, "text-play-micro-story")


def merge_from_meta(explicit_entry, keep_keys):
    base_entry = {k: meta[k] for k in keep_keys if k in meta and meta[k] not in ("", None, [])}
    return {**base_entry, **nonempty_dict(explicit_entry)}


def apply_runtime_defaults(entry):
    pattern_or_format = str(entry.get("pattern_or_format") or meta.get("pattern_or_format") or "").strip()
    if pattern_or_format != "text-play":
        return entry

    for key in [
        "text_play_type",
        "text_play_turn_count",
        "text_play_completed",
        "text_play_exit_reason",
    ]:
        if key not in entry and meta.get(key) not in ("", None, []):
            entry[key] = meta[key]

    text_play_type = str(entry.get("text_play_type") or "").strip()
    entry.setdefault("output_shape", infer_text_play_output_shape(text_play_type))
    entry.setdefault("visual_style", "chat-native")
    entry.setdefault("content_direction", "play")
    return entry


meta = read_json(meta_path)
setup = read_json(setup_path)

timestamp = (
    meta.get("sent_at")
    or meta.get("recorded_at")
    or meta.get("timestamp")
    or now_iso()
)

summary = str(meta.get("summary") or meta.get("last_gift_summary") or "").strip()
trigger_mode = str(meta.get("trigger_mode") or meta.get("last_run_mode") or "unknown").strip()
decision = str(meta.get("decision") or "sent").strip()

recent_limit = int(setup.get("recent_gifts_limit") or 30)
recent_gifts = list(setup.get("recent_gifts") or [])

recent_entry = meta.get("recent_gift_entry")
recent_keep_keys = [
    "sent_at",
    "trigger_mode",
    "gift_weight",
    "narrative_role",
    "tone",
    "pattern_or_format",
    "output_shape",
    "visual_style",
    "content_direction",
    "content_tags",
    "emotional_direction",
    "summary",
    "visual_elements",
    "concept_family",
    "concept_theme",
    "text_play_type",
    "text_play_turn_count",
    "text_play_completed",
    "text_play_exit_reason",
]
recent_entry = merge_from_meta(recent_entry, recent_keep_keys)
recent_entry = apply_runtime_defaults(recent_entry)
recent_entry.setdefault("sent_at", timestamp)
recent_entry.setdefault("trigger_mode", trigger_mode)
if summary:
    recent_entry.setdefault("summary", summary)

recent_gifts.append(recent_entry)
setup["recent_gifts"] = recent_gifts[-recent_limit:]
setup["last_sent_at"] = timestamp
setup["last_gift_summary"] = summary
setup["last_run_at"] = timestamp
setup["last_run_mode"] = trigger_mode
setup["last_run_outcome"] = decision

setup_path.parent.mkdir(parents=True, exist_ok=True)
setup_path.write_text(json.dumps(setup, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

history_record = meta.get("history_record")
history_keep_keys = [
    "record_type",
    "recorded_at",
    "sent_at",
    "trigger_mode",
    "decision",
    "gift_weight",
    "narrative_role",
    "tone",
    "pattern_or_format",
    "output_shape",
    "visual_style",
    "content_direction",
    "content_tags",
    "emotional_direction",
    "used_memory_dates",
    "used_source_quotes",
    "used_source_images",
    "summary",
    "why_it_mattered",
    "quality_note",
    "visual_elements",
    "concept_family",
    "concept_theme",
    "image_mode",
    "video_mode",
    "generate_audio",
    "text_play_type",
    "text_play_turn_count",
    "text_play_completed",
    "text_play_exit_reason",
]
history_record = merge_from_meta(history_record, history_keep_keys)
history_record = apply_runtime_defaults(history_record)
history_record.setdefault("record_type", "gift")
history_record.setdefault("recorded_at", timestamp)
history_record.setdefault("sent_at", timestamp)
history_record.setdefault("trigger_mode", trigger_mode)
history_record.setdefault("decision", decision)
if summary:
    history_record.setdefault("summary", summary)

history_path.parent.mkdir(parents=True, exist_ok=True)
with history_path.open("a", encoding="utf-8") as fh:
    fh.write(json.dumps(history_record, ensure_ascii=False) + "\n")
PY


path: scripts/fetch-music.sh
content: #!/bin/bash
set -euo pipefail

# Usage: fetch-music.sh <query> <output-path> [max-duration-seconds] [setup-state-file]
# Example: fetch-music.sh "warm piano loop" ./music.mp3 30 ./setup-state.json
# Returns the output path on success, empty on failure.

QUERY="${1:-}"
OUTPUT="${2:-}"
MAX_DUR="${3:-30}"
SETUP_STATE="${4:-}"

if [ -z "$QUERY" ] || [ -z "$OUTPUT" ]; then
  echo "Usage: fetch-music.sh <query> <output-path> [max-duration] [setup-state]" >&2
  exit 1
fi

# Try to get API token from setup-state or environment
API_TOKEN=""
if [ -n "$SETUP_STATE" ] && [ -f "$SETUP_STATE" ]; then
  API_TOKEN="$(python3 -c "
import json, sys
from pathlib import Path
try:
    d = json.loads(Path(sys.argv[1]).read_text())
    t = d.get('tools', {}).get('freesound', {})
    print(t.get('api_key', '') or t.get('token', ''))
except: print('')
" "$SETUP_STATE")"
fi

if [ -z "$API_TOKEN" ] && [ -n "${FREESOUND_API_KEY:-}" ]; then
  API_TOKEN="$FREESOUND_API_KEY"
fi

if [ -z "$API_TOKEN" ]; then
  echo "" # empty = no token, caller should fallback
  exit 0
fi

# Search Freesound with progressive query simplification
# If the full query returns no results, try with fewer words
search_freesound() {
  local q="$1"
  local url="https://freesound.org/apiv2/search/text/?query=$(echo "$q" | tr ' ' '+')&fields=id,name,duration,previews&page_size=10&[REDACTED_SECRET]"
  curl -s "$url" | python3 -c "
import json, sys
max_dur = float(sys.argv[1])
d = json.load(sys.stdin)
for r in d.get('results', []):
    if 5 <= r['duration'] <= max_dur:
        url = r.get('previews', {}).get('preview-hq-mp3', '')
        if url:
            print(url)
            break
" "$MAX_DUR" 2>/dev/null || true
}

PREVIEW_URL="$(search_freesound "$QUERY")"

# If no results, progressively drop words from the end and retry
if [ -z "$PREVIEW_URL" ]; then
  WORDS=($QUERY)
  WORD_COUNT=${#WORDS[@]}
  while [ -z "$PREVIEW_URL" ] && [ "$WORD_COUNT" -gt 1 ]; do
    WORD_COUNT=$((WORD_COUNT - 1))
    SHORTER_QUERY="${WORDS[@]:0:$WORD_COUNT}"
    PREVIEW_URL="$(search_freesound "$SHORTER_QUERY")"
  done
fi

if [ -z "$PREVIEW_URL" ]; then
  echo "" # empty = nothing found even after simplification
  exit 0
fi

# Filename sanity check — reject alert/notification sounds
FILENAME="$(basename "$PREVIEW_URL")"
if echo "$FILENAME" | grep -qiE 'alert|bell|notif|alarm|siren|horn|beep|click|ring|train|whistle'; then
  # Suspicious filename, likely not background music. Try next result or fallback.
  echo ""
  exit 0
fi

# Download preview
curl -s "$PREVIEW_URL" -o "$OUTPUT" 2>/dev/null

if [ -f "$OUTPUT" ] && [ -s "$OUTPUT" ]; then
  echo "$OUTPUT"
else
  echo ""
fi


path: scripts/fetch-asset-bundle.sh
content: #!/bin/bash
set -euo pipefail

# Usage: fetch-asset-bundle.sh <bundle-key> [base-dir] [manifest-path]
# Example: fetch-asset-bundle.sh "image-examples/meme-sticker" "/path/to/repo"
# Prints the extracted target directory on success.

BUNDLE_KEY="${1:-}"
BASE_DIR="${2:-}"
MANIFEST_PATH="${3:-}"

if [ -z "$BUNDLE_KEY" ]; then
  echo "Usage: fetch-asset-bundle.sh <bundle-key> [base-dir] [manifest-path]" >&2
  exit 1
fi

if [ -z "$BASE_DIR" ]; then
  BASE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
fi

if [ -z "$MANIFEST_PATH" ]; then
  MANIFEST_PATH="$BASE_DIR/references/asset-manifest.json"
fi

if [ ! -f "$MANIFEST_PATH" ]; then
  echo "asset manifest not found: $MANIFEST_PATH" >&2
  exit 1
fi

bundle_url="$(python3 - "$MANIFEST_PATH" "$BUNDLE_KEY" <<'PY'
import json
import sys
from pathlib import Path

manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
print(manifest.get(sys.argv[2], ""))
PY
)"

if [ -z "$bundle_url" ]; then
  echo "bundle key not found in manifest: $BUNDLE_KEY" >&2
  exit 1
fi

target_dir=""
case "$BUNDLE_KEY" in
  audio)
    target_dir="$BASE_DIR/assets/audio"
    ;;
  image-examples/*|video-examples/*)
    target_dir="$BASE_DIR/assets/examples/$BUNDLE_KEY"
    ;;
  examples/*)
    target_dir="$BASE_DIR/assets/$BUNDLE_KEY"
    ;;
  *)
    echo "unsupported bundle key: $BUNDLE_KEY" >&2
    exit 1
    ;;
esac

if [ -d "$target_dir" ] && [ -n "$(ls -A "$target_dir" 2>/dev/null || true)" ]; then
  printf '%s\n' "$target_dir"
  exit 0
fi

mkdir -p "$target_dir"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
zip_path="$tmpdir/bundle.zip"

case "$bundle_url" in
  http://*|https://*)
    curl -fsSL "$bundle_url" -o "$zip_path"
    ;;
  *)
    cp "$bundle_url" "$zip_path"
    ;;
esac

unzip -oq "$zip_path" -d "$target_dir"
printf '%s\n' "$target_dir"


path: scripts/deploy.sh
content: #!/bin/bash

set -euo pipefail

# Usage: deploy.sh <html-file> <domain> [provider]
# Example: deploy.sh ./gift.html your-gifts.surge.sh surge

HTML_FILE="${1:-}"
DOMAIN="${2:-}"
PROVIDER="${3:-surge}"
SURGE_DEPLOY_MAX_ATTEMPTS="${SURGE_DEPLOY_MAX_ATTEMPTS:-2}"
SURGE_DEPLOY_RETRY_DELAY_SECONDS="${SURGE_DEPLOY_RETRY_DELAY_SECONDS:-3}"

if [ -z "$HTML_FILE" ] || [ -z "$DOMAIN" ]; then
  echo "Usage: deploy.sh <html-file> <domain> [provider]" >&2
  exit 1
fi

if [ ! -f "$HTML_FILE" ]; then
  echo "HTML file not found: $HTML_FILE" >&2
  exit 1
fi

case "$PROVIDER" in
  surge)
    if ! command -v surge >/dev/null 2>&1; then
      echo "surge is not installed. Run: npm install -g surge" >&2
      exit 1
    fi

    TMPDIR="$(mktemp -d)"
    trap 'rm -rf "$TMPDIR"' EXIT
    cp "$HTML_FILE" "$TMPDIR/index.html"
    ATTEMPT=1

    while [ "$ATTEMPT" -le "$SURGE_DEPLOY_MAX_ATTEMPTS" ]; do
      set +e
      SURGE_OUTPUT="$(surge "$TMPDIR" --domain "$DOMAIN" 2>&1)"
      STATUS=$?
      set -e

      if [ "$STATUS" -eq 0 ]; then
        printf '%s\n' "$SURGE_OUTPUT"
        echo "https://$DOMAIN"
        exit 0
      fi
      printf '%s\n' "$SURGE_OUTPUT" >&2

      if [ "$ATTEMPT" -lt "$SURGE_DEPLOY_MAX_ATTEMPTS" ]; then
        echo "surge deploy attempt $ATTEMPT failed; retrying in ${SURGE_DEPLOY_RETRY_DELAY_SECONDS}s..." >&2
        sleep "$SURGE_DEPLOY_RETRY_DELAY_SECONDS"
      else
        echo "surge deploy failed after ${SURGE_DEPLOY_MAX_ATTEMPTS} attempts" >&2
        exit "$STATUS"
      fi

      ATTEMPT=$((ATTEMPT + 1))
    done
    ;;
  *)
    echo "Unsupported provider: $PROVIDER" >&2
    exit 1
    ;;
esac


path: scripts/render-image.sh
content: #!/bin/bash

set -euo pipefail

# Usage: render-image.sh <brief-json-file> <setup-state-file> [curl-bin]

BRIEF_FILE="${1:-}"
SETUP_STATE_FILE="${2:-}"
CURL_BIN="${3:-curl}"

if [ -z "$BRIEF_FILE" ] || [ -z "$SETUP_STATE_FILE" ]; then
  echo "Usage: render-image.sh <brief-json-file> <setup-state-file> [curl-bin]" >&2
  exit 1
fi

normalize_warning() {
  python3 -c 'import sys; print(sys.stdin.read().strip().replace("\n", " | "))'
}

print_result() {
  python3 - "$@" <<'PY'
import json
import sys

(
    render_mode,
    image_urls_json,
    tracking_url,
    provider,
    provider_path,
    model,
    genre,
    fallback_reason,
    warning,
) = sys.argv[1:]

try:
    image_urls = json.loads(image_urls_json)
except Exception:
    image_urls = []

print(
    json.dumps(
        {
            "render_mode": render_mode,
            "image_urls": image_urls,
            "tracking_url": tracking_url,
            "provider": provider,
            "provider_path": provider_path,
            "model": model,
            "genre": genre,
            "fallback_reason": fallback_reason,
            "warning": warning,
        },
        ensure_ascii=True,
    )
)
PY
}

read_runtime_bundle() {
  python3 - "$BRIEF_FILE" "$SETUP_STATE_FILE" <<'PY'
import json
import sys
from pathlib import Path

brief_path = Path(sys.argv[1])
setup_path = Path(sys.argv[2]).expanduser()


def emit(**kwargs):
    print(json.dumps(kwargs, ensure_ascii=True))


def build_prompt(brief):
    user_input = str(brief.get("user_input") or "").strip()
    scene_description = str(brief.get("scene_description") or "").strip()
    genre = str(brief.get("image_genre") or "").strip()
    style_hint = str(brief.get("style_hint") or "").strip()
    aspect_ratio_hint = str(brief.get("aspect_ratio_hint") or "").strip()
    characters = brief.get("characters")
    pov = str(brief.get("pov") or "").strip()
    text_overlay_spec = brief.get("text_overlay_spec")

    primary = user_input or scene_description
    if not primary:
        return ""

    parts = [primary]
    if genre:
        parts.append(f"Image genre: {genre}.")
    if style_hint:
        parts.append(f"Visual style guidance: {style_hint}.")
    if aspect_ratio_hint:
        parts.append(f"Target aspect ratio: {aspect_ratio_hint}.")
    if isinstance(characters, list) and characters:
        character_lines = []
        for character in characters:
            if not isinstance(character, dict):
                continue
            role = str(character.get("role") or "").strip()
            species = str(character.get("species") or "").strip()
            color = str(character.get("color") or "").strip()
            features = character.get("features")
            feature_text = ""
            if isinstance(features, list):
                feature_text = ", ".join(str(item).strip() for item in features if str(item).strip())
            elif isinstance(features, str):
                feature_text = features.strip()

            descriptors = [item for item in [species, color] if item]
            line = ""
            if role and descriptors:
                line = f"{role}: {' '.join(descriptors)}"
            elif role:
                line = f"{role}: established recurring character"
            elif descriptors:
                line = " ".join(descriptors)

            if feature_text:
                if line:
                    line += f" ({feature_text})"
                else:
                    line = feature_text
            if line:
                character_lines.append(line)
        if character_lines:
            parts.append("Characters in scene: " + "; ".join(character_lines) + ".")
    if pov:
        parts.append(f"Composition point of view: {pov}.")
    if isinstance(text_overlay_spec, dict) and text_overlay_spec:
        text_lines = []
        wording = (
            text_overlay_spec.get("text")
            or text_overlay_spec.get("wording")
            or text_overlay_spec.get("content")
            or ""
        )
        placement = str(text_overlay_spec.get("placement") or "")
        size = str(text_overlay_spec.get("size") or "")
        font_feel = str(text_overlay_spec.get("font_feel") or text_overlay_spec.get("font") or "")
        language = str(text_overlay_spec.get("language") or "")
        if wording:
            text_lines.append(f"On-image text: {wording}")
        if language:
            text_lines.append(f"Text language: {language}")
        if placement:
            text_lines.append(f"Text placement: {placement}")
        if size:
            text_lines.append(f"Text size: {size}")
        if font_feel:
            text_lines.append(f"Typography feel: {font_feel}")
        if text_lines:
            parts.append(" ".join(text_lines) + ".")
    return "\n".join(parts).strip()


try:
    brief = json.loads(brief_path.read_text(encoding="utf-8"))
except Exception:
    emit(status="fallback", fallback_reason="brief_invalid", warning="brief_invalid")
    raise SystemExit(0)

if not setup_path.exists():
    emit(status="fallback", fallback_reason="setup_state_missing", warning="setup_state_missing")
    raise SystemExit(0)

try:
    setup = json.loads(setup_path.read_text(encoding="utf-8"))
except Exception:
    emit(status="fallback", fallback_reason="setup_state_invalid", warning="setup_state_invalid")
    raise SystemExit(0)

image = setup.get("image") or {}
prompt = build_prompt(brief)
if not prompt:
    emit(status="fallback", fallback_reason="empty_prompt", warning="")
    raise SystemExit(0)

emit(
    status="ready",
    enabled=bool(image.get("enabled")),
    provider=str(image.get("provider") or "").strip(),
    model=str(image.get("model") or "").strip(),
    api_key_source=str(image.get("api_key_source") or "").strip(),
    [REDACTED_SECRET]"api_key") or ""),
    genre=str(brief.get("image_genre") or "").strip(),
    prompt=prompt,
    aspect_ratio_hint=str(brief.get("aspect_ratio_hint") or "").strip(),
    output_dir=str((setup_path.parent / "generated-images").resolve()),
)
PY
}

detect_provider_path() {
  python3 - "$1" <<'PY'
import json
import os
import re
import sys

runtime = json.loads(sys.argv[1])


def emit(**kwargs):
    print(json.dumps(kwargs, ensure_ascii=True))


if runtime.get("status") != "ready":
    emit(status="fallback", fallback_reason=runtime.get("fallback_reason", "runtime_invalid"), warning=runtime.get("warning", ""))
    raise SystemExit(0)

enabled = bool(runtime.get("enabled"))
provider = str(runtime.get("provider") or "").strip().lower()
model = str(runtime.get("model") or "").strip()
api_key_source = str(runtime.get("api_key_source") or "").strip()
[REDACTED_SECRET]"api_key") or "")

if not enabled:
    emit(status="fallback", fallback_reason="image_disabled", warning="")
    raise SystemExit(0)

if not provider or not model:
    emit(status="fallback", fallback_reason="image_config_incomplete", warning="")
    raise SystemExit(0)

has_env_key = bool(api_key_source and os.environ.get(api_key_source))
has_inline_key = bool(api_key)

if not has_env_key and not has_inline_key:
    emit(status="fallback", fallback_reason="image_api_key_missing", warning="image_api_key_missing")
    raise SystemExit(0)

provider_path = ""
source_upper = api_key_source.upper()

if provider in {"gemini", "google", "gemini-direct", "google-direct"}:
    provider_path = "gemini-direct"
elif provider in {"openrouter", "openrouter-image"}:
    provider_path = "openrouter"
elif source_upper in {"GEMINI_API_KEY", "GOOGLE_API_KEY"}:
    provider_path = "gemini-direct"
elif source_upper == "OPENROUTER_API_KEY":
    provider_path = "openrouter"
elif re.search(r"gemini|google", provider):
    provider_path = "gemini-direct"
elif re.search(r"openrouter", provider):
    provider_path = "openrouter"
else:
    emit(status="fallback", fallback_reason="image_provider_unsupported", warning="image_provider_unsupported")
    raise SystemExit(0)

emit(status="ready", provider_path=provider_path)
PY
}

json_get() {
  python3 - "$1" "$2" <<'PY'
import json
import sys
data = json.loads(sys.argv[1])
value = data.get(sys.argv[2], "")
if isinstance(value, bool):
    print("true" if value else "false")
else:
    print(str(value))
PY
}

parse_openrouter_images() {
  local response_file="$1"
  local output_dir="$2"
  python3 - "$response_file" "$output_dir" <<'PY'
import base64
import json
import mimetypes
import sys
import uuid
from pathlib import Path

response_path = Path(sys.argv[1])
output_dir = Path(sys.argv[2])
output_dir.mkdir(parents=True, exist_ok=True)


def suffix_for_mime(mime_type: str) -> str:
    guessed = mimetypes.guess_extension(mime_type or "")
    return guessed or ".png"


def save_base64_url(url: str) -> str:
    header, data = url.split(",", 1)
    mime_type = header.split(";")[0].split(":", 1)[1] if ":" in header else "image/png"
    raw = base64.b64decode(data)
    target = output_dir / f"openrouter-{uuid.uuid4().hex}{suffix_for_mime(mime_type)}"
    target.write_bytes(raw)
    return str(target)


def collect_urls(message):
    urls = []
    for item in message.get("images") or []:
        if not isinstance(item, dict):
            continue
        image_payload = item.get("image_url") or item.get("imageUrl") or {}
        if isinstance(image_payload, dict):
            url = image_payload.get("url") or ""
            if isinstance(url, str) and url:
                urls.append(url)

    content = message.get("content")
    if isinstance(content, list):
        for part in content:
            if not isinstance(part, dict):
                continue
            if part.get("type") == "image_url":
                image_payload = part.get("image_url") or part.get("imageUrl") or {}
                if isinstance(image_payload, dict):
                    url = image_payload.get("url") or ""
                    if isinstance(url, str) and url:
                        urls.append(url)
            elif part.get("type") == "image":
                source = part.get("source") or {}
                if isinstance(source, dict) and source.get("type") == "base64":
                    data = source.get("data") or ""
                    mime_type = source.get("media_type") or source.get("mime_type") or "image/png"
                    if data:
                        urls.append(f"data:{mime_type};base64,{data}")
    return urls


try:
    response = json.loads(response_path.read_text(encoding="utf-8"))
except Exception:
    print(json.dumps({"image_urls": [], "warning": "response_invalid"}, ensure_ascii=True))
    raise SystemExit(0)

choices = response.get("choices") or []
if not choices:
    print(json.dumps({"image_urls": [], "warning": "no_choices"}, ensure_ascii=True))
    raise SystemExit(0)

message = (choices[0] or {}).get("message") or {}
raw_urls = collect_urls(message)

# Also check message.images[] (OpenRouter/Gemini sometimes returns images here)
for img_item in (message.get("images") or []):
    if isinstance(img_item, dict):
        img_url_obj = img_item.get("image_url")
        if isinstance(img_url_obj, dict):
            url = img_url_obj.get("url", "")
        elif isinstance(img_url_obj, str):
            url = img_url_obj
        else:
            url = img_item.get("url", "")
        if url:
            raw_urls.append(url)
    elif isinstance(img_item, str) and img_item:
        raw_urls.append(img_item)

image_urls = []
for url in raw_urls:
    if not isinstance(url, str) or not url:
        continue
    if url.startswith("data:image/"):
        image_urls.append(save_base64_url(url))
    else:
        image_urls.append(url)

print(json.dumps({"image_urls": image_urls, "warning": ""}, ensure_ascii=True))
PY
}

parse_gemini_images() {
  local response_file="$1"
  local output_dir="$2"
  python3 - "$response_file" "$output_dir" <<'PY'
import base64
import json
import mimetypes
import sys
import uuid
from pathlib import Path

response_path = Path(sys.argv[1])
output_dir = Path(sys.argv[2])
output_dir.mkdir(parents=True, exist_ok=True)


def suffix_for_mime(mime_type: str) -> str:
    guessed = mimetypes.guess_extension(mime_type or "")
    return guessed or ".png"


def save_inline_image(mime_type: str, data: str) -> str:
    raw = base64.b64decode(data)
    target = output_dir / f"gemini-{uuid.uuid4().hex}{suffix_for_mime(mime_type)}"
    target.write_bytes(raw)
    return str(target)


try:
    response = json.loads(response_path.read_text(encoding="utf-8"))
except Exception:
    print(json.dumps({"image_urls": [], "warning": "response_invalid"}, ensure_ascii=True))
    raise SystemExit(0)

candidates = response.get("candidates") or []
parts = []
for candidate in candidates:
    if not isinstance(candidate, dict):
        continue
    content = candidate.get("content") or {}
    if isinstance(content, dict):
        candidate_parts = content.get("parts") or []
        if isinstance(candidate_parts, list):
            parts.extend(candidate_parts)

if not parts and isinstance(response.get("parts"), list):
    parts = response.get("parts") or []

image_urls = []
for part in parts:
    if not isinstance(part, dict):
        continue
    inline_data = part.get("inline_data") or part.get("inlineData") or {}
    if not isinstance(inline_data, dict):
        continue
    mime_type = inline_data.get("mime_type") or inline_data.get("mimeType") or "image/png"
    data = inline_data.get("data") or ""
    if data:
        image_urls.append(save_inline_image(mime_type, data))

print(json.dumps({"image_urls": image_urls, "warning": ""}, ensure_ascii=True))
PY
}

run_openrouter() {
  local runtime_json="$1"
  local provider model genre api_key_source api_key prompt aspect_ratio output_dir
  local payload_file response_file error_file payload_json response_info image_urls_json warning

  provider="$(json_get "$runtime_json" "provider")"
  model="$(json_get "$runtime_json" "model")"
  genre="$(json_get "$runtime_json" "genre")"
  api_key_source="$(json_get "$runtime_json" "api_key_source")"
  [REDACTED_SECRET] "$runtime_json" "api_key")"
  prompt="$(json_get "$runtime_json" "prompt")"
  aspect_ratio="$(json_get "$runtime_json" "aspect_ratio_hint")"
  output_dir="$(json_get "$runtime_json" "output_dir")"

  if [ -n "$api_key_source" ] && [ -n "${!api_key_source:-}" ]; then
    [REDACTED_SECRET]"
  fi

  if [ -z "$api_key" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "openrouter" "$model" "$genre" "api_key_not_found" ""
    return
  fi

  if [ -z "$prompt" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "openrouter" "$model" "$genre" "empty_prompt" ""
    return
  fi

  payload_file="$(mktemp)"
  response_file="$(mktemp)"
  error_file="$(mktemp)"
  python3 - "$payload_file" "$model" "$prompt" "$aspect_ratio" <<'PY'
import json
import sys
from pathlib import Path

payload_path = Path(sys.argv[1])
model = sys.argv[2]
prompt = sys.argv[3]
aspect_ratio = sys.argv[4]

payload = {
    "model": model,
    "messages": [
        {
            "role": "user",
            "content": prompt,
        }
    ],
    "modalities": ["image", "text"],
}

if aspect_ratio:
    payload["image_config"] = {"aspect_ratio": aspect_ratio}

payload_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
PY

  if ! "$CURL_BIN" -fsS -X POST "https://openrouter.ai/api/v1/chat/completions" \
    -H "Authorization: Bearer $api_key" \
    -H "Content-Type: application/json" \
    --data @"$payload_file" >"$response_file" 2>"$error_file"; then
    warning="$(normalize_warning <"$error_file")"
    rm -f "$payload_file" "$response_file" "$error_file"
    print_result "fallback_h5" "[]" "" "$provider" "openrouter" "$model" "$genre" "api_call_failed" "$warning"
    return
  fi

  response_info="$(parse_openrouter_images "$response_file" "$output_dir")"
  image_urls_json="$(python3 - "$response_info" <<'PY'
import json
import sys
print(json.dumps(json.loads(sys.argv[1]).get("image_urls") or []))
PY
)"
  warning="$(python3 - "$response_info" <<'PY'
import json
import sys
print(str(json.loads(sys.argv[1]).get("warning") or ""))
PY
)"
  rm -f "$payload_file" "$response_file" "$error_file"

  if [ "$image_urls_json" = "[]" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "openrouter" "$model" "$genre" "no_image_in_response" "$warning"
    return
  fi

  print_result "image_urls" "$image_urls_json" "" "$provider" "openrouter" "$model" "$genre" "" "$warning"
}

run_gemini_direct() {
  local runtime_json="$1"
  local provider model genre api_key_source api_key prompt output_dir
  local payload_file response_file error_file response_info image_urls_json warning

  provider="$(json_get "$runtime_json" "provider")"
  model="$(json_get "$runtime_json" "model")"
  genre="$(json_get "$runtime_json" "genre")"
  api_key_source="$(json_get "$runtime_json" "api_key_source")"
  [REDACTED_SECRET] "$runtime_json" "api_key")"
  prompt="$(json_get "$runtime_json" "prompt")"
  output_dir="$(json_get "$runtime_json" "output_dir")"

  if [ -n "$api_key_source" ] && [ -n "${!api_key_source:-}" ]; then
    [REDACTED_SECRET]"
  fi

  if [ -z "$api_key" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "gemini-direct" "$model" "$genre" "api_key_not_found" ""
    return
  fi

  if [ -z "$prompt" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "gemini-direct" "$model" "$genre" "empty_prompt" ""
    return
  fi

  payload_file="$(mktemp)"
  response_file="$(mktemp)"
  error_file="$(mktemp)"
  python3 - "$payload_file" "$prompt" <<'PY'
import json
import sys
from pathlib import Path

payload_path = Path(sys.argv[1])
prompt = sys.argv[2]
payload = {
    "contents": [
        {
            "parts": [
                {
                    "text": prompt,
                }
            ]
        }
    ]
}
payload_path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
PY

  if ! "$CURL_BIN" -fsS -X POST "https://generativelanguage.googleapis.com/v1beta/models/$model:generateContent" \
    -H "x-goog-[REDACTED_SECRET]" \
    -H "Content-Type: application/json" \
    --data @"$payload_file" >"$response_file" 2>"$error_file"; then
    warning="$(normalize_warning <"$error_file")"
    rm -f "$payload_file" "$response_file" "$error_file"
    print_result "fallback_h5" "[]" "" "$provider" "gemini-direct" "$model" "$genre" "api_call_failed" "$warning"
    return
  fi

  response_info="$(parse_gemini_images "$response_file" "$output_dir")"
  image_urls_json="$(python3 - "$response_info" <<'PY'
import json
import sys
print(json.dumps(json.loads(sys.argv[1]).get("image_urls") or []))
PY
)"
  warning="$(python3 - "$response_info" <<'PY'
import json
import sys
print(str(json.loads(sys.argv[1]).get("warning") or ""))
PY
)"
  rm -f "$payload_file" "$response_file" "$error_file"

  if [ "$image_urls_json" = "[]" ]; then
    print_result "fallback_h5" "[]" "" "$provider" "gemini-direct" "$model" "$genre" "no_image_in_response" "$warning"
    return
  fi

  print_result "image_urls" "$image_urls_json" "" "$provider" "gemini-direct" "$model" "$genre" "" "$warning"
}

dispatch_provider_path() {
  local runtime_json="$1"
  local provider_path="$2"

  case "$provider_path" in
    gemini-direct)
      run_gemini_direct "$runtime_json"
      ;;
    openrouter)
      run_openrouter "$runtime_json"
      ;;
    *)
      print_result \
        "fallback_h5" \
        "[]" \
        "" \
        "$(json_get "$runtime_json" "provider")" \
        "$provider_path" \
        "$(json_get "$runtime_json" "model")" \
        "$(json_get "$runtime_json" "genre")" \
        "image_provider_unsupported" \
        "image_provider_unsupported"
      ;;
  esac
}

RUNTIME_JSON="$(read_runtime_bundle)"
if [ "$(json_get "$RUNTIME_JSON" "status")" != "ready" ]; then
  print_result \
    "fallback_h5" \
    "[]" \
    "" \
    "" \
    "" \
    "" \
    "" \
    "$(json_get "$RUNTIME_JSON" "fallback_reason")" \
    "$(json_get "$RUNTIME_JSON" "warning")"
  exit 0
fi

DETECTION_JSON="$(detect_provider_path "$RUNTIME_JSON")"
if [ "$(json_get "$DETECTION_JSON" "status")" != "ready" ]; then
  print_result \
    "fallback_h5" \
    "[]" \
    "" \
    "$(json_get "$RUNTIME_JSON" "provider")" \
    "" \
    "$(json_get "$RUNTIME_JSON" "model")" \
    "$(json_get "$RUNTIME_JSON" "genre")" \
    "$(json_get "$DETECTION_JSON" "fallback_reason")" \
    "$(json_get "$DETECTION_JSON" "warning")"
  exit 0
fi

dispatch_provider_path "$RUNTIME_JSON" "$(json_get "$DETECTION_JSON" "provider_path")"


path: scripts/deliver-gift.sh
content: #!/bin/bash

set -euo pipefail

# Usage: deliver-gift.sh <html-file> <setup-state-file> [deploy-script]
# Example:
#   deliver-gift.sh ./workspace/gifts/2026-03-25-hello.html ./workspace/daily-gift/setup-state.json

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HTML_FILE="${1:-}"
SETUP_STATE_FILE="${2:-}"
DEPLOY_SCRIPT="${3:-$SCRIPT_DIR/deploy.sh}"

if [ -z "$HTML_FILE" ] || [ -z "$SETUP_STATE_FILE" ]; then
  echo "Usage: deliver-gift.sh <html-file> <setup-state-file> [deploy-script]" >&2
  exit 1
fi

if [ ! -f "$HTML_FILE" ]; then
  echo "HTML file not found: $HTML_FILE" >&2
  exit 1
fi

HTML_FILE_ABS="$(python3 -c 'import os,sys; print(os.path.abspath(sys.argv[1]))' "$HTML_FILE")"

read_setup_state() {
  python3 - "$1" <<'PY'
import json
import sys
from pathlib import Path

path = Path(sys.argv[1])

if not path.exists():
    print(
        json.dumps(
            {
                "setup_present": 0,
                "hosting_enabled": 0,
                "provider": "",
                "domain": "",
                "warning": "setup_state_missing",
            }
        )
    )
    raise SystemExit(0)

try:
    data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
    print(
        json.dumps(
            {
                "setup_present": 1,
                "hosting_enabled": 0,
                "provider": "",
                "domain": "",
                "warning": "setup_state_invalid",
            }
        )
    )
    raise SystemExit(0)

hosting = data.get("hosting") or {}
enabled = bool(hosting.get("enabled"))
provider = str(hosting.get("provider") or "")
domain = str(hosting.get("domain") or "")
warning = ""

if enabled and (not provider or not domain):
    enabled = False
    warning = "hosting_incomplete"
elif not enabled:
    warning = "hosting_disabled"

print(
    json.dumps(
        {
            "setup_present": 1,
            "hosting_enabled": 1 if enabled else 0,
            "provider": provider,
            "domain": domain,
            "warning": warning,
        }
    )
)
PY
}

print_result() {
  python3 - "$@" <<'PY'
import json
import sys

delivery_mode, url, html_file, provider, domain, fallback_reason, warning = sys.argv[1:]
print(
    json.dumps(
        {
            "delivery_mode": delivery_mode,
            "url": url,
            "html_file": html_file,
            "provider": provider,
            "domain": domain,
            "fallback_reason": fallback_reason,
            "warning": warning,
        },
        ensure_ascii=True,
    )
)
PY
}

SETUP_STATE_JSON="$(read_setup_state "$SETUP_STATE_FILE")"
HOSTING_ENABLED="$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["hosting_enabled"])' <<<"$SETUP_STATE_JSON")"
PROVIDER="$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["provider"])' <<<"$SETUP_STATE_JSON")"
DOMAIN="$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["domain"])' <<<"$SETUP_STATE_JSON")"
STATE_WARNING="$(python3 -c 'import json,sys; print(json.loads(sys.stdin.read())["warning"])' <<<"$SETUP_STATE_JSON")"

if [ "$HOSTING_ENABLED" = "1" ]; then
  if [ ! -x "$DEPLOY_SCRIPT" ]; then
    print_result "local_file" "" "$HTML_FILE_ABS" "" "" "deploy_script_not_executable" "Deploy script is missing or not executable: $DEPLOY_SCRIPT"
    exit 0
  fi

  if DEPLOY_OUTPUT="$("$DEPLOY_SCRIPT" "$HTML_FILE_ABS" "$DOMAIN" "$PROVIDER" 2>&1)"; then
    DEPLOY_URL="$(python3 -c 'import sys; lines=[line.strip() for line in sys.stdin.read().splitlines() if line.strip()]; urls=[line for line in lines if line.startswith(("http://", "https://"))]; print(urls[-1] if urls else (lines[-1] if lines else ""))' <<<"$DEPLOY_OUTPUT")"
    if [[ "$DEPLOY_URL" != http://* && "$DEPLOY_URL" != https://* ]]; then
      DEPLOY_WARNING="$(printf '%s' "$DEPLOY_OUTPUT" | python3 -c 'import sys; print(sys.stdin.read().strip().replace("\n", " | "))')"
      print_result "local_file" "" "$HTML_FILE_ABS" "" "" "deploy_output_missing_url" "$DEPLOY_WARNING"
      exit 0
    fi
    print_result "hosted_url" "$DEPLOY_URL" "$HTML_FILE_ABS" "$PROVIDER" "$DOMAIN" "" ""
    exit 0
  fi

  DEPLOY_WARNING="$(printf '%s' "$DEPLOY_OUTPUT" | python3 -c 'import sys; print(sys.stdin.read().strip().replace("\n", " | "))')"
  print_result "local_file" "" "$HTML_FILE_ABS" "" "" "deploy_failed" "$DEPLOY_WARNING"
  exit 0
fi

print_result "local_file" "" "$HTML_FILE_ABS" "" "" "$STATE_WARNING" ""


path: scripts/remove-bg.sh
content: #!/bin/bash

set -euo pipefail

# Usage: remove-bg.sh <input-image> <output-image> [api-key]

INPUT_IMAGE="${1:-}"
OUTPUT_IMAGE="${2:-}"
[REDACTED_SECRET]"
CURL_BIN="${REMOVE_BG_CURL_BIN:-curl}"

if [ -z "$INPUT_IMAGE" ] || [ -z "$OUTPUT_IMAGE" ]; then
  echo "Usage: remove-bg.sh <input-image> <output-image> [api-key]" >&2
  exit 1
fi

if [ -z "$API_KEY" ]; then
  echo "No remove.bg API key. Set REMOVE_BG_API_KEY or pass as arg 3." >&2
  exit 1
fi

if [ ! -f "$INPUT_IMAGE" ]; then
  echo "Input file not found: $INPUT_IMAGE" >&2
  exit 1
fi

HTTP_CODE="$(
  "$CURL_BIN" -sS -o "$OUTPUT_IMAGE" -w "%{http_code}" \
    -X POST "https://api.remove.bg/v1.0/removebg" \
    -H "X-[REDACTED_SECRET]" \
    -F "image_file=@$INPUT_IMAGE" \
    -F "size=auto"
)"

if [ "$HTTP_CODE" != "200" ]; then
  echo "remove.bg API returned HTTP $HTTP_CODE" >&2
  if [ -f "$OUTPUT_IMAGE" ]; then
    python3 - "$OUTPUT_IMAGE" <<'PY' >&2
from pathlib import Path
import sys

path = Path(sys.argv[1])
try:
    text = path.read_text(encoding="utf-8").strip()
except Exception:
    raise SystemExit(0)

if text:
    print(text[:4000])
PY
    rm -f "$OUTPUT_IMAGE"
  fi
  exit 1
fi

echo "$OUTPUT_IMAGE"


path: references/asset-manifest.json
content: {
  "image-examples/borrowed-media-layout": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-borrowed-media-layout.zip",
  "image-examples/emotion-poster": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-emotion-poster.zip",
  "image-examples/meme-sticker": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-meme-sticker.zip",
  "image-examples/mood-photo": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-mood-photo.zip",
  "image-examples/proxy-character-scene": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-proxy-character-scene.zip",
  "image-examples/surreal-film": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/img-surreal-film.zip",
  "video-examples/live-scene-doodle": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/vid-live-scene-doodle.zip",
  "video-examples/relatable-surrealism": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/vid-relatable-surrealism.zip",
  "examples/extension": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-extension.zip",
  "examples/gifted-data-viz": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-gifted-data-viz.zip",
  "examples/inner-mirror": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-inner-mirror.zip",
  "examples/kinetic-collage": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-kinetic-collage.zip",
  "examples/light-gamification": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-light-gamification.zip",
  "examples/memory-shelf": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-memory-shelf.zip",
  "examples/wind-scatter": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/h5-wind-scatter.zip",
  "audio": "https://dayfold-opensource.oss-cn-shanghai.aliyuncs.com/gift-everyday/audio-presets.zip"
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)