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