init
This commit is contained in:
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.js
|
||||
|
||||
# Production build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
*.log
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
!.vscode/settings.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
121
README.md
Normal file
121
README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Quartiles — Daily Word Fragment Puzzle Game
|
||||
|
||||
A sleek, Apple News+ inspired webapp clone of the addictive word puzzle game **Quartiles**. Combine letter tiles into words, discover the 5 hidden 4-tile Quartiles, claim the massive 40-point completion bonus, and climb the ranks from Novice to Expert!
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
## 🎮 How to Play
|
||||
|
||||
1. **The Board**: You are presented with a **4×5 grid of 20 letter tiles**. Each tile contains a 2 to 4 letter fragment (morpheme, syllable, or root).
|
||||
2. **Build Words**: Tap 1, 2, 3, or 4 tiles in sequence to construct valid English words.
|
||||
3. **Submit**: Tap the **✓ ENTER** button (or press `Enter` on your keyboard) to validate.
|
||||
4. **Tile Reusability**: Tiles stay on the board and can be reused to form as many different words as possible!
|
||||
|
||||
### 🏆 Points System
|
||||
|
||||
| Tile Count | Type | Points |
|
||||
| :---: | :---: | :---: |
|
||||
| **1 Tile** | Single-fragment word | **1 pt** |
|
||||
| **2 Tiles** | Compound word | **2 pts** |
|
||||
| **3 Tiles** | Multi-part word | **4 pts** |
|
||||
| **4 Tiles** | **"Quartile"** (Target word) | **8 pts** |
|
||||
|
||||
### ✨ The 40-Point Quartiles Bonus
|
||||
Every puzzle contains **5 target 4-tile Quartiles**. Finding all 5 target Quartiles awards an instant **40-point completion bonus** with celebratory confetti!
|
||||
|
||||
### 📈 Ranks & Tiers
|
||||
- **Novice**: 0 – 24 pts
|
||||
- **Apprentice**: 25 – 49 pts
|
||||
- **Builder**: 50 – 69 pts
|
||||
- **Artisan**: 70 – 84 pts
|
||||
- **Wordsmith**: 85 – 99 pts
|
||||
- **Expert**: **100+ pts** *(The primary daily milestone!)*
|
||||
- **Perfect**: 100% of all constructible words found on the board!
|
||||
|
||||
---
|
||||
|
||||
## 🌟 Key Features
|
||||
|
||||
- **Apple News+ Inspired Aesthetics**: Crisp typography, frosted glass (`backdrop-filter: blur`), curated color palettes, tactile tile presses, and smooth animations.
|
||||
- **Dark Mode & Light Mode**: Seamless toggle matching iOS system styling.
|
||||
- **Auto-Lock Solved Quartiles**: Toggle locking to automatically pin found 4-tile Quartiles in neat rows at the top of the grid with padlock badges, while allowing remaining tiles to shuffle freely.
|
||||
- **Shuffle & Sort A-Z**: Reorder unlocked tiles to spot new combinations or sort them alphabetically.
|
||||
- **Interactive Words Found Drawer**: Filter solved words by tile count (*All, Quartiles, 3-Tiles, 2-Tiles, 1-Tile*), view point values, and tile fragment breakdowns.
|
||||
- **Progressive Hint System**: Contextual hints guiding you to unfound Quartiles and words without spoiling the puzzle.
|
||||
- **Daily Calendar & Archives**: Over 30 curated daily and themed challenge puzzles (*Cosmos, Urban Pulse, Wild Kingdom, Mind & Memory, etc.*).
|
||||
- **Unlimited Random Mode**: Instant on-the-fly puzzle generator yielding infinite unique playable boards.
|
||||
- **Custom Puzzle Creator & Sharing**: Create your own puzzle using 5 custom four-tile words and share with friends via URL hash.
|
||||
- **Tactile Web Audio Feedback**: Realistic acoustic clicks, pops, harmonious chords, and celebratory fanfare built using the native Web Audio API (0 external sound files needed).
|
||||
- **Canvas Confetti**: High-performance canvas particle system for Quartile and Expert victories.
|
||||
- **Keyboard Shortcuts**:
|
||||
- `Enter`: Submit constructed word
|
||||
- `Backspace`: Remove last tile from tray
|
||||
- `Escape`: Clear active tray
|
||||
- `Spacebar`: Shuffle board tiles
|
||||
- **Progress Persistence**: Automatically saves your game progress, score, and solved words in `localStorage`.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18+)
|
||||
- npm
|
||||
|
||||
### Installation & Local Run
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Open your browser at http://localhost:5173
|
||||
```
|
||||
|
||||
### Production Build
|
||||
```bash
|
||||
npm run build
|
||||
npm run preview
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
quartiles/
|
||||
├── index.html # App shell with SEO and viewport configuration
|
||||
├── package.json # Scripts and dependencies (Vite)
|
||||
├── src/
|
||||
│ ├── main.js # App bootstrap, event wiring, keyboard handlers
|
||||
│ ├── styles/
|
||||
│ │ ├── main.css # Design tokens, variables, typography, reset
|
||||
│ │ ├── board.css # 4x5 grid layout, tactile tiles, active builder
|
||||
│ │ └── components.css # Score bar, action buttons, drawer, modals
|
||||
│ ├── components/
|
||||
│ │ ├── Header.js # Brand title, date badge, utility controls
|
||||
│ │ ├── ScoreRankBar.js # Score, rank progression, 5-segment Quartiles tracker
|
||||
│ │ ├── WordBuilder.js # Construction tray with drag & drop reorder
|
||||
│ │ ├── TileGrid.js # 20-tile responsive board with locked rows
|
||||
│ │ ├── ActionBar.js # Shuffle, Sort A-Z, Lock Quartiles, Hint
|
||||
│ │ ├── WordsDrawer.js # Categorized found words bottom sheet
|
||||
│ │ ├── Modals.js # How to Play, Calendar, Builder, Stats, Settings
|
||||
│ │ └── Toast.js # Spring floating notifications
|
||||
│ ├── data/
|
||||
│ │ ├── puzzles.js # Pre-solved daily and themed puzzles database
|
||||
│ │ └── lexicon.js # SCOWL recognized English vocabulary set
|
||||
│ └── engine/
|
||||
│ ├── solver.js # Tree-pruning word solver and rank calculator
|
||||
│ ├── generator.js # Unlimited and custom puzzle generator & URL encoder
|
||||
│ ├── audio.js # Web Audio API acoustic synthesizer
|
||||
│ └── confetti.js # Canvas particle confetti engine
|
||||
└── scripts/
|
||||
├── solver.mjs # Node solver script
|
||||
├── build-all-data.mjs # Curated puzzle builder
|
||||
└── build-client-lexicon.mjs
|
||||
```
|
||||
34
index.html
Normal file
34
index.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Quartiles — Daily Word Puzzle Game</title>
|
||||
<meta name="description" content="Play Quartiles, the addictive daily word fragment puzzle game. Combine letter tiles into words, chase the five 4-tile Quartiles, and score 100 points for Expert rank!" />
|
||||
|
||||
<!-- PWA & Theme Colors -->
|
||||
<meta name="theme-color" content="#1c1c1e" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Quartiles" />
|
||||
<meta name="application-name" content="Quartiles" />
|
||||
<meta name="msapplication-TileColor" content="#d97706" />
|
||||
|
||||
<!-- Web App Manifest -->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
<!-- Icons -->
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
|
||||
<!-- Google Fonts: Inter -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
838
package-lock.json
generated
Normal file
838
package-lock.json
generated
Normal file
@@ -0,0 +1,838 @@
|
||||
{
|
||||
"name": "quartiles",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "quartiles",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wordlist-english": "^1.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.3.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"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.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"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/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"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/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"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/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
||||
"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.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wordlist-english": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/wordlist-english/-/wordlist-english-1.2.1.tgz",
|
||||
"integrity": "sha512-vaiQQiu95UeON0WZFtiTrWfZmIiUd2rhrhMCBeEzwq3oTVzHCX8naGCqv/smSspE9kH2sN+E8lZyV0JZjqSzGw==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "quartiles",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"vite": "^8.3.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"wordlist-english": "^1.2.1"
|
||||
}
|
||||
}
|
||||
36
public/icon.svg
Normal file
36
public/icon.svg
Normal file
@@ -0,0 +1,36 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f59e0b" />
|
||||
<stop offset="100%" stop-color="#b45309" />
|
||||
</linearGradient>
|
||||
<linearGradient id="tileGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#ffffff" />
|
||||
<stop offset="100%" stop-color="#f3f4f6" />
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feDropShadow dx="0" dy="8" stdDeviation="10" flood-opacity="0.25" />
|
||||
</filter>
|
||||
</defs>
|
||||
<!-- Background iOS squircle -->
|
||||
<rect width="512" height="512" rx="112" fill="url(#bgGrad)" />
|
||||
|
||||
<!-- 4 Quartile Tiles forming a grid/Q -->
|
||||
<g filter="url(#shadow)">
|
||||
<!-- Top Left -->
|
||||
<rect x="116" y="116" width="124" height="124" rx="28" fill="url(#tileGrad)" />
|
||||
<text x="178" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-weight="800" font-size="68" fill="#1f2937" text-anchor="middle">Q</text>
|
||||
|
||||
<!-- Top Right -->
|
||||
<rect x="272" y="116" width="124" height="124" rx="28" fill="url(#tileGrad)" />
|
||||
<text x="334" y="200" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-weight="800" font-size="68" fill="#1f2937" text-anchor="middle">U</text>
|
||||
|
||||
<!-- Bottom Left -->
|
||||
<rect x="116" y="272" width="124" height="124" rx="28" fill="url(#tileGrad)" />
|
||||
<text x="178" y="356" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-weight="800" font-size="68" fill="#1f2937" text-anchor="middle">A</text>
|
||||
|
||||
<!-- Bottom Right with emerald accent -->
|
||||
<rect x="272" y="272" width="124" height="124" rx="28" fill="#10b981" />
|
||||
<text x="334" y="356" font-family="-apple-system, BlinkMacSystemFont, 'Inter', sans-serif" font-weight="800" font-size="68" fill="#ffffff" text-anchor="middle">R</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
26
public/manifest.webmanifest
Normal file
26
public/manifest.webmanifest
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Quartiles — Daily Word Puzzle",
|
||||
"short_name": "Quartiles",
|
||||
"description": "Combine letter tiles to form words, find the 5 hidden Quartiles, and score 100 points for Expert rank!",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait-primary",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#1c1c1e",
|
||||
"categories": ["games", "puzzle"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
76
public/sw.js
Normal file
76
public/sw.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const CACHE_NAME = 'quartiles-pwa-v1';
|
||||
|
||||
const STATIC_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.webmanifest',
|
||||
'/icon.svg'
|
||||
];
|
||||
|
||||
// Install: Cache initial shell assets
|
||||
self.addEventListener('install', event => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then(cache => {
|
||||
return cache.addAll(STATIC_ASSETS);
|
||||
}).then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// Activate: Clean up old cache versions
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(keys => {
|
||||
return Promise.all(
|
||||
keys.map(key => {
|
||||
if (key !== CACHE_NAME) {
|
||||
return caches.delete(key);
|
||||
}
|
||||
})
|
||||
);
|
||||
}).then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch: Stale-While-Revalidate for local assets, Network-First for HTML navigation
|
||||
self.addEventListener('fetch', event => {
|
||||
const request = event.request;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Only handle GET requests
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
// Handle HTML navigation requests (Offline support)
|
||||
if (request.mode === 'navigate') {
|
||||
event.respondWith(
|
||||
fetch(request)
|
||||
.then(response => {
|
||||
if (response && response.status === 200) {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(request, responseClone));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => caches.match('/index.html') || caches.match('/'))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin static assets or Google Fonts: Stale-While-Revalidate
|
||||
if (url.origin === location.origin || url.hostname.includes('googleapis.com') || url.hostname.includes('gstatic.com')) {
|
||||
event.respondWith(
|
||||
caches.match(request).then(cachedResponse => {
|
||||
const fetchPromise = fetch(request)
|
||||
.then(networkResponse => {
|
||||
if (networkResponse && networkResponse.status === 200) {
|
||||
const responseClone = networkResponse.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(request, responseClone));
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => cachedResponse);
|
||||
|
||||
return cachedResponse || fetchPromise;
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
323
scripts/build-all-data.mjs
Normal file
323
scripts/build-all-data.mjs
Normal file
@@ -0,0 +1,323 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import wordlistEnglish from 'wordlist-english';
|
||||
import { solveBoard } from './solver.mjs';
|
||||
|
||||
const targetWordsPool = [
|
||||
// Wonder & Exploration
|
||||
{ word: 'remarkable', tiles: ['re', 'mar', 'ka', 'ble'], theme: 'wonder', clue: 'Worthy of attention; striking' },
|
||||
{ word: 'unforgettable', tiles: ['un', 'for', 'get', 'table'], theme: 'wonder', clue: 'Impossible to forget; memorable' },
|
||||
{ word: 'photography', tiles: ['pho', 'to', 'gra', 'phy'], theme: 'wonder', clue: 'The art or practice of taking photos' },
|
||||
{ word: 'adventurous', tiles: ['ad', 'ven', 'tur', 'ous'], theme: 'wonder', clue: 'Willing to take risks or try new things' },
|
||||
{ word: 'inspiration', tiles: ['in', 'spi', 'ra', 'tion'], theme: 'wonder', clue: 'A stimulus to creative thought or action' },
|
||||
|
||||
// Space & Cosmos
|
||||
{ word: 'astronomy', tiles: ['as', 'tro', 'no', 'my'], theme: 'space', clue: 'The branch of science dealing with space' },
|
||||
{ word: 'gravitation', tiles: ['gra', 'vi', 'ta', 'tion'], theme: 'space', clue: 'The force that attracts bodies toward mass' },
|
||||
{ word: 'atmosphere', tiles: ['at', 'mo', 'sph', 'ere'], theme: 'space', clue: 'The envelope of gases surrounding a planet' },
|
||||
{ word: 'interstellar', tiles: ['in', 'ter', 'ste', 'llar'], theme: 'space', clue: 'Occurring between stars' },
|
||||
{ word: 'telescopic', tiles: ['tel', 'es', 'co', 'pic'], theme: 'space', clue: 'Having the power for viewing distant objects' },
|
||||
|
||||
// Science & Discovery
|
||||
{ word: 'required', tiles: ['re', 'qu', 'ir', 'ed'], theme: 'science', clue: 'Officially compulsory or needed' },
|
||||
{ word: 'equivalent', tiles: ['equ', 'iv', 'al', 'ent'], theme: 'science', clue: 'Equal in value, amount, or meaning' },
|
||||
{ word: 'objection', tiles: ['obj', 'ec', 'ti', 'on'], theme: 'science', clue: 'An expression or feeling of disapproval' },
|
||||
{ word: 'justification', tiles: ['ju', 'stif', 'ica', 'tion'], theme: 'science', clue: 'The action of showing something to be right' },
|
||||
{ word: 'mysterious', tiles: ['mys', 'ter', 'io', 'us'], theme: 'science', clue: 'Difficult or impossible to understand' },
|
||||
|
||||
// Nature & Earth
|
||||
{ word: 'biodiversity', tiles: ['bio', 'di', 'ver', 'sity'], theme: 'nature', clue: 'The variety of life in the world or habitat' },
|
||||
{ word: 'environment', tiles: ['en', 'vi', 'ron', 'ment'], theme: 'nature', clue: 'The surroundings or conditions in which one lives' },
|
||||
{ word: 'caterpillar', tiles: ['cat', 'er', 'pil', 'lar'], theme: 'nature', clue: 'The larval stage of a butterfly or moth' },
|
||||
{ word: 'underwater', tiles: ['un', 'der', 'wa', 'ter'], theme: 'nature', clue: 'Situated or occurring below water surface' },
|
||||
{ word: 'thunderstorm', tiles: ['thun', 'der', 'sto', 'rm'], theme: 'nature', clue: 'A transient storm of lightning and thunder' },
|
||||
|
||||
// Mind & Philosophy
|
||||
{ word: 'imagination', tiles: ['im', 'agi', 'na', 'tion'], theme: 'mind', clue: 'The faculty of forming new ideas or images' },
|
||||
{ word: 'understanding', tiles: ['un', 'der', 'stan', 'ding'], theme: 'mind', clue: 'The ability to comprehend something' },
|
||||
{ word: 'psychology', tiles: ['psy', 'cho', 'lo', 'gy'], theme: 'mind', clue: 'The scientific study of mind and behavior' },
|
||||
{ word: 'intelligence', tiles: ['in', 'tel', 'li', 'gence'], theme: 'mind', clue: 'The ability to acquire and apply knowledge' },
|
||||
{ word: 'consciousness', tiles: ['con', 'sci', 'ous', 'ness'], theme: 'mind', clue: 'The state of being aware of one surroundings' },
|
||||
|
||||
// Architecture & City
|
||||
{ word: 'architecture', tiles: ['ar', 'chi', 'tec', 'ture'], theme: 'city', clue: 'The art and practice of designing buildings' },
|
||||
{ word: 'metropolitan', tiles: ['me', 'tro', 'pol', 'itan'], theme: 'city', clue: 'Relating to a large, busy city' },
|
||||
{ word: 'construction', tiles: ['con', 'stru', 'cti', 'on'], theme: 'city', clue: 'The building of something typically large' },
|
||||
{ word: 'neighborhood', tiles: ['neigh', 'bor', 'ho', 'od'], theme: 'city', clue: 'A district or area forming a community' },
|
||||
{ word: 'skyscraper', tiles: ['sky', 'sc', 'ra', 'per'], theme: 'city', clue: 'A very tall building with many stories' },
|
||||
|
||||
// Music & Arts
|
||||
{ word: 'symphony', tiles: ['sym', 'pho', 'ni', 'cally'], theme: 'arts', clue: 'Relating to an elaborate musical composition' },
|
||||
{ word: 'masterpiece', tiles: ['mas', 'ter', 'pie', 'ce'], theme: 'arts', clue: 'A work of outstanding artistry or skill' },
|
||||
{ word: 'performance', tiles: ['per', 'for', 'man', 'ce'], theme: 'arts', clue: 'An act of staging or presenting a play or concert' },
|
||||
{ word: 'exhibition', tiles: ['ex', 'hi', 'bi', 'tion'], theme: 'arts', clue: 'A public display of works of art or items' },
|
||||
{ word: 'creativity', tiles: ['cre', 'at', 'iv', 'ity'], theme: 'arts', clue: 'The use of the imagination or original ideas' },
|
||||
|
||||
// Technology & Future
|
||||
{ word: 'technology', tiles: ['tec', 'hno', 'lo', 'gy'], theme: 'tech', clue: 'Application of scientific knowledge for practical aims' },
|
||||
{ word: 'electronic', tiles: ['el', 'ect', 'ro', 'nic'], theme: 'tech', clue: 'Having components like microchips or transistors' },
|
||||
{ word: 'automation', tiles: ['au', 'to', 'ma', 'tion'], theme: 'tech', clue: 'The use of largely automatic equipment in a system' },
|
||||
{ word: 'innovation', tiles: ['in', 'no', 'va', 'tion'], theme: 'tech', clue: 'A new method, idea, or product' },
|
||||
{ word: 'computation', tiles: ['com', 'pu', 'ta', 'tion'], theme: 'tech', clue: 'The action of mathematical calculation' },
|
||||
|
||||
// Society & Community
|
||||
{ word: 'celebration', tiles: ['cel', 'eb', 'ra', 'tion'], theme: 'society', clue: 'The action of marking one pleasure at an event' },
|
||||
{ word: 'communication', tiles: ['com', 'mu', 'ni', 'cation'], theme: 'society', clue: 'The imparting or exchanging of information' },
|
||||
{ word: 'relationship', tiles: ['re', 'la', 'tion', 'ship'], theme: 'society', clue: 'The way in which two or more concepts or people connect' },
|
||||
{ word: 'opportunity', tiles: ['op', 'por', 'tu', 'nity'], theme: 'society', clue: 'A set of circumstances making it possible to do something' },
|
||||
{ word: 'conversation', tiles: ['con', 'ver', 'sa', 'tion'], theme: 'society', clue: 'A talk between two or more people' },
|
||||
|
||||
// Literature & Words
|
||||
{ word: 'vocabulary', tiles: ['vo', 'cab', 'ul', 'ary'], theme: 'words', clue: 'The body of words used in a particular language' },
|
||||
{ word: 'encyclopedia', tiles: ['en', 'cy', 'clo', 'pedia'], theme: 'words', clue: 'A book or set providing information on many subjects' },
|
||||
{ word: 'dictionary', tiles: ['dic', 'tion', 'ar', 'y'], theme: 'words', clue: 'A reference book on words and definitions' },
|
||||
{ word: 'manuscript', tiles: ['man', 'us', 'cr', 'ipt'], theme: 'words', clue: 'An author text that has not yet been printed' },
|
||||
{ word: 'translation', tiles: ['trans', 'la', 'ti', 'on'], theme: 'words', clue: 'The process of translating words into another language' },
|
||||
|
||||
// Travel & Geography
|
||||
{ word: 'destination', tiles: ['des', 'ti', 'na', 'tion'], theme: 'travel', clue: 'The place to which someone is going or being sent' },
|
||||
{ word: 'navigation', tiles: ['na', 'vi', 'ga', 'tion'], theme: 'travel', clue: 'The process of accurately ascertaining one position' },
|
||||
{ word: 'continental', tiles: ['con', 'ti', 'nen', 'tal'], theme: 'travel', clue: 'Forming or belonging to a continent' },
|
||||
{ word: 'expedition', tiles: ['ex', 'pe', 'di', 'tion'], theme: 'travel', clue: 'A journey undertaken by a group with a definite purpose' },
|
||||
{ word: 'exploration', tiles: ['ex', 'plo', 'ra', 'tion'], theme: 'travel', clue: 'The action of traveling in or through an unfamiliar area' },
|
||||
|
||||
// Seasons & Weather
|
||||
{ word: 'temperatures', tiles: ['tem', 'per', 'at', 'ures'], theme: 'weather', clue: 'The degrees or intensities of heat present in an area' },
|
||||
{ word: 'precipitation', tiles: ['pre', 'cip', 'ita', 'tion'], theme: 'weather', clue: 'Rain, snow, sleet, or hail that falls to the ground' },
|
||||
{ word: 'meteorology', tiles: ['me', 'teo', 'rol', 'ogy'], theme: 'weather', clue: 'The science concerned with processes of the atmosphere' },
|
||||
{ word: 'hibernation', tiles: ['hi', 'ber', 'na', 'tion'], theme: 'weather', clue: 'The condition or period of an animal spending winter asleep' },
|
||||
{ word: 'illumination', tiles: ['il', 'lum', 'ina', 'tion'], theme: 'weather', clue: 'Lighting or light provided' },
|
||||
|
||||
// Botany & Garden
|
||||
{ word: 'photosynthesis', tiles: ['pho', 'to', 'syn', 'thesis'], theme: 'plants', clue: 'Process by which green plants synthesize nutrients' },
|
||||
{ word: 'chlorophyll', tiles: ['chl', 'oro', 'ph', 'yll'], theme: 'plants', clue: 'A green pigment responsible for light absorption in plants' },
|
||||
{ word: 'horticulture', tiles: ['hor', 'ti', 'cul', 'ture'], theme: 'plants', clue: 'The art or practice of garden cultivation' },
|
||||
{ word: 'germination', tiles: ['ger', 'mi', 'na', 'tion'], theme: 'plants', clue: 'The development of a plant from a seed or spore' },
|
||||
{ word: 'cultivation', tiles: ['cul', 'ti', 'va', 'tion'], theme: 'plants', clue: 'The act of caring for and encouraging plant growth' },
|
||||
|
||||
// Wisdom & Knowledge
|
||||
{ word: 'extraordinary', tiles: ['ex', 'tra', 'ordi', 'nary'], theme: 'wisdom', clue: 'Very unusual or remarkable' },
|
||||
{ word: 'satisfaction', tiles: ['sa', 'tis', 'fac', 'tion'], theme: 'wisdom', clue: 'Fulfillment of one wishes or expectations' },
|
||||
{ word: 'anticipation', tiles: ['an', 'ti', 'cip', 'ation'], theme: 'wisdom', clue: 'The act of expecting something in advance' },
|
||||
{ word: 'consideration', tiles: ['con', 'sid', 'era', 'tion'], theme: 'wisdom', clue: 'Careful thought over a period of time' },
|
||||
{ word: 'investigation', tiles: ['in', 'ves', 'ti', 'gation'], theme: 'wisdom', clue: 'A formal inquiry or systematic examination' },
|
||||
|
||||
// Ocean & Marine
|
||||
{ word: 'oceanography', tiles: ['oce', 'ano', 'gra', 'phy'], theme: 'ocean', clue: 'The branch of science that deals with physical oceans' },
|
||||
{ word: 'submersible', tiles: ['sub', 'mer', 'si', 'ble'], theme: 'ocean', clue: 'Designed to operate completely underwater' },
|
||||
{ word: 'navigation', tiles: ['na', 'vi', 'ga', 'tion'], theme: 'ocean', clue: 'Planning and directing route of ship or craft' },
|
||||
{ word: 'constellation', tiles: ['con', 'ste', 'lla', 'tion'], theme: 'ocean', clue: 'A group of stars forming a recognizable pattern' },
|
||||
{ word: 'mediterranean', tiles: ['med', 'ite', 'rra', 'nean'], theme: 'ocean', clue: 'Of or characteristic of the Mediterranean Sea' },
|
||||
|
||||
// Human Endeavor
|
||||
{ word: 'determination', tiles: ['de', 'ter', 'mi', 'nation'], theme: 'endeavor', clue: 'Firmness of purpose; resoluteness' },
|
||||
{ word: 'perseverance', tiles: ['per', 'sev', 'er', 'ance'], theme: 'endeavor', clue: 'Persistence in doing something despite difficulty' },
|
||||
{ word: 'accomplished', tiles: ['ac', 'com', 'pli', 'shed'], theme: 'endeavor', clue: 'Highly trained or skilled in a field' },
|
||||
{ word: 'achievement', tiles: ['ach', 'ie', 've', 'ment'], theme: 'endeavor', clue: 'A thing done successfully with effort' },
|
||||
{ word: 'distinguished', tiles: ['dis', 'tin', 'gui', 'shed'], theme: 'endeavor', clue: 'Successful, well known, and respected' },
|
||||
|
||||
// Energy & Physics
|
||||
{ word: 'electricity', tiles: ['el', 'ect', 'ri', 'city'], theme: 'physics', clue: 'Form of energy from charged elementary particles' },
|
||||
{ word: 'acceleration', tiles: ['ac', 'cel', 'era', 'tion'], theme: 'physics', clue: 'The rate of change of velocity per unit time' },
|
||||
{ word: 'thermodynamic', tiles: ['the', 'rmo', 'dyn', 'amic'], theme: 'physics', clue: 'Relating to heat and its relation to work and energy' },
|
||||
{ word: 'equilibrium', tiles: ['equ', 'ili', 'bri', 'um'], theme: 'physics', clue: 'A state in which opposing forces are balanced' },
|
||||
{ word: 'radioactivity', tiles: ['rad', 'ioa', 'cti', 'vity'], theme: 'physics', clue: 'The emission of ionizing radiation from nuclei' }
|
||||
];
|
||||
|
||||
console.log('Target words pool size:', targetWordsPool.length);
|
||||
|
||||
// Generate 30 curated daily and themed puzzles
|
||||
const dailyPuzzlesDefs = [
|
||||
{
|
||||
id: '2026-10-23',
|
||||
date: '2026-10-23',
|
||||
title: 'Daily Puzzle — Oct 23, 2026',
|
||||
theme: 'Discovery & Wonder',
|
||||
difficulty: 'Normal',
|
||||
words: ['remarkable', 'unforgettable', 'photography', 'adventurous', 'inspiration']
|
||||
},
|
||||
{
|
||||
id: '2026-10-24',
|
||||
date: '2026-10-24',
|
||||
title: 'Daily Puzzle — Oct 24, 2026',
|
||||
theme: 'Cosmic Horizons',
|
||||
difficulty: 'Challenging',
|
||||
words: ['astronomy', 'gravitation', 'atmosphere', 'interstellar', 'telescopic']
|
||||
},
|
||||
{
|
||||
id: '2026-10-25',
|
||||
date: '2026-10-25',
|
||||
title: 'Daily Puzzle — Oct 25, 2026',
|
||||
theme: 'Mind & Memory',
|
||||
difficulty: 'Normal',
|
||||
words: ['imagination', 'understanding', 'psychology', 'intelligence', 'consciousness']
|
||||
},
|
||||
{
|
||||
id: '2026-10-26',
|
||||
date: '2026-10-26',
|
||||
title: 'Daily Puzzle — Oct 26, 2026',
|
||||
theme: 'Urban Pulse',
|
||||
difficulty: 'Challenging',
|
||||
words: ['architecture', 'metropolitan', 'construction', 'neighborhood', 'skyscraper']
|
||||
},
|
||||
{
|
||||
id: '2026-10-27',
|
||||
date: '2026-10-27',
|
||||
title: 'Daily Puzzle — Oct 27, 2026',
|
||||
theme: 'Harmony & Arts',
|
||||
difficulty: 'Normal',
|
||||
words: ['symphony', 'masterpiece', 'performance', 'exhibition', 'creativity']
|
||||
},
|
||||
{
|
||||
id: '2026-10-28',
|
||||
date: '2026-10-28',
|
||||
title: 'Daily Puzzle — Oct 28, 2026',
|
||||
theme: 'Digital Future',
|
||||
difficulty: 'Normal',
|
||||
words: ['technology', 'electronic', 'automation', 'innovation', 'computation']
|
||||
},
|
||||
{
|
||||
id: '2026-10-29',
|
||||
date: '2026-10-29',
|
||||
title: 'Daily Puzzle — Oct 29, 2026',
|
||||
theme: 'Celebrations',
|
||||
difficulty: 'Easy',
|
||||
words: ['celebration', 'communication', 'relationship', 'opportunity', 'conversation']
|
||||
},
|
||||
{
|
||||
id: '2026-10-30',
|
||||
date: '2026-10-30',
|
||||
title: 'Daily Puzzle — Oct 30, 2026',
|
||||
theme: 'Wild Kingdom',
|
||||
difficulty: 'Normal',
|
||||
words: ['biodiversity', 'environment', 'caterpillar', 'underwater', 'thunderstorm']
|
||||
},
|
||||
{
|
||||
id: '2026-10-31',
|
||||
date: '2026-10-31',
|
||||
title: 'Daily Puzzle — Oct 31, 2026',
|
||||
theme: 'Mysteries & Legends',
|
||||
difficulty: 'Challenging',
|
||||
words: ['mysterious', 'determination', 'perseverance', 'investigation', 'anticipation']
|
||||
},
|
||||
{
|
||||
id: '2026-10-22',
|
||||
date: '2026-10-22',
|
||||
title: 'Daily Puzzle — Oct 22, 2026',
|
||||
theme: 'Scientific Insight',
|
||||
difficulty: 'Normal',
|
||||
words: ['required', 'equivalent', 'objection', 'justification', 'mysterious']
|
||||
},
|
||||
{
|
||||
id: '2026-10-21',
|
||||
date: '2026-10-21',
|
||||
title: 'Daily Puzzle — Oct 21, 2026',
|
||||
theme: 'Wordsmith Almanac',
|
||||
difficulty: 'Normal',
|
||||
words: ['vocabulary', 'encyclopedia', 'dictionary', 'manuscript', 'translation']
|
||||
},
|
||||
{
|
||||
id: '2026-10-20',
|
||||
date: '2026-10-20',
|
||||
title: 'Daily Puzzle — Oct 20, 2026',
|
||||
theme: 'Globe Trekker',
|
||||
difficulty: 'Normal',
|
||||
words: ['destination', 'navigation', 'continental', 'expedition', 'exploration']
|
||||
},
|
||||
{
|
||||
id: '2026-10-19',
|
||||
date: '2026-10-19',
|
||||
title: 'Daily Puzzle — Oct 19, 2026',
|
||||
theme: 'Atmospheric Skies',
|
||||
difficulty: 'Challenging',
|
||||
words: ['temperatures', 'precipitation', 'meteorology', 'hibernation', 'illumination']
|
||||
},
|
||||
{
|
||||
id: '2026-10-18',
|
||||
date: '2026-10-18',
|
||||
title: 'Daily Puzzle — Oct 18, 2026',
|
||||
theme: 'Green Oasis',
|
||||
difficulty: 'Normal',
|
||||
words: ['photosynthesis', 'chlorophyll', 'horticulture', 'germination', 'cultivation']
|
||||
},
|
||||
{
|
||||
id: '2026-10-17',
|
||||
date: '2026-10-17',
|
||||
title: 'Daily Puzzle — Oct 17, 2026',
|
||||
theme: 'Grit & Glory',
|
||||
difficulty: 'Normal',
|
||||
words: ['determination', 'perseverance', 'accomplished', 'achievement', 'distinguished']
|
||||
},
|
||||
{
|
||||
id: '2026-10-16',
|
||||
date: '2026-10-16',
|
||||
title: 'Daily Puzzle — Oct 16, 2026',
|
||||
theme: 'Oceanic Depths',
|
||||
difficulty: 'Challenging',
|
||||
words: ['oceanography', 'submersible', 'constellation', 'mediterranean', 'exploration']
|
||||
},
|
||||
{
|
||||
id: '2026-10-15',
|
||||
date: '2026-10-15',
|
||||
title: 'Daily Puzzle — Oct 15, 2026',
|
||||
theme: 'Energy & Waves',
|
||||
difficulty: 'Expert',
|
||||
words: ['electricity', 'acceleration', 'thermodynamic', 'equilibrium', 'radioactivity']
|
||||
}
|
||||
];
|
||||
|
||||
// Map target words for quick lookup
|
||||
const wordMap = new Map();
|
||||
for (const item of targetWordsPool) {
|
||||
wordMap.set(item.word, item);
|
||||
}
|
||||
|
||||
const solvedPuzzles = [];
|
||||
|
||||
for (const def of dailyPuzzlesDefs) {
|
||||
const quartets = def.words.map(w => {
|
||||
const found = wordMap.get(w);
|
||||
if (!found) throw new Error('Missing word in pool: ' + w);
|
||||
return found.tiles;
|
||||
});
|
||||
|
||||
const solved = solveBoard(quartets);
|
||||
|
||||
// Add clues to target quartiles
|
||||
const quartileDetails = def.words.map(w => {
|
||||
const item = wordMap.get(w);
|
||||
return {
|
||||
word: item.word,
|
||||
tiles: item.tiles,
|
||||
clue: item.clue
|
||||
};
|
||||
});
|
||||
|
||||
solvedPuzzles.push({
|
||||
id: def.id,
|
||||
date: def.date,
|
||||
title: def.title,
|
||||
theme: def.theme,
|
||||
difficulty: def.difficulty,
|
||||
quartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails,
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
});
|
||||
|
||||
console.log(`Generated: ${def.id} [${def.theme}] -> ${solved.wordCount} words, max score: ${solved.maxPossibleScore}`);
|
||||
}
|
||||
|
||||
// Write solved puzzles to src/data/puzzles.js
|
||||
const puzzlesFileContent = `// Pre-computed solved daily and themed puzzles for Quartiles
|
||||
export const CURATED_PUZZLES = ${JSON.stringify(solvedPuzzles, null, 2)};
|
||||
|
||||
export const TARGET_WORDS_POOL = ${JSON.stringify(targetWordsPool, null, 2)};
|
||||
`;
|
||||
|
||||
fs.mkdirSync('src/data', { recursive: true });
|
||||
fs.writeFileSync('src/data/puzzles.js', puzzlesFileContent, 'utf8');
|
||||
console.log('Saved src/data/puzzles.js successfully!');
|
||||
45
scripts/build-client-lexicon.mjs
Normal file
45
scripts/build-client-lexicon.mjs
Normal file
@@ -0,0 +1,45 @@
|
||||
import fs from 'node:fs';
|
||||
import wordlistEnglish from 'wordlist-english';
|
||||
|
||||
const validTwo = new Set([
|
||||
'am', 'an', 'as', 'at', 'be', 'by', 'do', 'go', 'he', 'hi',
|
||||
'if', 'in', 'is', 'it', 'me', 'my', 'no', 'of', 'oh', 'on',
|
||||
'or', 'so', 'to', 'up', 'us', 'we'
|
||||
]);
|
||||
|
||||
// Build a clean, high-frequency recognized vocabulary for client-side unlimited/custom puzzles
|
||||
const vocabulary = new Set();
|
||||
for (const word of wordlistEnglish.english) {
|
||||
const w = word.toLowerCase().trim();
|
||||
if (w.length === 2 && validTwo.has(w)) {
|
||||
vocabulary.add(w);
|
||||
} else if (w.length >= 3 && w.length <= 16 && /^[a-z]+$/.test(w)) {
|
||||
vocabulary.add(w);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Total vocabulary words:', vocabulary.size);
|
||||
|
||||
// Pack into space-separated string or array
|
||||
const sortedWords = [...vocabulary].sort();
|
||||
const lexiconContent = `// Client-side recognized English words for solver & custom puzzles
|
||||
// Loaded as a Set for O(1) instantaneous lookup
|
||||
const RAW_LEXICON = ${JSON.stringify(sortedWords)};
|
||||
|
||||
export const DICTIONARY_SET = new Set(RAW_LEXICON);
|
||||
|
||||
// Prefix set for lightning fast tree-pruning search (1 to 6 chars)
|
||||
export const PREFIX_SET = new Set();
|
||||
for (const word of RAW_LEXICON) {
|
||||
for (let len = 1; len <= Math.min(word.length, 6); len++) {
|
||||
PREFIX_SET.add(word.slice(0, len));
|
||||
}
|
||||
}
|
||||
|
||||
export function isWordValid(word) {
|
||||
return DICTIONARY_SET.has(word.toLowerCase());
|
||||
}
|
||||
`;
|
||||
|
||||
fs.writeFileSync('src/data/lexicon.js', lexiconContent, 'utf8');
|
||||
console.log('Saved src/data/lexicon.js successfully!');
|
||||
108
scripts/solver.mjs
Normal file
108
scripts/solver.mjs
Normal file
@@ -0,0 +1,108 @@
|
||||
import fs from 'node:fs';
|
||||
import wordlistEnglish from 'wordlist-english';
|
||||
|
||||
// Valid 2-letter English words accepted in standard games
|
||||
const validTwo = new Set([
|
||||
'am', 'an', 'as', 'at', 'be', 'by', 'do', 'go', 'he', 'hi',
|
||||
'if', 'in', 'is', 'it', 'me', 'my', 'no', 'of', 'oh', 'on',
|
||||
'or', 'so', 'to', 'up', 'us', 'we'
|
||||
]);
|
||||
|
||||
// Build standard clean dictionary (excluding vulgarities, numbers, non-alpha)
|
||||
const cleanWordSet = new Set();
|
||||
for (const raw of wordlistEnglish.english) {
|
||||
const w = raw.toLowerCase().trim();
|
||||
if (w.length === 2 && validTwo.has(w)) {
|
||||
cleanWordSet.add(w);
|
||||
} else if (w.length >= 3 && w.length <= 16 && /^[a-z]+$/.test(w)) {
|
||||
cleanWordSet.add(w);
|
||||
}
|
||||
}
|
||||
|
||||
// Build prefix set for super fast solver pruning (up to 6 letters)
|
||||
const prefixSet = new Set();
|
||||
for (const word of cleanWordSet) {
|
||||
for (let len = 1; len <= Math.min(word.length, 6); len++) {
|
||||
prefixSet.add(word.slice(0, len));
|
||||
}
|
||||
}
|
||||
|
||||
// Function to solve a 20-tile board and return all valid words and paths
|
||||
export function solveBoard(quartets) {
|
||||
const tiles = quartets.flat();
|
||||
const foundMap = new Map();
|
||||
|
||||
function search(prefix, usedIndices) {
|
||||
if (prefix.length > 0) {
|
||||
const checkLen = Math.min(prefix.length, 6);
|
||||
if (!prefixSet.has(prefix.slice(0, checkLen))) return;
|
||||
}
|
||||
|
||||
if (prefix.length >= 2 && cleanWordSet.has(prefix)) {
|
||||
if (!foundMap.has(prefix)) {
|
||||
foundMap.set(prefix, [...usedIndices]);
|
||||
}
|
||||
}
|
||||
if (usedIndices.length >= 4) return;
|
||||
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
if (usedIndices.includes(i)) continue;
|
||||
usedIndices.push(i);
|
||||
search(prefix + tiles[i], usedIndices);
|
||||
usedIndices.pop();
|
||||
}
|
||||
}
|
||||
|
||||
search('', []);
|
||||
|
||||
const quartilesList = quartets.map(q => q.join(''));
|
||||
const quartileSet = new Set(quartilesList);
|
||||
|
||||
const words = [];
|
||||
let totalScore = 0;
|
||||
let quartileCount = 0;
|
||||
|
||||
for (const [word, path] of foundMap.entries()) {
|
||||
const tileSegments = path.map(idx => tiles[idx]);
|
||||
const tileCount = path.length;
|
||||
let points = tileCount === 4 ? 8 : tileCount === 3 ? 4 : tileCount;
|
||||
const isTargetQuartile = quartileSet.has(word);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
if (isQuartile) quartileCount++;
|
||||
totalScore += points;
|
||||
|
||||
words.push({
|
||||
word,
|
||||
points,
|
||||
tileCount,
|
||||
tileIndices: path,
|
||||
tileSegments,
|
||||
isTargetQuartile,
|
||||
isQuartile
|
||||
});
|
||||
}
|
||||
|
||||
// Add 40 bonus points for finding all 5 quartiles
|
||||
const maxPossibleScore = totalScore + 40;
|
||||
|
||||
// Sort words: Quartiles first, then by points descending, then alphabetically
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
return {
|
||||
tiles,
|
||||
quartets,
|
||||
targetQuartiles: quartilesList,
|
||||
words,
|
||||
wordCount: words.length,
|
||||
quartileCount: 5,
|
||||
totalBaseScore: totalScore,
|
||||
maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Solver module ready. Clean words count:', cleanWordSet.size);
|
||||
47
src/components/ActionBar.js
Normal file
47
src/components/ActionBar.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createActionBar({ onShuffle, onSortAlpha, onToggleLock, onGetHint }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'action-bar';
|
||||
|
||||
function render(state) {
|
||||
const isLocked = state.lockQuartiles;
|
||||
|
||||
container.innerHTML = `
|
||||
<button class="btn-action" id="btn-action-shuffle" title="Shuffle unlocked tiles">
|
||||
<span class="action-icon">🔀</span>
|
||||
<span>Shuffle</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action" id="btn-action-sort" title="Sort tiles alphabetically A-Z">
|
||||
<span class="action-icon">🔤</span>
|
||||
<span>Sort A-Z</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action ${isLocked ? 'active' : ''}" id="btn-action-lock" title="${isLocked ? 'Unlock Quartiles to shuffle freely' : 'Lock found Quartiles at top rows'}">
|
||||
<span class="action-icon">${isLocked ? '🔒' : '🔓'}</span>
|
||||
<span>${isLocked ? 'Locked' : 'Unlocked'}</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-action" id="btn-action-hint" title="Get a hint for an unfound word">
|
||||
<span class="action-icon">💡</span>
|
||||
<span>Hint</span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
container.querySelector('#btn-action-shuffle')?.addEventListener('click', () => {
|
||||
sound.playShuffle();
|
||||
onShuffle();
|
||||
});
|
||||
|
||||
container.querySelector('#btn-action-sort')?.addEventListener('click', () => {
|
||||
sound.playShuffle();
|
||||
onSortAlpha();
|
||||
});
|
||||
|
||||
container.querySelector('#btn-action-lock')?.addEventListener('click', onToggleLock);
|
||||
container.querySelector('#btn-action-hint')?.addEventListener('click', onGetHint);
|
||||
}
|
||||
|
||||
return { element: container, render };
|
||||
}
|
||||
64
src/components/Header.js
Normal file
64
src/components/Header.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createHeader({
|
||||
onOpenCalendar,
|
||||
onOpenHowToPlay,
|
||||
onOpenStats,
|
||||
onOpenCustomBuilder,
|
||||
onOpenSettings,
|
||||
onNewRandomPuzzle
|
||||
}) {
|
||||
const header = document.createElement('header');
|
||||
header.className = 'app-header';
|
||||
|
||||
function render(state) {
|
||||
const puzzle = state.currentPuzzle;
|
||||
const isMuted = sound.isMuted();
|
||||
|
||||
header.innerHTML = `
|
||||
<div class="header-brand">
|
||||
<div>
|
||||
<h1 class="app-title">
|
||||
QUARTILES
|
||||
<span class="title-badge">DAILY</span>
|
||||
</h1>
|
||||
<div class="header-date-badge">${puzzle ? puzzle.title : 'Word Game'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-controls">
|
||||
<button class="btn-header-icon" id="btn-sound-toggle" title="${isMuted ? 'Unmute Sound' : 'Mute Sound'}">
|
||||
${isMuted ? '🔇' : '🔊'}
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-random-puzzle" title="New Random Unlimited Puzzle">
|
||||
🎲
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-calendar" title="Puzzles Archive">
|
||||
📅
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-help" title="How to Play">
|
||||
❓
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-stats" title="Statistics">
|
||||
🏆
|
||||
</button>
|
||||
<button class="btn-header-icon" id="btn-open-settings" title="Settings">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
header.querySelector('#btn-sound-toggle')?.addEventListener('click', () => {
|
||||
const nowMuted = sound.toggleMute();
|
||||
header.querySelector('#btn-sound-toggle').innerHTML = nowMuted ? '🔇' : '🔊';
|
||||
});
|
||||
|
||||
header.querySelector('#btn-random-puzzle')?.addEventListener('click', onNewRandomPuzzle);
|
||||
header.querySelector('#btn-open-calendar')?.addEventListener('click', onOpenCalendar);
|
||||
header.querySelector('#btn-open-help')?.addEventListener('click', onOpenHowToPlay);
|
||||
header.querySelector('#btn-open-stats')?.addEventListener('click', onOpenStats);
|
||||
header.querySelector('#btn-open-settings')?.addEventListener('click', onOpenSettings);
|
||||
}
|
||||
|
||||
return { element: header, render };
|
||||
}
|
||||
405
src/components/Modals.js
Normal file
405
src/components/Modals.js
Normal file
@@ -0,0 +1,405 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createModals({ onSelectPuzzle, onCustomPuzzleCreated, onResetPuzzle }) {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'app-modals-container';
|
||||
|
||||
let activeModal = null; // 'help', 'calendar', 'builder', 'stats', 'settings'
|
||||
|
||||
function render(state) {
|
||||
if (!activeModal) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="modal-backdrop open" id="generic-modal-backdrop">
|
||||
<div class="modal-sheet" style="max-height: 85vh;">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<div class="sheet-title">${getModalTitle(activeModal)}</div>
|
||||
<button class="btn-close-sheet" id="btn-close-generic-modal">✕</button>
|
||||
</div>
|
||||
<div class="sheet-body" style="padding: 16px 20px 24px;">
|
||||
${getModalBodyHtml(activeModal, state)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
container.querySelector('#btn-close-generic-modal')?.addEventListener('click', close);
|
||||
container.querySelector('#generic-modal-backdrop')?.addEventListener('click', e => {
|
||||
if (e.target.id === 'generic-modal-backdrop') {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
wireModalEvents(activeModal, state);
|
||||
}
|
||||
|
||||
function getModalTitle(type) {
|
||||
switch (type) {
|
||||
case 'help': return 'How to Play Quartiles';
|
||||
case 'calendar': return 'Daily Puzzles & Archives';
|
||||
case 'builder': return 'Create Custom Puzzle';
|
||||
case 'stats': return 'Player Statistics';
|
||||
case 'settings': return 'Game Settings';
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getModalBodyHtml(type, state) {
|
||||
if (type === 'help') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px; font-size:0.92rem; line-height:1.5;">
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--text-primary);">🎯 The Objective</h3>
|
||||
<p style="color:var(--text-secondary);">Combine letter tiles to form valid English words, find the 5 hidden <strong>Quartiles</strong>, and score at least <strong>100 points</strong> to achieve the <strong>Expert</strong> rank!</p>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg-primary); padding:12px; border-radius:var(--radius-md); border:1px solid var(--border-subtle);">
|
||||
<h3 style="font-size:1rem; margin-bottom:8px; color:var(--text-primary);">⭐ Points System</h3>
|
||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:8px;">
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>1 Tile:</strong> 1 point
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>2 Tiles:</strong> 2 points
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px;">
|
||||
<strong>3 Tiles:</strong> 4 points
|
||||
</div>
|
||||
<div style="background:var(--bg-secondary); padding:8px 12px; border-radius:8px; border:1px solid var(--accent-quartile);">
|
||||
<strong style="color:var(--accent-quartile);">4 Tiles (Quartile):</strong> 8 points
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--accent-quartile);">✨ The 40-Point Bonus</h3>
|
||||
<p style="color:var(--text-secondary);">Every puzzle has <strong>5 target Quartiles</strong> (4-tile words). Finding all 5 unlocks an instant <strong>40-point completion bonus</strong>!</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size:1.05rem; margin-bottom:4px; color:var(--text-primary);">🔤 Rules & Gameplay</h3>
|
||||
<ul style="padding-left:18px; color:var(--text-secondary); display:flex; flex-direction:column; gap:4px;">
|
||||
<li>Words can be 1, 2, 3, or 4 tiles long.</li>
|
||||
<li>Each tile contains 2 to 4 letters.</li>
|
||||
<li>Tiles stay on the board and can be reused for other words.</li>
|
||||
<li>Use <strong>Shuffle</strong> to mix tiles, or <strong>Sort A-Z</strong> to alphabetize.</li>
|
||||
<li>Enable <strong>Lock Quartiles</strong> to pin solved 4-tile words at the top.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'calendar') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:12px;">
|
||||
<p style="font-size:0.88rem; color:var(--text-secondary);">
|
||||
Choose from the official daily puzzles or themed challenge packs:
|
||||
</p>
|
||||
<div class="puzzles-list-grid">
|
||||
${CURATED_PUZZLES.map(p => {
|
||||
const isCurrent = state.currentPuzzle && state.currentPuzzle.id === p.id;
|
||||
const saved = localStorage.getItem(`quartiles_save_${p.id}`);
|
||||
let progressBadge = '';
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed.score >= 100) {
|
||||
progressBadge = '<span style="font-size:0.72rem; color:#10b981; font-weight:700;">🏆 Expert</span>';
|
||||
} else if (parsed.score > 0) {
|
||||
progressBadge = `<span style="font-size:0.72rem; color:var(--accent-quartile); font-weight:700;">${parsed.score} pts</span>`;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
return `
|
||||
<button class="puzzle-card-select ${isCurrent ? 'active' : ''}" data-id="${p.id}">
|
||||
<div style="font-weight:700; font-size:0.92rem; color:var(--text-primary);">${p.date || p.id}</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary); margin:2px 0 4px;">${p.theme}</div>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<span style="font-size:0.72rem; background:var(--bg-tertiary); padding:1px 6px; border-radius:4px;">${p.difficulty}</span>
|
||||
${progressBadge}
|
||||
</div>
|
||||
</button>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'builder') {
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:14px;">
|
||||
<p style="font-size:0.88rem; color:var(--text-secondary);">
|
||||
Create your own Quartiles puzzle! Enter <strong>5 four-tile words</strong> (each tile must be 2 to 4 letters):
|
||||
</p>
|
||||
|
||||
<div class="custom-builder-form" id="custom-builder-form">
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #1</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="UN" value="UN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="FOR" value="FOR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="GET" value="GET" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TABLE" value="TABLE" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #2</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="PHO" value="PHO" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TO" value="TO" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="GRA" value="GRA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="PHY" value="PHY" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #3</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="AD" value="AD" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="VEN" value="VEN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TUR" value="TUR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="OUS" value="OUS" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #4</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="IN" value="IN" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="SPI" value="SPI" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="RA" value="RA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="TION" value="TION" />
|
||||
</div>
|
||||
|
||||
<div style="font-size:0.8rem; font-weight:700; color:var(--text-secondary);">Quartile #5</div>
|
||||
<div class="custom-quartet-row">
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="RE" value="RE" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="MAR" value="MAR" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="KA" value="KA" />
|
||||
<input class="custom-quartet-input" maxlength="4" placeholder="BLE" value="BLE" />
|
||||
</div>
|
||||
|
||||
<div style="margin-top:6px;">
|
||||
<input id="custom-title-input" type="text" placeholder="Puzzle Title (e.g. Maya's Word Challenge)"
|
||||
style="width:100%; height:38px; padding:0 12px; border-radius:8px; border:1px solid var(--border-color); background:var(--bg-primary); color:var(--text-primary); font-size:0.9rem;" />
|
||||
</div>
|
||||
|
||||
<button class="btn-primary-modal" id="btn-submit-custom-puzzle" style="margin-top:8px;">
|
||||
Build & Play Puzzle
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'stats') {
|
||||
const stats = state.getStats();
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px;">
|
||||
<div style="display:grid; grid-template-columns: repeat(3, 1fr); gap:10px; text-align:center;">
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-submit);">${stats.totalWordsFound}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Words Found</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-quartile);">${stats.expertCount}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Expert Ranks</div>
|
||||
</div>
|
||||
<div style="background:var(--bg-primary); padding:14px 8px; border-radius:var(--radius-md);">
|
||||
<div style="font-size:1.8rem; font-weight:800; color:var(--accent-blue);">${stats.perfectCount}</div>
|
||||
<div style="font-size:0.75rem; color:var(--text-secondary); text-transform:uppercase; font-weight:600;">Perfect Clears</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg-primary); padding:14px; border-radius:var(--radius-md); font-size:0.88rem;">
|
||||
<div style="font-weight:700; margin-bottom:6px; color:var(--text-primary);">Current Puzzle Progress</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
|
||||
<span style="color:var(--text-secondary);">Score:</span>
|
||||
<strong>${state.score} pts</strong>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between; margin-bottom:4px;">
|
||||
<span style="color:var(--text-secondary);">Quartiles:</span>
|
||||
<strong>${state.getTargetQuartilesSolvedCount()} / 5</strong>
|
||||
</div>
|
||||
<div style="display:flex; justify-content:space-between;">
|
||||
<span style="color:var(--text-secondary);">Current Rank:</span>
|
||||
<strong>${state.getRank().current.badge} ${state.getRank().current.name}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (type === 'settings') {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
const isMuted = sound.isMuted();
|
||||
const lockSetting = state.lockQuartiles;
|
||||
|
||||
return `
|
||||
<div style="display:flex; flex-direction:column; gap:16px; font-size:0.92rem;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Dark Theme</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Toggle sleek dark appearance</div>
|
||||
</div>
|
||||
<button id="setting-toggle-theme" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${isDark ? '🌙 Dark' : '☀️ Light'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Sound Effects</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Tactile audio feedback and chimes</div>
|
||||
</div>
|
||||
<button id="setting-toggle-sound" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${isMuted ? '🔇 Off' : '🔊 On'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:10px 0; border-bottom:1px solid var(--border-subtle);">
|
||||
<div>
|
||||
<div style="font-weight:600; color:var(--text-primary);">Auto-Lock Quartiles</div>
|
||||
<div style="font-size:0.78rem; color:var(--text-secondary);">Lock completed Quartiles at top rows</div>
|
||||
</div>
|
||||
<button id="setting-toggle-lock" style="padding:6px 14px; border-radius:var(--radius-full); background:var(--bg-tertiary); font-weight:600;">
|
||||
${lockSetting ? '🔒 Locked' : '🔓 Off'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="padding-top:10px;">
|
||||
<button id="setting-reset-puzzle" style="width:100%; height:40px; border-radius:var(--radius-md); background:rgba(239, 68, 68, 0.1); color:var(--accent-danger); font-weight:700;">
|
||||
Reset Current Puzzle Progress
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function wireModalEvents(type, state) {
|
||||
if (type === 'calendar') {
|
||||
const cards = container.querySelectorAll('.puzzle-card-select');
|
||||
cards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const id = card.getAttribute('data-id');
|
||||
onSelectPuzzle(id);
|
||||
close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'builder') {
|
||||
const submitBtn = container.querySelector('#btn-submit-custom-puzzle');
|
||||
submitBtn?.addEventListener('click', async () => {
|
||||
const rows = container.querySelectorAll('.custom-quartet-row');
|
||||
const quartets = [];
|
||||
let hasError = false;
|
||||
|
||||
rows.forEach((row, rIdx) => {
|
||||
const inputs = row.querySelectorAll('.custom-quartet-input');
|
||||
const quartet = [];
|
||||
inputs.forEach(input => {
|
||||
const val = input.value.trim().toLowerCase();
|
||||
if (val.length < 2 || val.length > 4 || !/^[a-z]+$/.test(val)) {
|
||||
state.notifyToast(`Tile "${val.toUpperCase()}" in Quartile #${rIdx + 1} must be 2 to 4 letters!`, 'warning', 3500);
|
||||
input.style.borderColor = 'var(--accent-danger)';
|
||||
hasError = true;
|
||||
} else {
|
||||
input.style.borderColor = '';
|
||||
}
|
||||
quartet.push(val);
|
||||
});
|
||||
quartets.push(quartet);
|
||||
});
|
||||
|
||||
if (hasError) return;
|
||||
|
||||
const titleInput = container.querySelector('#custom-title-input');
|
||||
const title = (titleInput && titleInput.value.trim()) || 'Custom Community Puzzle';
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerText = 'Analyzing & Building...';
|
||||
|
||||
try {
|
||||
const { createCustomPuzzle, encodePuzzleToHash } = await import('../engine/generator.js');
|
||||
const customPuzzle = createCustomPuzzle(quartets, title);
|
||||
onCustomPuzzleCreated(customPuzzle);
|
||||
|
||||
// Update URL hash with encoded puzzle
|
||||
const hash = encodePuzzleToHash(quartets, title);
|
||||
window.location.hash = `puzzle=${hash}`;
|
||||
|
||||
close();
|
||||
} catch (err) {
|
||||
state.notifyToast('Error creating puzzle: ' + err.message, 'error', 4000);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerText = 'Build & Play Puzzle';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (type === 'settings') {
|
||||
container.querySelector('#setting-toggle-theme')?.addEventListener('click', () => {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
const nextTheme = current === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', nextTheme);
|
||||
localStorage.setItem('quartiles_theme', nextTheme);
|
||||
render(state);
|
||||
});
|
||||
|
||||
container.querySelector('#setting-toggle-sound')?.addEventListener('click', () => {
|
||||
sound.toggleMute();
|
||||
render(state);
|
||||
});
|
||||
|
||||
container.querySelector('#setting-toggle-lock')?.addEventListener('click', () => {
|
||||
state.toggleLockQuartiles();
|
||||
render(state);
|
||||
});
|
||||
|
||||
const resetBtn = container.querySelector('#setting-reset-puzzle');
|
||||
if (resetBtn) {
|
||||
let isConfirming = false;
|
||||
let timer = null;
|
||||
resetBtn.addEventListener('click', () => {
|
||||
if (!isConfirming) {
|
||||
isConfirming = true;
|
||||
resetBtn.innerText = 'Tap again to confirm reset';
|
||||
resetBtn.style.background = 'var(--accent-danger)';
|
||||
resetBtn.style.color = '#ffffff';
|
||||
timer = setTimeout(() => {
|
||||
isConfirming = false;
|
||||
if (resetBtn) {
|
||||
resetBtn.innerText = 'Reset Current Puzzle Progress';
|
||||
resetBtn.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
resetBtn.style.color = 'var(--accent-danger)';
|
||||
}
|
||||
}, 3500);
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
onResetPuzzle();
|
||||
close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function open(modalType, state) {
|
||||
activeModal = modalType;
|
||||
render(state);
|
||||
}
|
||||
|
||||
function close() {
|
||||
activeModal = null;
|
||||
container.innerHTML = '';
|
||||
}
|
||||
|
||||
return { element: container, render, open, close };
|
||||
}
|
||||
60
src/components/ScoreRankBar.js
Normal file
60
src/components/ScoreRankBar.js
Normal file
@@ -0,0 +1,60 @@
|
||||
export function createScoreRankBar({ onOpenWordsDrawer }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'score-rank-container';
|
||||
|
||||
function render(state) {
|
||||
const rankInfo = state.getRank();
|
||||
const quartilesSolved = state.getTargetQuartilesSolvedCount();
|
||||
const totalWords = state.currentPuzzle ? state.currentPuzzle.wordCount : 0;
|
||||
const foundWordsCount = state.foundWords.size;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="score-rank-card">
|
||||
<div class="score-rank-top">
|
||||
<div class="score-display">
|
||||
<span class="score-num" id="current-score-num">${state.score}</span>
|
||||
<span class="score-label">Points</span>
|
||||
</div>
|
||||
<div class="rank-badge-wrap">
|
||||
<div class="rank-pill ${rankInfo.isExpert ? 'expert' : ''}">
|
||||
<span>${rankInfo.current.badge}</span>
|
||||
<span>${rankInfo.current.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar-container" title="${rankInfo.next ? `Next: ${rankInfo.next.name} in ${rankInfo.pointsToNext} pts` : 'Top Rank!'}">
|
||||
<div class="progress-bar-fill ${rankInfo.isExpert ? 'expert' : ''}" style="width: ${rankInfo.progressPercent}%"></div>
|
||||
</div>
|
||||
|
||||
<div class="quartiles-indicator-row">
|
||||
<div class="quartiles-counter">
|
||||
<span>Quartiles:</span>
|
||||
<strong>${quartilesSolved} / 5</strong>
|
||||
${state.quartilesBonusAwarded ? '<span class="quartile-bonus-badge">+40 BONUS</span>' : ''}
|
||||
</div>
|
||||
<div class="quartiles-segments">
|
||||
${[0, 1, 2, 3, 4]
|
||||
.map(i => `<div class="quartile-segment-pip ${i < quartilesSolved ? 'solved' : ''}"></div>`)
|
||||
.join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="words-found-strip">
|
||||
<button class="btn-words-drawer" id="btn-words-found-toggle">
|
||||
<span>Words Found</span>
|
||||
<span class="badge-count">${foundWordsCount} ${rankInfo.isExpert ? `/ ${totalWords}` : ''}</span>
|
||||
<span style="font-size: 0.75rem;">▾</span>
|
||||
</button>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); font-weight: 500;">
|
||||
${rankInfo.next ? `Next rank in <strong>${rankInfo.pointsToNext}</strong> pts` : '🌟 Expert Reached!'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('#btn-words-found-toggle')?.addEventListener('click', onOpenWordsDrawer);
|
||||
}
|
||||
|
||||
return { element: container, render };
|
||||
}
|
||||
52
src/components/TileGrid.js
Normal file
52
src/components/TileGrid.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createTileGrid({ onTileSelected }) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'board-container';
|
||||
|
||||
function render(state) {
|
||||
const activeSet = new Set(state.activeTileIds);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="tiles-grid" id="main-tiles-grid">
|
||||
${state.boardTiles
|
||||
.map(tile => {
|
||||
const isSelected = activeSet.has(tile.id);
|
||||
const isLocked = tile.isLocked;
|
||||
|
||||
return `
|
||||
<button
|
||||
class="board-tile ${isSelected ? 'selected' : ''} ${isLocked ? 'locked' : ''}"
|
||||
data-id="${tile.id}"
|
||||
aria-label="Tile ${tile.text}"
|
||||
>
|
||||
${tile.text}
|
||||
</button>
|
||||
`;
|
||||
})
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
const tiles = container.querySelectorAll('.board-tile');
|
||||
tiles.forEach(tileBtn => {
|
||||
const id = parseInt(tileBtn.getAttribute('data-id'), 10);
|
||||
tileBtn.addEventListener('click', () => {
|
||||
sound.playTileClick();
|
||||
onTileSelected(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function triggerShuffleAnimation() {
|
||||
const tiles = container.querySelectorAll('.board-tile:not(.locked)');
|
||||
tiles.forEach((tile, index) => {
|
||||
setTimeout(() => {
|
||||
tile.classList.add('flipping');
|
||||
setTimeout(() => tile.classList.remove('flipping'), 400);
|
||||
}, (index % 4) * 40);
|
||||
});
|
||||
}
|
||||
|
||||
return { element: container, render, triggerShuffleAnimation };
|
||||
}
|
||||
35
src/components/Toast.js
Normal file
35
src/components/Toast.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export function createToastContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'toast-container';
|
||||
|
||||
function show({ message, type = 'info', duration = 2500 }) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
|
||||
let icon = 'ℹ️';
|
||||
if (type === 'success') icon = '✓';
|
||||
if (type === 'quartile') icon = '⭐';
|
||||
if (type === 'warning') icon = '⚠️';
|
||||
if (type === 'error') icon = '✕';
|
||||
|
||||
toast.innerHTML = `
|
||||
<span>${icon}</span>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transform = 'scale(0.85) translateY(-10px)';
|
||||
toast.style.transition = 'all 0.25s ease';
|
||||
setTimeout(() => {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 260);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return { element: container, show };
|
||||
}
|
||||
159
src/components/WordBuilder.js
Normal file
159
src/components/WordBuilder.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import { sound } from '../engine/audio.js';
|
||||
|
||||
export function createWordBuilder({ onTileRemoved, onTilesReordered, onSubmitWord, onClearWord, onBackspaceWord }) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'word-builder-wrapper';
|
||||
|
||||
let draggedIndex = null;
|
||||
let touchStartX = 0;
|
||||
let touchMoved = false;
|
||||
|
||||
function render(state) {
|
||||
const hasTiles = state.activeTileIds.length > 0;
|
||||
const tileObjects = state.activeTileIds.map(id => state.boardTiles.find(t => t.id === id)).filter(Boolean);
|
||||
|
||||
wrapper.innerHTML = `
|
||||
<div class="word-builder ${hasTiles ? 'has-tiles' : ''}" id="active-word-builder">
|
||||
<div class="active-tiles-container" id="active-tiles-tray">
|
||||
${
|
||||
hasTiles
|
||||
? tileObjects
|
||||
.map(
|
||||
(tile, idx) => `
|
||||
<div class="active-tile-chip" data-idx="${idx}" draggable="true" title="Tap to remove or drag to reorder">
|
||||
<span>${tile.text}</span>
|
||||
<span class="remove-icon">✕</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('')
|
||||
: `<span class="builder-placeholder">Tap tiles below to build a word</span>`
|
||||
}
|
||||
</div>
|
||||
<div class="builder-actions">
|
||||
<button class="btn-icon-tray" id="btn-builder-clear" title="Clear all tiles" ${!hasTiles ? 'style="opacity:0.3;pointer-events:none;"' : ''}>
|
||||
✕
|
||||
</button>
|
||||
<button class="btn-icon-tray" id="btn-builder-backspace" title="Remove last tile" ${!hasTiles ? 'style="opacity:0.3;pointer-events:none;"' : ''}>
|
||||
⌫
|
||||
</button>
|
||||
<button class="btn-submit-tray" id="btn-builder-submit" ${!hasTiles ? 'disabled' : ''}>
|
||||
<span>✓</span>
|
||||
<span>ENTER</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners for active chips
|
||||
const chips = wrapper.querySelectorAll('.active-tile-chip');
|
||||
chips.forEach(chip => {
|
||||
const idx = parseInt(chip.getAttribute('data-idx'), 10);
|
||||
|
||||
// Tap chip to remove (only if not dragged)
|
||||
chip.addEventListener('click', e => {
|
||||
if (!touchMoved) {
|
||||
sound.playTileRemove();
|
||||
onTileRemoved(idx);
|
||||
}
|
||||
});
|
||||
|
||||
// Desktop HTML5 drag and drop
|
||||
chip.addEventListener('dragstart', e => {
|
||||
draggedIndex = idx;
|
||||
chip.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
});
|
||||
|
||||
chip.addEventListener('dragend', () => {
|
||||
chip.classList.remove('dragging');
|
||||
chips.forEach(c => c.classList.remove('drag-over'));
|
||||
});
|
||||
|
||||
chip.addEventListener('dragover', e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
chip.classList.add('drag-over');
|
||||
});
|
||||
|
||||
chip.addEventListener('dragleave', () => {
|
||||
chip.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
chip.addEventListener('drop', e => {
|
||||
e.preventDefault();
|
||||
chip.classList.remove('drag-over');
|
||||
if (draggedIndex !== null && draggedIndex !== idx) {
|
||||
onTilesReordered(draggedIndex, idx);
|
||||
}
|
||||
draggedIndex = null;
|
||||
});
|
||||
|
||||
// Touch events for mobile reordering
|
||||
chip.addEventListener(
|
||||
'touchstart',
|
||||
e => {
|
||||
touchStartX = e.touches[0].clientX;
|
||||
touchMoved = false;
|
||||
draggedIndex = idx;
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
chip.addEventListener(
|
||||
'touchmove',
|
||||
e => {
|
||||
const currentX = e.touches[0].clientX;
|
||||
if (Math.abs(currentX - touchStartX) > 10) {
|
||||
touchMoved = true;
|
||||
chip.classList.add('dragging');
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
chip.addEventListener('touchend', e => {
|
||||
chip.classList.remove('dragging');
|
||||
if (touchMoved) {
|
||||
const endX = e.changedTouches[0].clientX;
|
||||
const endY = e.changedTouches[0].clientY;
|
||||
// Find which chip the touch ended on
|
||||
const targetEl = document.elementFromPoint(endX, endY);
|
||||
const targetChip = targetEl ? targetEl.closest('.active-tile-chip') : null;
|
||||
if (targetChip) {
|
||||
const targetIdx = parseInt(targetChip.getAttribute('data-idx'), 10);
|
||||
if (!isNaN(targetIdx) && targetIdx !== idx) {
|
||||
onTilesReordered(idx, targetIdx);
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
touchMoved = false;
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-clear')?.addEventListener('click', () => {
|
||||
sound.playTileRemove();
|
||||
onClearWord();
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-backspace')?.addEventListener('click', () => {
|
||||
sound.playTileRemove();
|
||||
onBackspaceWord();
|
||||
});
|
||||
|
||||
wrapper.querySelector('#btn-builder-submit')?.addEventListener('click', onSubmitWord);
|
||||
}
|
||||
|
||||
function triggerShake() {
|
||||
const builder = wrapper.querySelector('#active-word-builder');
|
||||
if (builder) {
|
||||
builder.classList.remove('invalid-shake');
|
||||
void builder.offsetWidth; // Trigger reflow
|
||||
builder.classList.add('invalid-shake');
|
||||
}
|
||||
}
|
||||
|
||||
return { element: wrapper, render, triggerShake };
|
||||
}
|
||||
173
src/components/WordsDrawer.js
Normal file
173
src/components/WordsDrawer.js
Normal file
@@ -0,0 +1,173 @@
|
||||
export function createWordsDrawer({ onRevealAll }) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'modal-backdrop';
|
||||
backdrop.id = 'words-drawer-backdrop';
|
||||
|
||||
let currentTab = 'all'; // 'all', 'quartiles', '3-tiles', '2-tiles', '1-tiles'
|
||||
|
||||
function render(state) {
|
||||
if (!state.currentPuzzle) return;
|
||||
|
||||
const foundWordsList = Array.from(state.foundWords.values());
|
||||
const allPuzzleWords = state.currentPuzzle.words;
|
||||
const isRevealed = state.isRevealed;
|
||||
|
||||
// Filter words according to current tab
|
||||
let displayWords = isRevealed ? allPuzzleWords : foundWordsList;
|
||||
|
||||
if (currentTab === 'quartiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 4);
|
||||
} else if (currentTab === '3-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 3);
|
||||
} else if (currentTab === '2-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 2);
|
||||
} else if (currentTab === '1-tiles') {
|
||||
displayWords = displayWords.filter(w => w.tileCount === 1);
|
||||
}
|
||||
|
||||
// Sort display words: target quartiles first, then points desc, then alphabetical
|
||||
displayWords.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
const quartilesCount = foundWordsList.filter(w => w.tileCount === 4).length;
|
||||
const threeTilesCount = foundWordsList.filter(w => w.tileCount === 3).length;
|
||||
const twoTilesCount = foundWordsList.filter(w => w.tileCount === 2).length;
|
||||
const oneTilesCount = foundWordsList.filter(w => w.tileCount === 1).length;
|
||||
|
||||
backdrop.innerHTML = `
|
||||
<div class="modal-sheet" id="words-drawer-sheet">
|
||||
<div class="sheet-handle"></div>
|
||||
<div class="sheet-header">
|
||||
<div>
|
||||
<div class="sheet-title">Words Found</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 2px;">
|
||||
${foundWordsList.length} of ${allPuzzleWords.length} words found (${state.score} pts)
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-close-sheet" id="btn-close-words-drawer">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-tabs">
|
||||
<button class="tab-btn ${currentTab === 'all' ? 'active' : ''}" data-tab="all">
|
||||
All (${foundWordsList.length})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === 'quartiles' ? 'active' : ''}" data-tab="quartiles">
|
||||
Quartiles (${quartilesCount}/5)
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '3-tiles' ? 'active' : ''}" data-tab="3-tiles">
|
||||
3-Tiles (${threeTilesCount})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '2-tiles' ? 'active' : ''}" data-tab="2-tiles">
|
||||
2-Tiles (${twoTilesCount})
|
||||
</button>
|
||||
<button class="tab-btn ${currentTab === '1-tiles' ? 'active' : ''}" data-tab="1-tiles">
|
||||
1-Tile (${oneTilesCount})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
${
|
||||
displayWords.length === 0
|
||||
? `<div style="text-align:center; padding: 32px 16px; color: var(--text-secondary);">
|
||||
<div style="font-size: 2rem; margin-bottom: 8px;">🔍</div>
|
||||
<strong>No words found in this category yet</strong>
|
||||
<div style="font-size: 0.85rem; margin-top: 4px;">Combine tiles on the board to discover valid words!</div>
|
||||
</div>`
|
||||
: displayWords
|
||||
.map(w => {
|
||||
const isFound = state.foundWords.has(w.word.toLowerCase());
|
||||
const isQuartile = w.tileCount === 4;
|
||||
|
||||
return `
|
||||
<div class="word-card ${isQuartile ? 'quartile-card' : ''}" style="${!isFound ? 'opacity: 0.6; border-style: dashed;' : ''}">
|
||||
<div class="word-card-left">
|
||||
<div class="word-card-word">
|
||||
${w.word.toUpperCase()}
|
||||
${isQuartile ? ' ⭐' : ''}
|
||||
${!isFound ? ' <span style="font-size:0.75rem; color:var(--text-tertiary);">(Unfound)</span>' : ''}
|
||||
</div>
|
||||
<div class="word-card-tiles">
|
||||
${w.tileSegments.map(s => `<span class="tile-chip-mini">${s.toUpperCase()}</span>`).join('+')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="word-card-points">+${w.points}</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="sheet-footer">
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
${isRevealed ? 'All solutions revealed' : state.getRank().isExpert ? '🏆 Expert reached! You can keep hunting or reveal solutions.' : 'Reach 100 points for Expert rank!'}
|
||||
</div>
|
||||
${
|
||||
!isRevealed
|
||||
? `<button class="btn-reveal-all" id="btn-trigger-reveal">
|
||||
Reveal All Words
|
||||
</button>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Event listeners
|
||||
backdrop.querySelector('#btn-close-words-drawer')?.addEventListener('click', () => {
|
||||
close();
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', e => {
|
||||
if (e.target === backdrop) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
const tabBtns = backdrop.querySelectorAll('.tab-btn');
|
||||
tabBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
currentTab = btn.getAttribute('data-tab');
|
||||
render(state);
|
||||
});
|
||||
});
|
||||
|
||||
const revealBtn = backdrop.querySelector('#btn-trigger-reveal');
|
||||
if (revealBtn) {
|
||||
let isConfirming = false;
|
||||
let timer = null;
|
||||
revealBtn.addEventListener('click', () => {
|
||||
if (!isConfirming) {
|
||||
isConfirming = true;
|
||||
revealBtn.innerText = 'Tap again to reveal all words';
|
||||
revealBtn.style.background = 'var(--accent-danger)';
|
||||
revealBtn.style.color = '#ffffff';
|
||||
timer = setTimeout(() => {
|
||||
isConfirming = false;
|
||||
if (revealBtn) {
|
||||
revealBtn.innerText = 'Reveal All Words';
|
||||
revealBtn.style.background = 'rgba(239, 68, 68, 0.1)';
|
||||
revealBtn.style.color = 'var(--accent-danger)';
|
||||
}
|
||||
}, 3500);
|
||||
} else {
|
||||
clearTimeout(timer);
|
||||
onRevealAll();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
backdrop.classList.add('open');
|
||||
}
|
||||
|
||||
function close() {
|
||||
backdrop.classList.remove('open');
|
||||
}
|
||||
|
||||
return { element: backdrop, render, open, close };
|
||||
}
|
||||
17
src/data/lexicon.js
Normal file
17
src/data/lexicon.js
Normal file
File diff suppressed because one or more lines are too long
10611
src/data/puzzles.js
Normal file
10611
src/data/puzzles.js
Normal file
File diff suppressed because it is too large
Load Diff
265
src/engine/audio.js
Normal file
265
src/engine/audio.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Web Audio API synthesizer for tactile, Apple-grade acoustic feedback
|
||||
* Requires 0 external audio files, operates instantly with zero latency
|
||||
*/
|
||||
class SoundEngine {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.muted = localStorage.getItem('quartiles_sound_muted') === 'true';
|
||||
}
|
||||
|
||||
initContext() {
|
||||
if (!this.ctx) {
|
||||
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
||||
if (AudioCtx) {
|
||||
this.ctx = new AudioCtx();
|
||||
}
|
||||
}
|
||||
if (this.ctx && this.ctx.state === 'suspended') {
|
||||
this.ctx.resume();
|
||||
}
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
setMuted(muted) {
|
||||
this.muted = !!muted;
|
||||
localStorage.setItem('quartiles_sound_muted', this.muted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.setMuted(!this.muted);
|
||||
return this.muted;
|
||||
}
|
||||
|
||||
// Soft tactile click for tile selection
|
||||
playTileClick() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(620, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(380, now + 0.04);
|
||||
|
||||
gain.gain.setValueAtTime(0.12, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.04);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.045);
|
||||
}
|
||||
|
||||
// Gentle acoustic pop when tile is removed
|
||||
playTileRemove() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const now = this.ctx.currentTime;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(320, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(540, now + 0.05);
|
||||
|
||||
gain.gain.setValueAtTime(0.1, now);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.05);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.055);
|
||||
}
|
||||
|
||||
// Shuffle swoosh sound
|
||||
playShuffle() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = i * 0.025;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(280 + i * 70, now + delay);
|
||||
osc.frequency.exponentialRampToValueAtTime(160, now + delay + 0.06);
|
||||
|
||||
gain.gain.setValueAtTime(0.06, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.06);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.065);
|
||||
}
|
||||
}
|
||||
|
||||
// Gentle double boop on invalid submission
|
||||
playError() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
[0, 0.09].forEach(delay => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.setValueAtTime(140, now + delay);
|
||||
osc.frequency.exponentialRampToValueAtTime(110, now + delay + 0.08);
|
||||
|
||||
gain.gain.setValueAtTime(0.08, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.08);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.085);
|
||||
});
|
||||
}
|
||||
|
||||
// Cheerful chime on valid word
|
||||
playWordValid(tileCount) {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
const baseFreqs = {
|
||||
1: [523.25, 659.25], // C5, E5
|
||||
2: [523.25, 659.25, 783.99], // C5, E5, G5
|
||||
3: [587.33, 739.99, 880.00, 1046.50] // D5, F#5, A5, C6
|
||||
};
|
||||
|
||||
const freqs = baseFreqs[tileCount] || baseFreqs[2];
|
||||
|
||||
freqs.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.045;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.12, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.22);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.23);
|
||||
});
|
||||
}
|
||||
|
||||
// Sparkling golden fanfare for 4-tile Quartile
|
||||
playQuartileFound() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
// Major 7th arpeggio: C5, E5, G5, B5, C6
|
||||
const notes = [523.25, 659.25, 783.99, 987.77, 1046.50];
|
||||
|
||||
notes.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.055;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.15, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.45);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.46);
|
||||
});
|
||||
}
|
||||
|
||||
// Grand celebration fanfare when all 5 Quartiles are discovered (+40 pts bonus!)
|
||||
playBonusCelebration() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
// Fanfare chord progression
|
||||
const chords = [
|
||||
{ delay: 0.0, freqs: [523.25, 659.25, 783.99], dur: 0.2 },
|
||||
{ delay: 0.2, freqs: [587.33, 739.99, 880.00], dur: 0.2 },
|
||||
{ delay: 0.4, freqs: [659.25, 830.61, 987.77], dur: 0.25 },
|
||||
{ delay: 0.7, freqs: [783.99, 987.77, 1174.66, 1567.98], dur: 0.8 }
|
||||
];
|
||||
|
||||
chords.forEach(chord => {
|
||||
chord.freqs.forEach(freq => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const startTime = now + chord.delay;
|
||||
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.setValueAtTime(freq, startTime);
|
||||
|
||||
gain.gain.setValueAtTime(0.14, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + chord.dur);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + chord.dur + 0.05);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Uplifting chord for rank progression (e.g. hitting Expert)
|
||||
playRankUp() {
|
||||
if (this.muted) return;
|
||||
this.initContext();
|
||||
if (!this.ctx) return;
|
||||
|
||||
const now = this.ctx.currentTime;
|
||||
const freqs = [440, 554.37, 659.25, 880];
|
||||
freqs.forEach((freq, idx) => {
|
||||
const osc = this.ctx.createOscillator();
|
||||
const gain = this.ctx.createGain();
|
||||
const delay = idx * 0.08;
|
||||
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(freq, now + delay);
|
||||
|
||||
gain.gain.setValueAtTime(0.13, now + delay);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, now + delay + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(this.ctx.destination);
|
||||
|
||||
osc.start(now + delay);
|
||||
osc.stop(now + delay + 0.36);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const sound = new SoundEngine();
|
||||
107
src/engine/confetti.js
Normal file
107
src/engine/confetti.js
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Lightweight canvas confetti system with multi-colored ribbons, stars and sparkles
|
||||
*/
|
||||
export function fireConfetti({
|
||||
particleCount = 75,
|
||||
spread = 70,
|
||||
origin = { x: 0.5, y: 0.6 },
|
||||
colors = ['#f59e0b', '#10b981', '#6366f1', '#ec4899', '#3b82f6', '#fbbf24']
|
||||
} = {}) {
|
||||
let canvas = document.getElementById('quartiles-confetti-canvas');
|
||||
if (!canvas) {
|
||||
canvas = document.createElement('canvas');
|
||||
canvas.id = 'quartiles-confetti-canvas';
|
||||
canvas.style.position = 'fixed';
|
||||
canvas.style.top = '0';
|
||||
canvas.style.left = '0';
|
||||
canvas.style.width = '100vw';
|
||||
canvas.style.height = '100vh';
|
||||
canvas.style.pointerEvents = 'none';
|
||||
canvas.style.zIndex = '9999';
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = window.innerWidth * dpr;
|
||||
canvas.height = window.innerHeight * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const particles = [];
|
||||
const startX = origin.x * window.innerWidth;
|
||||
const startY = origin.y * window.innerHeight;
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const angle = (Math.PI / 180) * (-90 + (Math.random() - 0.5) * spread * 2);
|
||||
const speed = 7 + Math.random() * 9;
|
||||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||||
const size = Math.random() * 8 + 6;
|
||||
const shape = Math.random() > 0.4 ? 'rect' : 'circle';
|
||||
|
||||
particles.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
rotation: Math.random() * 360,
|
||||
vRotation: (Math.random() - 0.5) * 12,
|
||||
scaleX: 1,
|
||||
vScale: (Math.random() - 0.5) * 0.1,
|
||||
color,
|
||||
size,
|
||||
shape,
|
||||
gravity: 0.28,
|
||||
drag: 0.96,
|
||||
opacity: 1,
|
||||
decay: 0.012 + Math.random() * 0.008
|
||||
});
|
||||
}
|
||||
|
||||
let animationFrame;
|
||||
|
||||
function render() {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
|
||||
let activeCount = 0;
|
||||
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i];
|
||||
if (p.opacity <= 0) continue;
|
||||
|
||||
activeCount++;
|
||||
p.vx *= p.drag;
|
||||
p.vy = p.vy * p.drag + p.gravity;
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.rotation += p.vRotation;
|
||||
p.scaleX += p.vScale;
|
||||
if (Math.abs(p.scaleX) > 1) p.vScale *= -1;
|
||||
p.opacity -= p.decay;
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(p.x, p.y);
|
||||
ctx.rotate((p.rotation * Math.PI) / 180);
|
||||
ctx.scale(p.scaleX, 1);
|
||||
ctx.globalAlpha = Math.max(0, p.opacity);
|
||||
ctx.fillStyle = p.color;
|
||||
|
||||
if (p.shape === 'rect') {
|
||||
ctx.fillRect(-p.size / 2, -p.size / 3, p.size, p.size / 1.5);
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, p.size / 2.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (activeCount > 0) {
|
||||
animationFrame = requestAnimationFrame(render);
|
||||
} else {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight);
|
||||
cancelAnimationFrame(animationFrame);
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
69
src/engine/constants.js
Normal file
69
src/engine/constants.js
Normal file
@@ -0,0 +1,69 @@
|
||||
export const POINTS_BY_TILE_COUNT = {
|
||||
1: 1,
|
||||
2: 2,
|
||||
3: 4,
|
||||
4: 8
|
||||
};
|
||||
|
||||
export const QUARTILE_COMPLETION_BONUS = 40;
|
||||
export const EXPERT_RANK_POINTS = 100;
|
||||
|
||||
export const RANK_TIERS = [
|
||||
{ name: 'Novice', minPoints: 0, badge: '🌱' },
|
||||
{ name: 'Apprentice', minPoints: 25, badge: '🛠️' },
|
||||
{ name: 'Builder', minPoints: 50, badge: '📐' },
|
||||
{ name: 'Artisan', minPoints: 70, badge: '🎨' },
|
||||
{ name: 'Wordsmith', minPoints: 85, badge: '✒️' },
|
||||
{ name: 'Expert', minPoints: 100, badge: '🏆' },
|
||||
{ name: 'Perfect', minPoints: 999999, badge: '👑' }
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets the current rank object and progress to the next rank
|
||||
*/
|
||||
export function getRankInfo(currentScore, foundCount, totalCount) {
|
||||
// If player found every word, rank is Perfect!
|
||||
if (totalCount > 0 && foundCount >= totalCount) {
|
||||
return {
|
||||
current: RANK_TIERS[6], // Perfect
|
||||
next: null,
|
||||
progressPercent: 100,
|
||||
pointsToNext: 0,
|
||||
isExpert: true,
|
||||
isPerfect: true
|
||||
};
|
||||
}
|
||||
|
||||
let currentRank = RANK_TIERS[0];
|
||||
let nextRank = RANK_TIERS[1];
|
||||
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (currentScore >= RANK_TIERS[i].minPoints) {
|
||||
currentRank = RANK_TIERS[i];
|
||||
nextRank = RANK_TIERS[i + 1] || null;
|
||||
}
|
||||
}
|
||||
|
||||
let progressPercent = 0;
|
||||
let pointsToNext = 0;
|
||||
|
||||
if (nextRank && nextRank.minPoints <= EXPERT_RANK_POINTS) {
|
||||
const range = nextRank.minPoints - currentRank.minPoints;
|
||||
const earned = currentScore - currentRank.minPoints;
|
||||
progressPercent = Math.min(100, Math.max(0, Math.round((earned / range) * 100)));
|
||||
pointsToNext = Math.max(0, nextRank.minPoints - currentScore);
|
||||
} else {
|
||||
// Already Expert, heading towards Perfect
|
||||
progressPercent = totalCount > 0 ? Math.min(100, Math.round((foundCount / totalCount) * 100)) : 100;
|
||||
pointsToNext = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
current: currentRank,
|
||||
next: nextRank,
|
||||
progressPercent: isNaN(progressPercent) ? 0 : progressPercent,
|
||||
pointsToNext,
|
||||
isExpert: currentScore >= EXPERT_RANK_POINTS,
|
||||
isPerfect: false
|
||||
};
|
||||
}
|
||||
114
src/engine/generator.js
Normal file
114
src/engine/generator.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { TARGET_WORDS_POOL } from '../data/puzzles.js';
|
||||
import { solveBoard } from './solver.js';
|
||||
|
||||
/**
|
||||
* Generates a random valid Quartiles puzzle from the target word pool
|
||||
*/
|
||||
export function generateRandomPuzzle(seed) {
|
||||
const pool = [...TARGET_WORDS_POOL];
|
||||
// Shuffle pool
|
||||
for (let i = pool.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||
}
|
||||
|
||||
// Pick 5 words that don't repeat letters/roots too excessively
|
||||
const selectedWords = pool.slice(0, 5);
|
||||
const quartets = selectedWords.map(w => w.tiles);
|
||||
|
||||
const solved = solveBoard(quartets);
|
||||
|
||||
const id = 'custom-' + Date.now();
|
||||
const theme = selectedWords[0].theme ? `${selectedWords[0].theme.toUpperCase()} & MORE` : 'Unlimited Challenge';
|
||||
|
||||
return {
|
||||
id,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: `Unlimited Puzzle #${Math.floor(Math.random() * 9000 + 1000)}`,
|
||||
theme,
|
||||
difficulty: 'Random',
|
||||
quartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails: selectedWords.map(w => ({
|
||||
word: w.word,
|
||||
tiles: w.tiles,
|
||||
clue: w.clue || 'A 4-part Quartile word'
|
||||
})),
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates and creates a custom puzzle from user-provided quartets
|
||||
* Expects array of 5 quartets: [[t1,t2,t3,t4], [t5,t6,t7,t8], ...]
|
||||
*/
|
||||
export function createCustomPuzzle(quartets, customTitle = 'Custom Community Puzzle') {
|
||||
if (!Array.isArray(quartets) || quartets.length !== 5) {
|
||||
throw new Error('Puzzle must have exactly 5 quartets (4-tile words)');
|
||||
}
|
||||
|
||||
for (let i = 0; i < quartets.length; i++) {
|
||||
const q = quartets[i];
|
||||
if (!Array.isArray(q) || q.length !== 4) {
|
||||
throw new Error(`Quartet #${i + 1} must contain exactly 4 tiles`);
|
||||
}
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const tile = q[j].toLowerCase().trim();
|
||||
if (!/^[a-z]{2,4}$/.test(tile)) {
|
||||
throw new Error(`Tile "${tile}" in quartet #${i + 1} must be 2 to 4 letters`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedQuartets = quartets.map(q => q.map(t => t.toLowerCase().trim()));
|
||||
const solved = solveBoard(normalizedQuartets);
|
||||
|
||||
return {
|
||||
id: 'user-' + btoa(normalizedQuartets.map(q => q.join('-')).join('|')).slice(0, 16),
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
title: customTitle,
|
||||
theme: 'Community Creation',
|
||||
difficulty: 'Custom',
|
||||
quartets: normalizedQuartets,
|
||||
tiles: solved.tiles,
|
||||
targetQuartiles: solved.targetQuartiles,
|
||||
quartileDetails: normalizedQuartets.map(q => ({
|
||||
word: q.join(''),
|
||||
tiles: q,
|
||||
clue: 'Player created Quartile'
|
||||
})),
|
||||
words: solved.words,
|
||||
wordCount: solved.wordCount,
|
||||
maxPossibleScore: solved.maxPossibleScore
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes custom quartets to URL safe hash string
|
||||
*/
|
||||
export function encodePuzzleToHash(quartets, title) {
|
||||
const payload = {
|
||||
t: title || 'Custom Puzzle',
|
||||
q: quartets
|
||||
};
|
||||
return encodeURIComponent(btoa(JSON.stringify(payload)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes URL safe hash string to custom puzzle
|
||||
*/
|
||||
export function decodePuzzleFromHash(hash) {
|
||||
try {
|
||||
const json = atob(decodeURIComponent(hash));
|
||||
const payload = JSON.parse(json);
|
||||
if (payload && payload.q) {
|
||||
return createCustomPuzzle(payload.q, payload.t);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to decode puzzle from hash:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
79
src/engine/solver.js
Normal file
79
src/engine/solver.js
Normal file
@@ -0,0 +1,79 @@
|
||||
import { DICTIONARY_SET, PREFIX_SET } from '../data/lexicon.js';
|
||||
import { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo } from './constants.js';
|
||||
|
||||
export { POINTS_BY_TILE_COUNT, QUARTILE_COMPLETION_BONUS, EXPERT_RANK_POINTS, RANK_TIERS, getRankInfo };
|
||||
|
||||
/**
|
||||
* Solves a 20-tile board (5 quartets)
|
||||
* Returns all constructible valid words, point values, and tile paths
|
||||
*/
|
||||
export function solveBoard(quartets) {
|
||||
const tiles = quartets.flat();
|
||||
const foundMap = new Map();
|
||||
|
||||
function search(prefix, usedIndices) {
|
||||
if (prefix.length > 0) {
|
||||
const checkLen = Math.min(prefix.length, 6);
|
||||
if (!PREFIX_SET.has(prefix.slice(0, checkLen))) return;
|
||||
}
|
||||
|
||||
if (prefix.length >= 2 && DICTIONARY_SET.has(prefix)) {
|
||||
if (!foundMap.has(prefix)) {
|
||||
foundMap.set(prefix, [...usedIndices]);
|
||||
}
|
||||
}
|
||||
if (usedIndices.length >= 4) return;
|
||||
|
||||
for (let i = 0; i < tiles.length; i++) {
|
||||
if (usedIndices.includes(i)) continue;
|
||||
usedIndices.push(i);
|
||||
search(prefix + tiles[i], usedIndices);
|
||||
usedIndices.pop();
|
||||
}
|
||||
}
|
||||
|
||||
search('', []);
|
||||
|
||||
const quartilesList = quartets.map(q => q.join('').toLowerCase());
|
||||
const quartileSet = new Set(quartilesList);
|
||||
|
||||
const words = [];
|
||||
let totalBaseScore = 0;
|
||||
|
||||
for (const [word, path] of foundMap.entries()) {
|
||||
const tileCount = path.length;
|
||||
const points = POINTS_BY_TILE_COUNT[tileCount] || 1;
|
||||
const isTargetQuartile = quartileSet.has(word);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
totalBaseScore += points;
|
||||
|
||||
words.push({
|
||||
word,
|
||||
points,
|
||||
tileCount,
|
||||
tileIndices: path,
|
||||
tileSegments: path.map(idx => tiles[idx]),
|
||||
isTargetQuartile,
|
||||
isQuartile
|
||||
});
|
||||
}
|
||||
|
||||
// Sort words: Quartiles first, then by points descending, then alphabetically
|
||||
words.sort((a, b) => {
|
||||
if (a.isTargetQuartile !== b.isTargetQuartile) return a.isTargetQuartile ? -1 : 1;
|
||||
if (b.points !== a.points) return b.points - a.points;
|
||||
return a.word.localeCompare(b.word);
|
||||
});
|
||||
|
||||
return {
|
||||
tiles,
|
||||
quartets,
|
||||
targetQuartiles: quartilesList,
|
||||
words,
|
||||
wordCount: words.length,
|
||||
quartileCount: 5,
|
||||
totalBaseScore,
|
||||
maxPossibleScore: totalBaseScore + QUARTILE_COMPLETION_BONUS
|
||||
};
|
||||
}
|
||||
326
src/main.js
Normal file
326
src/main.js
Normal file
@@ -0,0 +1,326 @@
|
||||
import './styles/main.css';
|
||||
import './styles/board.css';
|
||||
import './styles/components.css';
|
||||
|
||||
import { gameState } from './state/game-state.js';
|
||||
import { CURATED_PUZZLES } from './data/puzzles.js';
|
||||
import { sound } from './engine/audio.js';
|
||||
import { fireConfetti } from './engine/confetti.js';
|
||||
|
||||
import { createHeader } from './components/Header.js';
|
||||
import { createScoreRankBar } from './components/ScoreRankBar.js';
|
||||
import { createWordBuilder } from './components/WordBuilder.js';
|
||||
import { createTileGrid } from './components/TileGrid.js';
|
||||
import { createActionBar } from './components/ActionBar.js';
|
||||
import { createWordsDrawer } from './components/WordsDrawer.js';
|
||||
import { createModals } from './components/Modals.js';
|
||||
import { createToastContainer } from './components/Toast.js';
|
||||
|
||||
// PWA Service Worker Registration
|
||||
if ('serviceWorker' in navigator && window.location.protocol.startsWith('http')) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js')
|
||||
.then(reg => {
|
||||
console.log('Quartiles PWA Service Worker registered:', reg.scope);
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('PWA Service Worker registration error:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
const root = document.getElementById('app');
|
||||
if (!root) return;
|
||||
|
||||
// Initialize theme (dark/light)
|
||||
const savedTheme = localStorage.getItem('quartiles_theme');
|
||||
const systemPrefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const initialTheme = savedTheme || (systemPrefersDark ? 'dark' : 'light');
|
||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
||||
|
||||
// App Layout Container
|
||||
const appContainer = document.createElement('div');
|
||||
appContainer.className = 'app-container';
|
||||
root.appendChild(appContainer);
|
||||
|
||||
// Create Toast System
|
||||
const toastContainer = createToastContainer();
|
||||
document.body.appendChild(toastContainer.element);
|
||||
|
||||
gameState.subscribeToast(data => {
|
||||
toastContainer.show(data);
|
||||
});
|
||||
|
||||
// Create Modals System
|
||||
const modals = createModals({
|
||||
onSelectPuzzle: puzzleId => {
|
||||
gameState.loadPuzzle(puzzleId);
|
||||
},
|
||||
onCustomPuzzleCreated: customPuzzle => {
|
||||
gameState.loadPuzzle(customPuzzle);
|
||||
gameState.notifyToast('Custom puzzle loaded! Share your link with friends.', 'success');
|
||||
},
|
||||
onResetPuzzle: () => {
|
||||
if (gameState.currentPuzzle) {
|
||||
localStorage.removeItem(`quartiles_save_${gameState.currentPuzzle.id}`);
|
||||
gameState.loadPuzzle(gameState.currentPuzzle);
|
||||
gameState.notifyToast('Progress reset', 'info');
|
||||
}
|
||||
}
|
||||
});
|
||||
document.body.appendChild(modals.element);
|
||||
|
||||
// Create Words Found Drawer
|
||||
const wordsDrawer = createWordsDrawer({
|
||||
onRevealAll: () => {
|
||||
gameState.revealAllWords();
|
||||
gameState.notifyToast('All words revealed!', 'info');
|
||||
}
|
||||
});
|
||||
document.body.appendChild(wordsDrawer.element);
|
||||
|
||||
// Create Header Component
|
||||
const header = createHeader({
|
||||
onOpenCalendar: () => modals.open('calendar', gameState),
|
||||
onOpenHowToPlay: () => modals.open('help', gameState),
|
||||
onOpenStats: () => modals.open('stats', gameState),
|
||||
onOpenCustomBuilder: () => modals.open('builder', gameState),
|
||||
onOpenSettings: () => modals.open('settings', gameState),
|
||||
onNewRandomPuzzle: async () => {
|
||||
try {
|
||||
const { generateRandomPuzzle } = await import('./engine/generator.js');
|
||||
const randomPuzzle = generateRandomPuzzle();
|
||||
gameState.loadPuzzle(randomPuzzle);
|
||||
gameState.notifyToast('Loaded fresh unlimited puzzle!', 'success');
|
||||
fireConfetti({ particleCount: 30, spread: 50 });
|
||||
} catch (err) {
|
||||
console.error('Failed to generate puzzle:', err);
|
||||
gameState.notifyToast('Failed to generate puzzle', 'error');
|
||||
}
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(header.element);
|
||||
|
||||
// Create Score & Rank Bar
|
||||
const scoreRankBar = createScoreRankBar({
|
||||
onOpenWordsDrawer: () => {
|
||||
wordsDrawer.open();
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(scoreRankBar.element);
|
||||
|
||||
// Create Word Construction Tray
|
||||
const wordBuilder = createWordBuilder({
|
||||
onTileRemoved: index => {
|
||||
gameState.removeActiveTile(index);
|
||||
},
|
||||
onTilesReordered: (from, to) => {
|
||||
gameState.reorderActiveTiles(from, to);
|
||||
},
|
||||
onSubmitWord: () => {
|
||||
handleWordSubmission();
|
||||
},
|
||||
onClearWord: () => {
|
||||
gameState.clearActive();
|
||||
},
|
||||
onBackspaceWord: () => {
|
||||
gameState.backspace();
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(wordBuilder.element);
|
||||
|
||||
// Create 4x5 Board Grid
|
||||
const tileGrid = createTileGrid({
|
||||
onTileSelected: tileId => {
|
||||
gameState.onTileClicked(tileId);
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(tileGrid.element);
|
||||
|
||||
// Create Action Bar (Shuffle, Sort, Lock, Hint)
|
||||
const actionBar = createActionBar({
|
||||
onShuffle: () => {
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.shuffle();
|
||||
},
|
||||
onSortAlpha: () => {
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.sortAlphabetical();
|
||||
},
|
||||
onToggleLock: () => {
|
||||
gameState.toggleLockQuartiles();
|
||||
},
|
||||
onGetHint: () => {
|
||||
const hint = gameState.getHint();
|
||||
if (!hint) {
|
||||
gameState.notifyToast('You have found every single word!', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
if (hint.type === 'quartile') {
|
||||
gameState.notifyToast(`💡 Quartile Clue: "${hint.clue}" (Starts with "${hint.firstTile}")`, 'warning', 4000);
|
||||
} else {
|
||||
gameState.notifyToast(`💡 Word Hint: ${hint.tileCount}-tile word starting with "${hint.firstTile}" (+${hint.points} pts)`, 'info', 3500);
|
||||
}
|
||||
}
|
||||
});
|
||||
appContainer.appendChild(actionBar.element);
|
||||
|
||||
// Word submission handler
|
||||
function handleWordSubmission() {
|
||||
const prevRank = gameState.getRank();
|
||||
const result = gameState.submitWord();
|
||||
|
||||
if (!result.success) {
|
||||
sound.playError();
|
||||
wordBuilder.triggerShake();
|
||||
return;
|
||||
}
|
||||
|
||||
// Success!
|
||||
if (result.bonusUnlocked) {
|
||||
// 5/5 Quartiles bonus!
|
||||
sound.playBonusCelebration();
|
||||
gameState.notifyToast(`🎉 5/5 QUARTILES BONUS! +40 POINTS!`, 'quartile', 4500);
|
||||
fireConfetti({ particleCount: 160, spread: 100 });
|
||||
} else if (result.isQuartile) {
|
||||
// Quartile found!
|
||||
sound.playQuartileFound();
|
||||
gameState.notifyToast(`+${result.points} pts! QUARTILE! ⭐`, 'quartile', 3000);
|
||||
fireConfetti({ particleCount: 65, spread: 65 });
|
||||
} else {
|
||||
// Standard word
|
||||
sound.playWordValid(result.points >= 4 ? 3 : result.points);
|
||||
gameState.notifyToast(`+${result.points} pts: ${result.word.toUpperCase()}`, 'success', 2200);
|
||||
}
|
||||
|
||||
// Check rank progression
|
||||
const currentRank = gameState.getRank();
|
||||
if (!prevRank.isExpert && currentRank.isExpert) {
|
||||
setTimeout(() => {
|
||||
sound.playRankUp();
|
||||
gameState.notifyToast('🏆 EXPERT RANK ACHIEVED! Outstanding!', 'quartile', 4000);
|
||||
fireConfetti({ particleCount: 120, spread: 90 });
|
||||
}, 700);
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard typing buffer for intelligent physical keyboard support
|
||||
let typingBuffer = '';
|
||||
let typingClearTimer = null;
|
||||
|
||||
// Keyboard controls
|
||||
window.addEventListener('keydown', e => {
|
||||
// If inside an input field, do not hijack
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleWordSubmission();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
sound.playTileRemove();
|
||||
gameState.backspace();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
sound.playTileRemove();
|
||||
gameState.clearActive();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === ' ' || e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
sound.playShuffle();
|
||||
tileGrid.triggerShuffleAnimation();
|
||||
gameState.shuffle();
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// A-Z letter typing to select board tiles
|
||||
if (/^[a-zA-Z]$/.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
const letter = e.key.toLowerCase();
|
||||
typingBuffer += letter;
|
||||
|
||||
clearTimeout(typingClearTimer);
|
||||
typingClearTimer = setTimeout(() => {
|
||||
typingBuffer = '';
|
||||
}, 1500);
|
||||
|
||||
const activeSet = new Set(gameState.activeTileIds);
|
||||
const availableTiles = gameState.boardTiles.filter(t => !activeSet.has(t.id));
|
||||
|
||||
// 1. Exact match of accumulated typing buffer
|
||||
const exactMatch = availableTiles.find(t => t.text.toLowerCase() === typingBuffer);
|
||||
if (exactMatch) {
|
||||
sound.playTileClick();
|
||||
gameState.onTileClicked(exactMatch.id);
|
||||
typingBuffer = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Check if typingBuffer is a prefix of any available tile
|
||||
const hasPrefix = availableTiles.some(t => t.text.toLowerCase().startsWith(typingBuffer));
|
||||
if (!hasPrefix) {
|
||||
// Reset to just the newly typed letter and re-check
|
||||
typingBuffer = letter;
|
||||
const singleExact = availableTiles.find(t => t.text.toLowerCase() === letter);
|
||||
if (singleExact) {
|
||||
sound.playTileClick();
|
||||
gameState.onTileClicked(singleExact.id);
|
||||
typingBuffer = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Subscribe components to state changes
|
||||
gameState.subscribe(state => {
|
||||
header.render(state);
|
||||
scoreRankBar.render(state);
|
||||
wordBuilder.render(state);
|
||||
tileGrid.render(state);
|
||||
actionBar.render(state);
|
||||
wordsDrawer.render(state);
|
||||
modals.render(state);
|
||||
});
|
||||
|
||||
// Check URL hash for shared custom puzzle
|
||||
const hash = window.location.hash;
|
||||
if (hash && hash.includes('puzzle=')) {
|
||||
try {
|
||||
const rawHash = hash.split('puzzle=')[1];
|
||||
const { decodePuzzleFromHash } = await import('./engine/generator.js');
|
||||
const decoded = decodePuzzleFromHash(rawHash);
|
||||
if (decoded) {
|
||||
gameState.loadPuzzle(decoded);
|
||||
gameState.notifyToast(`Loaded "${decoded.title}"`, 'success');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load shared puzzle from hash:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load today's puzzle or default
|
||||
const todayStr = gameState.getTodayDateString();
|
||||
const todayPuzzle = CURATED_PUZZLES.find(p => p.date === todayStr) || CURATED_PUZZLES[0];
|
||||
gameState.loadPuzzle(todayPuzzle);
|
||||
}
|
||||
|
||||
// Start application when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
} else {
|
||||
initApp();
|
||||
}
|
||||
485
src/state/game-state.js
Normal file
485
src/state/game-state.js
Normal file
@@ -0,0 +1,485 @@
|
||||
import { CURATED_PUZZLES } from '../data/puzzles.js';
|
||||
import { getRankInfo, QUARTILE_COMPLETION_BONUS } from '../engine/constants.js';
|
||||
|
||||
class GameStateManager {
|
||||
constructor() {
|
||||
this.currentPuzzle = null;
|
||||
this.boardTiles = []; // Array of { id, text, isQuartileSolved, isLocked }
|
||||
this.activeTileIds = []; // Array of tile IDs in construction tray
|
||||
this.foundWords = new Map(); // word -> wordData
|
||||
this.score = 0;
|
||||
this.quartilesBonusAwarded = false;
|
||||
this.lockQuartiles = localStorage.getItem('quartiles_lock_setting') !== 'false';
|
||||
this.listeners = new Set();
|
||||
this.toastListeners = new Set();
|
||||
this.isRevealed = false;
|
||||
this.hintIndex = 0;
|
||||
}
|
||||
|
||||
subscribe(listener) {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
notify() {
|
||||
for (const listener of this.listeners) {
|
||||
listener(this);
|
||||
}
|
||||
}
|
||||
|
||||
subscribeToast(listener) {
|
||||
this.toastListeners.add(listener);
|
||||
return () => this.toastListeners.delete(listener);
|
||||
}
|
||||
|
||||
notifyToast(message, type = 'info', duration = 2500) {
|
||||
for (const listener of this.toastListeners) {
|
||||
listener({ message, type, duration });
|
||||
}
|
||||
}
|
||||
|
||||
// Load a puzzle (by object or find by date/id)
|
||||
loadPuzzle(puzzleOrId) {
|
||||
let puzzle = puzzleOrId;
|
||||
if (typeof puzzleOrId === 'string') {
|
||||
puzzle = CURATED_PUZZLES.find(p => p.id === puzzleOrId || p.date === puzzleOrId) || CURATED_PUZZLES[0];
|
||||
}
|
||||
|
||||
this.currentPuzzle = puzzle;
|
||||
this.activeTileIds = [];
|
||||
this.foundWords = new Map();
|
||||
this.score = 0;
|
||||
this.quartilesBonusAwarded = false;
|
||||
this.isRevealed = false;
|
||||
this.hintIndex = 0;
|
||||
|
||||
// Initialize board tiles from the 20 puzzle tiles
|
||||
this.boardTiles = puzzle.tiles.map((text, id) => ({
|
||||
id,
|
||||
text: text.toUpperCase(),
|
||||
originalText: text,
|
||||
isQuartileSolved: false,
|
||||
isLocked: false
|
||||
}));
|
||||
|
||||
// Restore saved state from LocalStorage if available
|
||||
this.restoreProgress();
|
||||
|
||||
// Organize board tiles according to lock state
|
||||
this.reorganizeBoard();
|
||||
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Get current date string in YYYY-MM-DD
|
||||
getTodayDateString() {
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
// Save current puzzle progress to LocalStorage
|
||||
saveProgress() {
|
||||
if (!this.currentPuzzle) return;
|
||||
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
||||
const payload = {
|
||||
foundWords: Array.from(this.foundWords.entries()),
|
||||
score: this.score,
|
||||
bonus: this.quartilesBonusAwarded,
|
||||
tileOrder: this.boardTiles.map(t => t.id)
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(saveKey, JSON.stringify(payload));
|
||||
} catch (err) {
|
||||
console.warn('LocalStorage save failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore puzzle progress
|
||||
restoreProgress() {
|
||||
if (!this.currentPuzzle) return;
|
||||
const saveKey = `quartiles_save_${this.currentPuzzle.id}`;
|
||||
try {
|
||||
const saved = localStorage.getItem(saveKey);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && Array.isArray(parsed.foundWords)) {
|
||||
this.foundWords = new Map(parsed.foundWords);
|
||||
this.score = parsed.score || 0;
|
||||
this.quartilesBonusAwarded = !!parsed.bonus;
|
||||
|
||||
// Re-mark solved quartiles
|
||||
this.updateSolvedQuartileTiles();
|
||||
|
||||
// Restore tile order if saved
|
||||
if (Array.isArray(parsed.tileOrder) && parsed.tileOrder.length === 20) {
|
||||
const tileMap = new Map(this.boardTiles.map(t => [t.id, t]));
|
||||
const reordered = parsed.tileOrder.map(id => tileMap.get(id)).filter(Boolean);
|
||||
if (reordered.length === 20) {
|
||||
this.boardTiles = reordered;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to restore progress:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a tile belongs to an already found Quartile
|
||||
updateSolvedQuartileTiles() {
|
||||
const solvedQuartileWords = new Set();
|
||||
for (const [w, data] of this.foundWords.entries()) {
|
||||
if (data.isTargetQuartile) {
|
||||
solvedQuartileWords.add(w.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// For each target quartet, check if solved
|
||||
this.boardTiles.forEach(tile => {
|
||||
tile.isQuartileSolved = false;
|
||||
tile.isLocked = false;
|
||||
});
|
||||
|
||||
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
||||
const quartet = this.currentPuzzle.quartets[qIdx];
|
||||
const word = quartet.join('').toLowerCase();
|
||||
if (solvedQuartileWords.has(word)) {
|
||||
// Mark tiles belonging to this quartet
|
||||
const startTileIndex = qIdx * 4;
|
||||
for (let i = startTileIndex; i < startTileIndex + 4; i++) {
|
||||
const tile = this.boardTiles.find(t => t.id === i);
|
||||
if (tile) {
|
||||
tile.isQuartileSolved = true;
|
||||
if (this.lockQuartiles) {
|
||||
tile.isLocked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reorganizes the board: locked solved quartiles go to top rows
|
||||
reorganizeBoard() {
|
||||
this.updateSolvedQuartileTiles();
|
||||
|
||||
if (this.lockQuartiles) {
|
||||
// Group locked solved quartiles first in neat rows
|
||||
const lockedTiles = [];
|
||||
for (let qIdx = 0; qIdx < this.currentPuzzle.quartets.length; qIdx++) {
|
||||
const quartet = this.currentPuzzle.quartets[qIdx];
|
||||
const word = quartet.join('').toLowerCase();
|
||||
if (this.foundWords.has(word)) {
|
||||
const start = qIdx * 4;
|
||||
for (let i = start; i < start + 4; i++) {
|
||||
const t = this.boardTiles.find(tile => tile.id === i);
|
||||
if (t) lockedTiles.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lockedSet = new Set(lockedTiles.map(t => t.id));
|
||||
const unlockedTiles = this.boardTiles.filter(t => !lockedSet.has(t.id));
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle lock quartiles setting
|
||||
toggleLockQuartiles() {
|
||||
this.lockQuartiles = !this.lockQuartiles;
|
||||
localStorage.setItem('quartiles_lock_setting', this.lockQuartiles ? 'true' : 'false');
|
||||
this.reorganizeBoard();
|
||||
this.notify();
|
||||
this.notifyToast(
|
||||
this.lockQuartiles ? 'Quartiles locked at top' : 'Quartiles unlocked for free shuffle',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
// Tile clicked on board: add to tray or remove if already in tray
|
||||
onTileClicked(tileId) {
|
||||
const existingIndex = this.activeTileIds.indexOf(tileId);
|
||||
if (existingIndex !== -1) {
|
||||
// Already selected, remove it
|
||||
this.removeActiveTile(existingIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTileIds.length >= 4) {
|
||||
this.notifyToast('Maximum 4 tiles per word', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeTileIds.push(tileId);
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Remove tile from active tray by index
|
||||
removeActiveTile(index) {
|
||||
if (index >= 0 && index < this.activeTileIds.length) {
|
||||
this.activeTileIds.splice(index, 1);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder tiles in active tray (e.g. from drag & drop)
|
||||
reorderActiveTiles(fromIndex, toIndex) {
|
||||
if (
|
||||
fromIndex >= 0 &&
|
||||
fromIndex < this.activeTileIds.length &&
|
||||
toIndex >= 0 &&
|
||||
toIndex < this.activeTileIds.length &&
|
||||
fromIndex !== toIndex
|
||||
) {
|
||||
const [item] = this.activeTileIds.splice(fromIndex, 1);
|
||||
this.activeTileIds.splice(toIndex, 0, item);
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Backspace: removes last tile in tray
|
||||
backspace() {
|
||||
if (this.activeTileIds.length > 0) {
|
||||
this.activeTileIds.pop();
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all tiles in tray
|
||||
clearActive() {
|
||||
if (this.activeTileIds.length > 0) {
|
||||
this.activeTileIds = [];
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle unlocked tiles
|
||||
shuffle() {
|
||||
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
|
||||
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
|
||||
|
||||
// Fisher-Yates shuffle
|
||||
for (let i = unlockedTiles.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[unlockedTiles[i], unlockedTiles[j]] = [unlockedTiles[j], unlockedTiles[i]];
|
||||
}
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
this.saveProgress();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Sort unlocked tiles alphabetically
|
||||
sortAlphabetical() {
|
||||
const lockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => t.isLocked) : [];
|
||||
const unlockedTiles = this.lockQuartiles ? this.boardTiles.filter(t => !t.isLocked) : [...this.boardTiles];
|
||||
|
||||
unlockedTiles.sort((a, b) => a.text.localeCompare(b.text));
|
||||
|
||||
this.boardTiles = [...lockedTiles, ...unlockedTiles];
|
||||
this.saveProgress();
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Submit current word in active construction tray
|
||||
submitWord() {
|
||||
if (this.activeTileIds.length === 0) {
|
||||
this.notifyToast('Tap tiles to form a word', 'info');
|
||||
return { success: false, reason: 'empty' };
|
||||
}
|
||||
|
||||
const constructedWord = this.activeTileIds
|
||||
.map(id => {
|
||||
const tile = this.boardTiles.find(t => t.id === id);
|
||||
return tile ? tile.text.toLowerCase() : '';
|
||||
})
|
||||
.join('');
|
||||
|
||||
// Check if already found
|
||||
if (this.foundWords.has(constructedWord)) {
|
||||
this.notifyToast(`"${constructedWord.toUpperCase()}" already found!`, 'warning');
|
||||
this.activeTileIds = [];
|
||||
this.notify();
|
||||
return { success: false, reason: 'duplicate' };
|
||||
}
|
||||
|
||||
// Look up in current puzzle's valid words list
|
||||
const match = this.currentPuzzle.words.find(w => w.word.toLowerCase() === constructedWord);
|
||||
|
||||
if (!match) {
|
||||
this.notifyToast(`"${constructedWord.toUpperCase()}" is not in the word list`, 'error');
|
||||
return { success: false, reason: 'invalid' };
|
||||
}
|
||||
|
||||
// Valid word!
|
||||
const tileSegments = this.activeTileIds.map(id => {
|
||||
const tile = this.boardTiles.find(t => t.id === id);
|
||||
return tile ? tile.text : '';
|
||||
});
|
||||
const tileCount = this.activeTileIds.length;
|
||||
const isTargetQuartile = this.currentPuzzle.targetQuartiles.some(t => t.toLowerCase() === constructedWord);
|
||||
const isQuartile = tileCount === 4;
|
||||
|
||||
const wordEntry = {
|
||||
word: constructedWord,
|
||||
points: match.points,
|
||||
tileCount,
|
||||
tileSegments,
|
||||
isTargetQuartile,
|
||||
isQuartile,
|
||||
foundAt: Date.now()
|
||||
};
|
||||
|
||||
this.foundWords.set(constructedWord, wordEntry);
|
||||
this.score += match.points;
|
||||
|
||||
// Check if all 5 target quartiles are solved
|
||||
let quartilesCount = 0;
|
||||
for (const target of this.currentPuzzle.targetQuartiles) {
|
||||
if (this.foundWords.has(target.toLowerCase())) {
|
||||
quartilesCount++;
|
||||
}
|
||||
}
|
||||
|
||||
let bonusUnlocked = false;
|
||||
if (quartilesCount === 5 && !this.quartilesBonusAwarded) {
|
||||
this.quartilesBonusAwarded = true;
|
||||
this.score += QUARTILE_COMPLETION_BONUS;
|
||||
bonusUnlocked = true;
|
||||
}
|
||||
|
||||
// Clear active tray
|
||||
this.activeTileIds = [];
|
||||
|
||||
// Reorganize board if quartiles locked
|
||||
this.reorganizeBoard();
|
||||
|
||||
// Save progress and update player stats
|
||||
this.saveProgress();
|
||||
this.updateStats();
|
||||
|
||||
this.notify();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
word: constructedWord,
|
||||
points: match.points,
|
||||
isQuartile,
|
||||
isTargetQuartile,
|
||||
quartilesFound: quartilesCount,
|
||||
bonusUnlocked
|
||||
};
|
||||
}
|
||||
|
||||
// Get target quartiles solved count
|
||||
getTargetQuartilesSolvedCount() {
|
||||
if (!this.currentPuzzle) return 0;
|
||||
let count = 0;
|
||||
for (const q of this.currentPuzzle.targetQuartiles) {
|
||||
if (this.foundWords.has(q.toLowerCase())) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// Get rank data
|
||||
getRank() {
|
||||
return getRankInfo(
|
||||
this.score,
|
||||
this.foundWords.size,
|
||||
this.currentPuzzle ? this.currentPuzzle.wordCount : 0
|
||||
);
|
||||
}
|
||||
|
||||
// Progressive hint system
|
||||
getHint() {
|
||||
if (!this.currentPuzzle) return null;
|
||||
|
||||
// 1. Prioritize unfound target Quartiles
|
||||
const unfoundQuartiles = this.currentPuzzle.quartileDetails.filter(
|
||||
qd => !this.foundWords.has(qd.word.toLowerCase())
|
||||
);
|
||||
|
||||
if (unfoundQuartiles.length > 0) {
|
||||
const target = unfoundQuartiles[this.hintIndex % unfoundQuartiles.length];
|
||||
this.hintIndex++;
|
||||
return {
|
||||
type: 'quartile',
|
||||
word: target.word,
|
||||
firstTile: target.tiles[0].toUpperCase(),
|
||||
clue: target.clue || 'A 4-part Quartile word',
|
||||
remainingQuartiles: unfoundQuartiles.length
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Unfound other words
|
||||
const unfoundWords = this.currentPuzzle.words.filter(
|
||||
w => !this.foundWords.has(w.word.toLowerCase())
|
||||
);
|
||||
|
||||
if (unfoundWords.length > 0) {
|
||||
const sample = unfoundWords[Math.floor(Math.random() * unfoundWords.length)];
|
||||
return {
|
||||
type: 'word',
|
||||
tileCount: sample.tileCount,
|
||||
firstTile: sample.tileSegments[0].toUpperCase(),
|
||||
points: sample.points,
|
||||
remainingWords: unfoundWords.length
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reveal all words (when expert reached or explicitly requested)
|
||||
revealAllWords() {
|
||||
this.isRevealed = true;
|
||||
this.notify();
|
||||
}
|
||||
|
||||
// Update persistent stats in LocalStorage
|
||||
updateStats() {
|
||||
try {
|
||||
const statsKey = 'quartiles_player_stats';
|
||||
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
||||
|
||||
stats.totalWordsFound = (stats.totalWordsFound || 0) + 1;
|
||||
const rank = this.getRank();
|
||||
if (rank.isExpert) {
|
||||
stats.expertPuzzles = stats.expertPuzzles || {};
|
||||
stats.expertPuzzles[this.currentPuzzle.id] = true;
|
||||
}
|
||||
if (rank.isPerfect) {
|
||||
stats.perfectPuzzles = stats.perfectPuzzles || {};
|
||||
stats.perfectPuzzles[this.currentPuzzle.id] = true;
|
||||
}
|
||||
|
||||
localStorage.setItem(statsKey, JSON.stringify(stats));
|
||||
} catch (err) {
|
||||
console.warn('Stats update error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Get player stats
|
||||
getStats() {
|
||||
try {
|
||||
const statsKey = 'quartiles_player_stats';
|
||||
const stats = JSON.parse(localStorage.getItem(statsKey) || '{}');
|
||||
const expertCount = Object.keys(stats.expertPuzzles || {}).length;
|
||||
const perfectCount = Object.keys(stats.perfectPuzzles || {}).length;
|
||||
|
||||
return {
|
||||
totalWordsFound: stats.totalWordsFound || 0,
|
||||
expertCount,
|
||||
perfectCount
|
||||
};
|
||||
} catch (err) {
|
||||
return { totalWordsFound: 0, expertCount: 0, perfectCount: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameStateManager();
|
||||
300
src/styles/board.css
Normal file
300
src/styles/board.css
Normal file
@@ -0,0 +1,300 @@
|
||||
/* ==========================================================================
|
||||
BOARD & WORD BUILDER STYLES
|
||||
========================================================================== */
|
||||
|
||||
/* Active Word Construction Tray */
|
||||
.word-builder-wrapper {
|
||||
margin: 6px 0 10px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.word-builder {
|
||||
min-height: 52px;
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.word-builder.has-tiles {
|
||||
border-color: var(--border-highlight);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.word-builder.invalid-shake {
|
||||
animation: shake 0.45s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
|
||||
border-color: var(--accent-danger);
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
10%, 90% { transform: translate3d(-2px, 0, 0); }
|
||||
20%, 80% { transform: translate3d(4px, 0, 0); }
|
||||
30%, 50%, 70% { transform: translate3d(-6px, 0, 0); }
|
||||
40%, 60% { transform: translate3d(6px, 0, 0); }
|
||||
}
|
||||
|
||||
/* Selected Tiles in Tray */
|
||||
.active-tiles-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-height: 48px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.active-tiles-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.builder-placeholder {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Active Tile Chip */
|
||||
.active-tile-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 14px;
|
||||
background: var(--tile-bg);
|
||||
color: var(--text-primary);
|
||||
border: 1.5px solid var(--tile-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
box-shadow: var(--tile-shadow);
|
||||
cursor: grab;
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
|
||||
animation: chipInsert 0.22s var(--ease-spring);
|
||||
}
|
||||
|
||||
.active-tile-chip:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.active-tile-chip.dragging {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.active-tile-chip.drag-over {
|
||||
border-color: var(--accent-blue);
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.active-tile-chip .remove-icon {
|
||||
margin-left: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@keyframes chipInsert {
|
||||
0% { transform: scale(0.7) translateY(8px); opacity: 0; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Action controls inside the tray */
|
||||
.builder-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.btn-icon-tray {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-icon-tray:hover {
|
||||
background: var(--border-highlight);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-icon-tray:active {
|
||||
transform: translateY(1px) scale(0.94);
|
||||
}
|
||||
|
||||
.btn-submit-tray {
|
||||
height: 40px;
|
||||
padding: 0 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 8px var(--accent-submit-glow);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-submit-tray:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px var(--accent-submit-glow);
|
||||
}
|
||||
|
||||
.btn-submit-tray:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
}
|
||||
|
||||
.btn-submit-tray:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
THE 4x5 BOARD GRID (20 TILES)
|
||||
========================================================================== */
|
||||
|
||||
.board-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.tiles-grid {
|
||||
grid-gap: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Single Board Tile */
|
||||
.board-tile {
|
||||
aspect-ratio: 1.35 / 1;
|
||||
max-height: 56px;
|
||||
min-height: 44px;
|
||||
background: var(--tile-bg);
|
||||
border: 1.5px solid var(--tile-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--tile-shadow);
|
||||
color: var(--tile-text);
|
||||
font-size: 1.12rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast), box-shadow var(--transition-fast), opacity var(--transition-fast);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.board-tile {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.board-tile:hover:not(.selected) {
|
||||
background: var(--tile-bg-hover);
|
||||
border-color: var(--border-highlight);
|
||||
box-shadow: var(--tile-shadow-hover);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.board-tile:active:not(.selected) {
|
||||
background: var(--tile-bg-active);
|
||||
transform: translateY(1px) scale(0.96);
|
||||
box-shadow: var(--tile-shadow-selected);
|
||||
}
|
||||
|
||||
/* Selected state: tile is currently in construction tray */
|
||||
.board-tile.selected {
|
||||
opacity: 0.38;
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-subtle);
|
||||
transform: scale(0.95);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.board-tile.selected::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Locked solved Quartile tile state */
|
||||
.board-tile.locked {
|
||||
background: linear-gradient(145deg, var(--tile-bg), var(--bg-tertiary));
|
||||
border-color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.board-tile.locked::before {
|
||||
content: '🔒';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 6px;
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Board tile shuffle animation */
|
||||
.board-tile.flipping {
|
||||
animation: tileFlip 0.4s var(--ease-smooth);
|
||||
}
|
||||
|
||||
@keyframes tileFlip {
|
||||
0% { transform: scale(1) rotateY(0); }
|
||||
50% { transform: scale(0.85) rotateY(90deg); opacity: 0.5; }
|
||||
100% { transform: scale(1) rotateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Solved Quartile Section Header */
|
||||
.locked-section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 6px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
664
src/styles/components.css
Normal file
664
src/styles/components.css
Normal file
@@ -0,0 +1,664 @@
|
||||
/* ==========================================================================
|
||||
COMPONENTS: HEADER, SCORE BAR, ACTION BAR, DRAWER & MODALS
|
||||
========================================================================== */
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0 8px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title-badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.header-date-badge {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.header-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.btn-header-icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.95rem;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-header-icon:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-header-icon:active {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
SCORE, RANK & QUARTILES PROGRESS BAR
|
||||
========================================================================== */
|
||||
|
||||
.score-rank-card {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 8px 14px;
|
||||
margin-bottom: 6px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.score-rank-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.score-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.score-num {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-primary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.score-label {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rank-badge-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rank-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.rank-pill.expert {
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 2px 8px var(--accent-quartile-glow);
|
||||
}
|
||||
|
||||
/* Rank Progress Bar */
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 5px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--accent-submit-gradient);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.4s var(--ease-smooth);
|
||||
}
|
||||
|
||||
.progress-bar-fill.expert {
|
||||
background: var(--accent-quartile-gradient);
|
||||
}
|
||||
|
||||
/* Quartiles 5-Segment Indicator */
|
||||
.quartiles-indicator-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.quartiles-counter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.quartiles-segments {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.quartile-segment-pip {
|
||||
width: 28px;
|
||||
height: 9px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.3s var(--ease-spring);
|
||||
}
|
||||
|
||||
.quartile-segment-pip.solved {
|
||||
background: var(--accent-quartile-gradient);
|
||||
border-color: var(--accent-quartile);
|
||||
box-shadow: 0 0 8px var(--accent-quartile-glow);
|
||||
transform: scaleY(1.15);
|
||||
}
|
||||
|
||||
.quartile-bonus-badge {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
padding: 2px 8px;
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-full);
|
||||
box-shadow: 0 2px 6px var(--accent-quartile-glow);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.05); }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
WORDS FOUND STRIP / BUTTON
|
||||
========================================================================== */
|
||||
|
||||
.words-found-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btn-words-drawer {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-words-drawer:hover {
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-words-drawer .badge-count {
|
||||
background: var(--accent-blue);
|
||||
color: #ffffff;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
ACTION BAR (SHUFFLE, SORT, LOCK, HINT)
|
||||
========================================================================== */
|
||||
|
||||
.action-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
height: 38px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.03);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-highlight);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
transform: translateY(1px) scale(0.96);
|
||||
}
|
||||
|
||||
.btn-action.active {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-quartile);
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.btn-action .action-icon {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
WORDS FOUND DRAWER / MODAL
|
||||
========================================================================== */
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-backdrop {
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.modal-sheet {
|
||||
background: var(--bg-secondary);
|
||||
width: 100%;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
border-radius: 24px 24px 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.25);
|
||||
transform: translateY(100%);
|
||||
transition: transform var(--transition-normal);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-sheet {
|
||||
border-radius: 20px;
|
||||
max-height: 80vh;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-backdrop.open .modal-sheet {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-backdrop.open .modal-sheet {
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-handle {
|
||||
width: 36px;
|
||||
height: 5px;
|
||||
background: var(--border-color);
|
||||
border-radius: var(--radius-full);
|
||||
margin: 10px auto 4px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sheet-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
padding: 14px 20px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sheet-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-close-sheet {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Tabs inside Drawer */
|
||||
.sheet-tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sheet-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
white-space: nowrap;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* Words List Content */
|
||||
.sheet-body {
|
||||
padding: 16px 20px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.word-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.word-card.quartile-card {
|
||||
border-color: var(--accent-quartile);
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.08), transparent);
|
||||
}
|
||||
|
||||
.word-card-left {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.word-card-word {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.word-card-tiles {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tile-chip-mini {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.word-card-points {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
color: var(--accent-submit);
|
||||
}
|
||||
|
||||
.word-card.quartile-card .word-card-points {
|
||||
color: var(--accent-quartile);
|
||||
}
|
||||
|
||||
.sheet-footer {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.btn-reveal-all {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-danger);
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-reveal-all:hover {
|
||||
background: rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
TOAST NOTIFICATIONS
|
||||
========================================================================== */
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
animation: toastPop 0.3s var(--ease-spring);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@keyframes toastPop {
|
||||
0% { transform: scale(0.8) translateY(-16px); opacity: 0; }
|
||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.toast.toast-info {
|
||||
background: var(--text-primary);
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
.toast.toast-success {
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-quartile {
|
||||
background: var(--accent-quartile-gradient);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-warning {
|
||||
background: #f59e0b;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.toast.toast-error {
|
||||
background: var(--accent-danger);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Calendar & Custom Modals Content */
|
||||
.puzzles-list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.puzzle-card-select {
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1.5px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
text-align: left;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.puzzle-card-select:hover {
|
||||
border-color: var(--accent-quartile);
|
||||
background: var(--bg-tertiary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.puzzle-card-select.active {
|
||||
border-color: var(--accent-quartile);
|
||||
background: linear-gradient(135deg, rgba(245, 158, 11, 0.1), transparent);
|
||||
}
|
||||
|
||||
.custom-builder-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.custom-quartet-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.custom-quartet-input {
|
||||
height: 38px;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1.5px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.custom-quartet-input:focus {
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.btn-primary-modal {
|
||||
height: 44px;
|
||||
background: var(--accent-submit-gradient);
|
||||
color: #ffffff;
|
||||
border-radius: var(--radius-md);
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 4px 12px var(--accent-submit-glow);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-primary-modal:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
142
src/styles/main.css
Normal file
142
src/styles/main.css
Normal file
@@ -0,0 +1,142 @@
|
||||
/* ==========================================================================
|
||||
QUARTILES DESIGN SYSTEM - APPLE NEWS+ PREMIUM AESTHETICS
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* Color Palette - Light Theme */
|
||||
--bg-primary: #f5f5f7;
|
||||
--bg-secondary: #ffffff;
|
||||
--bg-tertiary: #e5e5ea;
|
||||
--bg-surface: rgba(255, 255, 255, 0.85);
|
||||
--bg-glass: rgba(255, 255, 255, 0.72);
|
||||
--border-color: rgba(0, 0, 0, 0.08);
|
||||
--border-subtle: rgba(0, 0, 0, 0.04);
|
||||
--border-highlight: rgba(0, 0, 0, 0.16);
|
||||
|
||||
--text-primary: #1c1c1e;
|
||||
--text-secondary: #636366;
|
||||
--text-tertiary: #8e8e93;
|
||||
--text-inverse: #ffffff;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-quartile: #d97706;
|
||||
--accent-quartile-gradient: linear-gradient(135deg, #f59e0b, #d97706);
|
||||
--accent-quartile-glow: rgba(245, 158, 11, 0.35);
|
||||
|
||||
--accent-submit: #10b981;
|
||||
--accent-submit-gradient: linear-gradient(135deg, #34d399, #059669);
|
||||
--accent-submit-glow: rgba(16, 185, 129, 0.3);
|
||||
|
||||
--accent-blue: #0284c7;
|
||||
--accent-blue-gradient: linear-gradient(135deg, #38bdf8, #0284c7);
|
||||
|
||||
--accent-purple: #6366f1;
|
||||
--accent-danger: #ef4444;
|
||||
|
||||
/* Tile Styling */
|
||||
--tile-bg: #ffffff;
|
||||
--tile-bg-hover: #fcfcfd;
|
||||
--tile-bg-active: #ebebed;
|
||||
--tile-text: #1d1d1f;
|
||||
--tile-border: #d2d2d7;
|
||||
--tile-shadow: 0 2px 4px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
--tile-shadow-hover: 0 4px 8px rgba(0, 0, 0, 0.08), 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
--tile-shadow-selected: inset 0 2px 4px rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* Dimensions & Radius */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 14px;
|
||||
--radius-lg: 18px;
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Transitions */
|
||||
--ease-spring: cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
--ease-smooth: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.25s var(--ease-smooth);
|
||||
}
|
||||
|
||||
/* Dark Theme Overrides */
|
||||
[data-theme='dark'] {
|
||||
--bg-primary: #000000;
|
||||
--bg-secondary: #1c1c1e;
|
||||
--bg-tertiary: #2c2c2e;
|
||||
--bg-surface: rgba(28, 28, 30, 0.88);
|
||||
--bg-glass: rgba(28, 28, 30, 0.75);
|
||||
--border-color: rgba(255, 255, 255, 0.12);
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-highlight: rgba(255, 255, 255, 0.24);
|
||||
|
||||
--text-primary: #f5f5f7;
|
||||
--text-secondary: #a1a1a6;
|
||||
--text-tertiary: #6e6e73;
|
||||
--text-inverse: #000000;
|
||||
|
||||
/* Tile Styling Dark */
|
||||
--tile-bg: #2c2c2e;
|
||||
--tile-bg-hover: #3a3a3c;
|
||||
--tile-bg-active: #1c1c1e;
|
||||
--tile-text: #f5f5f7;
|
||||
--tile-border: rgba(255, 255, 255, 0.14);
|
||||
--tile-shadow: 0 3px 6px rgba(0, 0, 0, 0.4);
|
||||
--tile-shadow-hover: 0 6px 14px rgba(0, 0, 0, 0.55);
|
||||
--tile-shadow-selected: inset 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||
|
||||
--accent-quartile: #f59e0b;
|
||||
--accent-quartile-glow: rgba(245, 158, 11, 0.45);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
RESET & TYPOGRAPHY
|
||||
========================================================================== */
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', 'SF Pro Icons', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
letter-spacing: -0.01em;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* App Container */
|
||||
.app-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 6px 14px 16px;
|
||||
position: relative;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-container {
|
||||
max-width: 480px;
|
||||
padding: 10px 16px 20px;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user