This commit is contained in:
2026-10-23 22:31:08 -04:00
commit 270b402ccc
31 changed files with 15846 additions and 0 deletions

44
.gitignore vendored Normal file
View 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
View 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!
![Quartiles Web App](https://img.shields.io/badge/Game-Quartiles-amber?style=for-the-badge)
![Built with Vite](https://img.shields.io/badge/Built_With-Vite_%26_Vanilla_JS-646CFF?style=for-the-badge&logo=vite)
![Design System](https://img.shields.io/badge/Design-Apple_iOS_Aesthetic-10B981?style=for-the-badge)
---
## 🎮 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
View 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
View 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
View 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
View 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

View 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
View 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
View 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!');

View 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
View 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);

View 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
View 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
View 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 };
}

View 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 };
}

View 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
View 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 };
}

View 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 };
}

View 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

File diff suppressed because one or more lines are too long

10611
src/data/puzzles.js Normal file

File diff suppressed because it is too large Load Diff

265
src/engine/audio.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}