diff --git a/.cursor/plans/dsl_pipeline_generalization_ccc49eab.plan.md b/.cursor/plans/dsl_pipeline_generalization_ccc49eab.plan.md new file mode 100644 index 0000000..ef0323b --- /dev/null +++ b/.cursor/plans/dsl_pipeline_generalization_ccc49eab.plan.md @@ -0,0 +1,1448 @@ +--- +name: DSL Pipeline Generalization +overview: Build Pydantic models that validate the card game DSL, prompt templates for Perplexity research and DSL generation via OpenAI Codex 5.3 High, and a pipeline that chains research -> generation -> validation with retry. Late stage (future) pipes validated DSL into Claude for code generation against a templated board game webapp. +todos: + - id: pydantic-schemas + content: "Build Pydantic models: research.py (ResearchedRules with CardTypeResearch, ZoneResearch, SpecialMechanic -- structured Stage 1 output for form controls), primitives.py, operations.py (~24 op types with discriminated union), components.py (Zone with on_draw triggers), actions.py (Action with reaction_window, card_count, card_match_rule, any_phase, nullable max_per_turn), game.py with cross-reference validators, and verify uno.json validates against GameSchema" + status: pending + - id: prompt-templates + content: "Write prompt templates for Codex 5.3 High: Perplexity research prompt (structured sections mapping to DSL including INTERRUPT/REACTION MECHANICS and COMBO/MULTI-CARD PLAY sections), two-phase generation prompts (plan then JSON), and retry prompt with validation error feedback" + status: pending + - id: pipeline-impl + content: "Implement pipeline: research.py (Perplexity Sonar call + Codex parse to ResearchedRules + serialize_to_rules_text), generate.py (Codex 5.3 High plan + DSL generation + Pydantic validation retry loop), orchestrator.py (chains all steps, SSE streaming, research_override support)" + status: pending + - id: api-endpoint + content: "Add POST /api/v1/generate endpoint with SSE streaming: accepts game name, streams stage progress, returns validated GameSchema JSON. Add GET /api/v1/generate/{job_id} for status polling fallback." + status: pending + - id: validate-examples + content: "Validate the pipeline by running it on 3-4 diverse games: Uno (static validation), Exploding Kittens (interrupts, combos, elimination), Poker (betting, hand eval), Go Fish (player targeting, set collection)" + status: pending +isProject: false +--- + +# Boardify DSL Pipeline for Generalized Card Games + +## Architecture Overview + +The pipeline has 5 stages. Stages 1, 2, and 3 are implemented now. Stages 1.5 and 4 are future work. + +```mermaid +flowchart TB + subgraph stage1 [Stage 1: Research] + A[User Input: game name] --> B[Perplexity Sonar] + B --> B2[Markdown rules text] + B2 --> B3[Codex 5.3 High: parse to JSON] + B3 --> C[ResearchedRules JSON] + end + + subgraph stage15 [Stage 1.5: Rule Editing -- FUTURE] + C --> C2[Frontend renders form controls] + C2 --> C3{User edits?} + C3 -->|yes| C4[Updated ResearchedRules] + C3 -->|no| C5[Pass through] + C4 --> C6[serialize_to_rules_text] + C5 --> C6 + end + + subgraph stage2 [Stage 2: DSL Generation] + C6 --> D[Codex 5.3 High: game plan] + D --> E[Codex 5.3 High: DSL JSON] + end + + subgraph stage3 [Stage 3: Validation] + E --> F{Pydantic validates?} + F -->|fail| G[Feed errors back to Codex] + G --> E + F -->|pass| H[Valid GameSchema JSON] + end + + subgraph stage4 [Stage 4: Code Gen -- FUTURE] + H --> I[Claude: reads DSL + template webapp] + I --> J[Modified game instance] + J --> K[Playable webapp with WebSockets] + end +``` + +### Provider roles + +- **Perplexity Sonar** -- web search for authoritative game rules (Stage 1) +- **User** -- reviews and optionally edits the researched rules (Stage 1.5, future). Can correct errors, add house rules, remove unwanted mechanics, or clarify ambiguities before DSL generation. +- **OpenAI Codex 5.3 High** -- DSL plan generation and JSON schema output (Stage 2), retry corrections (Stage 3) +- **Claude** -- code generation against the templated board game webapp (Stage 4, future). Receives the validated DSL JSON + the template app source and produces a modified game instance with all components, game logic, and WebSocket event handlers wired up. + +### Stage 1 output: structured ResearchedRules model + +The Perplexity response is markdown text, but the pipeline needs structured data that: + +1. The frontend can render as form controls (number inputs, toggles, dropdowns) +2. The user can directly edit (card counts, player range, enable/disable mechanics) +3. Can be serialized back to text for Stage 2 (Codex DSL generation) + +**Two-step approach:** + +- Step 1a: Perplexity Sonar returns markdown (good at web search, natural text) +- Step 1b: Codex parses the markdown into `ResearchedRules` JSON (structured output validated by Pydantic) + +```mermaid +flowchart LR + PX[Perplexity Sonar] -->|markdown| Parse[Codex 5.3 High] + Parse -->|JSON| RR[ResearchedRules] + RR --> Form[Frontend form controls] + Form -->|user edits| RR2[Edited ResearchedRules] + RR2 -->|serialize to text| Stage2[Stage 2: DSL Generation] +``` + +File: `backend/app/schemas/research.py` + +```python +class CardTypeResearch(BaseModel): + """A distinct card type. Frontend renders as a row in an editable table.""" + name: str = Field(..., description="Card type name, e.g. 'Skip', 'Wild Draw Four', 'Exploding Kitten'") + count: int = Field(..., ge=0, description="Number of this card in the deck. UI: number spinner.") + count_rule: str | None = Field( + None, + description="Dynamic count formula if count depends on player count. " + "e.g. 'players - 1' for Exploding Kittens. UI: shown as help text next to count." + ) + properties: dict[str, str | int | None] = Field( + default_factory=dict, + description="Card attributes: {color, suit, rank, value, type}. UI: key-value pills." + ) + effect: str = Field("", description="What this card does when played/drawn. UI: text input.") + category: str | None = Field( + None, + description="Grouping label: 'number', 'action', 'wild', 'cat', 'explosive', etc. " + "UI: used to group cards in the table." + ) + + +class ZoneResearch(BaseModel): + """A game zone. Frontend renders as a card in a zone list.""" + name: str = Field(..., description="Zone identifier, e.g. 'draw_pile', 'discard_pile', 'player_hand'") + display_name: str = Field(..., description="Human-readable name, e.g. 'Draw Pile'") + description: str = Field("", description="What this zone is for") + visibility: str = Field(..., description="Who can see cards: hidden, top_only, owner_only, all") + per_player: bool = Field(False, description="Is there one per player (e.g. hand)?") + + +class TurnPhaseResearch(BaseModel): + """A phase in the turn structure.""" + name: str + description: str + mandatory: bool = Field(True, description="Must the player complete this phase? UI: toggle.") + + +class SpecialMechanic(BaseModel): + """A game mechanic that can be toggled on/off by the user.""" + name: str = Field(..., description="Short name, e.g. 'Nope interrupts', 'Stacking Draw 2'") + description: str = Field(..., description="What this mechanic does") + enabled: bool = Field(True, description="UI: toggle switch. User can disable to simplify game.") + category: str = Field( + "core", + description="'core' (required for game to work), 'optional' (can be toggled), 'house_rule' (user-added)" + ) + + +class CardEffectResearch(BaseModel): + """What a card type does when played.""" + card_name: str = Field(..., description="Which card type this effect applies to") + trigger: str = Field("on_play", description="When the effect fires: on_play, on_draw, on_discard, combo") + description: str = Field(..., description="What happens. UI: editable text area.") + targets_player: bool = Field(False, description="Does this effect target another player?") + + +class ResearchedRules(BaseModel): + """Structured research output. Every field maps to a frontend form control. + + This model is what the frontend renders as an editable form. + Users adjust card counts, toggle mechanics, set player ranges, etc. + """ + + # --- General game controls (top of form) --- + game_name: str = Field(..., description="UI: text input, pre-filled") + player_count_min: int = Field(..., ge=1, description="UI: number spinner") + player_count_max: int = Field(..., ge=1, description="UI: number spinner") + estimated_play_time_minutes: int | None = Field(None, ge=1, description="UI: number spinner, optional") + win_condition_type: str = Field( + ..., + description="UI: dropdown. Options: first_empty_hand, last_alive, highest_score, " + "most_sets, best_hand, custom" + ) + win_condition_description: str = Field(..., description="UI: text input for details") + + # --- Deck composition (editable card table) --- + card_types: list[CardTypeResearch] = Field( + ..., + description="UI: editable table. Columns: name, count (spinner), properties, effect. " + "User can add/remove rows." + ) + + # --- Game structure --- + zones: list[ZoneResearch] = Field(..., description="UI: list of zone cards") + turn_phases: list[TurnPhaseResearch] = Field( + ..., description="UI: ordered list with drag-to-reorder and mandatory toggle" + ) + + # --- Card effects (editable) --- + card_effects: list[CardEffectResearch] = Field( + default_factory=list, + description="UI: expandable list per card type" + ) + + # --- Mechanics toggles --- + special_mechanics: list[SpecialMechanic] = Field( + default_factory=list, + description="UI: toggle list. Core mechanics shown but not disableable. " + "Optional mechanics have toggle switches. User can add house rules." + ) + + # --- Flags (derived from mechanics, shown as info chips) --- + has_interrupts: bool = Field(False, description="Does the game have out-of-turn reaction cards?") + has_player_elimination: bool = Field(False, description="Can players be eliminated mid-game?") + has_combos: bool = Field(False, description="Can multiple cards be played together?") + has_scoring: bool = Field(False, description="Does the game use a point system?") + has_betting: bool = Field(False, description="Does the game have a pot/betting mechanic?") + + # --- Reference --- + raw_rules_text: str = Field( + ..., + description="Original Perplexity markdown. UI: collapsible 'Raw Rules' section at bottom. " + "Read-only reference so user can cross-check edits." + ) + + # --- House rules (user-added free text) --- + additional_rules: str | None = Field( + None, + description="UI: text area at bottom. User can type custom rules or clarifications. " + "Appended as-is to the rules text sent to Stage 2." + ) +``` + +**Parsing prompt** (Codex extracts structured data from Perplexity markdown): + +File: `backend/app/pipeline/prompts/parse_research.py` + +```python +PARSE_SYSTEM = """You are a data extraction engine. Parse the game rules document +into the exact JSON schema provided. Extract every card type with its EXACT count. +If a count depends on player number, put the formula in count_rule and use the +count for a typical game (e.g. 4 players).""" + +PARSE_USER = """## Raw Rules Document +{raw_rules_text} + +## Output JSON Schema +{research_schema} + +Parse the rules into this exact schema. Include ALL card types, ALL zones, +ALL turn phases, ALL special mechanics. Set the boolean flags (has_interrupts, +has_player_elimination, etc.) based on the rules content. + +Output ONLY valid JSON.""" +``` + +**Serialization back to text** (for Stage 2): + +When the user finishes editing and the pipeline continues to Stage 2, the `ResearchedRules` is serialized back to a rules text document that Codex can consume: + +```python +def serialize_to_rules_text(rules: ResearchedRules) -> str: + """Convert structured ResearchedRules back to markdown for Stage 2.""" + sections = [] + + sections.append(f"# {rules.game_name}") + sections.append(f"Players: {rules.player_count_min}-{rules.player_count_max}") + if rules.estimated_play_time_minutes: + sections.append(f"Play time: ~{rules.estimated_play_time_minutes} minutes") + sections.append(f"Win condition: {rules.win_condition_type} -- {rules.win_condition_description}") + + sections.append("\n## DECK COMPOSITION") + for ct in rules.card_types: + count_note = f" ({ct.count_rule})" if ct.count_rule else "" + props = ", ".join(f"{k}={v}" for k, v in ct.properties.items()) if ct.properties else "" + sections.append(f"- {ct.name}: {ct.count} cards{count_note}. {props}. {ct.effect}") + + sections.append("\n## GAME ZONES") + for z in rules.zones: + sections.append(f"- {z.display_name} ({z.name}): {z.description}. Visibility: {z.visibility}") + + sections.append("\n## TURN STRUCTURE") + for i, tp in enumerate(rules.turn_phases, 1): + sections.append(f"{i}. {tp.name}: {tp.description} {'(mandatory)' if tp.mandatory else '(optional)'}") + + sections.append("\n## CARD EFFECTS") + for ce in rules.card_effects: + sections.append(f"- {ce.card_name} ({ce.trigger}): {ce.description}") + + sections.append("\n## SPECIAL MECHANICS") + for sm in rules.special_mechanics: + if sm.enabled: + sections.append(f"- {sm.name}: {sm.description}") + else: + sections.append(f"- ~~{sm.name}~~: DISABLED BY USER") + + if rules.additional_rules: + sections.append(f"\n## ADDITIONAL RULES (USER)\n{rules.additional_rules}") + + return "\n".join(sections) +``` + +### Stage 1.5 design notes (future, not implemented now) + +After Stage 1 produces `ResearchedRules`, the user gets a chance to review and edit before DSL generation. This is important because: + +- Perplexity may get card counts wrong or miss obscure rules +- Users may want to add house rules (e.g. "stacking Draw 2 cards") +- Some games have regional variants the user wants to pick +- The user may want to simplify a complex game (disable betting in Poker, disable Nope cards, etc.) + +**Frontend form layout** (how `ResearchedRules` maps to UI): + +``` ++----------------------------------------------+ +| Game: [Exploding Kittens ] (text input) | +| Players: [2] to [5] (number spinners)| +| Play time: [15] min (number spinner) | +| Win condition: [Last alive v] (dropdown) | ++----------------------------------------------+ + +| DECK COMPOSITION | +| Name | Count | Properties | Effect | +|---------------|-------|-------------|----------| +| Exploding K. | [3] | explosive | Death | +| Defuse | [6] | action | Save | +| Attack | [4] | action | 2 turns | +| Skip | [4] | action | No draw | +| Nope | [5] | reaction | Cancel | +| Tacocat | [4] | cat | Combo | +| ... | | | | +| [+ Add card type] | ++----------------------------------------------+ + +| MECHANICS | +| [x] Nope interrupts (core) | +| [x] Cat card combos (core) | +| [x] 5-different combo (optional) [toggle]| +| [ ] Stacking attacks (house rule) [toggle]| +| [+ Add house rule] | ++----------------------------------------------+ + +| ADDITIONAL RULES | +| [ ] | +| [ Free text area for house rules ] | ++----------------------------------------------+ +``` + +**Architectural impact -- why this shapes the current design:** + +The pipeline must be **splittable** at the research boundary. Instead of one continuous stream, we need: + +1. **Phase A** (`POST /api/v1/generate/research`) -- runs Stage 1 (Perplexity + Codex parse), returns `ResearchedRules` JSON, stores it keyed by `session_id` +2. **User edits** -- client renders `ResearchedRules` as form, user modifies fields +3. **Phase B** (`POST /api/v1/generate/build`) -- accepts `session_id` + edited `ResearchedRules`, serializes to text, runs Stages 2-3 + +This is why the orchestrator's `generate_game_dsl()` accepts a `research` parameter rather than only `game_name` -- so it can receive pre-edited, serialized rules from Phase A. The current implementation runs both phases back-to-back (no pause), but the interface is already designed to split. + +### Stage 4 design notes (future, not implemented now) + +The templated board game webapp already has: + +- WebSocket infrastructure for real-time multiplayer +- Generic UI components (card rendering, zones, player panels, action buttons) +- Game state management shell + +Claude's job in Stage 4 will be to take the validated `GameSchema` JSON and: + +1. Wire deck_manifest into the card rendering system +2. Map zones to the zone layout components +3. Implement the FSM (turn phases, routines) as game state transitions +4. Bind actions to UI event handlers (click_card, click_zone, click_button, reaction) +5. Implement card_effects as post-play hooks +6. Handle reaction_windows as interrupt UI flows +7. Connect everything to the WebSocket event system + +This stage will be a separate pipeline step with its own prompt template that includes the DSL JSON and key template webapp source files as context. + +### Core insight for generalization + +**The Pydantic schema IS the prompt.** When you call `GameSchema.model_json_schema()`, it produces a JSON Schema with all your `Field(description=...)` annotations baked in. This schema gets passed directly to Codex 5.3 High as its output format constraint. Every description you write on a Pydantic field becomes documentation the LLM reads. + +--- + +## 1. Pydantic Schema Design (the "generalization grammar") + +### File structure + +``` +backend/app/schemas/ + primitives.py # ValueRef, Condition, PlayerCount + operations.py # All op types + Operation discriminated union + components.py # Zone, DeckManifestEntry, CardEffect, Variables + actions.py # Action (with reaction_window, combos), FSM + game.py # GameSchema (top-level) + __init__.py # Re-exports GameSchema +``` + +### Key technique: Discriminated unions for operations + +The `op` field is the discriminator. Each operation gets its own model with a `Literal` op type, so the LLM knows exactly which fields belong to which operation: + +```python +from typing import Annotated, Literal, Union +from pydantic import BaseModel, Field + +class MoveOp(BaseModel): + op: Literal["move"] = "move" + entity: str | None = Field(None, description="Card ref like '$card'. Omit when using from+count.") + from_: str | None = Field(None, alias="from", description="Source zone name, e.g. 'draw_pile', or '$args.target_player.hand' for stealing") + to: str = Field(..., description="Dest zone or '$player.hand'") + count: int | str | None = Field(None, description="Cards to move. Int or '$global.stack_penalty'") + random: bool = Field(False, description="If true, pick card(s) randomly from source (for stealing)") + store_as: str | None = Field(None, description="Store moved card(s) in $args.{name}") + +class BranchOp(BaseModel): + op: Literal["branch"] = "branch" + condition: "Condition" = Field(..., description="Boolean condition tree") + if_true: list["Operation"] = Field(..., description="Sequence when true") + if_false: list["Operation"] = Field(default_factory=list, description="Sequence when false") + +# ... ~24 total op types + +Operation = Annotated[ + Union[MoveOp, ShuffleOp, DealOp, BranchOp, SetGlobalOp, ...], + Field(discriminator="op") +] +``` + +### Complete operation set (~24 ops) + +**Core ops (from Uno DSL):** + +- `spawn_from_manifest` -- create cards from deck manifest templates +- `shuffle` -- randomize a zone +- `deal` -- distribute cards to all players +- `move` -- move card(s) between zones (now with `random` flag for stealing) +- `move_all_except_top` -- recycle zone leaving top card +- `set_global` -- set a global variable +- `set_player_var` -- set a per-player variable +- `mutate_global` -- arithmetic mutation (add, negate, multiply) +- `branch` -- conditional if/else with nested sequences +- `advance_turn` -- move to next player (skips eliminated players) +- `transition_phase` -- change FSM phase +- `trigger_routine` -- call a named routine +- `prompt` -- ask player to choose from options (color pick, etc.) +- `game_over` -- end game with winner +- `log` -- debug/UI message + +**Broad card game ops (Poker, Rummy, Go Fish, War):** + +- `reveal` -- flip hidden cards face-up +- `evaluate_hand` -- rank a hand by rule set (poker_standard, rummy_sets_runs) +- `compare_hands` -- compare two hands and branch on winner +- `add_score` -- add/subtract points per player +- `collect_to_pot` / `award_pot` -- betting mechanics +- `for_each_player` -- iterate over all players with a sequence +- `check_group` -- test if cards form a valid set/run + +**Exploding Kittens ops (interrupts, targeting, elimination):** + +- `**peek**` -- view top N cards of a zone without moving them (See the Future) +- `**insert_at**` -- insert a card at a player-chosen position in a zone (Defuse mechanic) +- `**choose_player**` -- prompt player to select a target, store as `$args.{name}` (Favor, cat combos) +- `**eliminate_player**` -- remove player from game, skip in turn order (Exploding Kitten death) +- `**choose_from_zone**` -- pick a specific card from a visible zone (5-different cat combo picking from discard) + +### Exploding Kittens gap analysis -- why these ops are needed + +| Mechanic | DSL Gap | Solution | +| ----------------------------------- | -------------------------- | ---------------------------------------------------------------------- | +| **Nope (interrupt)** | No out-of-turn play | `reaction_window` on Action + `any_phase` flag | +| **See the Future** | No peek without moving | `peek` op | +| **Defuse + reinsert** | `move` always goes to top | `insert_at` op with `position: "player_choice"` | +| **Favor (give card)** | No targeting other players | `choose_player` op + `prompt` with `player: "$args.target"` | +| **Cat combo (steal)** | No random steal | `move` with `random: true` + `from: "$args.target.hand"` | +| **3-of-a-kind (named steal)** | No named card steal | `prompt` (name a card) + `branch` (has it?) + `move` | +| **5-different (pick from discard)** | No browsing a zone | `choose_from_zone` op | +| **Exploding Kitten (death)** | No player elimination | `eliminate_player` op | +| **Attack (multi-turn)** | No turn count modification | `turns_remaining` per-player var + `mutate_player_var` op | +| **Free play phase** | `max_per_turn: 1` forced | `max_per_turn: null` (nullable int) | +| **Multi-card combos** | Single-card actions only | `card_count` + `card_match_rule` on Action | +| **Draw triggers** | No on-draw event | `on_draw` trigger in Zone config or `branch` after `move` + `store_as` | + +### FSM / Action extensions for interrupt games + +```python +class ReactionWindow(BaseModel): + """After this action resolves, other players may play reaction cards.""" + eligible_card_type: str = Field(..., description="Card type that can react, e.g. 'nope'") + allows_chain: bool = Field(True, description="Can reactions be reacted to? (Nope the Nope)") + timeout_seconds: int | None = Field(None, description="Seconds before window closes. null=until all pass") + +class Action(BaseModel): + phase: str | list[str] = Field(..., description="Phase(s) when this action is available") + trigger: str = Field(..., description="UI trigger: click_card, click_zone, click_button, reaction") + source_zone: str | None = Field(None) + mutex_group: str | None = Field(None) + max_per_turn: int | None = Field(None, description="Max times per turn. null=unlimited (free play). 1=once.") + card_count: int = Field(1, description="Cards required for this action. 2 for two-of-a-kind combo, 3 for three-of-a-kind, 5 for five-different.") + card_match_rule: str | None = Field(None, description="'same_type' for matching combos, 'all_different' for 5-different combo, null for single card") + any_phase: bool = Field(False, description="If true, can be played outside normal turn order (interrupt/reaction cards like Nope)") + reaction_window: ReactionWindow | None = Field(None, description="If set, after this action, other players get a chance to react (e.g. play Nope)") + conditions: list["Condition"] = Field(default_factory=list) + sequence: list["Operation"] = Field(...) + ui_label: str | None = Field(None, description="Button label for click_button triggers") +``` + +### Zone extensions for draw triggers + +```python +class Zone(BaseModel): + behavior: str = Field(..., description="stack, hand_fan, spread, grid") + visibility: str = Field(..., description="hidden, top_only, owner_only, all") + per_player: bool = Field(False) + on_empty: OnEmpty | None = Field(None, description="What happens when zone is empty") + on_draw: list["Operation"] | None = Field(None, description="Sequence triggered after a card is drawn from this zone. Use $args.drawn_card to reference the drawn card. Example: check if drawn card is Exploding Kitten.") +``` + +### Condition tree (recursive, composable) + +```python +class LeafCondition(BaseModel): + op: Literal["eq", "neq", "gt", "lt", "gte", "lte"] + val1: str | int | float | bool | None = Field(..., description="Left operand. Use '$global.x', '$player.x', '$card.data.y', '$args.target.hand.count', etc.") + val2: str | int | float | bool | None = Field(..., description="Right operand") + +class CompoundCondition(BaseModel): + op: Literal["and", "or"] + args: list["Condition"] = Field(..., min_length=2) + +class NotCondition(BaseModel): + op: Literal["not"] + arg: "Condition" + +class HasMatchingCondition(BaseModel): + """Check if a zone has N cards matching a field value. Used for combo validation.""" + op: Literal["has_matching"] + zone: str = Field(..., description="Zone to search, e.g. '$player.hand'") + field: str = Field(..., description="Card data field to match on, e.g. 'data.type'") + count: int = Field(..., description="Minimum number of matching cards required") + +class PlayerAliveCondition(BaseModel): + """Check if a player is still in the game.""" + op: Literal["is_alive"] + player: str = Field(..., description="Player reference, e.g. '$current_player' or '$args.target'") + +Condition = Union[LeafCondition, CompoundCondition, NotCondition, HasMatchingCondition, PlayerAliveCondition] +``` + +### $-Reference extensions for Exploding Kittens + +New reference paths beyond what Uno uses: + +- `$args.target_player` -- player ref stored by `choose_player` +- `$args.target_player.hand` -- target player's hand zone (for steal moves) +- `$args.drawn_card` -- card just drawn (for on_draw triggers) +- `$args.drawn_card.data.type` -- type of drawn card (branch on "exploding_kitten") +- `$player.is_alive` -- elimination check +- `$player.turns_remaining` -- multi-turn tracking (Attack card) + +### Exploding Kittens DSL sketch (key fragments) + +Setup deals Defuse to each player, then inserts Exploding Kittens into deck: + +```json +{ + "setup": [ + { + "op": "spawn_from_manifest", + "manifest": "base_deck", + "dest": "draw_pile" + }, + { + "op": "deal", + "from": "draw_pile", + "count": 1, + "to": "all_players", + "filter": { "data.type": "defuse" } + }, + { "op": "deal", "from": "draw_pile", "count": 7, "to": "all_players" }, + { + "op": "spawn_from_manifest", + "manifest": "exploding_kittens", + "dest": "draw_pile" + }, + { "op": "shuffle", "zone": "draw_pile" } + ] +} +``` + +Draw triggers Exploding Kitten check: + +```json +{ + "draw_card": { + "phase": "must_draw", + "trigger": "click_zone", + "source_zone": "draw_pile", + "max_per_turn": 1, + "sequence": [ + { + "op": "move", + "from": "draw_pile", + "count": 1, + "to": "$player.hand", + "store_as": "drawn_card" + }, + { + "op": "branch", + "condition": { + "op": "eq", + "val1": "$args.drawn_card.data.type", + "val2": "exploding_kitten" + }, + "if_true": [ + { + "op": "branch", + "condition": { + "op": "has_matching", + "zone": "$player.hand", + "field": "data.type", + "count": 1 + }, + "if_true": [ + { + "op": "move", + "entity": "$player.hand.find(data.type=defuse)", + "to": "discard_pile" + }, + { + "op": "insert_at", + "entity": "$args.drawn_card", + "zone": "draw_pile", + "position": "player_choice" + } + ], + "if_false": [ + { + "op": "eliminate_player", + "player": "$current_player" + }, + { + "op": "move", + "entity": "$args.drawn_card", + "to": "discard_pile" + } + ] + } + ], + "if_false": [{ "op": "transition_phase", "to": "turn_end" }] + } + ] + } +} +``` + +Nope as an interrupt action: + +```json +{ + "play_nope": { + "any_phase": true, + "trigger": "reaction", + "source_zone": "player_hand", + "conditions": [ + { "op": "eq", "val1": "$card.data.type", "val2": "nope" } + ], + "reaction_window": { + "eligible_card_type": "nope", + "allows_chain": true + }, + "sequence": [ + { "op": "move", "entity": "$card", "to": "discard_pile" }, + { "op": "log", "message": "NOPED!" } + ] + } +} +``` + +Two-of-a-kind cat combo (steal random): + +```json +{ + "two_of_a_kind": { + "phase": "play_cards", + "trigger": "click_card", + "source_zone": "player_hand", + "card_count": 2, + "card_match_rule": "same_type", + "max_per_turn": null, + "reaction_window": { + "eligible_card_type": "nope", + "allows_chain": true + }, + "sequence": [ + { "op": "move", "entity": "$cards", "to": "discard_pile" }, + { + "op": "choose_player", + "player": "$current_player", + "message": "Steal from whom?", + "exclude_self": true, + "store_as": "target_player" + }, + { + "op": "move", + "from": "$args.target_player.hand", + "to": "$player.hand", + "count": 1, + "random": true + } + ] + } +} +``` + +### Cross-reference validators + +Add model-level validators on `GameSchema` that check semantic correctness -- zone names in operations actually exist in `zones`, variable keys in `set_global` exist in `variables.global`, etc. This catches errors that JSON Schema alone cannot: + +```python +class GameSchema(BaseModel): + # ... fields ... + + @model_validator(mode="after") + def validate_zone_references(self) -> "GameSchema": + zone_names = set(self.zones.keys()) + # Walk all operations in FSM routines and actions, + # check that zone refs like "draw_pile" exist in zone_names + ... + return self + + @model_validator(mode="after") + def validate_reaction_windows(self) -> "GameSchema": + # If any action has reaction_window, verify there exists + # an action with any_phase=True that can respond + ... + return self +``` + +--- + +## 2. Orchestration Endpoint and Sequence Diagram + +### Sequence diagram -- current flow (Stages 1-3, no human edit pause) + +This is what we implement now. The pipeline runs straight through. When Stage 1.5 is added later, the split happens at the boundary marked below. + +```mermaid +sequenceDiagram + participant Client + participant API as FastAPI Backend + participant PX as Perplexity Sonar + participant Codex as Codex 5.3 High + participant Val as Pydantic Validator + + Client->>API: POST /api/v1/generate {game_name} + API-->>Client: SSE stream opened + + Note over API: Stage 1 -- Research + API-->>Client: event: stage {stage: "research", status: "started"} + API->>PX: system + user prompt with game_name + PX-->>API: structured rules document + API-->>Client: event: stage {stage: "research", status: "done", data: rules} + + Note over API,Client: --- Stage 1.5 split point (future) --- + + Note over API: Stage 2a -- Game Plan + API-->>Client: event: stage {stage: "plan", status: "started"} + API->>Codex: PLAN_SYSTEM + PLAN_USER with rules + Codex-->>API: natural language game plan + API-->>Client: event: stage {stage: "plan", status: "done"} + + Note over API: Stage 2b -- DSL JSON + API-->>Client: event: stage {stage: "dsl", status: "started"} + API->>Codex: DSL_SYSTEM + DSL_USER with plan + JSON schema + uno example + Codex-->>API: raw JSON string + API-->>Client: event: stage {stage: "dsl", status: "done"} + + Note over API: Stage 3 -- Validate + Retry + API-->>Client: event: stage {stage: "validation", status: "started"} + API->>Val: GameSchema.model_validate_json(raw_json) + + alt Validation passes + Val-->>API: GameSchema instance + API-->>Client: event: stage {stage: "validation", status: "done"} + else Validation fails (up to 3 retries) + Val-->>API: ValidationError + API-->>Client: event: stage {stage: "validation", status: "retry", attempt: 1} + API->>Codex: RETRY prompt with validation errors + Codex-->>API: corrected JSON + API->>Val: re-validate + Val-->>API: GameSchema instance + API-->>Client: event: stage {stage: "validation", status: "done"} + end + + API-->>Client: event: result {dsl: GameSchema JSON} + API-->>Client: SSE stream closed +``` + +### Sequence diagram -- future split flow (with Stage 1.5 + Stage 4) + +When Stage 1.5 is implemented, the pipeline becomes two separate HTTP requests with a human pause in between. + +```mermaid +sequenceDiagram + participant Client + participant API as FastAPI Backend + participant PX as Perplexity Sonar + participant User as User in Browser + participant Codex as Codex 5.3 High + participant Val as Pydantic Validator + participant Claude as Claude -- FUTURE + + Note over Client,PX: Phase A -- Research + Client->>API: POST /api/v1/generate/research {game_name} + API->>PX: structured rules query + PX-->>API: rules document + API-->>Client: {session_id, rules} + + Note over Client,User: Stage 1.5 -- Human Edit + Client->>User: display rules in editable form + User->>Client: edits sections, adds house rules + Client->>Client: build RuleOverrides + + Note over Client,Val: Phase B -- Generate DSL + Client->>API: POST /api/v1/generate/build {session_id, rule_overrides?} + API-->>Client: SSE stream opened + API->>API: merge_rules(original, overrides) + API->>Codex: plan generation + Codex-->>API: game plan + API->>Codex: DSL JSON generation + Codex-->>API: raw JSON + API->>Val: validate + Val-->>API: GameSchema + API-->>Client: event: result {dsl} + + Note over Client,Claude: Stage 4 -- FUTURE + Client->>API: POST /api/v1/codegen {dsl_json, template_id} + API->>Claude: DSL + template webapp + Claude-->>API: modified game code + API-->>Client: game instance +``` + +### Orchestration endpoint -- request/response models + +File: `backend/app/routers/generate.py` + +The endpoint models are designed so the current all-in-one flow works today, and the split flow (Stage 1.5) can be added later by just adding two new routes that reuse the same orchestrator. + +```python +from pydantic import BaseModel, Field +from enum import Enum + + +# --- Current: all-in-one endpoint --- + +class GenerateRequest(BaseModel): + """All-in-one request. Runs Stages 1-3 back-to-back. + When Stage 1.5 is added, callers will use /research + /build instead.""" + game_name: str = Field(..., description="Name of the card game, e.g. 'Exploding Kittens'") + player_count: int | None = Field(None, description="Override player count for dynamic deck sizing") + # Future: accept pre-researched rules to skip Stage 1 + research_override: str | None = Field( + None, + description="If provided, skip Perplexity research and use this text as the rules document. " + "Enables Stage 1.5: user edits rules externally, then passes them in." + ) + + +class StageStatus(str, Enum): + started = "started" + done = "done" + retry = "retry" + error = "error" + +class StageEvent(BaseModel): + """SSE event sent during pipeline execution.""" + stage: str # "research" | "plan" | "dsl" | "validation" + status: StageStatus + attempt: int | None = None + detail: str | None = None + elapsed_ms: int | None = None + +class GenerateResult(BaseModel): + """Final SSE event with the validated DSL.""" + game_name: str + dsl: dict # The validated GameSchema as JSON + research_output: str | None = None # full rules text (for Stage 1.5 review) + stages_elapsed_ms: dict[str, int] + + +# --- Future: split endpoints for Stage 1.5 --- + +class ResearchRequest(BaseModel): + """Phase A: research only. Returns rules for user editing.""" + game_name: str + player_count: int | None = None + +class ResearchResult(BaseModel): + """Phase A response. Client displays this for user editing.""" + session_id: str = Field(..., description="Use this to continue in Phase B") + game_name: str + rules: str = Field(..., description="Structured rules document from Perplexity, in editable markdown sections") + elapsed_ms: int + +class RuleOverrides(BaseModel): + """User edits to apply before DSL generation.""" + sections: dict[str, str] = Field( + default_factory=dict, + description="Section name -> replacement text. Keys: 'DECK COMPOSITION', 'TURN STRUCTURE', etc." + ) + appended_rules: str | None = Field( + None, description="Free-text house rules appended as an extra section" + ) + removed_sections: list[str] = Field( + default_factory=list, + description="Section names to remove entirely" + ) + +class BuildRequest(BaseModel): + """Phase B: generate DSL from (possibly edited) rules.""" + session_id: str = Field(..., description="From ResearchResult") + rule_overrides: RuleOverrides | None = Field( + None, description="User edits. If None, use original research as-is." + ) + +# --- Future: Stage 4 code generation --- + +class CodegenRequest(BaseModel): + dsl_json: dict = Field(..., description="Validated GameSchema JSON from /generate") + template_id: str = Field("default", description="Which template webapp to use") + +class CodegenResult(BaseModel): + game_id: str + files_modified: list[str] + preview_url: str | None = None +``` + +### Orchestration endpoint -- SSE streaming implementation + +The endpoint accepts an optional `research_override` field. If provided, it skips Perplexity and uses the provided text as the rules document. This is what Stage 1.5 will use: the client calls `/research` first, lets the user edit, then calls `/generate` with `research_override` set to the edited text. + +```python +from fastapi import APIRouter +from fastapi.responses import StreamingResponse +import json, time + +router = APIRouter(prefix="/generate", tags=["generate"]) + +@router.post("") +async def generate_game(request: GenerateRequest): + """Generate a game DSL via SSE-streamed pipeline. + + If research_override is provided, skips Stage 1 (Perplexity) and uses + the provided rules text directly. This enables the future Stage 1.5 + flow where the user edits rules before generation. + """ + + async def event_stream(): + t0 = time.monotonic() + timings = {} + + # --- Stage 1: Research (skippable) --- + if request.research_override: + research = request.research_override + yield sse_event("stage", {"stage": "research", "status": "done", "detail": "using provided rules"}) + timings["research"] = 0 + else: + yield sse_event("stage", {"stage": "research", "status": "started"}) + t1 = time.monotonic() + research = await research_game(request.game_name) + timings["research"] = int((time.monotonic() - t1) * 1000) + yield sse_event("stage", { + "stage": "research", "status": "done", + "elapsed_ms": timings["research"], + }) + + # Emit research text so client can display/cache it for Stage 1.5 + yield sse_event("research_output", {"rules": research}) + + # --- Stage 2a: Game Plan --- + yield sse_event("stage", {"stage": "plan", "status": "started"}) + t2 = time.monotonic() + plan = await generate_game_plan(research) + timings["plan"] = int((time.monotonic() - t2) * 1000) + yield sse_event("stage", {"stage": "plan", "status": "done", "elapsed_ms": timings["plan"]}) + + # --- Stage 2b: DSL JSON --- + yield sse_event("stage", {"stage": "dsl", "status": "started"}) + t3 = time.monotonic() + json_schema = GameSchema.model_json_schema() + uno_example = load_example("uno.json") + raw_json = await generate_dsl_json(plan, json_schema, uno_example) + timings["dsl"] = int((time.monotonic() - t3) * 1000) + yield sse_event("stage", {"stage": "dsl", "status": "done", "elapsed_ms": timings["dsl"]}) + + # --- Stage 3: Validate + Retry --- + yield sse_event("stage", {"stage": "validation", "status": "started"}) + t4 = time.monotonic() + max_retries = settings.PIPELINE_MAX_RETRIES + schema = None + for attempt in range(max_retries): + try: + schema = GameSchema.model_validate_json(raw_json) + break + except ValidationError as e: + if attempt == max_retries - 1: + yield sse_event("stage", {"stage": "validation", "status": "error", "detail": str(e)}) + yield sse_event("error", {"message": f"Validation failed after {max_retries} retries"}) + return + yield sse_event("stage", {"stage": "validation", "status": "retry", "attempt": attempt + 1}) + raw_json = await retry_with_errors(raw_json, str(e)) + + timings["validation"] = int((time.monotonic() - t4) * 1000) + yield sse_event("stage", {"stage": "validation", "status": "done", "elapsed_ms": timings["validation"]}) + + # --- Final result --- + yield sse_event("result", { + "game_name": request.game_name, + "dsl": schema.model_dump(by_alias=True), + "research_output": research, + "stages_elapsed_ms": timings, + }) + + return StreamingResponse(event_stream(), media_type="text/event-stream") + + +def sse_event(event_type: str, data: dict) -> str: + return f"event: {event_type}\ndata: {json.dumps(data)}\n\n" +``` + +### Future split endpoints (Stage 1.5 -- not implemented now) + +When Stage 1.5 is added, the flow becomes two requests. These are stubs showing how they reuse the same orchestrator: + +```python +# Phase A: research only +@router.post("/research") +async def research_game_rules(request: ResearchRequest): + """Run Perplexity research, return rules for user editing.""" + t0 = time.monotonic() + research = await research_game(request.game_name) + session_id = str(uuid.uuid4()) + _sessions[session_id] = {"game_name": request.game_name, "rules": research} + return ResearchResult( + session_id=session_id, + game_name=request.game_name, + rules=research, + elapsed_ms=int((time.monotonic() - t0) * 1000), + ) + +# Phase B: generate from (edited) rules +@router.post("/build") +async def build_game_dsl(request: BuildRequest): + """Generate DSL from rules. Apply rule_overrides if provided.""" + session = _sessions.get(request.session_id) + if not session: + raise HTTPException(404, "Session not found") + rules = session["rules"] + if request.rule_overrides: + rules = merge_rules(rules, request.rule_overrides) + # Reuse the same endpoint by injecting research_override + gen_request = GenerateRequest( + game_name=session["game_name"], + research_override=rules, + ) + return await generate_game(gen_request) +``` + +### Polling fallback (for clients that cannot use SSE) + +```python +# In-memory job store (swap for Redis in production) +_jobs: dict[str, dict] = {} + +@router.post("/async") +async def generate_game_async(request: GenerateRequest): + """Start pipeline as background task, return job_id for polling.""" + job_id = str(uuid.uuid4()) + _jobs[job_id] = {"status": "running", "stage": "research", "result": None} + background_tasks.add_task(run_pipeline, job_id, request) + return {"job_id": job_id} + +@router.get("/{job_id}") +async def get_job_status(job_id: str): + """Poll pipeline progress.""" + job = _jobs.get(job_id) + if not job: + raise HTTPException(404, "Job not found") + return job +``` + +--- + +## 3. Prompt Templates + +### Stage 1: Perplexity Sonar -- Structured Rules Research + +The key here is asking Perplexity for output in **sections that map 1:1 to DSL sections**. This makes the LLM's job in Stage 2 much easier. Sections now include interrupt mechanics and combos. + +File: `backend/app/pipeline/prompts/research.py` + +```python +RESEARCH_SYSTEM = """You are a board game rules researcher. Extract COMPLETE, PRECISE rules. +Do NOT summarize or simplify. Include exact card counts, exact turn sequences, and all edge cases.""" + +RESEARCH_USER = """Research the card game "{game_name}" and provide COMPLETE rules in these exact sections: + +## DECK COMPOSITION +- Every distinct card type +- Exact count of each type +- Card properties (suit, color, rank, value, special attributes) +- Cards with dynamic count based on player number (e.g. Exploding Kittens = players - 1) + +## GAME ZONES +- All areas where cards exist (draw pile, discard pile, hands, melds, community cards, pot, etc.) +- How many cards are visible in each zone and to whom +- What happens when a zone is empty + +## TURN STRUCTURE +- List every phase of a turn in order +- What the active player MUST do and what they MAY do in each phase +- Can multiple cards be played per turn? Is there a mandatory action (e.g. must draw)? + +## CARD EFFECTS +- What happens when each card type is played/revealed +- Special interactions between card types +- Cards that trigger on DRAW rather than on PLAY + +## COMBO / MULTI-CARD PLAYS +- Can multiple cards be played together as a combo? +- What combinations are valid (pairs, triples, N-of-a-kind, N-different)? +- What does each combo do? + +## INTERRUPT / REACTION MECHANICS +- Can any card be played OUT OF TURN to cancel or counter another card? +- Can interrupts be chained (e.g. counter the counter)? +- What is the timing window for reactions? + +## PLAYER TARGETING +- Do any cards require choosing another player as a target? +- What happens when a player is targeted? (steal, give, reveal, etc.) + +## PLAYER ELIMINATION +- Can players be eliminated mid-game? +- What triggers elimination? +- How does the game handle eliminated players (turn order, win condition)? + +## WIN CONDITION +- How the game ends +- How the winner is determined (last alive, highest score, empty hand, etc.) +- Scoring rules if applicable + +## SPECIAL MECHANICS +- Betting, asking for cards, challenging, slapping, melding, etc. +- Penalty rules +- Any mechanics involving inserting cards at a specific position in a deck + +Be exhaustive. Missing a rule means the game will be broken.""" +``` + +### Stage 2: DSL Generation via Codex 5.3 High -- Two-Phase Prompting + +This is the most critical prompt. The **two-phase approach** dramatically improves generalization. Both phases use **OpenAI Codex 5.3 High** for its strong structured output and JSON schema adherence. + +**Phase 2a**: Generate a "game plan" in natural language first (what zones, what phases, what variables). This forces Codex to reason about game structure before committing to JSON. + +**Phase 2b**: Generate the actual DSL JSON from the plan, constrained by the Pydantic JSON Schema passed as `response_format`. + +File: `backend/app/pipeline/prompts/generation.py` + +```python +# Model: OpenAI Codex 5.3 High (via OpenAI API) +# Use response_format={"type": "json_schema", "json_schema": {...}} for Phase 2b + +PLAN_SYSTEM = """You are a game architect designing a data-driven card game engine. +Given researched rules, produce a DESIGN PLAN listing every zone, variable, phase, action, and card effect needed. +Think step by step. Be exhaustive.""" + +PLAN_USER = """Design a game engine plan for the following card game. + +## Researched Rules +{research_output} + +Produce a plan with these sections: +1. ZONES: name, behavior (stack/hand_fan/spread/grid), visibility, on_draw triggers +2. VARIABLES: global vars and per-player vars with types and defaults + - Include turns_remaining (for attack/extra-turn mechanics) and is_alive (for elimination games) if needed +3. DECK MANIFEST: all card templates with template_vars +4. TURN PHASES: ordered list (include free-play phase if players can play multiple cards) +5. ROUTINES: what happens in each phase (pseudocode using move, branch, set_global, etc.) +6. ACTIONS: what the player can do, with conditions + - For each action: can it be interrupted? (reaction_window) + - For each action: is it a multi-card combo? (card_count + card_match_rule) + - For each action: can it be played out of turn? (any_phase) +7. CARD EFFECTS: what each special card does post-play +8. INTERRUPT FLOW: if the game has interrupt/reaction cards, describe the resolution stack""" + +DSL_SYSTEM = """You are a JSON code generator. Convert the game plan into a valid game DSL JSON document. + +## $-Reference Conventions +- $global.{key} -- read a global variable +- $player.{key} -- read current player's variable +- $card -- the card being acted on (single card actions) +- $cards -- the cards being played (multi-card combo actions) +- $card.data.{field} -- a property of that card +- $zone.{name}.top -- top card of a zone +- $zone.{name}.top.data.{field} -- property of top card +- $player.hand -- current player's hand zone +- $player.hand.count -- card count in hand +- $current_player -- reference to the acting player +- $args.{key} -- value stored earlier via store_as +- $args.target_player -- player chosen by choose_player op +- $args.target_player.hand -- target player's hand zone +- $args.drawn_card -- card stored after a move with store_as +- $args.drawn_card.data.{field} -- property of stored card + +## Action Fields +- any_phase: true -- card can be played outside normal turn (Nope-style interrupts) +- reaction_window: {...} -- after this action, other players can react +- card_count: N -- how many cards this action plays (2 for pairs, 3 for triples) +- card_match_rule: "same_type" | "all_different" | null +- max_per_turn: null -- unlimited plays per turn (free play phase) + +Output ONLY valid JSON. No markdown, no explanation.""" + +DSL_USER = """## JSON Schema (your output MUST conform to this) +{json_schema} + +## Complete Working Example (Uno) +{uno_example} + +## Game Design Plan +{game_plan} + +Generate the complete game DSL JSON for this game.""" +``` + +### Stage 3: Retry with Validation Errors (Codex 5.3 High) + +When Pydantic rejects the output, feed the exact errors back to Codex: + +```python +RETRY_USER = """The JSON you generated failed validation with these errors: + +{validation_errors} + +Fix ONLY the errors above. Keep everything else the same. Output the complete corrected JSON.""" +``` + +### Stage 4: Claude Code Generation (FUTURE -- not implemented now) + +This stage will use Claude to transform the validated DSL into a working game instance by modifying the template webapp. Prompt sketch: + +```python +CODEGEN_SYSTEM = """You are modifying a templated multiplayer card game webapp. +The template already has WebSocket infrastructure, generic card/zone/player UI components, +and a game state management shell. Your job is to wire everything up according to the DSL. + +Do NOT rewrite the WebSocket layer or generic components. Only modify game-specific files.""" + +CODEGEN_USER = """## Game DSL (validated schema) +{dsl_json} + +## Template files you may modify +{template_file_listing} + +## Key template source files +{template_sources} + +Wire up the game: +1. Populate deck from deck_manifest +2. Layout zones according to zones config +3. Implement FSM turn phases and routines as state transitions +4. Bind actions to UI event handlers +5. Implement card_effects as post-play hooks +6. Handle reaction_windows as interrupt UI flows (pause, show reaction prompt to other players) +7. Connect all state mutations to WebSocket broadcast + +Output the modified files.""" +``` + +--- + +## 4. Pipeline Orchestrator Implementation + +File: `backend/app/pipeline/orchestrator.py` + +```python +import json +from typing import Callable +from pydantic import ValidationError + +from app.llm import get_model, generate_text_sync +from app.schemas.game import GameSchema +from app.pipeline.prompts.research import RESEARCH_SYSTEM, RESEARCH_USER +from app.pipeline.prompts.generation import ( + PLAN_SYSTEM, PLAN_USER, DSL_SYSTEM, DSL_USER, RETRY_USER, +) + + +def _load_example(name: str) -> str: + """Load a reference DSL example from backend/app/examples/.""" + import pathlib + path = pathlib.Path(__file__).parent.parent / "examples" / name + return path.read_text() + + +async def generate_game_dsl( + game_name: str, + research: str | None = None, + max_retries: int = 3, + on_stage: Callable | None = None, +) -> GameSchema: + """Full pipeline: research -> plan -> DSL -> validate. + + Args: + game_name: Name of the card game. + research: Pre-researched rules text. If provided, skips Stage 1 + (Perplexity). This is the hook for Stage 1.5: the caller + runs research separately, lets the user edit, then passes + the edited text here. + max_retries: Pydantic validation retry attempts. + on_stage: Callback for progress events (SSE streaming). + + Providers: + - Perplexity Sonar for Stage 1 (web search) -- skipped if research is provided + - OpenAI Codex 5.3 High for Stages 2-3 (generation + retry) + """ + + codex = get_model("openai", "codex-5.3-high") + + # --- Stage 1: Research via Perplexity Sonar (skippable) --- + if research is None: + perplexity = get_model("perplexity", "sonar") + if on_stage: await on_stage("research", "started") + research = generate_text_sync( + perplexity, + prompt=RESEARCH_USER.format(game_name=game_name), + system=RESEARCH_SYSTEM, + ).text + if on_stage: await on_stage("research", "done") + else: + if on_stage: await on_stage("research", "done") # skipped, emit done immediately + + # --- Stage 2a: Game plan (natural language reasoning) --- + if on_stage: await on_stage("plan", "started") + plan = generate_text_sync( + codex, + prompt=PLAN_USER.format(research_output=research), + system=PLAN_SYSTEM, + ).text + if on_stage: await on_stage("plan", "done") + + # --- Stage 2b: DSL JSON (structured output) --- + if on_stage: await on_stage("dsl", "started") + json_schema = GameSchema.model_json_schema() + uno_example = _load_example("uno.json") + raw_json = generate_text_sync( + codex, + prompt=DSL_USER.format( + json_schema=json.dumps(json_schema, indent=2), + uno_example=uno_example, + game_plan=plan, + ), + system=DSL_SYSTEM, + response_format={"type": "json_schema", "json_schema": json_schema}, + ).text + if on_stage: await on_stage("dsl", "done") + + # --- Stage 3: Validate + retry loop --- + if on_stage: await on_stage("validation", "started") + for attempt in range(max_retries): + try: + schema = GameSchema.model_validate_json(raw_json) + if on_stage: await on_stage("validation", "done") + return schema + except ValidationError as e: + if attempt == max_retries - 1: + raise + if on_stage: await on_stage("validation", "retry", attempt=attempt + 1) + raw_json = generate_text_sync( + codex, + prompt=RETRY_USER.format(validation_errors=str(e)), + system=DSL_SYSTEM, + response_format={"type": "json_schema", "json_schema": json_schema}, + ).text + + # Should not reach here, but satisfy type checker + raise RuntimeError("Unreachable") +``` + +### Stage 4 hook (future -- not implemented now) + +The orchestrator is designed so Stage 4 can be appended without changing Stages 1-3: + +```python +async def generate_game_full(game_name: str) -> dict: + """Full pipeline including code generation (future Stage 4).""" + # Stages 1-3: produce validated DSL + schema = await generate_game_dsl(game_name) + + # Stage 4 (future) -- Claude code generation against template webapp + # claude = get_model("anthropic", "claude-sonnet-4-20250514") + # template_files = load_template_webapp_sources() + # modified_code = generate_text_sync( + # claude, + # prompt=CODEGEN_USER.format( + # dsl_json=schema.model_dump_json(indent=2, by_alias=True), + # template_file_listing=list_template_files(), + # template_sources=template_files, + # ), + # system=CODEGEN_SYSTEM, + # ).text + # return {"dsl": schema, "code": modified_code} + + return {"dsl": schema} +``` + +### Why two-phase generation matters for generalization + +When you ask Codex to go directly from "Exploding Kittens rules" to a 500-line JSON blob, it often: + +- Forgets zones it needs +- Gets the FSM phase order wrong +- Misses edge-case card effects +- Omits the reaction window for Nope cards +- Forgets that draw triggers need to check for Exploding Kittens + +The plan phase forces it to explicitly enumerate all components first. The JSON phase then has a concrete blueprint to follow. This is analogous to chain-of-thought prompting but structured for schema generation. + +--- + +## 5. Files to Create/Modify + +New files: + +- `[backend/app/schemas/research.py](backend/app/schemas/research.py)` -- ResearchedRules, CardTypeResearch, ZoneResearch, TurnPhaseResearch, SpecialMechanic, CardEffectResearch (structured Stage 1 output that maps to frontend form controls) +- `[backend/app/schemas/primitives.py](backend/app/schemas/primitives.py)` -- ValueRef, Condition (6 types including HasMatchingCondition, PlayerAliveCondition), PlayerCount +- `[backend/app/schemas/operations.py](backend/app/schemas/operations.py)` -- ~24 operation models + Operation discriminated union (including peek, insert_at, choose_player, eliminate_player, choose_from_zone) +- `[backend/app/schemas/components.py](backend/app/schemas/components.py)` -- Zone (with on_draw trigger), DeckManifestEntry, CardEffect, Variables +- `[backend/app/schemas/actions.py](backend/app/schemas/actions.py)` -- Action (with reaction_window, card_count, card_match_rule, any_phase), ReactionWindow, FSM +- `[backend/app/schemas/game.py](backend/app/schemas/game.py)` -- GameSchema with cross-ref validators (including reaction window consistency check) +- `[backend/app/schemas/__init__.py](backend/app/schemas/__init__.py)` -- Re-export GameSchema +- `[backend/app/pipeline/__init__.py](backend/app/pipeline/__init__.py)` +- `[backend/app/pipeline/prompts/__init__.py](backend/app/pipeline/prompts/__init__.py)` +- `[backend/app/pipeline/prompts/research.py](backend/app/pipeline/prompts/research.py)` -- Perplexity prompt templates (with interrupt, combo, elimination sections) +- `[backend/app/pipeline/prompts/parse_research.py](backend/app/pipeline/prompts/parse_research.py)` -- Codex prompt to parse Perplexity markdown into ResearchedRules JSON +- `[backend/app/pipeline/prompts/generation.py](backend/app/pipeline/prompts/generation.py)` -- DSL generation prompts (with $-reference docs for targeting and reactions) +- `[backend/app/pipeline/research.py](backend/app/pipeline/research.py)` -- Perplexity research step +- `[backend/app/pipeline/generate.py](backend/app/pipeline/generate.py)` -- DSL generation + retry step +- `[backend/app/pipeline/orchestrator.py](backend/app/pipeline/orchestrator.py)` -- Full pipeline +- `[backend/app/routers/generate.py](backend/app/routers/generate.py)` -- POST /api/v1/generate endpoint +- `[backend/app/examples/exploding_kittens.json](backend/app/examples/exploding_kittens.json)` -- Second reference example for the LLM (demonstrates interrupts, combos, elimination) + +Modified files: + +- `[backend/app/main.py](backend/app/main.py)` -- Register generate router +- `[backend/app/config.py](backend/app/config.py)` -- Add pipeline config (PIPELINE_MAX_RETRIES, DEFAULT_CODEX_MODEL="codex-5.3-high") +- `[backend/requirements.txt](backend/requirements.txt)` -- Already has pydantic, httpx, openai; may need `sse-starlette` for SSE support diff --git a/.cursor/plans/old_dsl_schema_and_pipeline_c03fc426.plan.md b/.cursor/plans/old_dsl_schema_and_pipeline_c03fc426.plan.md new file mode 100644 index 0000000..18fe592 --- /dev/null +++ b/.cursor/plans/old_dsl_schema_and_pipeline_c03fc426.plan.md @@ -0,0 +1,316 @@ +--- +name: DSL Schema and Pipeline +overview: Design Pydantic schemas for the card game DSL, build prompt templates for Perplexity research and DSL generation, and wire up an orchestration endpoint that chains the full pipeline with validation-loop retries. +todos: + - id: pydantic-schema + content: "Build the full Pydantic schema in schemas/__init__.py: GameDSL root model, all sub-models, and the Operation discriminated union covering ~18 op types" + status: pending + - id: validate-uno + content: Validate that uno.json passes the new Pydantic schema (fix any mismatches) + status: pending + - id: prompt-templates + content: "Create prompt templates in backend/app/prompts/: research.py (Perplexity), generate.py (DSL generation + op vocab), retry.py (error feedback)" + status: pending + - id: generate-endpoint + content: Build the /generate orchestration endpoint in routers/generate.py with the research -> generate -> validate retry loop + status: pending + - id: register-router + content: Register the generate router in main.py + status: pending + - id: ek-example + content: Hand-craft an Exploding Kittens DSL example as a second few-shot reference (exercises react_window, peek, insert_at) + status: pending +isProject: false +--- + +# Card Game DSL: Pydantic Schema, Prompts, and Pipeline + +## 1. Why the current DSL needs to grow + +The `uno.json` DSL is already expressive, but Exploding Kittens and Monopoly Deal require operations that don't exist yet: + +| Game | Missing capability | New ops needed | +| ---- | ------------------ | -------------- | + +- **Exploding Kittens**: Interrupt/reaction system (Nope), peek at deck (See the Future), insert card at arbitrary position (Defuse), card combos (2-of-a-kind steal) -- needs `peek`, `insert_at`, `target_player`, `react_window` +- **Monopoly Deal**: Multiple per-player zones (hand, bank, properties), targeted theft (Sly Deal, Deal Breaker), payment/debt mechanics, set-completion win condition -- needs `target_player`, `transfer`, `check_sets`, richer win conditions + +## 2. Pydantic schema design ([backend/app/schemas/**init**.py](backend/app/schemas/__init__.py)) + +Use **discriminated unions** on the `op` field so each operation type has its own validation, and `model_json_schema()` exports a clean vocabulary for the LLM. + +### Key models (sketch) + +```python +# -- Meta -- +class PlayerCount(BaseModel): + min: int = Field(ge=2) + max: int = Field(ge=2) + +class GameMeta(BaseModel): + game_name: str + version: str = "1.0" + player_count: PlayerCount + win_condition: Literal[ + "first_empty_hand", # Uno + "last_player_standing", # Exploding Kittens + "collection_target", # Monopoly Deal (3 complete sets) + "most_points", # generic scoring + ] + win_params: dict[str, Any] | None = None # e.g. {"sets_needed": 3} + +# -- Assets -- +class CardTemplate(BaseModel): + id: str + template_vars: dict[str, list[str | int]] | None = None + data: dict[str, Any] + img: str + per_combination: int = Field(ge=1) + +class Assets(BaseModel): + deck_manifest: list[CardTemplate] + +# -- Zones -- +class ZoneOnEmpty(BaseModel): + sequence: list["Operation"] + +class Zone(BaseModel): + behavior: Literal["stack", "hand_fan", "spread", "grid"] + visibility: Literal["hidden", "top_only", "owner_only", "public"] + per_player: bool = False + on_empty: ZoneOnEmpty | None = None + +# -- Variables -- +class VarDef(BaseModel): + type: Literal["number", "string", "boolean"] + default: Any + +class Variables(BaseModel): + global_vars: dict[str, VarDef] = Field(alias="global") + per_player: dict[str, VarDef] = {} + +# -- Operations (discriminated union) -- +class MoveOp(BaseModel): + op: Literal["move"] + entity: str | None = None # "$card" reference + from_: str | None = Field(None, alias="from") + to: str + count: int | str | None = None + store_as: str | None = None + +class ShuffleOp(BaseModel): + op: Literal["shuffle"] + zone: str + +class BranchOp(BaseModel): + op: Literal["branch"] + condition: dict[str, Any] # nested condition tree + if_true: list["Operation"] + if_false: list["Operation"] = [] + +class PeekOp(BaseModel): + op: Literal["peek"] + zone: str + count: int + player: str # who sees them + store_as: str | None = None + +class InsertAtOp(BaseModel): + op: Literal["insert_at"] + entity: str + zone: str + position: str | int # "top", "bottom", or player-chosen index + +class TargetPlayerOp(BaseModel): + op: Literal["target_player"] + player: str + exclude_self: bool = True + store_as: str + +class ReactWindowOp(BaseModel): + op: Literal["react_window"] + eligible: str # "$all_other_players" + card_match: dict # e.g. {"data.type": "nope"} + timeout_ms: int = 10000 + on_react: list["Operation"] + on_timeout: list["Operation"] + +class PromptOp(BaseModel): + op: Literal["prompt"] + player: str + kind: Literal["choose_option", "choose_card", "choose_position"] + options: list[str] | None = None + message: str + store_as: str + +# ... (15-20 total op types) + +Operation = Annotated[ + Union[MoveOp, ShuffleOp, BranchOp, PeekOp, InsertAtOp, ...], + Field(discriminator="op") +] +``` + +### Design principles + +- **Discriminated union on `op**`: Pydantic validates each operation's specific fields; `model_json_schema()`emits a`oneOf`with`op` as discriminator -- this becomes the LLM's vocabulary. +- `**$`-prefixed references (e.g. `$card`, `$player.hand`, `$global.stack_penalty`): Kept as strings; validated at runtime by the game engine, not Pydantic. Pydantic just checks structural correctness. +- `**win_condition` as enum + `win_params` dict: Generalizes across "empty hand" (Uno), "survival" (EK), and "collection" (Monopoly Deal) without an explosion of fields. +- `**react_window` op: The key generalization for interrupt-style mechanics (Nope, Just Say No). It opens a timed window for other players to respond. + +## 3. Prompt templates + +### 3a. Perplexity Sonar -- structured rules extraction + +Store at `backend/app/prompts/research.py` or as a string constant. + +``` +You are a board game rules researcher. Find the complete official rules for "{game_name}". + +Extract the following in structured sections: + +**CARD_TYPES** +For each distinct card type: name, quantity in deck, any variants (colors/numbers), and exact effect when played. + +**SETUP** +Step-by-step setup: deck preparation, cards dealt per player, initial zone layout. + +**TURN_STRUCTURE** +Phases of a turn. Actions available in each phase. Whether a player MUST or MAY take each action. + +**REACTIONS** +Any cards playable out-of-turn (interrupts, counters). Timing rules. + +**WIN_CONDITION** +Exact condition(s) to win. Any elimination rules. + +**SPECIAL_RULES** +Penalties, challenges, stacking, card combos, and any edge cases. + +Be exhaustive with card counts and precise about effect wording. +``` + +**Why this works for generalization**: By extracting into fixed sections that map 1:1 to DSL blocks (CARD_TYPES -> `deck_manifest`, SETUP -> `routines.setup`, TURN_STRUCTURE -> `fsm`, REACTIONS -> `react_window` ops), the downstream DSL generation prompt can systematically map each section. + +### 3b. DSL generation prompt + +``` +You are a card game DSL compiler. Convert the game rules below into a JSON document that conforms EXACTLY to the provided JSON Schema. + +## RULES +{perplexity_output} + +## JSON SCHEMA (your output MUST validate against this) +{json_schema} + +## OPERATION REFERENCE +{op_vocabulary_with_descriptions} + +## EXAMPLE (Uno) +{uno_json} + +## INSTRUCTIONS +1. Every card type in CARD_TYPES -> a deck_manifest entry. Use template_vars for parameterized cards (colors x values). +2. Every play area -> a zone. Use per_player: true for player-specific zones. +3. SETUP steps -> the "setup" routine, using only ops from the vocabulary. +4. Each TURN_STRUCTURE phase -> an FSM routine. Actions -> the "actions" block with conditions and sequences. +5. REACTIONS -> use react_window ops to open interrupt windows where needed. +6. WIN_CONDITION -> meta.win_condition + win_params, and a game_over op in the appropriate sequence. +7. SPECIAL_RULES -> card_effects entries and/or branch ops in routines. + +Output ONLY the JSON object. No markdown fences, no commentary. +``` + +### 3c. Validation error retry prompt + +``` +The JSON you produced failed schema validation: + +{validation_errors} + +Fix ONLY the structural issues listed above. Do not change game logic unnecessarily. Output the corrected JSON only. +``` + +### Key prompting techniques for generalization + +- **Schema-as-prompt**: `GameDSL.model_json_schema()` is injected directly. The LLM sees every field, type constraint, and enum value. +- **Operation vocabulary reference**: A separate human-readable list of each op with a 1-line description and parameter table. This is more digestible than raw JSON Schema for the `Operation` union. +- **Sectioned extraction -> sectioned mapping**: The Perplexity prompt and DSL prompt use the same section names, creating a clear mapping the LLM can follow. +- **Few-shot example**: `uno.json` shows the target format concretely. Adding a second example (e.g. Exploding Kittens) for games with reactions would significantly improve generalization. +- **Retry loop with typed errors**: Pydantic `ValidationError` produces precise, field-level error messages that are highly actionable for the LLM. + +## 4. Orchestration endpoint + +New file: [backend/app/routers/generate.py](backend/app/routers/generate.py) + +```mermaid +sequenceDiagram + participant Client + participant FastAPI as GenerateEndpoint + participant Perplexity as PerplexitySonar + participant LLM as AnthropicClaude + participant Pydantic as PydanticValidator + + Client->>FastAPI: POST /api/v1/generate {game_name} + FastAPI->>Perplexity: Research prompt + Perplexity-->>FastAPI: Structured rules text + FastAPI->>LLM: DSL generation prompt + rules + schema + LLM-->>FastAPI: Raw JSON string + FastAPI->>Pydantic: GameDSL.model_validate_json(raw) + alt Validation passes + Pydantic-->>FastAPI: GameDSL instance + FastAPI-->>Client: 200 {dsl: ...} + else Validation fails + Pydantic-->>FastAPI: ValidationError + FastAPI->>LLM: Retry prompt + errors + LLM-->>FastAPI: Fixed JSON + FastAPI->>Pydantic: Re-validate (up to 3 retries) + end +``` + +### Endpoint structure + +```python +class GenerateRequest(BaseModel): + game_name: str + rules_text: str | None = None # optional pre-provided rules + +class GenerateResponse(BaseModel): + dsl: GameDSL + research_text: str # the Perplexity output, for debugging + +@router.post("/generate", response_model=GenerateResponse) +async def generate_dsl(req: GenerateRequest): + # 1. Research + rules = req.rules_text or await research_game(req.game_name) + + # 2. Generate + validate loop + schema = GameDSL.model_json_schema() + dsl = await generate_and_validate(rules, schema, max_retries=3) + + return GenerateResponse(dsl=dsl, research_text=rules) +``` + +### The validation loop (`generate_and_validate`) + +```python +async def generate_and_validate(rules: str, schema: dict, max_retries: int = 3) -> GameDSL: + raw = await call_llm(build_generation_prompt(rules, schema)) + + for attempt in range(max_retries): + try: + return GameDSL.model_validate_json(raw) + except ValidationError as e: + errors = e.json() + raw = await call_llm(build_retry_prompt(raw, errors)) + + raise HTTPException(422, "Failed to generate valid DSL after retries") +``` + +## 5. File plan + +- **[backend/app/schemas/init.py](backend/app/schemas/__init__.py)** -- All Pydantic models: `GameDSL`, `GameMeta`, `Assets`, `Zone`, `Variables`, `CardEffect`, `FSM`, and the `Operation` discriminated union (roughly 300-400 lines) +- **[backend/app/prompts/](backend/app/prompts/)** (new directory) -- `research.py` (Perplexity prompt template), `generate.py` (DSL generation prompt template + op vocabulary reference), `retry.py` (validation error retry prompt) +- **[backend/app/routers/generate.py](backend/app/routers/generate.py)** (new file) -- `/generate` endpoint with the research -> generate -> validate pipeline +- **[backend/app/main.py](backend/app/main.py)** -- Register the new router +- **[backend/app/examples/exploding_kittens.json](backend/app/examples/exploding_kittens.json)** (new file) -- Second few-shot example to improve generalization for reaction/interrupt games diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9aff995 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.DS_Store +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +.env.development +.env.test +.env.production +.env.development.local +.env.test.local +.env.production.local \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..d507445 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,21 @@ +# Boardify Backend – Environment Variables +# Copy to .env and fill in values + +# App +APP_NAME=Boardify API +DEBUG=false + +# CORS (comma-separated origins) +CORS_ORIGINS=http://localhost:3000 + +# LLM API Keys (only required for providers you use) +# OPENAI_API_KEY=sk-... +# ANTHROPIC_API_KEY=sk-ant-... +# GOOGLE_GENERATIVE_AI_API_KEY=... +# PERPLEXITY_API_KEY=pplx-... + +# Optional: override default models +# DEFAULT_OPENAI_MODEL=gpt-4o-mini +# DEFAULT_ANTHROPIC_MODEL=claude-3-5-haiku-20241022 +# DEFAULT_GEMINI_MODEL=gemini-1.5-flash +# DEFAULT_PERPLEXITY_MODEL=sonar diff --git a/backend/app/config.py b/backend/app/config.py index 31aed5e..aa4e9ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,8 +8,25 @@ class Settings(BaseSettings): DEBUG: bool = False API_V1_PREFIX: str = "/api/v1" - # CORS - CORS_ORIGINS: list[str] = ["http://localhost:3000"] + # CORS (comma-separated in .env, e.g. CORS_ORIGINS=http://localhost:3000,http://localhost:3001) + CORS_ORIGINS: str = "http://localhost:3000" + + @property + def cors_origins_list(self) -> list[str]: + """Parse CORS_ORIGINS comma-separated string into a list.""" + return [x.strip() for x in self.CORS_ORIGINS.split(",") if x.strip()] + + # LLM API Keys (set in .env; only required for providers you use) + OPENAI_API_KEY: str | None = None + ANTHROPIC_API_KEY: str | None = None + GOOGLE_GENERATIVE_AI_API_KEY: str | None = None + PERPLEXITY_API_KEY: str | None = None + + # Default model per provider (optional overrides) + DEFAULT_OPENAI_MODEL: str = "gpt-4o-mini" + DEFAULT_ANTHROPIC_MODEL: str = "claude-3-5-haiku-20241022" + DEFAULT_GEMINI_MODEL: str = "gemini-1.5-flash" + DEFAULT_PERPLEXITY_MODEL: str = "sonar" model_config = {"env_file": ".env", "extra": "ignore"} diff --git a/backend/app/examples/uno.json b/backend/app/examples/uno.json new file mode 100644 index 0000000..d92e1b1 --- /dev/null +++ b/backend/app/examples/uno.json @@ -0,0 +1,424 @@ +{ + "meta": { + "game_name": "Uno", + "version": "2.0", + "player_count": { "min": 2, "max": 10 }, + "win_condition": "first_empty_hand" + }, + + "assets": { + "deck_manifest": [ + { + "id": "{color}_0", + "template_vars": { + "color": ["red", "blue", "green", "yellow"] + }, + "data": { "color": "{color}", "type": "number", "value": 0 }, + "img": "{color}_0.png", + "per_combination": 1 + }, + { + "id": "{color}_{value}", + "template_vars": { + "color": ["red", "blue", "green", "yellow"], + "value": [1, 2, 3, 4, 5, 6, 7, 8, 9] + }, + "data": { + "color": "{color}", + "type": "number", + "value": "{value}" + }, + "img": "{color}_{value}.png", + "per_combination": 2 + }, + { + "id": "{color}_skip", + "template_vars": { + "color": ["red", "blue", "green", "yellow"] + }, + "data": { "color": "{color}", "type": "skip", "value": null }, + "img": "{color}_skip.png", + "per_combination": 2 + }, + { + "id": "{color}_reverse", + "template_vars": { + "color": ["red", "blue", "green", "yellow"] + }, + "data": { + "color": "{color}", + "type": "reverse", + "value": null + }, + "img": "{color}_reverse.png", + "per_combination": 2 + }, + { + "id": "{color}_draw2", + "template_vars": { + "color": ["red", "blue", "green", "yellow"] + }, + "data": { + "color": "{color}", + "type": "draw_two", + "value": null + }, + "img": "{color}_draw2.png", + "per_combination": 2 + }, + { + "id": "wild", + "data": { "color": "any", "type": "wild", "value": null }, + "img": "wild.png", + "per_combination": 4 + }, + { + "id": "wild_draw4", + "data": { + "color": "any", + "type": "wild_draw_four", + "value": null + }, + "img": "wild_draw4.png", + "per_combination": 4 + } + ] + }, + + "zones": { + "draw_pile": { + "behavior": "stack", + "visibility": "hidden", + "on_empty": { + "sequence": [ + { + "op": "move_all_except_top", + "from": "discard_pile", + "to": "draw_pile" + }, + { "op": "shuffle", "zone": "draw_pile" } + ] + } + }, + "discard_pile": { + "behavior": "stack", + "visibility": "top_only" + }, + "player_hand": { + "behavior": "hand_fan", + "visibility": "owner_only", + "per_player": true + } + }, + + "variables": { + "global": { + "turn_index": { "type": "number", "default": 0 }, + "play_direction": { "type": "number", "default": 1 }, + "current_color": { "type": "string", "default": null }, + "stack_penalty": { "type": "number", "default": 0 } + }, + "per_player": { + "called_uno": { "type": "boolean", "default": false }, + "has_acted": { "type": "boolean", "default": false } + } + }, + + "card_effects": [ + { + "match": { "data.type": "skip" }, + "sequence": [{ "op": "advance_turn", "skip": 1 }] + }, + { + "match": { "data.type": "reverse" }, + "sequence": [ + { + "op": "mutate_global", + "key": "play_direction", + "mutation": "negate" + } + ] + }, + { + "match": { "data.type": "draw_two" }, + "sequence": [ + { + "op": "mutate_global", + "key": "stack_penalty", + "mutation": "add", + "operand": 2 + }, + { "op": "advance_turn", "skip": 1 } + ] + }, + { + "match": { "data.type": "wild_draw_four" }, + "sequence": [ + { + "op": "mutate_global", + "key": "stack_penalty", + "mutation": "add", + "operand": 4 + }, + { "op": "advance_turn", "skip": 1 } + ] + } + ], + + "fsm": { + "turn_phases": [ + "turn_start", + "await_action", + "resolve_effects", + "turn_end" + ], + + "routines": { + "setup": [ + { + "op": "spawn_from_manifest", + "manifest": "deck_manifest", + "dest": "draw_pile" + }, + { "op": "shuffle", "zone": "draw_pile" }, + { + "op": "deal", + "from": "draw_pile", + "count": 7, + "to": "all_players" + }, + { + "op": "move", + "from": "draw_pile", + "count": 1, + "to": "discard_pile" + }, + { + "op": "set_global", + "key": "current_color", + "value": "$zone.discard_pile.top.data.color" + }, + { "op": "transition_phase", "to": "turn_start" } + ], + + "turn_start": [ + { "op": "set_player_var", "key": "has_acted", "value": false }, + { "op": "set_player_var", "key": "called_uno", "value": false }, + { + "op": "branch", + "condition": { + "op": "gt", + "val1": "$global.stack_penalty", + "val2": 0 + }, + "if_true": [ + { + "op": "move", + "from": "draw_pile", + "count": "$global.stack_penalty", + "to": "$player.hand" + }, + { + "op": "set_global", + "key": "stack_penalty", + "value": 0 + }, + { "op": "transition_phase", "to": "turn_end" } + ], + "if_false": [ + { "op": "transition_phase", "to": "await_action" } + ] + } + ], + + "resolve_effects": [ + { + "op": "trigger_routine", + "name": "_eval_card_effects", + "args": { "card": "$card" } + }, + { "op": "transition_phase", "to": "turn_end" } + ], + + "turn_end": [ + { + "op": "branch", + "condition": { + "op": "and", + "args": [ + { + "op": "eq", + "val1": "$player.hand.count", + "val2": 1 + }, + { + "op": "eq", + "val1": "$player.called_uno", + "val2": false + } + ] + }, + "if_true": [ + { + "op": "log", + "message": "Uno penalty: player did not call Uno" + }, + { + "op": "move", + "from": "draw_pile", + "count": 2, + "to": "$player.hand" + } + ], + "if_false": [] + }, + { "op": "advance_turn" }, + { "op": "transition_phase", "to": "turn_start" } + ] + }, + + "actions": { + "play_card": { + "phase": "await_action", + "trigger": "click_card", + "source_zone": "player_hand", + "mutex_group": "play_or_draw", + "max_per_turn": 1, + "conditions": [ + { + "op": "or", + "args": [ + { + "op": "eq", + "val1": "$card.data.color", + "val2": "$global.current_color" + }, + { + "op": "eq", + "val1": "$card.data.color", + "val2": "any" + }, + { + "op": "and", + "args": [ + { + "op": "neq", + "val1": "$card.data.value", + "val2": null + }, + { + "op": "eq", + "val1": "$card.data.value", + "val2": "$zone.discard_pile.top.data.value" + } + ] + }, + { + "op": "eq", + "val1": "$card.data.type", + "val2": "$zone.discard_pile.top.data.type" + } + ] + } + ], + "sequence": [ + { "op": "move", "entity": "$card", "to": "discard_pile" }, + { + "op": "branch", + "condition": { + "op": "eq", + "val1": "$card.data.color", + "val2": "any" + }, + "if_true": [ + { + "op": "prompt", + "player": "$current_player", + "kind": "choose_option", + "options": ["red", "blue", "green", "yellow"], + "message": "Choose a color", + "store_as": "chosen_color" + }, + { + "op": "set_global", + "key": "current_color", + "value": "$args.chosen_color" + } + ], + "if_false": [ + { + "op": "set_global", + "key": "current_color", + "value": "$card.data.color" + } + ] + }, + { + "op": "branch", + "condition": { + "op": "eq", + "val1": "$player.hand.count", + "val2": 0 + }, + "if_true": [ + { "op": "game_over", "winner": "$current_player" } + ], + "if_false": [] + }, + { + "op": "set_player_var", + "key": "has_acted", + "value": true + }, + { "op": "transition_phase", "to": "resolve_effects" } + ] + }, + + "draw_card": { + "phase": "await_action", + "trigger": "click_zone", + "source_zone": "draw_pile", + "mutex_group": "play_or_draw", + "max_per_turn": 1, + "conditions": [], + "sequence": [ + { + "op": "move", + "from": "draw_pile", + "count": 1, + "to": "$player.hand", + "store_as": "drawn_card" + }, + { + "op": "set_player_var", + "key": "has_acted", + "value": true + }, + { "op": "transition_phase", "to": "turn_end" } + ] + }, + + "call_uno": { + "phase": "await_action", + "trigger": "click_button", + "ui_label": "UNO!", + "mutex_group": null, + "max_per_turn": 1, + "conditions": [ + { + "op": "eq", + "val1": "$player.hand.count", + "val2": 2 + } + ], + "sequence": [ + { + "op": "set_player_var", + "key": "called_uno", + "value": true + } + ] + } + } + } +} diff --git a/backend/app/llm/__init__.py b/backend/app/llm/__init__.py new file mode 100644 index 0000000..cbdaff5 --- /dev/null +++ b/backend/app/llm/__init__.py @@ -0,0 +1,24 @@ +"""LLM connections for various providers. + +Provides a unified interface to OpenAI, Anthropic, and Google Gemini +using the official Python SDKs. +""" + +from app.llm.providers import ( + get_model, + get_openai_model, + get_anthropic_model, + get_gemini_model, + get_perplexity_model, +) +from app.llm.client import generate_text_sync, stream_text_async + +__all__ = [ + "get_model", + "get_openai_model", + "get_anthropic_model", + "get_gemini_model", + "get_perplexity_model", + "generate_text_sync", + "stream_text_async", +] diff --git a/backend/app/llm/_anthropic.py b/backend/app/llm/_anthropic.py new file mode 100644 index 0000000..8b5e0bf --- /dev/null +++ b/backend/app/llm/_anthropic.py @@ -0,0 +1,67 @@ +"""Anthropic provider using official anthropic Python SDK.""" + +from dataclasses import dataclass + +from app.config import settings + + +@dataclass +class AnthropicModel: + model_id: str + provider: str = "anthropic" + + def generate(self, prompt: str, system: str | None = None, **kwargs) -> "GenerateResult": + """Generate text synchronously.""" + from anthropic import Anthropic + + client = Anthropic(api_key=settings.ANTHROPIC_API_KEY) + create_kwargs = {"model": self.model_id, "max_tokens": 4096, **kwargs} + if system: + create_kwargs["system"] = system + create_kwargs["messages"] = [{"role": "user", "content": prompt}] + + resp = client.messages.create(**create_kwargs) + text = "" + if resp.content: + for block in resp.content: + if hasattr(block, "text"): + text += block.text + + usage = resp.usage + return GenerateResult( + text=text, + finish_reason=getattr(resp.stop_reason, "value", str(resp.stop_reason)) if resp.stop_reason else "end_turn", + usage=Usage( + prompt_tokens=usage.input_tokens, + completion_tokens=usage.output_tokens, + total_tokens=usage.input_tokens + usage.output_tokens, + ), + ) + + async def stream(self, prompt: str, system: str | None = None, **kwargs): + """Stream text asynchronously.""" + from anthropic import AsyncAnthropic + + client = AsyncAnthropic(api_key=settings.ANTHROPIC_API_KEY) + create_kwargs = {"model": self.model_id, "max_tokens": 4096, **kwargs} + if system: + create_kwargs["system"] = system + create_kwargs["messages"] = [{"role": "user", "content": prompt}] + + async with client.messages.stream(**create_kwargs) as stream: + async for text_event in stream.text_stream: + yield text_event + + +@dataclass +class Usage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class GenerateResult: + text: str + finish_reason: str + usage: Usage diff --git a/backend/app/llm/_gemini.py b/backend/app/llm/_gemini.py new file mode 100644 index 0000000..dff5a55 --- /dev/null +++ b/backend/app/llm/_gemini.py @@ -0,0 +1,79 @@ +"""Google Gemini provider using official google-generativeai Python SDK.""" + +import asyncio +from dataclasses import dataclass + +from app.config import settings + + +@dataclass +class GeminiModel: + model_id: str + provider: str = "gemini" + + def generate(self, prompt: str, system: str | None = None, **kwargs) -> "GenerateResult": + """Generate text synchronously.""" + import google.generativeai as genai + + genai.configure(api_key=settings.GOOGLE_GENERATIVE_AI_API_KEY) + model = genai.GenerativeModel(self.model_id) + + full_prompt = prompt + if system: + full_prompt = f"{system}\n\n{prompt}" + + resp = model.generate_content(full_prompt, **kwargs) + text = resp.text if resp.text else "" + + usage = getattr(resp, "usage_metadata", None) + return GenerateResult( + text=text, + finish_reason=getattr(resp.candidates[0], "finish_reason", "stop") if resp.candidates else "stop", + usage=Usage( + prompt_tokens=usage.prompt_token_count if usage else 0, + completion_tokens=usage.candidates_token_count if usage else 0, + total_tokens=(usage.prompt_token_count + usage.candidates_token_count) if usage else 0, + ), + ) + + async def stream(self, prompt: str, system: str | None = None, **kwargs): + """Stream text asynchronously (runs sync API in executor).""" + import google.generativeai as genai + + genai.configure(api_key=settings.GOOGLE_GENERATIVE_AI_API_KEY) + model = genai.GenerativeModel(self.model_id) + + full_prompt = prompt + if system: + full_prompt = f"{system}\n\n{prompt}" + + def _sync_stream(): + resp = model.generate_content(full_prompt, stream=True, **kwargs) + for chunk in resp: + if chunk.text: + yield chunk.text + + loop = asyncio.get_event_loop() + gen = _sync_stream() + while True: + try: + chunk = await loop.run_in_executor(None, lambda g=gen: next(g, StopIteration)) + except StopIteration: + break + if chunk is StopIteration: + break + yield chunk + + +@dataclass +class Usage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class GenerateResult: + text: str + finish_reason: str + usage: Usage diff --git a/backend/app/llm/_openai.py b/backend/app/llm/_openai.py new file mode 100644 index 0000000..ac98dc5 --- /dev/null +++ b/backend/app/llm/_openai.py @@ -0,0 +1,72 @@ +"""OpenAI provider using official openai Python SDK.""" + +from dataclasses import dataclass + +from app.config import settings + + +@dataclass +class OpenAIModel: + model_id: str + provider: str = "openai" + + def generate(self, prompt: str, system: str | None = None, **kwargs) -> "GenerateResult": + """Generate text synchronously.""" + from openai import OpenAI + + client = OpenAI(api_key=settings.OPENAI_API_KEY) + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + resp = client.chat.completions.create( + model=self.model_id, + messages=messages, + **kwargs, + ) + choice = resp.choices[0] + usage = resp.usage + return GenerateResult( + text=choice.message.content or "", + finish_reason=choice.finish_reason or "stop", + usage=Usage( + prompt_tokens=usage.prompt_tokens if usage else 0, + completion_tokens=usage.completion_tokens if usage else 0, + total_tokens=usage.total_tokens if usage else 0, + ), + ) + + async def stream(self, prompt: str, system: str | None = None, **kwargs): + """Stream text asynchronously.""" + from openai import AsyncOpenAI + + client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + stream = await client.chat.completions.create( + model=self.model_id, + messages=messages, + stream=True, + **kwargs, + ) + async for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + + +@dataclass +class Usage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class GenerateResult: + text: str + finish_reason: str + usage: Usage diff --git a/backend/app/llm/_perplexity.py b/backend/app/llm/_perplexity.py new file mode 100644 index 0000000..4d98eef --- /dev/null +++ b/backend/app/llm/_perplexity.py @@ -0,0 +1,81 @@ +"""Perplexity Sonar provider using OpenAI-compatible API.""" + +from dataclasses import dataclass + +from app.config import settings + + +PERPLEXITY_BASE_URL = "https://api.perplexity.ai" + + +@dataclass +class PerplexityModel: + model_id: str + provider: str = "perplexity" + + def generate(self, prompt: str, system: str | None = None, **kwargs) -> "GenerateResult": + """Generate text synchronously.""" + from openai import OpenAI + + client = OpenAI( + api_key=settings.PERPLEXITY_API_KEY, + base_url=PERPLEXITY_BASE_URL, + ) + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + resp = client.chat.completions.create( + model=self.model_id, + messages=messages, + **kwargs, + ) + choice = resp.choices[0] + usage = resp.usage + return GenerateResult( + text=choice.message.content or "", + finish_reason=choice.finish_reason or "stop", + usage=Usage( + prompt_tokens=usage.prompt_tokens if usage else 0, + completion_tokens=usage.completion_tokens if usage else 0, + total_tokens=usage.total_tokens if usage else 0, + ), + ) + + async def stream(self, prompt: str, system: str | None = None, **kwargs): + """Stream text asynchronously.""" + from openai import AsyncOpenAI + + client = AsyncOpenAI( + api_key=settings.PERPLEXITY_API_KEY, + base_url=PERPLEXITY_BASE_URL, + ) + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + stream = await client.chat.completions.create( + model=self.model_id, + messages=messages, + stream=True, + **kwargs, + ) + async for chunk in stream: + if chunk.choices and chunk.choices[0].delta.content: + yield chunk.choices[0].delta.content + + +@dataclass +class Usage: + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +@dataclass +class GenerateResult: + text: str + finish_reason: str + usage: Usage diff --git a/backend/app/llm/client.py b/backend/app/llm/client.py new file mode 100644 index 0000000..50d2af4 --- /dev/null +++ b/backend/app/llm/client.py @@ -0,0 +1,35 @@ +"""Convenience wrappers for LLM text generation. + +Use these for a consistent API across providers. +""" + + +def generate_text_sync(model, prompt: str, system: str | None = None, **kwargs): + """Generate text synchronously. + + Args: + model: Model from get_model() or get_openai_model(), etc. + prompt: User prompt + system: Optional system message + **kwargs: Passed to model.generate() (e.g. max_tokens) + + Returns: + Result with .text, .usage, .finish_reason + """ + return model.generate(prompt=prompt, system=system, **kwargs) + + +async def stream_text_async(model, prompt: str, system: str | None = None, **kwargs): + """Stream text asynchronously. + + Args: + model: Model from get_model() or provider-specific getter + prompt: User prompt + system: Optional system message + **kwargs: Passed to model.stream() + + Yields: + Text chunks from the model + """ + async for chunk in model.stream(prompt=prompt, system=system, **kwargs): + yield chunk diff --git a/backend/app/llm/providers.py b/backend/app/llm/providers.py new file mode 100644 index 0000000..8944905 --- /dev/null +++ b/backend/app/llm/providers.py @@ -0,0 +1,91 @@ +"""LLM provider connections using official Python SDKs. + +Provides a unified interface compatible with AI SDK patterns. +Each provider reads API keys from environment variables (via app.config.settings). +""" + +from typing import Protocol + +from app.config import settings + + +class LLMModel(Protocol): + """Protocol for model-like objects returned by get_*_model.""" + + provider: str + model_id: str + + +def get_openai_model(model_id: str | None = None) -> "OpenAIModel": + """Get an OpenAI model instance. + + Requires OPENAI_API_KEY in environment. + """ + from app.llm._openai import OpenAIModel + + model = model_id or settings.DEFAULT_OPENAI_MODEL + return OpenAIModel(model_id=model) + + +def get_anthropic_model(model_id: str | None = None) -> "AnthropicModel": + """Get an Anthropic Claude model instance. + + Requires ANTHROPIC_API_KEY in environment. + """ + from app.llm._anthropic import AnthropicModel + + model = model_id or settings.DEFAULT_ANTHROPIC_MODEL + return AnthropicModel(model_id=model) + + +def get_gemini_model(model_id: str | None = None) -> "GeminiModel": + """Get a Google Gemini model instance. + + Requires GOOGLE_GENERATIVE_AI_API_KEY in environment. + """ + from app.llm._gemini import GeminiModel + + model = model_id or settings.DEFAULT_GEMINI_MODEL + return GeminiModel(model_id=model) + + +def get_perplexity_model(model_id: str | None = None) -> "PerplexityModel": + """Get a Perplexity Sonar model instance. + + Requires PERPLEXITY_API_KEY in environment. + Uses OpenAI-compatible API at api.perplexity.ai. + Models: sonar, sonar-pro, sonar-pro-search, etc. + """ + from app.llm._perplexity import PerplexityModel + + model = model_id or settings.DEFAULT_PERPLEXITY_MODEL + return PerplexityModel(model_id=model) + + +_PROVIDER_REGISTRY = { + "openai": get_openai_model, + "anthropic": get_anthropic_model, + "gemini": get_gemini_model, + "perplexity": get_perplexity_model, +} + + +def get_model(provider: str, model_id: str | None = None) -> LLMModel: + """Get a model instance by provider name. + + Args: + provider: One of "openai", "anthropic", "gemini", "perplexity" + model_id: Optional model override (e.g. "gpt-4o", "claude-3-opus-20240229") + + Returns: + Model instance with .generate() and .stream() methods + + Raises: + ValueError: If provider is not supported + """ + provider = provider.lower() + if provider not in _PROVIDER_REGISTRY: + raise ValueError( + f"Unknown provider: {provider}. Supported: {list(_PROVIDER_REGISTRY.keys())}" + ) + return _PROVIDER_REGISTRY[provider](model_id) diff --git a/backend/app/main.py b/backend/app/main.py index aebb1eb..5930206 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,7 +2,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routers import health +from app.routers import health, chat app = FastAPI( title=settings.APP_NAME, @@ -13,7 +13,7 @@ # CORS middleware – allow the Next.js frontend to call the API app.add_middleware( CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, + allow_origins=settings.cors_origins_list, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -21,3 +21,4 @@ # Routers app.include_router(health.router, prefix=settings.API_V1_PREFIX) +app.include_router(chat.router, prefix=settings.API_V1_PREFIX) diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py new file mode 100644 index 0000000..aab283f --- /dev/null +++ b/backend/app/routers/chat.py @@ -0,0 +1,57 @@ +"""Example chat router using LLM connections.""" + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from app.config import settings +from app.llm import get_model, generate_text_sync + +router = APIRouter(prefix="/chat", tags=["chat"]) + + +class ChatRequest(BaseModel): + prompt: str + provider: str = "openai" + model_id: str | None = None + + +class ChatResponse(BaseModel): + text: str + provider: str + model: str + + +@router.post("", response_model=ChatResponse) +def chat(request: ChatRequest): + """Generate text using the specified LLM provider.""" + provider = request.provider.lower() + if provider not in ("openai", "anthropic", "gemini", "perplexity"): + raise HTTPException(400, f"Unknown provider: {provider}") + + # Check API key is configured + key_map = { + "openai": settings.OPENAI_API_KEY, + "anthropic": settings.ANTHROPIC_API_KEY, + "gemini": settings.GOOGLE_GENERATIVE_AI_API_KEY, + "perplexity": settings.PERPLEXITY_API_KEY, + } + if not key_map.get(provider): + raise HTTPException( + 503, + f"Provider {provider} is not configured. Set the API key in .env", + ) + + model = get_model(provider, request.model_id) + result = generate_text_sync(model, prompt=request.prompt) + + default_models = { + "openai": settings.DEFAULT_OPENAI_MODEL, + "anthropic": settings.DEFAULT_ANTHROPIC_MODEL, + "gemini": settings.DEFAULT_GEMINI_MODEL, + "perplexity": settings.DEFAULT_PERPLEXITY_MODEL, + } + return ChatResponse( + text=result.text, + provider=provider, + model=request.model_id or default_models[provider], + ) diff --git a/backend/requirements.txt b/backend/requirements.txt index a0f8511..4c04c7b 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,6 @@ pydantic>=2.10.0 pydantic-settings>=2.7.0 python-dotenv>=1.0.0 httpx>=0.28.0 +openai>=1.0.0 +anthropic>=0.39.0 +google-generativeai>=0.8.0 diff --git a/boardify/package-lock.json b/boardify/package-lock.json index b2a0554..59d1d85 100644 --- a/boardify/package-lock.json +++ b/boardify/package-lock.json @@ -1,910 +1,910 @@ { - "name": "boardify", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "boardify", - "version": "0.1.0", - "dependencies": { - "@t3-oss/env-nextjs": "^0.12.0", - "next": "^15.2.3", - "react": "^19.0.0", - "react-dom": "^19.0.0", - "zod": "^3.24.2" - }, - "devDependencies": { - "@biomejs/biome": "^2.2.5", - "@tailwindcss/postcss": "^4.0.15", - "@types/node": "^20.14.10", - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "postcss": "^8.5.3", - "tailwindcss": "^4.0.15", - "typescript": "^5.8.2" - } - }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@biomejs/biome": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz", - "integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==", - "dev": true, - "license": "MIT OR Apache-2.0", - "bin": { - "biome": "bin/biome" - }, - "engines": { - "node": ">=14.21.3" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/biome" - }, - "optionalDependencies": { - "@biomejs/cli-darwin-arm64": "2.3.15", - "@biomejs/cli-darwin-x64": "2.3.15", - "@biomejs/cli-linux-arm64": "2.3.15", - "@biomejs/cli-linux-arm64-musl": "2.3.15", - "@biomejs/cli-linux-x64": "2.3.15", - "@biomejs/cli-linux-x64-musl": "2.3.15", - "@biomejs/cli-win32-arm64": "2.3.15", - "@biomejs/cli-win32-x64": "2.3.15" - } - }, - "node_modules/@biomejs/cli-darwin-arm64": { - "version": "2.3.15", - "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", - "integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT OR Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=14.21.3" - } - }, - "node_modules/@img/colour": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", - "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", - "license": "MIT", - "optional": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@next/env": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", - "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", - "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@swc/helpers": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", - "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.8.0" - } - }, - "node_modules/@t3-oss/env-core": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.12.0.tgz", - "integrity": "sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==", - "license": "MIT", - "peerDependencies": { - "typescript": ">=5.0.0", - "valibot": "^1.0.0-beta.7 || ^1.0.0", - "zod": "^3.24.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@t3-oss/env-nextjs": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.12.0.tgz", - "integrity": "sha512-rFnvYk1049RnNVUPvY8iQ55AuQh1Rr+qZzQBh3t++RttCGK4COpXGNxS4+45afuQq02lu+QAOy/5955aU8hRKw==", - "license": "MIT", - "dependencies": { - "@t3-oss/env-core": "0.12.0" - }, - "peerDependencies": { - "typescript": ">=5.0.0", - "valibot": "^1.0.0-beta.7 || ^1.0.0", - "zod": "^3.24.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - }, - "valibot": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.1", - "lightningcss": "1.30.2", - "magic-string": "^0.30.21", - "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - }, - "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" - } - }, - "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" - } - }, - "node_modules/@types/node": { - "version": "20.19.33", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", - "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001769", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", - "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/client-only": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", - "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", - "license": "MIT" - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.19.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", - "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, - "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" - } - }, - "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/next": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", - "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", - "license": "MIT", - "dependencies": { - "@next/env": "15.5.12", - "@swc/helpers": "0.5.15", - "caniuse-lite": "^1.0.30001579", - "postcss": "8.4.31", - "styled-jsx": "5.1.6" - }, - "bin": { - "next": "dist/bin/next" - }, - "engines": { - "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" - }, - "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.12", - "@next/swc-darwin-x64": "15.5.12", - "@next/swc-linux-arm64-gnu": "15.5.12", - "@next/swc-linux-arm64-musl": "15.5.12", - "@next/swc-linux-x64-gnu": "15.5.12", - "@next/swc-linux-x64-musl": "15.5.12", - "@next/swc-win32-arm64-msvc": "15.5.12", - "@next/swc-win32-x64-msvc": "15.5.12", - "sharp": "^0.34.3" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.51.1", - "babel-plugin-react-compiler": "*", - "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", - "sass": "^1.3.0" - }, - "peerDependenciesMeta": { - "@opentelemetry/api": { - "optional": true - }, - "@playwright/test": { - "optional": true - }, - "babel-plugin-react-compiler": { - "optional": true - }, - "sass": { - "optional": true - } - } - }, - "node_modules/next/node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.6", - "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.11", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "optional": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/styled-jsx": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", - "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", - "license": "MIT", - "dependencies": { - "client-only": "0.0.1" - }, - "engines": { - "node": ">= 12.0.0" - }, - "peerDependencies": { - "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", - "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", - "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", - "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", - "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", - "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", - "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", - "cpu": [ - "arm64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.12", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", - "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", - "cpu": [ - "x64" - ], - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - } - } + "name": "boardify", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "boardify", + "version": "0.1.0", + "dependencies": { + "@t3-oss/env-nextjs": "^0.12.0", + "next": "^15.2.3", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.2.5", + "@tailwindcss/postcss": "^4.0.15", + "@types/node": "^20.14.10", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.0.15", + "typescript": "^5.8.2" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@biomejs/biome": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.3.15.tgz", + "integrity": "sha512-u+jlPBAU2B45LDkjjNNYpc1PvqrM/co4loNommS9/sl9oSxsAQKsNZejYuUztvToB5oXi1tN/e62iNd6ESiY3g==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.3.15", + "@biomejs/cli-darwin-x64": "2.3.15", + "@biomejs/cli-linux-arm64": "2.3.15", + "@biomejs/cli-linux-arm64-musl": "2.3.15", + "@biomejs/cli-linux-x64": "2.3.15", + "@biomejs/cli-linux-x64-musl": "2.3.15", + "@biomejs/cli-win32-arm64": "2.3.15", + "@biomejs/cli-win32-x64": "2.3.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.3.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.3.15.tgz", + "integrity": "sha512-SDCdrJ4COim1r8SNHg19oqT50JfkI/xGZHSyC6mGzMfKrpNe/217Eq6y98XhNTc0vGWDjznSDNXdUc6Kg24jbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@next/env": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", + "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", + "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@t3-oss/env-core": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@t3-oss/env-core/-/env-core-0.12.0.tgz", + "integrity": "sha512-lOPj8d9nJJTt81mMuN9GMk8x5veOt7q9m11OSnCBJhwp1QrL/qR+M8Y467ULBSm9SunosryWNbmQQbgoiMgcdw==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@t3-oss/env-nextjs": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@t3-oss/env-nextjs/-/env-nextjs-0.12.0.tgz", + "integrity": "sha512-rFnvYk1049RnNVUPvY8iQ55AuQh1Rr+qZzQBh3t++RttCGK4COpXGNxS4+45afuQq02lu+QAOy/5955aU8hRKw==", + "license": "MIT", + "dependencies": { + "@t3-oss/env-core": "0.12.0" + }, + "peerDependencies": { + "typescript": ">=5.0.0", + "valibot": "^1.0.0-beta.7 || ^1.0.0", + "zod": "^3.24.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", + "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "postcss": "^8.4.41", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", + "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", + "license": "MIT", + "dependencies": { + "@next/env": "15.5.12", + "@swc/helpers": "0.5.15", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "15.5.12", + "@next/swc-darwin-x64": "15.5.12", + "@next/swc-linux-arm64-gnu": "15.5.12", + "@next/swc-linux-arm64-musl": "15.5.12", + "@next/swc-linux-x64-gnu": "15.5.12", + "@next/swc-linux-x64-musl": "15.5.12", + "@next/swc-win32-arm64-msvc": "15.5.12", + "@next/swc-win32-x64-msvc": "15.5.12", + "sharp": "^0.34.3" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } }