aboutsummaryrefslogtreecommitdiff
path: root/static/bv
diff options
context:
space:
mode:
authorNat Lasseter <user@4574.co.uk>2025-04-01 11:45:48 +0100
committerNat Lasseter <user@4574.co.uk>2025-04-01 11:45:48 +0100
commit293df027348307de896232f1b867dd220ae8caa3 (patch)
treed63fedaadde32ef336f3004efb99c6dcf1f23585 /static/bv
parente26f3cdaf3919375b8ba825ee1a486d229f21def (diff)
[bracket] rename
Diffstat (limited to 'static/bv')
-rw-r--r--static/bv/bc-favicon.pngbin0 -> 15538 bytes
-rw-r--r--static/bv/bracket.css1286
-rw-r--r--static/bv/bracket.js4071
-rw-r--r--static/bv/index.html30
-rw-r--r--static/bv/puzzleencoder.js83
-rw-r--r--static/bv/tutorial/index.html22
-rw-r--r--static/bv/tutorial/tutorial-init.js22
-rw-r--r--static/bv/tutorial/tutorial.css945
-rw-r--r--static/bv/tutorial/tutorial.js1011
9 files changed, 7470 insertions, 0 deletions
diff --git a/static/bv/bc-favicon.png b/static/bv/bc-favicon.png
new file mode 100644
index 0000000..dc5136a
--- /dev/null
+++ b/static/bv/bc-favicon.png
Binary files differ
diff --git a/static/bv/bracket.css b/static/bv/bracket.css
new file mode 100644
index 0000000..ab0285f
--- /dev/null
+++ b/static/bv/bracket.css
@@ -0,0 +1,1286 @@
+/* ===================
+ Root Variables and Resets
+ =================== */
+ :root {
+ --main-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ --highlight-color: #fefcbf;
+ }
+
+ *, *::before, *::after {
+ box-sizing: border-box;
+ }
+
+ /* ===================
+ Container Structure
+ =================== */
+ .puzzle-container {
+ width: 100%;
+ margin: 0 auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ background: white;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ @media (min-width: 640px) {
+ .puzzle-container {
+ max-width: 42rem;
+ }
+ }
+
+ /* ===================
+ Header Styles
+ =================== */
+ /* Header Base Styles */
+ .puzzle-header {
+ background: white;
+ border-bottom: 1px solid #e2e8f0;
+ box-sizing: border-box;
+ padding: 0.6rem;
+ position: relative; /* Desktop default */
+ text-align: center;
+ z-index: 10;
+ }
+
+ /* Mobile-specific header styles */
+ @media (max-width: 640px) {
+ .puzzle-header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ padding: max(0.4rem, env(safe-area-inset-top));
+ }
+ }
+
+ /* Header title */
+ .puzzle-header h1 {
+ margin: 0 0 0.5rem 0;
+ text-align: center;
+ font-size: clamp(2rem, 8vw, 3rem);
+ cursor: pointer;
+ font-family: var(--main-font);
+ font-variant: small-caps;
+ color: #1a202c;
+ font-weight: 600;
+ }
+
+ @media (max-width: 640px) {
+ .puzzle-header h1 {
+ margin: 0;
+ font-size: clamp(1.8rem, 7vw, 2.8rem);
+ }
+ }
+
+ /* Help button */
+ .help-icon {
+ background: none;
+ border: 2px solid #333;
+ border-radius: 50%;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ font-size: 1.2rem;
+ color: #333;
+ }
+
+ .puzzle-header .help-button {
+ position: absolute;
+ top: 10px;
+ right: 20px;
+ background: none;
+ border: 2px solid #333;
+ border-radius: 50%;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-weight: bold;
+ font-size: 1.2rem;
+ color: #333;
+ }
+
+ @media (max-width: 640px) {
+ .puzzle-header .help-button {
+ right: 10px;
+ }
+ }
+
+ /* Text bubble for help button */
+.help-button.new-player-highlight::after {
+ content: "try the tutorial!";
+ position: absolute;
+ top: 42px;
+ right: -10px;
+ background-color: #333;
+ color: white;
+ padding: 6px 10px;
+ border-radius: 6px;
+ font-size: 14px;
+ font-weight: normal;
+ white-space: nowrap;
+ z-index: 10;
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,0.2));
+ /*animation: fade-in-out 3s infinite;*/
+}
+
+/* Triangle pointer for the bubble */
+.help-button.new-player-highlight::before {
+ content: "";
+ position: absolute;
+ top: 35px;
+ right: 10px;
+ width: 0;
+ height: 0;
+ border-left: 8px solid transparent;
+ border-right: 8px solid transparent;
+ border-bottom: 8px solid #333;
+ z-index: 10;
+ /*animation: fade-in-out 3s infinite;*/
+
+}
+
+/* Animation for the bubble to fade in and out */
+@keyframes fade-in-out {
+ 0%, 100% { opacity: 0.7; }
+ 50% { opacity: 1; }
+}
+
+/* Keep the pulsing animation for the button itself */
+.help-button.new-player-highlight {
+ border-color: rgb(0, 0, 0);
+ color: rgb(0, 0, 0);
+ animation: pulse 1s infinite;
+}
+
+ /* Optional: Pulse animation to draw attention */
+ @keyframes pulse {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.10); }
+ 100% { transform: scale(1); }
+ }
+
+ /* Help button */
+ .puzzle-header .info-button {
+ position: absolute;
+ top: 10px;
+ left: 20px;
+ background: none;
+ border: 2px solid #333;
+ border-radius: 50%;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-weight: bold;
+ font-size: 1.2rem;
+ font-family: menlo;
+ color: #333;
+ }
+
+ /* Style for the unseen info button */
+ .info-button.unseen-info {
+ border-color: #000000; /* Red border */
+ color: #ef4444; /* Red text */
+ /*animation: pulse 1s infinite; Optional: add a subtle pulse animation */
+ }
+
+ /* Optional: Pulse animation to draw attention */
+ @keyframes pulse {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.10); }
+ 100% { transform: scale(1); }
+ }
+
+ @media (max-width: 640px) {
+ .puzzle-header .info-button {
+ left: 10px;
+ }
+ }
+
+ /* Navigation container */
+ .puzzle-header .nav-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ }
+
+ /* Navigation buttons */
+ .puzzle-header .nav-button {
+ padding: 4px 12px;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ font-weight: bold;
+ font-size: 1.5rem;
+ color: #333;
+ }
+
+ .puzzle-header .nav-button:disabled {
+ opacity: 0.3;
+ cursor: default;
+ }
+
+ /* Puzzle date */
+ .puzzle-header .puzzle-date {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ font-family: var(--main-font);
+ font-variant: small-caps;
+ color: #1a202c;
+ }
+
+ /* ===================
+ Main Content Area
+ =================== */
+ /* New rules for loading state */
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ }
+
+ .puzzle-content {
+ padding: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0rem;
+ width: 100%;
+ }
+
+ /* ===================
+ Puzzle Display
+ =================== */
+ .puzzle-display {
+ width: 100%;
+ min-height: 40vh;
+ max-height: 60vh;
+ background: #f1f5f9;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ font-family: var(--mono-font);
+ font-size: 24px;
+ line-height: 1.5;
+ overflow-y: auto;
+ word-break: keep-all;
+ overflow-wrap: break-word;
+ hyphens: none;
+ transition: all 0.3s ease;
+ }
+
+ .puzzle-display.completed {
+ min-height: auto;
+ max-height: none;
+ height: auto;
+ }
+
+ .blank-line {
+ display: inline-block;
+ border-bottom: .15rem solid currentColor;
+ line-height: 1;
+ position: relative;
+ top: .2rem; /* Move line down slightly */
+ padding: 0 2px;
+ margin: 0 1px;
+ }
+
+ @media (max-width: 640px) {
+ .blank-line {
+ border-bottom: .1rem solid currentColor;
+ }
+ }
+
+ /* ===================
+ Active Clue & Mark Styles
+ =================== */
+ .active-clue {
+ background-color: rgba(255, 255, 0, 0.2);
+ border-radius: 3px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ transition: background-color 0.3s ease;
+ cursor: pointer;
+ }
+
+ .active-clue:hover {
+ background-color: rgba(255, 255, 0, 0.3);
+ }
+
+ mark.solved {
+ background-color: rgba(0, 255, 0, 0.2);
+ border-radius: 3px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ }
+
+ mark {
+ background-color: transparent;
+ color: inherit;
+ }
+
+ /* ===================
+ Input Styles
+ =================== */
+ .input-container {
+ width: 100%;
+ margin: 10px 0 0 0;
+ position: relative;
+ }
+
+ .answer-input {
+ width: 100%;
+ padding: 16px;
+ height: 60px;
+ font-size: 16px !important;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ margin-bottom: 0px;
+ box-sizing: border-box;
+ font-family: var(--mono-font);
+ }
+
+ /* ===================
+ Message Styles
+ =================== */
+ .message {
+ padding: 0.5rem;
+ margin: 0.5rem 0;
+ border-radius: 0.375rem;
+ text-align: center;
+ font-family: var(--mono-font);
+ display: none;
+ }
+
+ .message.success {
+ background-color: #c6f6d5;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1.2rem;
+ display: block;
+ padding: 20px;
+ line-height: 2rem;
+ }
+
+
+ .message.success .completion-link {
+ color: #2563eb;
+ text-decoration: underline;
+ }
+
+ .message.error {
+ background-color: #fee2e2;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1.2rem;
+ display: block;
+ padding: 20px;
+ line-height: 2rem;
+ }
+
+ /* ===================
+ Completion Message
+ =================== */
+ .completion-message {
+ margin: 0.5rem 0;
+ padding: 0rem;
+ border-radius: 0.375rem;
+ text-align: center;
+ font-family: var(--mono-font);
+ font-size: 1.2rem;
+ line-height: 1.5;
+ }
+
+ /* ===================
+ Solved Expressions
+ =================== */
+ .solved-expressions {
+ margin-top: 0rem;
+ }
+
+ .solved-expressions h3 {
+ font-size: 1.2rem;
+ font-weight: 500;
+ margin-bottom: 0.75rem;
+ font-family: var(--main-font);
+ color: #1a202c;
+ }
+
+ .expression-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1.2rem;
+ font-family: var(--mono-font);
+ line-height: 1.5;
+ }
+
+ .expression-item .solution {
+ font-weight: 600;
+ color: #1a202c;
+ }
+
+
+ /* ===================
+ Stats & Completion
+ =================== */
+ .stats-content {
+ margin-top: 0rem;
+ }
+
+ .stat-items {
+ margin: 0.5rem 0;
+ }
+
+ .stat-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ font-family: var(--main-font);
+ }
+
+ /* ===================
+ Share Button Styles
+ =================== */
+ .share-buttons {
+ margin-top: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .share-button {
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 1rem;
+ font-family: var(--main-font);
+ background: #1a202c;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ }
+
+ .share-button.reset {
+ background-color: #ff4444;
+ }
+
+ .share-button:hover {
+ background: #2d3748;
+ }
+
+ .share-message {
+ font-family: var(--main-font);
+ color: #1a202c;
+ text-align: center;
+ padding: 0.5rem;
+ margin-top: 0.5rem;
+ background: var(--highlight-color);
+ border-radius: 0.375rem;
+ }
+
+ /* ===================
+ Rank Display
+ =================== */
+ .rank-display {
+ font-family: var(--mono-font);
+ position: relative;
+ overflow: hidden;
+ padding: 1rem;
+ text-align: center;
+ font-size: 1.2em;
+ font-weight: 500;
+ border-radius: 0.375rem;
+ margin: 0rem 0rem 1rem 0rem;
+ border: 1px solid rgba(255,255,255,0.4);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+ cursor: pointer;
+ transition: transform 0.2s;
+ user-select: none;
+ }
+
+ .rank-display:active {
+ transform: scale(0.95);
+ }
+ .rank-display .next-rank {
+ font-size: 0.8em;
+ font-weight: normal;
+ }
+
+ .rank-display .share-hint {
+ font-size: 0.7em;
+ margin-top: 0.5em;
+ color: #666;
+ }
+
+ /* Rank gradients */
+ .rank-display[data-rank="Tourist"] { background: linear-gradient(135deg, #f0fdf4 0%, #6ee7b7 100%); }
+ .rank-display[data-rank="Commuter"] { background: linear-gradient(135deg, #fff7ed 0%, #fbd38d 100%); }
+ .rank-display[data-rank="Resident"] { background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%); }
+ .rank-display[data-rank="Council Member"] { background: linear-gradient(135deg, #fafaf9 0%, #d6d3d1 100%); }
+ .rank-display[data-rank="Chief of Police"] { background: linear-gradient(135deg, #f0f9ff 0%, #93c5fd 100%); }
+ .rank-display[data-rank="Mayor"] { background: linear-gradient(135deg, #fff1f2 0%, #fda4af 100%); }
+ .rank-display[data-rank="Power Broker"] { background: linear-gradient(135deg, #fefce8 0%, #92400e 100%); }
+ .rank-display[data-rank="Kingmaker"] { background: linear-gradient(135deg, #fffbeb 0%, #fcd34d 100%); }
+ .rank-display[data-rank="Puppet Master"] { background: linear-gradient(135deg, #faf5ff 0%, #d8b4fe 100%); }
+
+ /* ===================
+ Shimmer Animation
+ =================== */
+ @keyframes shimmer {
+ 0% { transform: translateX(-150%) rotate(45deg); }
+ 100% { transform: translateX(150%) rotate(45deg); }
+ }
+
+ .rank-display::after {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -100%;
+ width: 300%;
+ height: 200%;
+ background: linear-gradient(
+ to bottom right,
+ rgba(255,255,255,0) 0%,
+ rgba(255,255,255,0.3) 50%,
+ rgba(255,255,255,0) 100%
+ );
+ transform: rotate(45deg);
+ animation: shimmer 4s infinite linear;
+ pointer-events: none;
+ }
+
+ /* ===================
+ Mobile Styles
+ =================== */
+ @media (max-width: 640px) {
+ body {
+ margin: 0;
+ padding: 0;
+ height: 100vh;
+ font-size: 14px;
+ overflow: hidden;
+ }
+
+ .message.success {
+ background-color: #c6f6d5;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1rem;
+ display: block;
+ padding: 10px;
+ line-height: 1.4rem;
+ margin-top: 0.2rem;
+ }
+
+ .message.error {
+ background-color: #fee2e2;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1rem;
+ display: block;
+ padding: 10px;
+ line-height: 1.4rem;
+ margin-top: 0.2rem;
+ }
+
+
+
+ /* Mobile container setup */
+ .puzzle-container {
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* Mobile content area */
+ .puzzle-content {
+ position: absolute;
+ top: 85px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 0;
+ gap: 0rem;
+ }
+
+ @font-face {
+ font-family: 'CustomMono';
+ /* Use ui-monospace for every character except underscore */
+ src: local("ui-monospace");
+ /* All Unicode except underscore (U+005F) */
+ unicode-range: U+0000-005E, U+0060-10FFFF;
+ }
+
+ @font-face {
+ font-family: 'CustomMono';
+ /* Use Menlo for underscore only */
+ src: local("Menlo");
+ unicode-range: U+005F;
+ }
+
+ .puzzle-display {
+ height: auto;
+ padding: 0.5rem 1rem 1rem 1rem;
+ margin: 0;
+ font-size: 1.2rem;
+ background: #f1f5f9;
+ padding-bottom: 200px;
+ font-family: ui-monospace, "SF Mono", "Roboto Mono", "Menlo", "Consolas", "Liberation Mono", monospace;
+ }
+
+ .puzzle-display.completed {
+ padding-bottom: 1rem !important;
+ }
+
+
+ .rank-display {
+ margin: 0rem 0.5rem 1rem 0.5rem;
+ }
+
+
+ /* Mobile input container */
+ .input-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ z-index: 10;
+ width: 100%;
+ border-top: 1px solid #e2e8f0;
+ box-shadow: 0 -2px 6px rgba(0,0,0,0.1);
+ padding: 0.5rem calc(0.5rem + env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-right));
+ box-sizing: border-box;
+ }
+
+ .custom-input {
+ border: 1px solid #e2e8f0;
+ padding: 0.63rem;
+ border-radius: 6px;
+ margin-bottom: 0.125rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 1.2rem;
+ min-height: 2.4rem;
+ background: white;
+ width: 100%;
+ color: #000;
+ }
+
+ .placeholder {
+ color: #9ca3af;
+ }
+
+ /* Mobile keyboard */
+ .custom-keyboard {
+ width: 100%;
+ padding: .25rem 0 0 0;
+ box-sizing: border-box;
+ background-color: white;
+ touch-action: none;
+ }
+
+ .keyboard-row {
+ display: grid;
+ grid-template-columns: repeat(10, 1fr);
+ gap: 0.02rem;
+ margin-bottom: 0.125rem;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .keyboard-key {
+ height: 3.3rem;
+ min-height: 2.2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.2rem;
+ margin: 0.1rem 0.15rem 0.1rem 0.15rem;
+ cursor: pointer;
+ color: black;
+ background-color:rgb(233, 233, 233);
+ box-sizing: border-box;
+ touch-action: manipulation;
+ text-align: center;
+ font-size: 1.125rem;
+ font-weight: 600;
+ border: 1px solid rgb(200, 200, 200);
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-appearance: none !important;
+ appearance: none !important;
+ border-radius: 4px !important;
+ outline: none !important;
+ -webkit-tap-highlight-color: transparent !important;
+ transition: background-color 0.1s ease-out,
+ transform 0.1s ease-out;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ }
+
+ .keyboard-key:active {
+ background-color: #94a3b8;
+ transform: translateY(2px);
+ }
+
+ .stat-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ }
+
+ /* Hide desktop elements */
+ .bottom-controls,
+ .reset-button,
+ .solved-expressions,
+ .keystroke-stats,
+ .share-button.copy {
+ display: none;
+ }
+
+ /* Mobile completion state */
+ .puzzle-container.completed .puzzle-content {
+ position: static;
+ height: auto;
+ overflow: visible;
+ margin-top: 85px;
+ padding: 1rem;
+ }
+
+ .puzzle-container.completed {
+ position: static;
+ height: auto;
+ min-height: 100vh;
+ overflow-y: auto;
+ }
+
+ .puzzle-display.completed .input-container {
+ display: none;
+ }
+
+ .puzzle-container.completed .solved-expressions,
+ .puzzle-container.completed .keystroke-stats {
+ display: block;
+ margin: 1rem;
+ }
+
+ .keystroke-stats {
+ margin-top: 0rem;
+ }
+ }
+
+ /* ===================
+ iOS-specific Styles
+ =================== */
+ @supports (-webkit-touch-callout: none) {
+ .keyboard-key:active {
+ background-color: #94a3b8 !important;
+ transform: translateY(2px) !important;
+ }
+ }
+
+ /* ===================
+ Score Bar
+ =================== */
+
+ .score-bar {
+ margin: 1.5rem 0;
+ text-align: center;
+ font-size: 1.1rem;
+ font-family: var(--mono-font), monospace;
+ border: 2px dashed #aaa;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ background-color: rgba(255, 255, 255, 0.5);
+ }
+
+ .score-value {
+ font-weight: bold;
+ margin-bottom: 0rem;
+ }
+
+ .progress-bar {
+ font-size: 1.5rem;
+ letter-spacing: 0.1rem;
+ line-height: 1.5;
+ }
+
+ /* Mobile adjustments */
+ @media (max-width: 640px) {
+ .score-bar {
+ margin: 1rem 0;
+ font-size: 1rem;
+ padding: 0.75rem;
+ }
+
+ .progress-bar {
+ font-size: 1.2rem;
+ }
+ }
+
+ /* Submit button and input wrapper */
+ .input-submit-wrapper {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ }
+
+ .submit-answer {
+ padding: 0 1.5rem;
+ height: 60px;
+ background-color: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ white-space: nowrap;
+ font-size: 1rem;
+ }
+
+ .submit-answer:hover {
+ background-color: #1d4ed8;
+ }
+
+ .submit-answer:active {
+ background-color: #1e40af;
+ transform: scale(0.98);
+ }
+
+ .mobile-submit-button {
+ background-color: #2563eb !important;
+ color: white !important;
+ font-size: 1rem !important;
+ font-weight: 500 !important;
+ }
+
+/* Date Picker Styles */
+.puzzle-date {
+ cursor: pointer;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ transition: color 0.2s ease;
+ padding: 4px; /* Larger tap target */
+ border-radius: 4px;
+ user-select: none;
+ -webkit-tap-highlight-color: rgba(0,0,0,0); /* Remove default mobile tap highlight */
+ touch-action: manipulation; /* Optimize for touch */
+}
+
+.puzzle-date:hover {
+ color: #4299e1;
+ background-color: rgba(66, 153, 225, 0.1);
+}
+
+.puzzle-date:active {
+ transform: scale(0.98);
+}
+
+.puzzle-date::after {
+ content: "\23F7"; /* dropdown arrow maybe */
+ display: inline-block;
+ margin-left: 0.5rem;
+ font-size: 0.8em;
+ opacity: 0.7;
+}
+
+/* Visible hint on mobile */
+@media (max-width: 640px) {
+ .puzzle-date {
+ padding: 5px 8px;
+ border: none !important; /* Explicitly remove any border */
+ }
+
+ .puzzle-date::after {
+ font-size: 1em;
+ margin-left: 8px;
+ }
+}
+
+.date-picker-container {
+ font-family: var(--main-font);
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+ border: 1px solid rgba(0, 0, 0, 0.1);
+ background-color: white;
+ animation: fadeIn 0.2s ease;
+ box-sizing: border-box;
+ overflow: hidden; /* Ensure nothing bleeds outside the container */
+ padding: 1rem !important; /* Force padding */
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; transform: translateY(-10px) translateX(-50%); }
+ to { opacity: 1; transform: translateY(0) translateX(-50%); }
+}
+
+.month-nav {
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: background-color 0.2s;
+}
+
+.month-nav:hover {
+ background-color: #f0f0f0;
+}
+
+.weekdays {
+ font-size: 0.85rem;
+ color: #4a5568;
+ padding-bottom: 0.5rem;
+}
+
+.calendar-grid {
+ font-size: 0.95rem;
+}
+
+.date-cell {
+ transition: transform 0.1s ease, box-shadow 0.1s ease;
+ user-select: none;
+ aspect-ratio: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.date-cell.has-puzzle:hover:not([disabled="true"]) {
+ transform: translateY(-2px);
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+}
+
+.date-cell.has-puzzle:active:not([disabled="true"]) {
+ transform: translateY(0);
+}
+
+.date-cell.has-puzzle[data-status="complete"] {
+ position: relative;
+}
+
+.date-cell.has-puzzle[data-status="complete"]::after {
+ content: "\2713";
+ position: absolute;
+ top: 0;
+ right: 2px;
+ font-size: 8px;
+ color: #22543d;
+}
+
+.date-cell.has-puzzle[data-status="started"] {
+ position: relative;
+}
+
+.date-cell.has-puzzle[data-status="started"]::after {
+ content: "\2022\2022\2022";
+ position: absolute;
+ bottom: 1px;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 8px;
+ line-height: 1;
+ color: #744210;
+}
+
+.empty-day {
+ aspect-ratio: 1;
+}
+
+/* Mobile styles */
+/* Fix for the date picker right column padding issue on mobile */
+
+/* Balanced fix for date picker columns on mobile */
+
+/* Robust fix for date picker edge issues on mobile */
+
+@media (max-width: 640px) {
+ /* Force container to be smaller than viewport width and centered */
+ .date-picker-container {
+ width: 85vw !important; /* Reduced from 90vw to ensure space */
+ max-width: 300px !important; /* Smaller max-width */
+ left: 50% !important;
+ transform: translateX(-50%) !important;
+ margin-left: 0 !important;
+ margin-right: 0 !important;
+ padding: 0.75rem !important; /* Slightly reduced padding */
+ box-sizing: border-box !important;
+ border-radius: 0.5rem !important;
+ overflow: hidden !important; /* Force content to stay within bounds */
+ }
+
+ /* Force grid to be contained */
+ .calendar-grid {
+ display: grid !important;
+ grid-template-columns: repeat(7, 1fr) !important;
+ gap: 3px !important; /* Reduced gap */
+ width: 100% !important;
+ max-width: 100% !important; /* Ensure it can't exceed container */
+ overflow: visible !important; /* Allow cell content to be visible */
+ padding: 0 !important;
+ margin: 0 !important;
+ }
+
+ /* Ensure weekday headers don't overflow */
+ .weekdays {
+ display: grid !important;
+ grid-template-columns: repeat(7, 1fr) !important;
+ gap: 3px !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ padding: 0 !important;
+ margin: 0 0 8px 0 !important;
+ }
+
+ /* Make date cells slightly smaller */
+ .date-cell {
+ width: 100% !important;
+ height: auto !important;
+ min-height: 30px !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+ padding: 0.2rem 0 !important;
+ font-size: 0.85rem !important; /* Slightly smaller text */
+ margin: 0 !important;
+ box-sizing: border-box !important;
+ }
+
+ /* Explicitly set column sizes */
+ .calendar-grid > div, .weekdays > div {
+ width: 100% !important;
+ box-sizing: border-box !important;
+ }
+
+ /* Additional safeguard for month selector */
+ .date-picker-header {
+ padding: 0 !important;
+ margin-bottom: 0.75rem !important;
+ }
+}
+
+/* iOS Toggle Switch */
+.toggle-container {
+ margin: 1em 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.toggle-labels {
+ display: flex;
+ flex-direction: column;
+}
+
+.toggle-title {
+ font-weight: 500;
+ margin-bottom: 0.3em;
+}
+
+.toggle-description {
+ font-size: 0.8em;
+ color: #666;
+ line-height: 1.2;
+}
+
+.toggle-switch {
+ position: relative;
+ display: inline-block;
+ width: 50px;
+ height: 28px;
+ flex-shrink: 0;
+ margin-left: 12px;
+}
+
+.toggle-switch input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: .4s;
+ border-radius: 34px;
+}
+
+.toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 24px;
+ width: 24px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: .3s;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+}
+
+input:checked + .toggle-slider {
+ background-color: #2563eb;
+}
+
+input:checked + .toggle-slider:before {
+ transform: translateX(22px);
+}
+
+/* Mode labels */
+.mode-indicator {
+ margin-top: 0.8em;
+ font-size: 0.9em;
+ color: #555;
+ text-align: center;
+}
+
+.mode-name {
+ font-weight: 600;
+ color: #2563eb;
+}
+
+/* Modal Styles */
+.announcement-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 2000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.3s ease;
+ pointer-events: none;
+}
+
+.announcement-modal-overlay.visible {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.announcement-modal {
+ background-color: white;
+ border-radius: 0.5rem;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+ width: 90%;
+ max-width: 500px;
+ max-height: 90vh;
+ overflow-y: auto;
+ padding: 1.5rem;
+ position: relative;
+ transform: translateY(-20px);
+ transition: transform 0.3s ease;
+}
+
+.announcement-modal-overlay.visible .announcement-modal {
+ transform: translateY(0);
+}
+
+.announcement-modal-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-bottom: 1rem;
+ color: #1a202c;
+ text-align: center;
+ font-family: var(--main-font);
+}
+
+.announcement-modal-content {
+ font-size: 1rem;
+ line-height: 1.5;
+ color: #4a5568;
+ margin-bottom: 1.5rem;
+ font-family: var(--main-font);
+}
+
+.announcement-modal-button {
+ display: block;
+ width: 100%;
+ padding: 0.75rem;
+ background-color: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ font-size: 1rem;
+ font-weight: 500;
+ cursor: pointer;
+ text-align: center;
+ transition: background-color 0.2s;
+ font-family: var(--main-font);
+}
+
+.announcement-modal-button:hover {
+ background-color: #1d4ed8;
+}
+
+.announcement-modal-button:active {
+ background-color: #1e40af;
+ transform: scale(0.98);
+}
+
+/* Add more space between list items */
+.announcement-modal-content li {
+ margin-bottom: 0.75rem;
+}
+
+/* Remove extra margin from the last list item */
+.announcement-modal-content li:last-child {
+ margin-bottom: 0;
+}
+
+@media (max-width: 640px) {
+ .announcement-modal {
+ width: 85%;
+ padding: 1rem;
+ }
+
+ .announcement-modal-title {
+ font-size: 1.25rem;
+ }
+
+ .announcement-modal-content {
+ font-size: 0.95rem;
+ }
+}
diff --git a/static/bv/bracket.js b/static/bv/bracket.js
new file mode 100644
index 0000000..0d42747
--- /dev/null
+++ b/static/bv/bracket.js
@@ -0,0 +1,4071 @@
+class BracketCityRank {
+ /**
+ * @typedef {Object} RankConfig
+ * @property {number} peekPenalty - Points deducted per peek
+ * @property {number} megaPeekPenalty - Points deducted per mega peek
+ * @property {number} wrongGuessPenalty - Points deducted per wrong guess
+ * @property {Object.<string, number>} rankThresholds - Score thresholds for each rank
+ */
+
+ /**
+ * @param {Partial<RankConfig>} config - Optional configuration overrides
+ */
+ constructor(config = {}) {
+ // Define rank emojis with consistent Unicode escapes
+ this.rankEmojis = {
+ 'Tourist': '\u{1F4F8}', // Camera with flash
+ 'Commuter': '\u{1F682}', // Steam locomotive
+ 'Resident': '\u{1F3E0}', // House
+ 'Council Member': '\u{1F3DB}', // Classical building
+ 'Chief of Police': '\u{1F46E}', // Police officer
+ 'Mayor': '\u{1F396}', // Military medal
+ 'Power Broker': '\u{1F4BC}', // Briefcase
+ 'Kingmaker': '\u{1F451}', // Crown
+ 'Puppet Master': '\u{1F52E}' // Crystal ball
+ };
+
+ // Default configuration
+ const defaultConfig = {
+ peekPenalty: 5,
+ megaPeekPenalty: 15,
+ wrongGuessPenalty: 2, // For regular mode
+ rankThresholds: {
+ 'Tourist': 0,
+ 'Commuter': 11,
+ 'Resident': 21,
+ 'Council Member': 31,
+ 'Chief of Police': 51,
+ 'Mayor': 68,
+ 'Power Broker': 79,
+ 'Kingmaker': 91,
+ 'Puppet Master': 100
+ },
+ regularRankThresholds: {
+ 'Tourist': 0,
+ 'Commuter': 10,
+ 'Resident': 20,
+ 'Council Member': 45,
+ 'Chief of Police': 65,
+ 'Mayor': 80,
+ 'Power Broker': 90,
+ 'Kingmaker': 100,
+ 'Puppet Master': 100
+ }
+ };
+
+ // Merge provided config with defaults
+ this.config = {
+ ...defaultConfig,
+ ...config,
+ // Ensure rankThresholds is fully merged
+ rankThresholds: {
+ ...defaultConfig.rankThresholds,
+ ...(config.rankThresholds || {})
+ },
+ regularRankThresholds: {
+ ...defaultConfig.regularRankThresholds,
+ ...(config.regularRankThresholds || {})
+ }
+ };
+ }
+
+ /**
+ * Modified calculateScore method to account for different scoring systems
+ * @param {number} efficiency - Base efficiency score (0-100)
+ * @param {number} peekCount - Number of peeks used
+ * @param {number} megaPeekCount - Number of mega peeks used
+ * @param {number} wrongGuessCount - Number of wrong guesses
+ * @returns {Object} Detailed score breakdown
+ */
+ calculateScore(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) {
+ // Always ensure inputs are numbers and non-negative
+ const safepeekCount = Math.max(0, Number(peekCount) || 0);
+ const safeMegaPeekCount = Math.max(0, Number(megaPeekCount) || 0);
+ const safeWrongGuessCount = Math.max(0, Number(wrongGuessCount) || 0);
+
+ // Calculate penalties
+ const peekPenalty = safepeekCount * this.config.peekPenalty;
+ const megaPeekPenalty = safeMegaPeekCount * this.config.megaPeekPenalty;
+
+ // Different base score calculation based on mode
+ let baseScore, wrongGuessPenalty, totalPenalty, finalScore;
+
+ if (this.inputMode === 'classic') {
+ // Classic mode: score based on keystroke efficiency
+ baseScore = Math.max(0, Number(efficiency) || 0);
+ wrongGuessPenalty = 0; // No wrong guess penalty in classic mode
+ totalPenalty = peekPenalty + megaPeekPenalty;
+ } else {
+ // Submit mode: score starts at 100, penalty for wrong guesses
+ baseScore = 100; // Start at max
+ wrongGuessPenalty = safeWrongGuessCount * this.config.wrongGuessPenalty;
+ totalPenalty = peekPenalty + megaPeekPenalty + wrongGuessPenalty;
+ }
+
+ // Calculate final score (capped at 100)
+ finalScore = Math.min(100, Math.max(0, baseScore - totalPenalty));
+
+ return {
+ baseScore,
+ peekPenalty,
+ megaPeekPenalty,
+ wrongGuessPenalty,
+ totalPenalty,
+ finalScore
+ };
+ }
+
+ /**
+ * Generates detailed stats including rank and scoring information
+ * Accounts for different modes
+ * @param {number} efficiency - Base efficiency score
+ * @param {number} peekCount - Number of peeks used
+ * @param {number} megaPeekCount - Number of mega peeks used
+ * @param {number} wrongGuessCount - Number of wrong guesses
+ * @returns {Object} Detailed stats object
+ */
+ getDetailedStats(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) {
+ const scoreDetails = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount);
+ const rank = this.calculateRank(efficiency, peekCount, megaPeekCount, wrongGuessCount);
+
+ // Calculate points needed for next rank
+ const thresholds = this.inputMode === 'classic' ?
+ this.config.rankThresholds :
+ this.config.regularRankThresholds;
+
+ const ranks = Object.entries(thresholds)
+ .sort((a, b) => a[1] - b[1]); // Sort ascending
+
+ const currentRankIndex = ranks.findIndex(([r]) => r === rank);
+ const nextRank = currentRankIndex < ranks.length - 1 ?
+ ranks[currentRankIndex + 1] : null;
+
+ const pointsToNextRank = nextRank ?
+ Math.max(0, nextRank[1] - scoreDetails.finalScore) : 0;
+
+ return {
+ rank,
+ rankEmoji: this.rankEmojis[rank] || this.rankEmojis['Tourist'],
+ ...scoreDetails,
+ nextRankName: nextRank ? nextRank[0] : null,
+ pointsToNextRank: Math.ceil(pointsToNextRank), // Round up for user-friendly display
+ config: this.config
+ };
+ }
+
+ /**
+ * Determines rank based on final score
+ * @param {number} efficiency - Base efficiency score
+ * @param {number} peekCount - Number of peeks used
+ * @param {number} megaPeekCount - Number of mega peeks used
+ * @param {number} wrongGuessCount - Number of wrong guesses
+ * @returns {string} Rank title
+ */
+ calculateRank(efficiency, peekCount = 0, megaPeekCount = 0, wrongGuessCount = 0) {
+ const { finalScore } = this.calculateScore(efficiency, peekCount, megaPeekCount, wrongGuessCount);
+
+ // Only keep the Puppet Master special case
+ // Puppet Master: 100% keystroke-efficient (no excess keystrokes) AND no peeks/wrong guesses
+ const isPerfect = efficiency === 100;
+ const isNoHelp = peekCount === 0 && megaPeekCount === 0 && wrongGuessCount === 0;
+
+ if (isPerfect && isNoHelp) {
+ return 'Puppet Master';
+ }
+
+ // For all other cases, follow normal threshold logic
+ const thresholds = this.inputMode === 'classic' ?
+ this.config.rankThresholds :
+ this.config.regularRankThresholds;
+
+ const ranks = Object.entries(thresholds)
+ .sort((a, b) => b[1] - a[1]);
+
+ // Find highest rank threshold that score exceeds
+ const rank = ranks.find(([_, threshold]) => finalScore >= threshold);
+ return rank ? rank[0] : 'Tourist';
+ }
+}
+
+ /**
+ * @typedef {Object} GameState
+ * @property {string} displayState - Current puzzle display with HTML markup
+ * @property {Set<string>} solvedExpressions - Set of solved expression strings
+ * @property {string} message - Current user message
+ * @property {number} totalKeystrokes - Total keystrokes used
+ * @property {number} minimumKeystrokes - Minimum keystrokes needed
+ * @property {Array<{start: number, end: number, text: string, expression: string}>} activeClues
+ * @property {Set<string>} hintModeClues - Clues that have been peeked
+ * @property {Set<string>} peekedClues - Tracking for peek penalties
+ * @property {Set<string>} megaPeekedClues - Clues that were fully revealed
+ */
+
+
+ class BracketPuzzle {
+
+ /****************************
+ Core initilization methods
+ ****************************/
+
+ /**
+ * Constructor and Initialization
+ */
+
+ constructor(rootElement) {
+ this.root = rootElement;
+ this.API_ENDPOINT = 'https://user.4574.co.uk/bracket-api';
+ this.PUZZLE_DATA = null;
+
+ this.MODE_STORAGE_KEY = 'bracketCityInputMode';
+ this.inputMode = this.getInputMode();
+
+ // Detect device type
+ this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+
+ // Add new property specifically for input method decision
+ this.useCustomKeyboard = this.isMobile && !this.isTablet();
+
+ // Track keyboard layout
+ this.isAltKeyboard = false;
+
+ // Initialize user ID
+ this.userId = this.initializeUserId();
+
+ // Initialize empty state immediately
+ this.state = {
+ displayState: '',
+ solvedExpressions: new Set(),
+ message: '',
+ totalKeystrokes: 0,
+ minimumKeystrokes: 0,
+ activeClues: [],
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ wrongGuesses: 0
+ };
+
+ // Initialize rank calculator
+ this.rankCalculator = new BracketCityRank();
+ this.rankCalculator.inputMode = this.inputMode;
+
+ // Start initialization
+ this.initializeGame();
+ }
+
+ /**
+ * Initializes the game
+ */
+ async initializeGame() {
+ try {
+ console.log('Starting game initialization');
+
+ // Show loading state immediately
+ this.root.innerHTML = this.generateLoadingHTML();
+
+ // Get initial puzzle date, checking encoded puzzles first
+ const urlParams = new URLSearchParams(window.location.search);
+ const encoded = urlParams.get('d');
+
+ if (encoded) {
+ const decoded = PuzzleEncoder.decodePuzzle(encoded);
+ if (decoded) {
+ this.currentPuzzleDate = decoded.puzzleDate;
+ } else {
+ this.currentPuzzleDate = this.getPuzzleDateFromURL();
+ }
+ } else {
+ this.currentPuzzleDate = this.getPuzzleDateFromURL();
+ }
+
+ console.log('Current puzzle date set to:', this.currentPuzzleDate);
+
+ // Load saved input mode preference
+ this.inputMode = this.getInputMode();
+
+ // Update rankCalculator with the input mode
+ this.rankCalculator = new BracketCityRank();
+ this.rankCalculator.inputMode = this.inputMode;
+
+ // Start showing basic UI elements
+ this.root.innerHTML = this.generateInitialHTML();
+ this.setupInputHandlers();
+
+ // Load puzzle data
+ console.log('Loading puzzle data');
+ await this.initializePuzzleData();
+
+ if (!this.PUZZLE_DATA) {
+ throw new Error('Failed to initialize puzzle data');
+ }
+
+ // Load or initialize game state
+ console.log('Loading saved state');
+ const savedState = await this.loadSavedState(this.currentPuzzleDate);
+ console.log('Saved state loaded:', savedState ? 'found' : 'not found');
+
+ // Initialize game state
+ if (savedState && savedState.initialPuzzle === this.PUZZLE_DATA.initialPuzzle) {
+ console.log('Using saved state');
+ this.updateGameState({
+ displayState: savedState.displayState,
+ solvedExpressions: new Set(savedState.solvedExpressions),
+ message: savedState.message || '',
+ totalKeystrokes: savedState.totalKeystrokes,
+ minimumKeystrokes: savedState.minimumKeystrokes,
+ activeClues: this.findActiveClues(savedState.displayState),
+ hintModeClues: new Set(savedState.hintModeClues || []),
+ peekedClues: new Set(savedState.peekedClues || []),
+ megaPeekedClues: new Set(savedState.megaPeekedClues || []),
+ wrongGuesses: savedState.wrongGuesses || 0
+ });
+ } else {
+ console.log('Initializing fresh state');
+ this.updateGameState({
+ displayState: this.PUZZLE_DATA.initialPuzzle,
+ solvedExpressions: new Set(),
+ message: '',
+ totalKeystrokes: 0,
+ minimumKeystrokes: this.calculateMinimumKeystrokes(),
+ activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle),
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ wrongGuesses: 0
+ });
+ }
+
+ this.setupDatePicker();
+
+ // Update URL to match current puzzle
+ if (!encoded) { // Only update URL if not an encoded puzzle
+ this.updateURL(this.currentPuzzleDate);
+ }
+
+ // Do the initial render
+ console.log('Performing initial render');
+ this.render();
+
+ // Load non-critical features
+ requestAnimationFrame(() => {
+ console.log('Setting up auxiliary features');
+ this.setupShareButtons();
+ this.setupPuzzleDisplay();
+
+ this.checkAdjacentPuzzles(this.currentPuzzleDate).then(() => {
+ this.setupNavigationHandlers();
+ console.log('Navigation setup complete');
+ });
+ });
+ // Show important announcement after UI is fully rendered
+ // Change the id parameter for each new announcement to ensure it shows
+ this.showImportantAnnouncement({
+ title: '\u{1F6A8} Welcome to [Bracket Village] \u{1F6A8}',
+ message: `
+ <ul style="text-align: left; margin: 1rem 0; padding-left: 1.5rem;">
+ <li>Click <span class='help-icon'>?</span> to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">tutorial</mark></li>
+ <li>Click <span class='help-icon'>!</span> to access <mark style="background-color:rgba(255, 255, 0, 0.2);">news and settings</mark></li>
+ <li>Click the date header to access the <mark style="background-color:rgba(255, 255, 0, 0.2);">date picker</mark> and browse the archive</li>
+ </ul>
+ <p><strong>Thank you for playing Bracket Village!</strong></p>
+ `,
+ buttonText: 'ok great thanks',
+ id: 'update-2025-3-11' // Change this ID for a new announcement
+ });
+
+
+ } catch (error) {
+ console.error('Failed to initialize game:', error);
+ this.showErrorMessage('Failed to load the game. Please refresh the page to try again.');
+ }
+ }
+
+ /**
+ * Initializes or retrieves the user ID
+ * @returns {string} User ID
+ */
+ initializeUserId() {
+ const storageKey = 'bracketCityUserId';
+ let userId = localStorage.getItem(storageKey);
+
+ if (!userId) {
+ userId = crypto.randomUUID();
+ localStorage.setItem(storageKey, userId);
+ }
+
+ return userId;
+ }
+
+ async initializePuzzleData() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const encoded = urlParams.get('d');
+
+ if (encoded) {
+ // Try PuzzleEncoder format first
+ const decodedPuzzle = PuzzleEncoder.decodePuzzle(encoded);
+ if (decodedPuzzle) {
+ this.PUZZLE_DATA = decodedPuzzle;
+ } else {
+ // Try direct base64 JSON format
+ try {
+ const jsonStr = atob(encoded);
+ const puzzleData = JSON.parse(jsonStr);
+
+ // Validate required fields
+ if (puzzleData.initialPuzzle && puzzleData.solutions) {
+ this.PUZZLE_DATA = {
+ initialPuzzle: puzzleData.initialPuzzle,
+ solutions: puzzleData.solutions
+ };
+ } else {
+ console.error('Invalid puzzle data structure in URL');
+ await this.fetchPuzzleForDate(this.currentPuzzleDate);
+ }
+ } catch (e) {
+ console.error('Failed to decode puzzle data:', e);
+ await this.fetchPuzzleForDate(this.currentPuzzleDate);
+ }
+ }
+ } else {
+ // No encoded puzzle, fetch for specific date or today
+ await this.fetchPuzzleForDate(this.currentPuzzleDate);
+ }
+
+ if (!this.PUZZLE_DATA) {
+ throw new Error('Failed to initialize puzzle data');
+ }
+ }
+
+ /**
+ * Checks if running in development environment
+ */
+ isDevelopmentEnvironment() {
+ const hostname = window.location.hostname;
+ return hostname === 'localhost' ||
+ hostname === '127.0.0.1' ||
+ hostname === 'staging.bracket.city' ||
+ hostname === 'dev.bracket.city';
+ }
+
+
+ /**
+ * Gets the current input mode from localStorage or defaults to 'classic'
+ * @returns {string} The input mode ('classic' or 'submit')
+ */
+ getInputMode() {
+ return localStorage.getItem(this.MODE_STORAGE_KEY) || 'submit';
+ }
+
+ /**
+ * Determines if a puzzle has been meaningfully started based on consistent criteria
+ * @param {Object} puzzleData - The puzzle state to check
+ * @returns {boolean} Whether the puzzle is considered started
+ */
+ isPuzzleStarted(puzzleData = this.state) {
+ // If no data provided or using current state, check directly
+ if (puzzleData === this.state) {
+ return (this.state.totalKeystrokes > 5) ||
+ (this.state.peekedClues && this.state.peekedClues.size > 0) ||
+ (this.state.megaPeekedClues && this.state.megaPeekedClues.size > 0) ||
+ (this.state.wrongGuesses > 0) ||
+ (this.state.solvedExpressions && this.state.solvedExpressions.size > 0);
+ }
+
+ // For saved state data from localStorage (which has arrays instead of Sets)
+ return (puzzleData.totalKeystrokes > 5) ||
+ (puzzleData.peekedClues && puzzleData.peekedClues.length > 0) ||
+ (puzzleData.megaPeekedClues && puzzleData.megaPeekedClues.length > 0) ||
+ (puzzleData.wrongGuesses > 0) ||
+ (puzzleData.solvedExpressions && puzzleData.solvedExpressions.length > 0);
+ }
+
+ /**
+ * Sets the input mode and updates the UI
+ * @param {string} mode - The input mode to set ('classic' or 'submit')
+ */
+ setInputMode(mode) {
+ if (mode !== 'classic' && mode !== 'submit') {
+ console.error('Invalid input mode:', mode);
+ return;
+ }
+
+ // Check if the puzzle has been started
+ const hasPuzzleStarted = this.isPuzzleStarted();
+
+ // Only allow mode changes if the puzzle hasn't been started
+ if (hasPuzzleStarted) {
+ // Don't show a message - the disabled toggle with explanatory text is sufficient
+ console.log('Mode change prevented: puzzle already started');
+ return;
+ }
+
+ this.inputMode = mode;
+ localStorage.setItem(this.MODE_STORAGE_KEY, mode);
+
+ // Update rankCalculator's input mode
+ this.rankCalculator.inputMode = mode;
+
+ // Update the UI to reflect the new mode
+ this.updateInputModeUI();
+
+ // If help/info is currently visible, ensure inputs stay disabled
+ if (this.state.helpVisible) {
+ this.disableInputsForHelp();
+ }
+ }
+
+ /**
+ * New helper method to ensure inputs are disabled when help is visible
+ */
+ disableInputsForHelp() {
+ // Get the input elements
+ const desktopInput = this.root.querySelector('.answer-input');
+
+ // Disable input while help is visible
+ if (desktopInput) {
+ desktopInput.disabled = true;
+ }
+
+ // Disable submit button (desktop)
+ const submitButton = this.root.querySelector('.submit-answer');
+ if (submitButton) {
+ submitButton.disabled = true;
+ submitButton.style.opacity = '0.5';
+ submitButton.style.pointerEvents = 'none';
+ }
+
+ // For mobile, disable keyboard and input without hiding
+ if (this.isMobile) {
+ // Make custom input appear disabled
+ const customInput = this.root.querySelector('.custom-input');
+ if (customInput) {
+ customInput.style.opacity = '0.5';
+ customInput.style.pointerEvents = 'none';
+ }
+
+ // Disable all keyboard keys
+ const keyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ keyboardKeys.forEach(key => {
+ key.disabled = true;
+ key.style.opacity = '0.5';
+ key.style.pointerEvents = 'none';
+ });
+
+ // Disable mobile submit button
+ const mobileSubmitButton = this.root.querySelector('.mobile-submit-button');
+ if (mobileSubmitButton) {
+ mobileSubmitButton.disabled = true;
+ mobileSubmitButton.style.opacity = '0.5';
+ mobileSubmitButton.style.pointerEvents = 'none';
+ }
+ }
+ }
+
+ /**
+ * Updates the UI to match the current input mode
+ */
+ updateInputModeUI() {
+ // Replace the input container HTML
+ const inputContainer = this.root.querySelector('.input-container');
+ if (!inputContainer) return;
+
+ // Store current input value before replacing the container
+ let currentInputValue = '';
+ if (this.isMobile) {
+ currentInputValue = this.customInputValue || '';
+ } else {
+ const inputEl = this.root.querySelector('.answer-input');
+ currentInputValue = inputEl ? inputEl.value : '';
+ }
+
+ // Generate fresh input container HTML based on current mode
+ inputContainer.innerHTML = this.generateInputContainerHTML();
+
+ // Re-setup input handlers
+ this.setupInputHandlers();
+
+ // Restore input value
+ if (this.isMobile) {
+ this.customInputValue = currentInputValue;
+ this.updateCustomInputDisplay();
+ } else {
+ const inputEl = this.root.querySelector('.answer-input');
+ if (inputEl) inputEl.value = currentInputValue;
+ }
+
+ // Re-render to show updated stats if the puzzle is complete
+ if (this.isPuzzleComplete()) {
+ const keystrokeStats = this.root.querySelector('.keystroke-stats');
+ if (keystrokeStats) {
+ this.renderCompletionStats(keystrokeStats);
+ }
+ }
+ }
+ /****************
+ State Management
+ *****************/
+
+ /**
+ * Updates game state while maintaining consistency
+ * @param {Partial<GameState>} updates - State properties to update
+ */
+ updateGameState(updates) {
+ // Create new state with updates
+ const newState = {
+ ...this.state,
+ ...updates
+ };
+
+ // Ensure Sets remain Sets
+ if (updates.solvedExpressions) {
+ newState.solvedExpressions = new Set(updates.solvedExpressions);
+ }
+ if (updates.hintModeClues) {
+ newState.hintModeClues = new Set(updates.hintModeClues);
+ }
+ if (updates.peekedClues) {
+ newState.peekedClues = new Set(updates.peekedClues);
+ }
+ if (updates.megaPeekedClues) {
+ newState.megaPeekedClues = new Set(updates.megaPeekedClues);
+ }
+
+ // Update active clues if display state changes
+ if (updates.displayState) {
+ newState.activeClues = this.findActiveClues(updates.displayState);
+ }
+
+ // Update state and persist
+ this.state = newState;
+ this.saveState();
+ }
+
+ /**
+ * Gets the storage key for the current puzzle - enhanced version with explicit date parameter
+ * @param {string} puzzleDate - Optional specific puzzle date
+ * @returns {string} Storage key
+ */
+ getStorageKey(puzzleDate) {
+ const dateToUse = puzzleDate || this.currentPuzzleDate;
+ if (!dateToUse) {
+ console.error('No puzzle date available when getting storage key!');
+ return null;
+ }
+ return `bracketPuzzle_${dateToUse}`;
+ }
+
+ /**
+ * Enhanced loadSavedState method with input mode handling
+ * @param {string} targetDate - The puzzle date to load state for
+ * @returns {Object|null} Saved state or null if none exists or validation fails
+ */
+ async loadSavedState(targetDate) {
+ try {
+ // Ensure we're loading state for the correct puzzle date
+ const puzzleDate = targetDate || this.currentPuzzleDate;
+
+ if (!puzzleDate) {
+ console.warn('Cannot load state: missing puzzle date');
+ return null;
+ }
+
+ const storageKey = `bracketPuzzle_${puzzleDate}`;
+
+ if (!this.PUZZLE_DATA) {
+ console.warn('Cannot load state: missing puzzle data');
+ return null;
+ }
+
+ const savedData = localStorage.getItem(storageKey);
+ if (!savedData) {
+ console.log('No saved state found for key:', storageKey);
+ return null;
+ }
+
+ const parsed = JSON.parse(savedData);
+
+ // Enhanced validation: verify the puzzle date in the saved state matches the requested date
+ if (parsed.puzzleDate !== puzzleDate) {
+ console.warn(`Saved state puzzle date (${parsed.puzzleDate}) doesn't match requested date (${puzzleDate})`);
+ return null;
+ }
+
+ // Additional validation: verify the initialPuzzle matches
+ if (parsed.initialPuzzle !== this.PUZZLE_DATA.initialPuzzle) {
+ console.warn('Saved state initialPuzzle doesn\'t match current puzzle data');
+ return null;
+ }
+
+ return {
+ displayState: parsed.displayState,
+ solvedExpressions: parsed.solvedExpressions || [],
+ message: '', // Always start with empty message
+ messageType: null,
+ totalKeystrokes: parsed.totalKeystrokes || 0,
+ minimumKeystrokes: parsed.minimumKeystrokes || this.calculateMinimumKeystrokes(),
+ hintModeClues: parsed.hintModeClues || [],
+ peekedClues: parsed.peekedClues || [],
+ megaPeekedClues: parsed.megaPeekedClues || [],
+ initialPuzzle: parsed.initialPuzzle,
+ puzzleDate: parsed.puzzleDate,
+ wrongGuesses: parsed.wrongGuesses || 0,
+ // Include the saved input mode if it exists
+ inputMode: parsed.inputMode || this.getInputMode(),
+ // Preserve help system state properties if they exist
+ helpVisible: parsed.helpVisible || false,
+ previousDisplay: parsed.previousDisplay || null
+ };
+
+ } catch (error) {
+ console.error('Error loading saved state:', error);
+ return null;
+ }
+ }
+
+ saveState() {
+ try {
+ const storageKey = this.getStorageKey();
+ if (!storageKey) {
+ console.error('Cannot save state: missing storage key');
+ return false;
+ }
+
+ const stateToSave = {
+ displayState: this.state.displayState,
+ solvedExpressions: Array.from(this.state.solvedExpressions),
+ totalKeystrokes: this.state.totalKeystrokes,
+ minimumKeystrokes: this.state.minimumKeystrokes,
+ hintModeClues: Array.from(this.state.hintModeClues),
+ peekedClues: Array.from(this.state.peekedClues),
+ megaPeekedClues: Array.from(this.state.megaPeekedClues),
+ initialPuzzle: this.PUZZLE_DATA.initialPuzzle,
+ puzzleDate: this.currentPuzzleDate,
+ isComplete: this.state.isComplete || false,
+ completionStats: this.state.completionStats || null,
+ wrongGuesses: this.state.wrongGuesses || 0,
+ inputMode: this.inputMode // Save the input mode with puzzle state
+ };
+
+ localStorage.setItem(storageKey, JSON.stringify(stateToSave));
+ return true;
+
+ } catch (error) {
+ console.error('Error saving state:', error);
+ return false;
+ }
+ }
+
+
+ /**
+ * Resets puzzle state and storage
+ */
+ resetPuzzle() {
+ if (!confirm('Are you sure you want to reset the puzzle? All progress will be lost.')) {
+ return;
+ }
+
+ // Clear saved state.
+ const storageKey = this.getStorageKey();
+ if (storageKey) {
+ localStorage.removeItem(storageKey);
+ }
+
+ // Get the user's preferred input mode
+ const preferredInputMode = this.getInputMode();
+
+ // Update the current input mode to the preferred mode
+ this.inputMode = preferredInputMode;
+
+ // Update the rankCalculator's input mode
+ this.rankCalculator.inputMode = this.inputMode;
+
+ // Reset game state
+ this.updateGameState({
+ displayState: this.PUZZLE_DATA.initialPuzzle,
+ solvedExpressions: new Set(),
+ message: '',
+ totalKeystrokes: 0,
+ minimumKeystrokes: this.calculateMinimumKeystrokes(),
+ activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle),
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ helpVisible: false,
+ previousDisplay: null,
+ wrongGuesses: 0 // Reset wrong guesses
+ });
+
+ // Remove any existing completion message and "completed" classes.
+ const completionMessage = this.root.querySelector('.completion-message');
+ if (completionMessage) {
+ completionMessage.remove();
+ }
+ const container = this.root.querySelector('.puzzle-container');
+ if (container) {
+ container.classList.remove('completed');
+ }
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (puzzleDisplay) {
+ puzzleDisplay.classList.remove('completed');
+ }
+
+ // Rebuild the UI within your dedicated container.
+ this.root.innerHTML = this.generateInitialHTML();
+
+ // Re‑attach event handlers.
+ this.setupInputHandlers();
+ this.setupShareButtons();
+ this.setupPuzzleDisplay();
+ this.setupDatePicker();
+ // Re‑attach navigation handlers. Use a timeout to allow the new DOM to settle.
+ setTimeout(() => {
+ this.setupNavigationHandlers();
+ this.checkAdjacentPuzzles(this.currentPuzzleDate);
+ }, 0);
+
+ // Make sure the input container is visible.
+ const inputContainer = this.root.querySelector('.input-container');
+ if (inputContainer) {
+ inputContainer.style.display = 'block';
+ }
+ if (!this.isMobile && this.root.querySelector('.answer-input')) {
+ this.root.querySelector('.answer-input').focus();
+ }
+
+ this.render();
+ this.showMessage('Puzzle reset successfully!');
+ }
+
+ calculateMinimumKeystrokes() {
+ return Object.values(this.PUZZLE_DATA.solutions)
+ .reduce((total, solution) => total + solution.length, 0);
+ }
+
+ /*************
+ Data and API
+ **************/
+
+ /**
+ * Fetches and processes puzzle data for a date
+ */
+ async fetchPuzzleForDate(targetDate) {
+ try {
+ // Try to get puzzle for specified date
+ let response = await fetch(`${this.API_ENDPOINT}/${targetDate}`, {
+ cache: 'no-store',
+ headers: {
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache'
+ }
+ });
+
+ // If that date's puzzle doesn't exist, get latest puzzle
+ if (!response.ok) {
+ // Get base list of puzzles
+ response = await fetch(this.API_ENDPOINT, {
+ cache: 'no-store',
+ headers: {
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache'
+ }
+ });
+ if (!response.ok) {
+ throw new Error('Unable to fetch puzzle list');
+ }
+
+ const puzzles = await response.json();
+ if (!puzzles || puzzles.length === 0) {
+ throw new Error('No puzzles available');
+ }
+
+ // Find the most recent puzzle date
+ const latestPuzzleDate = puzzles.reduce((latest, current) => {
+ return latest > current ? latest : current;
+ });
+
+ // Fetch the latest puzzle
+ response = await fetch(`${this.API_ENDPOINT}/${latestPuzzleDate}`, {
+ cache: 'no-store',
+ headers: {
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache'
+ }
+ });
+ if (!response.ok) {
+ throw new Error('Failed to fetch latest puzzle');
+ }
+ }
+
+ const puzzleData = await response.json();
+
+ /*console.log('Raw puzzle data received:', {
+ date: targetDate,
+ puzzleData,
+ timestamp: new Date().toISOString()
+ });*/
+
+ // Clean up escaped quotes and store in PUZZLE_DATA
+ this.PUZZLE_DATA = {
+ ...puzzleData,
+ initialPuzzle: this.unescapeString(puzzleData.initialPuzzle),
+ solutions: Object.fromEntries(
+ Object.entries(puzzleData.solutions).map(([key, value]) => [
+ this.unescapeString(key),
+ this.unescapeString(value)
+ ])
+ )
+ };
+
+ return true;
+
+ } catch (error) {
+ console.error('Error fetching puzzle:', error);
+ return false;
+ }
+ }
+
+ unescapeString(str) {
+ if (typeof str !== 'string') return str;
+ return str.replace(/\\"/g, '"')
+ .replace(/\\'/g, "'")
+ .replace(/\\\\/g, "\\");
+ }
+
+ /***********************
+ Date and URL management
+ ************************/
+
+ getNYCDate() {
+ // Create a date object
+ const date = new Date();
+
+ // Get the NY time options
+ const nyOptions = {
+ timeZone: 'America/New_York',
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ };
+
+ // Format date in NY timezone
+ const nyDateStr = date.toLocaleString('en-US', nyOptions);
+
+ // Split into components and rearrange into YYYY-MM-DD
+ const [month, day, year] = nyDateStr.split('/');
+ return `${year}-${month}-${day}`;
+ }
+
+ formatNYCDate(date = this.getNYCDate()) {
+ // Split the YYYY-MM-DD format
+ const [year, month, day] = date.split('-');
+
+ // Create a Date object in the NYC timezone
+ // First create a date string with the format that ensures it's interpreted as NYC time
+ const nycDateString = `${year}-${month}-${day}T12:00:00-05:00`; // Noon in NYC to avoid DST issues
+ const dateObj = new Date(nycDateString);
+
+ // Format it for display using the NYC timezone
+ return dateObj.toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric',
+ year: 'numeric',
+ timeZone: 'America/New_York'
+ });
+ }
+
+ /**
+ * Updates URL with the current puzzle date
+ * @param {string} puzzleDate - Date in YYYY-MM-DD format
+ */
+ updateURL(puzzleDate) {
+ const url = new URL(window.location.href);
+
+ // If we have an encoded puzzle, don't modify URL
+ if (url.searchParams.has('d')) {
+ return;
+ }
+
+ // Get today's date in NYC time zone
+ const todayPuzzleDate = this.getNYCDate();
+
+ // Check if we're using path format or query parameter format
+ const isPathFormat = window.location.pathname.match(/\/puzzles\/\d{4}\/\d+\/\d+/);
+
+ if (isPathFormat) {
+ // If using path format, keep using path format
+ if (puzzleDate !== todayPuzzleDate) {
+ // Format the date components for the path
+ const [year, month, day] = puzzleDate.split('-');
+ const newPath = `/puzzles/${year}/${parseInt(month)}/${parseInt(day)}`;
+ window.history.pushState({}, '', newPath);
+ } else {
+ // If it's today's puzzle, go to home
+ window.history.pushState({}, '', '/');
+ }
+ } else {
+ // Using query parameter format, stick with that
+ url.searchParams.delete('date');
+ if (puzzleDate !== todayPuzzleDate) {
+ url.searchParams.set('date', puzzleDate);
+ }
+ window.history.pushState({}, '', url.toString());
+ }
+ }
+
+ /**
+ * Gets puzzle date from URL or defaults to current NYC date
+ * @returns {string} Puzzle date in YYYY-MM-DD format
+ */
+ getPuzzleDateFromURL() {
+ // NEW: First check path for /puzzles/YYYY/M/D pattern
+ const pathMatch = window.location.pathname.match(/\/puzzles\/(\d{4})\/(\d{1,2})\/(\d{1,2})/);
+ if (pathMatch) {
+ const year = pathMatch[1];
+ const month = pathMatch[2].padStart(2, '0');
+ const day = pathMatch[3].padStart(2, '0');
+ const dateStr = `${year}-${month}-${day}`;
+
+ if (this.isValidPuzzleDate(dateStr)) {
+ return dateStr;
+ }
+ }
+
+ // UNCHANGED: Your existing query parameter logic stays exactly the same
+ const urlParams = new URLSearchParams(window.location.search);
+
+ if (urlParams.has('d')) {
+ const decoded = PuzzleEncoder.decodePuzzle(urlParams.get('d'));
+ return decoded?.puzzleDate || this.getNYCDate();
+ }
+
+ const dateParam = urlParams.get('date');
+ if (dateParam && this.isValidPuzzleDate(dateParam)) {
+ return dateParam;
+ }
+
+ return this.getNYCDate();
+ }
+
+ /**
+ * Validates a puzzle date string
+ * @param {string} dateStr - Date string to validate
+ * @returns {boolean} Whether the date is valid
+ */
+ isValidPuzzleDate(dateStr) {
+ // Check format (YYYY-MM-DD)
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
+ return false;
+ }
+
+ const date = new Date(dateStr);
+ if (isNaN(date.getTime())) {
+ return false;
+ }
+
+ // On dev servers, allow any valid date
+ if (this.isDevelopmentEnvironment()) {
+ return true;
+ }
+
+ // On production, don't allow future dates
+ const nycDate = new Date(this.getNYCDate());
+ return date <= nycDate;
+ }
+
+
+ /**********
+ Game logic
+ ***********/
+
+ /**
+ * Finds all active clues in the current puzzle state
+ * @param {string} puzzleText - Current puzzle text
+ * @returns {Array<{start: number, end: number, text: string, expression: string}>}
+ */
+ findActiveClues(puzzleText) {
+ const activeClues = [];
+
+ function findClues(str, startOffset = 0) {
+ let i = 0;
+ while (i < str.length) {
+ if (str[i] === '[') {
+ const startIndex = i;
+ let bracketCount = 1;
+ let hasNestedBrackets = false;
+ i++;
+
+ let innerContent = '';
+ while (i < str.length && bracketCount > 0) {
+ if (str[i] === '[') {
+ bracketCount++;
+ hasNestedBrackets = true;
+ } else if (str[i] === ']') {
+ bracketCount--;
+ }
+ innerContent += str[i];
+ i++;
+ }
+
+ innerContent = innerContent.slice(0, -1);
+
+ if (!hasNestedBrackets) {
+ // Clean expression text of HTML markup
+ const cleanExpression = innerContent.replace(/<\/?[^>]+(>|$)/g, '');
+ activeClues.push({
+ start: startOffset + startIndex,
+ end: startOffset + i,
+ text: str.substring(startIndex, i),
+ expression: cleanExpression
+ });
+ }
+
+ if (hasNestedBrackets) {
+ findClues(innerContent, startOffset + startIndex + 1);
+ }
+ } else {
+ i++;
+ }
+ }
+ }
+
+ const cleanText = puzzleText.replace(/<\/?span[^>]*(>|$)/g, '');
+ findClues(cleanText);
+ return activeClues;
+ }
+
+ /**
+ * Finds available expressions that can be solved
+ * @param {string} text - Current puzzle text
+ * @returns {Array<{expression: string, startIndex: number, endIndex: number}>}
+ */
+ findAvailableExpressions(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const results = [];
+ const regex = /\[([^\[\]]+?)\]/g;
+ const matchedPositions = new Set();
+ let match;
+
+ while ((match = regex.exec(cleanText)) !== null) {
+ const startIdx = match.index;
+ const endIdx = startIdx + match[0].length;
+
+ if (!matchedPositions.has(startIdx)) {
+ matchedPositions.add(startIdx);
+ results.push({
+ expression: match[1],
+ startIndex: startIdx,
+ endIndex: endIdx
+ });
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Generates new state after solving a clue
+ * @param {Object} clue - Clue object
+ * @param {string} solution - Solution text
+ * @returns {string} New display state
+ */
+ generateSolvedClueState(clue, solution) {
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ return (
+ cleanState.slice(0, clue.startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(clue.endIndex)
+ );
+ }
+
+ /**
+ * Handles user input for solving expressions
+ * @param {string} input - User input string
+ */
+ handleInput(input) {
+ const normalizedInput = this.normalizeInput(input);
+ if (!normalizedInput) {
+ return;
+ }
+
+ const match = this.findMatchingExpression(normalizedInput);
+ if (!match) {
+ return;
+ }
+
+ this.solveExpression(match);
+ }
+
+ /**
+ * Normalizes and validates user input
+ * @param {string} input - Raw user input
+ * @returns {string|null} Normalized input or null if invalid
+ */
+ normalizeInput(input) {
+ if (!input?.trim()) {
+ return null;
+ }
+ return input.trim().toLowerCase();
+ }
+
+ /**
+ * Handles submission in submit mode
+ */
+ handleSubmission() {
+ const input = this.isMobile ? this.customInputValue : this.answerInput.value;
+ if (!input || !input.trim()) return;
+
+ const normalizedInput = this.normalizeInput(input);
+ const match = this.findMatchingExpression(normalizedInput);
+
+ if (match) {
+ this.solveExpression(match);
+
+ // Check if puzzle is complete after solving
+ if (this.isPuzzleComplete()) {
+ this.renderCompletedPuzzle(
+ this.root.querySelector('.puzzle-display'),
+ this.root.querySelector('.input-container'),
+ this.root.querySelector('.keystroke-stats')
+ );
+ }
+ } else {
+ // Increment wrong guesses counter and show error message
+ this.updateGameState({
+ wrongGuesses: (this.state.wrongGuesses || 0) + 1
+ });
+
+ this.showMessage('Incorrect!', 'error');
+ }
+
+ // Clear input
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.updateCustomInputDisplay();
+ } else {
+ this.answerInput.value = '';
+ this.answerInput.focus();
+ }
+ }
+
+ /**
+ * Finds an unsolved expression matching the input
+ * @param {string} normalizedInput - Normalized user input
+ * @returns {Object|null} Matching expression info or null if not found
+ */
+ findMatchingExpression(normalizedInput) {
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {expression, startIndex, endIndex} of availableExpressions) {
+ const solution = this.PUZZLE_DATA.solutions[expression];
+
+ if (solution?.toLowerCase() === normalizedInput &&
+ !this.state.solvedExpressions.has(expression)) {
+ return {
+ expression,
+ solution,
+ startIndex,
+ endIndex
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Processes a correct solution and updates game state
+ * @param {Object} match - Expression match information
+ */
+ solveExpression(match) {
+ const { expression, solution, startIndex, endIndex } = match;
+
+ const isFirstSolve = this.state.solvedExpressions.size === 0;
+
+ // Generate the new puzzle display state by replacing the solved expression
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ // Add new expression at the beginning of the Set to maintain reverse chronological order
+ const updatedExpressions = new Set([expression, ...Array.from(this.state.solvedExpressions)]);
+
+ // turn off tutorial prompt
+ this.turnOffTutorialPulse();
+
+ // Update game state with the new display and mark the expression as solved
+ this.updateGameState({
+ displayState: newDisplayState,
+ solvedExpressions: updatedExpressions
+ });
+
+ // Show a success message (auto-clears later)
+ this.showMessage('Correct!', 'success');
+
+ // Clear the input based on device type:
+ if (this.isMobile) {
+ this.customInputValue = '';
+ if (this.customInputEl) {
+ this.updateCustomInputDisplay();
+ }
+ } else {
+ if (this.answerInput) {
+ this.answerInput.value = '';
+ }
+ }
+
+ // Re-render the puzzle display
+ requestAnimationFrame(() => {
+ this.render();
+ });
+ }
+
+ /**
+ * Processes a solution reveal for megapeek
+ * @param {Object} match - Expression match information
+ */
+ processSolutionReveal(expression, solution) {
+ // Clean the display state first
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {startIndex, endIndex} of availableExpressions) {
+ const currentExpression = this.unescapeString(
+ cleanState.slice(startIndex + 1, endIndex - 1).trim()
+ );
+
+ if (this.unescapeString(expression) === currentExpression) {
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ return newDisplayState;
+ }
+ }
+ return null;
+ }
+
+ /************
+ Hint system
+ *************/
+
+ /**
+ * Toggles hint mode for an expression between peek (show length) and mega peek (reveal answer)
+ * @param {string} expression - The expression to toggle hints for
+ */
+ toggleHintMode(expression) {
+ // Early validation
+ if (!expression || this.state.megaPeekedClues.has(expression)) {
+ return;
+ }
+
+ // Handle mega peek (second click)
+ if (this.state.hintModeClues.has(expression)) {
+ this.handleMegaPeek(expression);
+ return;
+ }
+
+ // Handle first peek
+ this.handleFirstPeek(expression);
+ }
+
+ /**
+ * Handles the first peek at an expression (shows length)
+ * @param {string} expression - The expression to peek at
+ */
+ handleFirstPeek(expression) {
+ // Add confirmation dialog for regular peek
+ if (!confirm('You sure you want to peek at the first letter of this answer?')) {
+ return;
+ }
+
+ this.updateGameState({
+ hintModeClues: new Set([...this.state.hintModeClues, expression]),
+ peekedClues: new Set([...this.state.peekedClues, expression])
+ });
+
+ this.render();
+ }
+
+ /**
+ * Handles the mega peek action (reveals answer)
+ * @param {string} expression - The expression to reveal
+ */
+ handleMegaPeek(expression) {
+ if (!confirm('You sure you want to reveal this answer?')) {
+ return;
+ }
+
+ const solution = this.PUZZLE_DATA.solutions[expression];
+ if (!solution) {
+ console.error('No solution found for expression:', expression);
+ return;
+ }
+
+ const newDisplayState = this.processSolutionReveal(expression, solution);
+ if (!newDisplayState) {
+ console.error('Could not find expression in puzzle state');
+ return;
+ }
+
+ this.updateGameState({
+ displayState: newDisplayState,
+ totalKeystrokes: this.state.totalKeystrokes + solution.length,
+ megaPeekedClues: new Set([...this.state.megaPeekedClues, expression]),
+ solvedExpressions: new Set([expression, ...Array.from(this.state.solvedExpressions)]),
+ hintModeClues: new Set([...this.state.hintModeClues].filter(e => e !== expression))
+ });
+
+ this.render();
+ }
+
+ /***************
+ Input Handling
+ ****************/
+
+ /**
+ * Conservatively detects if the current device is a tablet (iPad or Android)
+ * @returns {boolean} True if the device is confidently identified as a tablet
+ */
+ isTablet() {
+ // IPAD DETECTION
+ const isLegacyIPad = /iPad/i.test(navigator.userAgent);
+ const isModernIPad = !(/iPhone|iPod/.test(navigator.userAgent)) &&
+ navigator.platform === 'MacIntel' &&
+ navigator.maxTouchPoints > 1 &&
+ !window.MSStream;
+
+ // Any iPad detection is sufficient
+ const isIPad = isLegacyIPad || isModernIPad;
+
+ // ANDROID TABLET DETECTION
+ // Standard way: Android without "Mobile" in UA string typically means tablet
+ const isAndroidTablet = /Android/i.test(navigator.userAgent) &&
+ !/Mobile/i.test(navigator.userAgent);
+
+ // Alternative Android tablet signals
+ const hasExplicitTabletIdentifier = /Tablet|Tab/i.test(navigator.userAgent) ||
+ /SM-T[0-9]{3}/i.test(navigator.userAgent); // Samsung Galaxy Tab model numbers
+
+ // GENERIC TABLET SIZE CHECK (as backup, not primary signal)
+ // Most tablets have at least one dimension ≥ 768px
+ const hasTabletDimensions = Math.max(
+ screen.width,
+ screen.height,
+ window.innerWidth,
+ window.innerHeight
+ ) >= 768;
+
+ // EXPLICIT PHONE DETECTION (to avoid false positives)
+ const isExplicitlyPhone = /iPhone|iPod|Android.*Mobile|Mobile.*Android|BlackBerry|IEMobile|Opera Mini|Windows Phone/i.test(navigator.userAgent);
+
+ // Combined tablet detection:
+ return isIPad || ((isAndroidTablet || hasExplicitTabletIdentifier) &&
+ hasTabletDimensions &&
+ !isExplicitlyPhone);
+ }
+
+ /**
+ * Sets up input handlers based on current mode
+ */
+ setupInputHandlers() {
+ if (this.useCustomKeyboard) {
+ // Use custom keyboard for phones
+ this.setupMobileInput();
+ } else {
+ // Use native keyboard for tablets and desktops
+ this.setupDesktopInput();
+ }
+ }
+
+ /**
+ * Sets up desktop input handlers
+ */
+ setupDesktopInput() {
+ this.answerInput = this.root.querySelector('.answer-input');
+ const submitButton = this.root.querySelector('.submit-answer');
+
+ if (!this.answerInput) {
+ console.error('Answer input element not found');
+ return;
+ }
+
+ if (this.inputMode === 'submit') {
+ // Submit mode - only handle input on submit button click
+ if (submitButton) {
+ submitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+ }
+
+ // Handle Enter key press
+ this.answerInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+
+ // Apply visual feedback if submit button exists
+ if (submitButton) {
+ submitButton.style.transform = 'scale(0.95)';
+ submitButton.style.backgroundColor = '#1e40af';
+
+ // Remove the styles after a short delay
+ setTimeout(() => {
+ submitButton.style.transform = '';
+ submitButton.style.backgroundColor = '';
+ }, 85);
+ }
+
+ this.handleSubmission();
+ }
+ });
+ } else {
+ // Classic mode - auto-snap answers as typed
+ this.answerInput.addEventListener('input', (e) => {
+ this.handleInput(e.target.value);
+ });
+ }
+
+ this.answerInput.addEventListener('keydown', (e) => {
+ this.handleKeyDown(e);
+ });
+
+ this.answerInput.addEventListener('paste', (e) => {
+ e.preventDefault();
+ });
+
+ this.answerInput.focus();
+ }
+
+ /**
+ * Sets up mobile input handlers
+ */
+ setupMobileInput() {
+ this.customInputValue = this.customInputValue || '';
+ this.customInputEl = this.root.querySelector('.custom-input');
+
+ if (!this.customInputEl) {
+ console.error('Custom input display element not found');
+ return;
+ }
+
+ // Clean up old placeholder if it exists
+ const oldPlaceholder = this.customInputEl.querySelector('.placeholder');
+ if (oldPlaceholder) {
+ oldPlaceholder.remove();
+ }
+
+ // Create fresh placeholder
+ const placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = this.inputMode === 'classic' ? 'start typing answers...' : 'type any answer...';
+ this.customInputEl.appendChild(placeholderEl);
+
+ // Set up the mobile submit button (if in submit mode)
+ if (this.inputMode === 'submit') {
+ const mobileSubmitButton = this.root.querySelector('.mobile-submit-button');
+ if (mobileSubmitButton) {
+ mobileSubmitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+ }
+ }
+
+ // Clean up any existing listeners first
+ const oldKeyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ oldKeyboardKeys.forEach(keyEl => {
+ const newKeyEl = keyEl.cloneNode(true);
+ keyEl.parentNode.replaceChild(newKeyEl, keyEl);
+ });
+
+ // Attach fresh click listeners to keyboard keys
+ const keyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ keyboardKeys.forEach(keyEl => {
+ keyEl.addEventListener('click', () => {
+ const key = keyEl.getAttribute('data-key');
+
+ if (key === 'backspace') {
+ this.customInputValue = this.customInputValue.slice(0, -1);
+ } else if (key === '123' || key === 'ABC') {
+ this.isAltKeyboard = !this.isAltKeyboard;
+ // Re-render the keyboard
+ const keyboardContainer = this.root.querySelector('.custom-keyboard');
+ if (keyboardContainer) {
+ keyboardContainer.innerHTML = this.generateKeyboardButtonsHTML();
+ }
+ // Re-attach event listeners while preserving input
+ this.setupMobileInput();
+ return;
+ } else {
+ this.customInputValue += key;
+
+ this.updateGameState({ totalKeystrokes: this.state.totalKeystrokes + 1 });
+
+ }
+
+ this.updateCustomInputDisplay();
+
+ // In classic mode, check for solution match after each key
+ if (this.inputMode === 'classic') {
+ this.handleInput(this.customInputValue);
+ }
+ });
+
+ // Prevent dragging/swiping on the key
+ keyEl.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ }, { passive: false });
+ });
+
+ // Enable :active states on iOS
+ document.addEventListener('touchstart', () => {}, false);
+
+ // Update display to show current input value
+ this.updateCustomInputDisplay();
+ }
+
+ updateCustomInputDisplay() {
+ if (!this.customInputEl) return;
+
+ // Find or create placeholder
+ let placeholderEl = this.customInputEl.querySelector('.placeholder');
+ if (!placeholderEl) {
+ placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = 'type any answer...';
+ }
+
+ // Clear the input
+ this.customInputEl.innerHTML = '';
+
+ // Show either placeholder or input value
+ if (this.customInputValue.length > 0) {
+ this.customInputEl.textContent = this.customInputValue;
+ } else {
+ this.customInputEl.appendChild(placeholderEl);
+ }
+ }
+
+ /**
+ * Handles keydown events for keystroke tracking
+ * @param {KeyboardEvent} event - The keyboard event
+ */
+ handleKeyDown(event) {
+ // turn off tutorial pulse
+ // Only count meaningful keystrokes (not control keys)
+ if (this.isCountableKeystroke(event)) {
+ this.updateGameState({
+ totalKeystrokes: this.state.totalKeystrokes + 1
+ });
+
+ }
+ }
+
+ /**
+ * Determines if a keystroke should be counted
+ * @param {KeyboardEvent} event - The keyboard event
+ * @returns {boolean} Whether the keystroke should be counted
+ */
+ isCountableKeystroke(event) {
+ // Only count single printable characters
+ return event.key.length === 1 &&
+ /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/? ]$/.test(event.key);
+ }
+
+ /***********
+ Navigation
+ ************/
+
+ /**
+ * Creates and attaches the date picker functionality
+ */
+ setupDatePicker() {
+ // Find the date element to attach the picker to
+ const dateElement = this.root.querySelector('.puzzle-date');
+ if (!dateElement) return;
+
+ // Remove any existing date picker containers to prevent duplicates
+ const existingContainers = this.root.querySelectorAll('.date-picker-container');
+ existingContainers.forEach(container => container.remove());
+
+ // Clean up any existing event listeners by cloning the element
+ const dateParent = dateElement.parentNode;
+ const newDateElement = dateElement.cloneNode(true);
+ dateParent.replaceChild(newDateElement, dateElement);
+
+ // Make it look clickable
+ newDateElement.style.cursor = 'pointer';
+ newDateElement.title = 'Tap to open puzzle calendar';
+
+ // Add visual indication for mobile users
+ const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent);
+ if (isMobile) {
+ // Add a calendar icon or more visible indicator for mobile
+ dateParent.style.position = 'relative';
+
+ // Enhance tap target size for mobile
+ newDateElement.style.padding = '8px 4px';
+ newDateElement.style.position = 'relative';
+ newDateElement.style.zIndex = '1';
+ }
+
+ // Create a container for the date picker (initially hidden)
+ const datePickerContainer = document.createElement('div');
+ datePickerContainer.className = 'date-picker-container';
+ datePickerContainer.style.display = 'none';
+ datePickerContainer.style.position = 'absolute';
+ datePickerContainer.style.top = '100%';
+ datePickerContainer.style.left = '50%';
+ datePickerContainer.style.transform = 'translateX(-50%)';
+ datePickerContainer.style.zIndex = '1500'; // Increased z-index to ensure visibility
+ datePickerContainer.style.backgroundColor = 'white';
+ datePickerContainer.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
+ datePickerContainer.style.borderRadius = '0.5rem';
+ datePickerContainer.style.padding = '1rem';
+ datePickerContainer.style.marginTop = '0.5rem';
+ datePickerContainer.style.maxWidth = isMobile ? '300px' : '340px';
+ datePickerContainer.style.width = isMobile ? '90vw' : '100%';
+ datePickerContainer.style.boxSizing = 'border-box'; // Ensure padding is included in width
+
+ // Insert the container after the date element
+ dateParent.style.position = 'relative';
+ dateParent.style.zIndex = '100'; // Ensure parent has reasonable z-index
+ dateParent.appendChild(datePickerContainer);
+
+ // Toggle function to open/close the date picker
+ const toggleDatePicker = (e) => {
+ // Prevent default to avoid any browser handling
+ e.preventDefault();
+ e.stopPropagation();
+
+ // Add log for debugging
+ console.log('Date picker toggle clicked', {
+ containerDisplay: datePickerContainer.style.display,
+ isCompletionState: this.isPuzzleComplete()
+ });
+
+ if (datePickerContainer.style.display === 'none') {
+ this.renderDatePicker(datePickerContainer);
+ datePickerContainer.style.display = 'block';
+
+ // Create a close handler that works for both mouse and touch
+ const closeCalendar = (event) => {
+ // Don't close if clicked on the date element or within the picker
+ if (!datePickerContainer.contains(event.target) && event.target !== newDateElement) {
+ datePickerContainer.style.display = 'none';
+ document.removeEventListener('click', closeCalendar);
+ document.removeEventListener('touchend', closeCalendar);
+ }
+ };
+
+ // Add both mouse and touch event listeners to handle closing
+ document.addEventListener('click', closeCalendar);
+ document.addEventListener('touchend', closeCalendar);
+ } else {
+ datePickerContainer.style.display = 'none';
+ }
+ };
+
+ // Add event listeners for both click and touch events with preventTouchFocus fix
+ const preventTouchFocus = (e) => {
+ e.preventDefault(); // Prevent focus/highlight issues on some mobile browsers
+ toggleDatePicker(e);
+ };
+
+ newDateElement.addEventListener('click', toggleDatePicker);
+ newDateElement.addEventListener('touchend', preventTouchFocus);
+
+ // Add visible debug helper in development environments
+ if (this.isDevelopmentEnvironment()) {
+ newDateElement.setAttribute('data-debug', 'date-picker-trigger');
+ }
+ }
+
+ /**
+ * Renders the date picker with available puzzles
+ * @param {HTMLElement} container - The container to render the date picker in
+ */
+ async renderDatePicker(container) {
+ // Show loading state
+ container.innerHTML = '<div class="loading-calendar">Loading calendar...</div>';
+
+ try {
+ // Fetch available puzzles
+ const availableDates = await this.fetchAvailablePuzzleDates();
+ if (!availableDates || availableDates.length === 0) {
+ container.innerHTML = '<div class="error">No puzzles available</div>';
+ return;
+ }
+
+ // Get completion status for all puzzles
+ const puzzleStatuses = this.getPuzzleCompletionStatuses(availableDates);
+
+ // Determine the current month and year (in NYC timezone)
+ const currentDateNYC = new Date(this.getNYCDate());
+ let currentViewMonth = currentDateNYC.getMonth();
+ let currentViewYear = currentDateNYC.getFullYear();
+
+ // Render initial calendar
+ this.renderMonthCalendar(container, currentViewMonth, currentViewYear, availableDates, puzzleStatuses);
+
+ } catch (error) {
+ console.error('Error rendering date picker:', error);
+ container.innerHTML = '<div class="error">Failed to load calendar</div>';
+ }
+ }
+
+ /**
+ * Renders a specific month's calendar
+ * @param {HTMLElement} container - The container element
+ * @param {number} month - Month to render (0-11)
+ * @param {number} year - Year to render
+ * @param {string[]} availableDates - Array of available puzzle dates
+ * @param {Object} puzzleStatuses - Map of dates to completion status
+ */
+ renderMonthCalendar(container, month, year, availableDates, puzzleStatuses) {
+ // Calculate NYC date strings correctly
+ const getConsistentDateStr = (year, month, day) => {
+ // This ensures we're constructing dates consistently as YYYY-MM-DD
+ return `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ };
+
+ // Get month info - using local date objects for display purposes only
+ const monthStart = new Date(year, month, 1);
+ const monthName = monthStart.toLocaleString('en-US', { month: 'long', year: 'numeric' });
+
+ // Get days in month
+ const daysInMonth = new Date(year, month + 1, 0).getDate();
+
+ // Get the day of week the month starts on (0 = Sunday, 6 = Saturday)
+ const startDay = monthStart.getDay();
+
+ // Get today's date in NYC
+ const todayNYC = this.getNYCDate();
+
+ // Create calendar HTML
+ let calendarHTML = `
+ <div class="date-picker-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
+ <button class="month-nav prev" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">&lsaquo;</button>
+ <h3 style="margin: 0; text-align: center; font-size: 1.2rem;">${monthName}</h3>
+ <button class="month-nav next" style="background: none; border: none; cursor: pointer; font-size: 1.2rem;">&rsaquo;</button>
+ </div>
+
+ <div class="weekdays" style="display: grid; grid-template-columns: repeat(7, 1fr); text-align: center; font-weight: bold; margin-bottom: 0.5rem;">
+ <div>Su</div>
+ <div>Mo</div>
+ <div>Tu</div>
+ <div>We</div>
+ <div>Th</div>
+ <div>Fr</div>
+ <div>Sa</div>
+ </div>
+
+ <div class="calendar-grid" style="display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; width: 100%;">
+ `;
+
+ // Add empty cells for days before the month starts
+ for (let i = 0; i < startDay; i++) {
+ calendarHTML += `<div class="empty-day"></div>`;
+ }
+
+ // Add cells for each day in the month
+ for (let day = 1; day <= daysInMonth; day++) {
+ // Format date string consistently
+ const dateStr = getConsistentDateStr(year, month, day);
+
+ // Check if this date has a puzzle available
+ const isPuzzleAvailable = availableDates.includes(dateStr);
+
+ // Get puzzle status for this date
+ const puzzleStatus = puzzleStatuses[dateStr] || 'unsolved';
+
+ // Determine if this is today's date
+ const isToday = dateStr === todayNYC;
+
+ // Determine if this is the current puzzle date
+ const isCurrentPuzzle = dateStr === this.currentPuzzleDate;
+
+ // Determine future dates (not yet available)
+ const isFutureDate = dateStr > todayNYC && !this.isDevelopmentEnvironment();
+
+ // Status-specific styles
+ let statusStyle = '';
+ if (isPuzzleAvailable) {
+ switch (puzzleStatus) {
+ case 'complete':
+ statusStyle = 'background-color: #c6f6d5; color: #22543d; border: 1px solid #9ae6b4;';
+ break;
+ case 'started':
+ statusStyle = 'background-color: #fefcbf; color: #744210; border: 1px solid #f6e05e;';
+ break;
+ case 'unsolved':
+ statusStyle = 'background-color: #e9f5f9; color: #2c5282; border: 1px solid #bee3f8;';
+ break;
+ default:
+ statusStyle = 'background-color: #fff; color: #1a202c; border: 1px solid #e2e8f0;';
+ }
+ } else {
+ // No puzzle available
+ statusStyle = 'background-color: #f9f9f9; color: #a0aec0; border: 1px solid #edf2f7;';
+ }
+
+ // Today and current puzzle styles
+ const todayStyle = isToday ? 'font-weight: bold; border: 2px solid #4a5568;' : '';
+ const currentStyle = isCurrentPuzzle ? 'box-shadow: 0 0 0 2px #4299e1;' : '';
+
+ // Cell classes and attributes
+ const cellClass = isPuzzleAvailable ? 'date-cell has-puzzle' : 'date-cell no-puzzle';
+ const disabledAttr = (isFutureDate || !isPuzzleAvailable) ? 'disabled="true"' : '';
+ const dataAttr = isPuzzleAvailable ? `data-date="${dateStr}" data-status="${puzzleStatus}"` : '';
+
+ // Add cell to calendar
+ calendarHTML += `
+ <div
+ class="${cellClass}"
+ ${dataAttr}
+ ${disabledAttr}
+ style="
+ padding: 0.4rem 0;
+ border-radius: 0.375rem;
+ text-align: center;
+ ${isFutureDate || !isPuzzleAvailable ? 'cursor: default; opacity: 0.5;' : 'cursor: pointer;'}
+ ${statusStyle}
+ ${todayStyle}
+ ${currentStyle}
+ "
+ title="${this.formatDateMonthDay(dateStr)}${isPuzzleAvailable ? ` (${puzzleStatus})` : ' (no puzzle)'}"
+ >
+ ${day}
+ </div>
+ `;
+ }
+
+ calendarHTML += `
+ </div>
+ <div class="calendar-footer" style="margin-top: 1rem; font-size: 0.8rem; display: flex; justify-content: center; gap: 1rem;">
+ <span style="display: flex; align-items: center;">
+ <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #c6f6d5; border: 1px solid #9ae6b4; border-radius: 2px;"></span> Complete
+ </span>
+ <span style="display: flex; align-items: center;">
+ <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #fefcbf; border: 1px solid #f6e05e; border-radius: 2px;"></span> Started
+ </span>
+ <span style="display: flex; align-items: center;">
+ <span style="display: inline-block; width: 12px; height: 12px; margin-right: 4px; background-color: #e9f5f9; border: 1px solid #bee3f8; border-radius: 2px;"></span> Available
+ </span>
+ </div>
+ `;
+
+ // Set the calendar HTML
+ container.innerHTML = calendarHTML;
+
+ // Add navigation handlers
+ this.setupMonthNavigation(container, month, year, availableDates, puzzleStatuses);
+
+ // Add date selection handlers
+ this.setupDateSelectionHandlers(container);
+ }
+
+ /**
+ * Format date as "Month Day" (e.g. "March 15")
+ * @param {string} dateStr - Date string in YYYY-MM-DD format
+ * @returns {string} Formatted date
+ */
+ formatDateMonthDay(dateStr) {
+ // Parse the YYYY-MM-DD string directly
+ const [year, month, day] = dateStr.split('-');
+
+ // Create a date string that displays as intended
+ return `${new Date(Number(year), Number(month) - 1, Number(day)).toLocaleDateString('en-US', {
+ month: 'long',
+ day: 'numeric'
+ })}`;
+ }
+
+ /**
+ * Sets up month navigation buttons
+ * @param {HTMLElement} container - Container element
+ * @param {number} currentMonth - Current month view (0-11)
+ * @param {number} currentYear - Current year view
+ * @param {string[]} availableDates - Available puzzle dates
+ * @param {Object} puzzleStatuses - Puzzle completion statuses
+ */
+ setupMonthNavigation(container, currentMonth, currentYear, availableDates, puzzleStatuses) {
+ const prevButton = container.querySelector('.month-nav.prev');
+ const nextButton = container.querySelector('.month-nav.next');
+
+ // Make buttons more touch-friendly
+ const enhanceButton = (button) => {
+ button.style.width = '36px';
+ button.style.height = '36px';
+ button.style.fontSize = '1.5rem';
+ button.style.cursor = 'pointer';
+ button.style.userSelect = 'none';
+ button.style.touchAction = 'manipulation';
+ };
+
+ enhanceButton(prevButton);
+ enhanceButton(nextButton);
+
+ // Handler for previous month - Works with both mouse and touch
+ const handlePrevMonth = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ let newMonth = currentMonth - 1;
+ let newYear = currentYear;
+
+ if (newMonth < 0) {
+ newMonth = 11;
+ newYear--;
+ }
+
+ this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses);
+ };
+
+ // Handler for next month - Works with both mouse and touch
+ const handleNextMonth = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ let newMonth = currentMonth + 1;
+ let newYear = currentYear;
+
+ if (newMonth > 11) {
+ newMonth = 0;
+ newYear++;
+ }
+
+ this.renderMonthCalendar(container, newMonth, newYear, availableDates, puzzleStatuses);
+ };
+
+ // Add event listeners for both mouse and touch events
+ prevButton.addEventListener('click', handlePrevMonth);
+ prevButton.addEventListener('touchend', handlePrevMonth);
+
+ nextButton.addEventListener('click', handleNextMonth);
+ nextButton.addEventListener('touchend', handleNextMonth);
+ }
+
+ /**
+ * Sets up event handlers for date selection
+ * @param {HTMLElement} container - Calendar container
+ */
+ setupDateSelectionHandlers(container) {
+ // Find all date cells with puzzles
+ const dateCells = container.querySelectorAll('.date-cell.has-puzzle:not([disabled])');
+
+ dateCells.forEach(cell => {
+ // Create a visual feedback effect for touch
+ cell.style.transition = 'transform 0.1s ease, opacity 0.1s ease';
+
+ // Enhanced tap/click area
+ cell.style.margin = '1px';
+ cell.style.position = 'relative';
+
+ // Handler function that works for both click and touch
+ const handleDateSelection = async (e) => {
+ e.preventDefault();
+ e.stopPropagation(); // Prevent closing the calendar immediately
+
+ // Visual feedback
+ cell.style.transform = 'scale(0.95)';
+ cell.style.opacity = '0.8';
+
+ // Reset visual feedback after a short delay
+ setTimeout(() => {
+ cell.style.transform = 'scale(1)';
+ cell.style.opacity = '1';
+ }, 150);
+
+ const targetDate = cell.dataset.date;
+ if (!targetDate || targetDate === this.currentPuzzleDate) {
+ // Already on this puzzle or invalid date, just close the picker
+ container.style.display = 'none';
+ return;
+ }
+
+ // Navigate to selected puzzle
+ container.style.display = 'none';
+
+ await this.navigateToPuzzle(targetDate);
+ };
+
+ // Add both mouse and touch event listeners
+ cell.addEventListener('click', handleDateSelection);
+ cell.addEventListener('touchend', handleDateSelection);
+ });
+ }
+
+ /**
+ * Fetches all available puzzle dates from the API
+ * @returns {Promise<string[]>} Array of available dates in YYYY-MM-DD format
+ */
+ async fetchAvailablePuzzleDates() {
+ try {
+ const response = await fetch(this.API_ENDPOINT, {
+ cache: 'no-store',
+ headers: {
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to fetch available puzzles');
+ }
+
+ const puzzles = await response.json();
+
+ // Filter out future puzzles if not in dev environment
+ const today = this.getNYCDate();
+ const isDevEnvironment = this.isDevelopmentEnvironment();
+
+ return puzzles
+ .map(puzzle => typeof puzzle === 'string' ? puzzle : (puzzle.puzzleDate || puzzle))
+ .filter(date => isDevEnvironment || date <= today)
+ .sort();
+
+ } catch (error) {
+ console.error('Error fetching available puzzles:', error);
+ return [];
+ }
+ }
+
+ /**
+ * Scans localStorage to determine completion status for all puzzles
+ * @param {string[]} dates - Array of puzzle dates to check
+ * @returns {Object} Map of dates to completion status
+ */
+ getPuzzleCompletionStatuses(dates) {
+ const statusMap = {};
+
+ dates.forEach(date => {
+ const storageKey = `bracketPuzzle_${date}`;
+ const savedData = localStorage.getItem(storageKey);
+
+ if (!savedData) {
+ // No saved data - puzzle exists but not started
+ statusMap[date] = 'unsolved';
+ } else {
+ try {
+ const puzzleData = JSON.parse(savedData);
+
+ // Check if puzzle is complete
+ if (puzzleData.isComplete || !puzzleData.displayState.includes('[')) {
+ statusMap[date] = 'complete';
+ } else if (this.isPuzzleStarted(puzzleData)) {
+ statusMap[date] = 'started';
+ } else {
+ statusMap[date] = 'unsolved';
+ }
+ } catch (e) {
+ console.error('Error parsing saved puzzle data:', e);
+ statusMap[date] = 'unsolved';
+ }
+ }
+ });
+
+ return statusMap;
+ }
+
+ /**
+ * Checks for available puzzles before and after current date
+ * @param {string} currentDate - Current puzzle date in YYYY-MM-DD format
+ */
+ async checkAdjacentPuzzles(currentDate) {
+ try {
+ // Get current NYC date for availability check
+ const nycDate = this.getNYCDate();
+ const isDevEnvironment = this.isDevelopmentEnvironment();
+
+ // 1. Fetch available puzzles
+ const response = await fetch(this.API_ENDPOINT, {
+ cache: 'no-store',
+ headers: {
+ 'Cache-Control': 'no-cache',
+ 'Pragma': 'no-cache'
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error('Unable to fetch puzzle list');
+ }
+
+ const puzzles = await response.json();
+ if (!puzzles?.length) {
+ console.log('No puzzles available');
+ return this.updateAdjacentPuzzles(null, null);
+ }
+
+ // 2. Extract and normalize dates
+ const puzzleDates = puzzles
+ .map(puzzle => puzzle.puzzleDate || puzzle.date)
+ .filter(Boolean)
+ .sort(); // Sort chronologically
+
+ // 3. Find current puzzle's position
+ const currentIndex = puzzleDates.indexOf(currentDate);
+ if (currentIndex === -1) {
+ console.warn('Current puzzle not found in available puzzles');
+ return this.updateAdjacentPuzzles(null, null);
+ }
+
+ // 4. Determine previous puzzle (always available)
+ const previousPuzzle = currentIndex > 0 ? puzzleDates[currentIndex - 1] : null;
+
+ // 5. Determine next puzzle (with availability check)
+ let nextPuzzle = null;
+ if (currentIndex < puzzleDates.length - 1) {
+ const potentialNext = puzzleDates[currentIndex + 1];
+ // Allow future puzzles in dev, restrict in production
+ if (isDevEnvironment || potentialNext <= nycDate) {
+ nextPuzzle = potentialNext;
+ }
+ }
+
+ // 6. Update navigation state
+ this.updateAdjacentPuzzles(previousPuzzle, nextPuzzle);
+
+ } catch (error) {
+ console.error('Error checking adjacent puzzles:', error);
+ // Fail gracefully - disable navigation if we can't determine availability
+ this.updateAdjacentPuzzles(null, null);
+ }
+ }
+
+ /**
+ * Updates navigation state and UI for adjacent puzzles
+ * @param {string|null} previous - Previous puzzle date
+ * @param {string|null} next - Next puzzle date
+ */
+ updateAdjacentPuzzles(previous, next) {
+ // Update state
+ this.adjacentPuzzleDates = { previous, next };
+
+ // Update UI
+ const prevButton = this.root.querySelector('.nav-button.prev');
+ const nextButton = this.root.querySelector('.nav-button.next');
+
+ if (prevButton) {
+ prevButton.disabled = !previous;
+ prevButton.style.opacity = previous ? '1' : '0.3';
+ prevButton.style.cursor = previous ? 'pointer' : 'default';
+ }
+
+ if (nextButton) {
+ nextButton.disabled = !next;
+ nextButton.style.opacity = next ? '1' : '0.3';
+ nextButton.style.cursor = next ? 'pointer' : 'default';
+ }
+ }
+
+ /**
+ * Improved navigateToPuzzle method to handle input mode properly
+ * @param {string} targetDate - Date to navigate to
+ */
+ async navigateToPuzzle(targetDate) {
+ try {
+ console.log('Starting navigation to date:', targetDate);
+ this.setNavigationLoading(true);
+
+ // Store the previous date before changing
+ const previousDate = this.currentPuzzleDate;
+
+ // Store the current preferred input mode (from localStorage)
+ const preferredInputMode = this.getInputMode();
+
+ // Reset mobile input state before navigation
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.customInputEl = null;
+ }
+
+ // Fetch and process new puzzle data
+ await this.fetchPuzzleForDate(targetDate);
+
+ if (!this.PUZZLE_DATA) {
+ throw new Error(`Failed to fetch puzzle for ${targetDate}`);
+ }
+
+ // Important: Update current puzzle date AFTER successful fetch but BEFORE loading state
+ this.currentPuzzleDate = targetDate;
+ this.updateURL(targetDate);
+
+ // Check for available adjacent puzzles
+ await this.checkAdjacentPuzzles(targetDate);
+
+ // Reset state to prevent leakage
+ this.state = {
+ displayState: '',
+ solvedExpressions: new Set(),
+ message: '',
+ messageType: null,
+ totalKeystrokes: 0,
+ minimumKeystrokes: 0,
+ activeClues: [],
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ wrongGuesses: 0,
+ helpVisible: false,
+ previousDisplay: null
+ };
+
+ // Try to load saved state for this puzzle with explicit date parameter
+ const savedState = await this.loadSavedState(targetDate);
+
+ // Initialize game state
+ if (savedState && savedState.puzzleDate === targetDate) { // Extra validation
+ console.log(`Loading saved state for ${targetDate}`);
+
+ // Check if the saved state has an input mode
+ const savedInputMode = savedState.inputMode;
+
+ // If the puzzle has been started (has progress), respect its saved mode
+ // Otherwise use the preferred mode
+ const hasPuzzleProgress = this.isPuzzleStarted(savedState);
+
+ // Set the input mode based on progress status
+ if (hasPuzzleProgress && savedInputMode) {
+ this.inputMode = savedInputMode;
+ } else {
+ this.inputMode = preferredInputMode;
+ }
+
+ // Update rankCalculator with the correct input mode
+ this.rankCalculator.inputMode = this.inputMode;
+
+ this.updateGameState({
+ displayState: savedState.displayState,
+ solvedExpressions: new Set(savedState.solvedExpressions || []),
+ totalKeystrokes: savedState.totalKeystrokes || 0,
+ minimumKeystrokes: savedState.minimumKeystrokes || this.calculateMinimumKeystrokes(),
+ activeClues: this.findActiveClues(savedState.displayState),
+ hintModeClues: new Set(savedState.hintModeClues || []),
+ peekedClues: new Set(savedState.peekedClues || []),
+ megaPeekedClues: new Set(savedState.megaPeekedClues || []),
+ wrongGuesses: savedState.wrongGuesses || 0,
+ helpVisible: savedState.helpVisible || false,
+ previousDisplay: savedState.previousDisplay || null
+ });
+ } else {
+ console.log(`Initializing fresh state for ${targetDate}`);
+
+ // For a fresh puzzle, use the preferred input mode
+ this.inputMode = preferredInputMode;
+
+ // Update rankCalculator with the correct input mode
+ this.rankCalculator.inputMode = this.inputMode;
+
+ this.updateGameState({
+ displayState: this.PUZZLE_DATA.initialPuzzle,
+ solvedExpressions: new Set(),
+ message: '',
+ totalKeystrokes: 0,
+ minimumKeystrokes: this.calculateMinimumKeystrokes(),
+ activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle),
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ wrongGuesses: 0,
+ helpVisible: false,
+ previousDisplay: null
+ });
+ }
+
+ // Reset UI and reattach handlers
+ this.root.innerHTML = this.generateInitialHTML();
+ this.setupInputHandlers();
+ this.setupShareButtons();
+ this.setupPuzzleDisplay();
+ this.setupNavigationHandlers();
+
+ // Render with new state
+ this.render();
+
+ this.setupDatePicker();
+ } catch (error) {
+ console.error('Navigation failed:', error);
+ this.showErrorMessage('Failed to load puzzle. Please try again.');
+ } finally {
+ this.setNavigationLoading(false);
+ }
+ }
+
+ /**
+ * Sets the loading state for navigation buttons
+ */
+ setNavigationLoading(isLoading) {
+ const prevButton = this.root.querySelector('.nav-button.prev');
+ const nextButton = this.root.querySelector('.nav-button.next');
+
+ if (prevButton) {
+ prevButton.disabled = isLoading || !this.adjacentPuzzleDates.previous;
+ prevButton.style.cursor = isLoading ? 'wait' : 'pointer';
+ prevButton.style.opacity = prevButton.disabled ? '0.5' : '1';
+ }
+ if (nextButton) {
+ nextButton.disabled = isLoading || !this.adjacentPuzzleDates.next;
+ nextButton.style.cursor = isLoading ? 'wait' : 'pointer';
+ nextButton.style.opacity = nextButton.disabled ? '0.5' : '1';
+ }
+ }
+
+ /**
+ * Sets up navigation button event handlers
+ */
+ setupNavigationHandlers() {
+ // First remove any existing handlers to prevent duplicates
+ this.cleanupEventListeners();
+
+ const prevButton = this.root.querySelector('.nav-button.prev');
+ const nextButton = this.root.querySelector('.nav-button.next');
+
+ // Track if navigation is in progress to prevent double-clicks
+ let isNavigating = false;
+
+ // Previous puzzle handler
+ if (prevButton) {
+ prevButton.addEventListener('click', async () => {
+ if (isNavigating || !this.adjacentPuzzleDates.previous) return;
+
+ try {
+ isNavigating = true;
+ prevButton.style.cursor = 'wait';
+ await this.navigateToPuzzle(this.adjacentPuzzleDates.previous);
+ } catch (error) {
+ console.error('Navigation failed:', error);
+ this.showErrorMessage('Failed to load previous puzzle. Please try again.');
+ } finally {
+ isNavigating = false;
+ prevButton.style.cursor = this.adjacentPuzzleDates.previous ? 'pointer' : 'default';
+ }
+ });
+ }
+
+ // Next puzzle handler
+ if (nextButton) {
+ nextButton.addEventListener('click', async () => {
+ if (isNavigating || !this.adjacentPuzzleDates.next) return;
+
+ try {
+ isNavigating = true;
+ nextButton.style.cursor = 'wait';
+ await this.navigateToPuzzle(this.adjacentPuzzleDates.next);
+ } catch (error) {
+ console.error('Navigation failed:', error);
+ this.showErrorMessage('Failed to load next puzzle. Please try again.');
+ } finally {
+ isNavigating = false;
+ nextButton.style.cursor = this.adjacentPuzzleDates.next ? 'pointer' : 'default';
+ }
+ });
+ }
+ }
+
+ /**
+ * Removes existing event listeners to prevent duplicates
+ */
+ cleanupEventListeners() {
+ const prevButton = this.root.querySelector('.nav-button.prev');
+ const nextButton = this.root.querySelector('.nav-button.next');
+
+ if (prevButton) {
+ const newPrevButton = prevButton.cloneNode(true);
+ prevButton.parentNode.replaceChild(newPrevButton, prevButton);
+ }
+
+ if (nextButton) {
+ const newNextButton = nextButton.cloneNode(true);
+ nextButton.parentNode.replaceChild(newNextButton, nextButton);
+ }
+ }
+
+ /*************
+ UI rendering
+ **************/
+
+ /*
+ * Main render method - updates all UI elements based on current state
+ */
+ render() {
+ const elements = this.getDOMElements();
+
+ // Early return if required elements aren't found
+ if (!this.validateElements(elements)) {
+ console.error('Required DOM elements not found');
+ return;
+ }
+
+ this.renderPuzzleDisplay(elements.puzzleDisplay);
+ this.renderSolvedExpressions(elements.expressionsList);
+ this.renderMessage(elements.message);
+
+ // Handle completion state
+ if (this.isPuzzleComplete()) {
+ this.renderCompletedPuzzle(elements.puzzleDisplay, elements.inputContainer, elements.keystrokeStats);
+ } else {
+ this.renderInProgressState(elements.keystrokeStats);
+ }
+ }
+
+ /**
+ * Renders the main puzzle display
+ * @param {HTMLElement} displayElement - Puzzle display element
+ */
+ renderPuzzleDisplay(displayElement) {
+ let highlightedState = this.applyActiveClueHighlights(this.state.displayState);
+ highlightedState = this.replaceUnderscoreSequences(highlightedState);
+ displayElement.innerHTML = highlightedState;
+ }
+
+ replaceUnderscoreSequences(text) {
+ // Regular expression to match sequences of 2 or more underscores
+ return text.replace(/_{2,}/g, (match) => {
+ // Calculate width based on number of underscores (em units work well here)
+ const width = (match.length * 0.6) + 'em';
+ return `<span class="blank-line" style="width: ${width};"></span>`;
+ });
+ }
+
+ /**
+ * Renders the solved expressions list
+ * @param {HTMLElement} listElement - Expressions list element
+ */
+ renderSolvedExpressions(listElement) {
+ if (!listElement) return;
+
+ const solvedHTML = Array.from(this.state.solvedExpressions)
+ .map(expression => this._generateSolvedExpressionItemHTML(
+ expression,
+ this.PUZZLE_DATA.solutions[expression]
+ ))
+ .join('');
+
+ listElement.innerHTML = solvedHTML;
+ }
+
+ /**
+ * Renders the current message
+ * @param {HTMLElement} messageElement - Message element
+ */
+ renderMessage(messageElement) {
+ if (!messageElement) return;
+
+ if (this.state.message) {
+ messageElement.textContent = this.state.message;
+ messageElement.className = `message ${this.state.messageType || 'success'}`;
+
+ // Apply error styling if needed
+ if (this.state.messageType === 'error') {
+ messageElement.style.cssText = `
+ padding: 1rem;
+ margin: 1rem;
+ background-color: #fee2e2;
+ color: #ef4444;
+ border-radius: 0.375rem;
+ `;
+ } else {
+ messageElement.style.cssText = ''; // Reset styles for non-error messages
+ }
+ } else {
+ messageElement.textContent = '';
+ messageElement.className = 'message';
+ messageElement.style.cssText = '';
+ }
+ }
+
+ /**
+ * Renders the in-progress state
+ * @param {HTMLElement} statsElement - Stats element
+ */
+ renderInProgressState(statsElement) {
+ // Hide stats during gameplay
+ statsElement.style.display = 'none';
+ }
+
+ renderCompletedPuzzle(puzzleDisplay, inputContainer, keystrokeStats) {
+ // Validate required elements
+ if (!puzzleDisplay || !inputContainer || !keystrokeStats) {
+ console.error('Missing required elements for completion rendering');
+ return;
+ }
+
+ // Hide the input container (for both desktop and mobile)
+ inputContainer.style.display = 'none';
+
+ // Set up the completed state
+ this.setupCompletionState(puzzleDisplay);
+
+ // Use the puzzle display state; if the completion text isn't already present, prepend it
+ const hasCompletionText = this.state.displayState.includes(this.PUZZLE_DATA.completionText);
+ const finalDisplayText = hasCompletionText
+ ? this.state.displayState
+ : `<strong>${this.PUZZLE_DATA.completionText}</strong>\n\n${this.state.displayState}`;
+ puzzleDisplay.innerHTML = finalDisplayText;
+
+ // Remove any existing completion message to avoid duplicates
+ const existingCompletionMessage = this.root.querySelector('.completion-message');
+ if (existingCompletionMessage) {
+ existingCompletionMessage.remove();
+ }
+
+ // Find the puzzle-content container
+ const puzzleContent = this.root.querySelector('.puzzle-content');
+ if (!puzzleContent) {
+ console.error('Could not find puzzle-content container');
+ return;
+ }
+
+ // Create the completion message
+ const completionMessageWrapper = document.createElement('div');
+ completionMessageWrapper.className = 'completion-message';
+ completionMessageWrapper.innerHTML = this.generateCompletionMessage();
+
+ // Insert after puzzle display
+ const insertAfterElement = puzzleDisplay;
+ if (insertAfterElement && insertAfterElement.nextSibling) {
+ puzzleContent.insertBefore(completionMessageWrapper, insertAfterElement.nextSibling);
+ } else {
+ puzzleContent.appendChild(completionMessageWrapper);
+ }
+
+ // Show stats container and render completion stats UI
+ keystrokeStats.style.display = 'block';
+
+ // Calculate stats with wrong guesses
+ const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100);
+ const stats = this.rankCalculator.getDetailedStats(
+ efficiency,
+ this.state.peekedClues.size,
+ this.state.megaPeekedClues.size,
+ this.state.wrongGuesses // Include wrong guesses
+ );
+
+ // Render stats
+ keystrokeStats.querySelector('.stats-content').innerHTML = `
+ ${this.generateRankDisplay(stats)}
+ ${this.generateStatItems(stats)}
+ `;
+
+ this.attachCompletionHandlers(keystrokeStats);
+
+ // Scroll to top
+ setTimeout(() => {
+ window.scrollTo({
+ top: 0,
+ behavior: 'smooth'
+ });
+ this.setupDatePicker();
+ }, 100);
+ }
+
+ renderCompletionStats(keystrokeStats) {
+ const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100);
+ const stats = this.rankCalculator.getDetailedStats(
+ efficiency,
+ this.state.peekedClues.size,
+ this.state.megaPeekedClues.size
+ );
+
+ keystrokeStats.style.display = 'block';
+ keystrokeStats.querySelector('.stats-content').innerHTML = `
+ ${this.generateRankDisplay(stats)}
+ ${this.generateStatItems(stats)}
+ `;
+
+ this.attachCompletionHandlers(keystrokeStats);
+ }
+
+ /**
+ * Apply highlighting to active clues with first letter peek
+ * @param {string} text - Current puzzle text
+ * @returns {string} HTML with highlights applied
+ */
+ applyActiveClueHighlights(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+
+ // Sort clues by start position in reverse order to process them from end to beginning
+ activeClues.sort((a, b) => b.start - a.start);
+
+ let highlightedText = cleanText;
+
+ activeClues.forEach(clue => {
+ const expressionText = clue.expression.trim();
+
+ // Create a precise slice by using the exact indices
+ const before = highlightedText.slice(0, clue.start);
+ let clueText = highlightedText.slice(clue.start, clue.end);
+ const after = highlightedText.slice(clue.end);
+
+ // Handle different clue states
+ if (!clueText.includes('[') && !this.state.hintModeClues.has(expressionText)) {
+ // Already solved clue - leave as is
+ highlightedText = before + clueText + after;
+ } else {
+ // Active or hint mode clue
+ if (this.state.hintModeClues.has(expressionText)) {
+ const solution = this.PUZZLE_DATA.solutions[expressionText];
+ // Show the first letter hint
+ if (solution) {
+ const firstLetter = solution.charAt(0).toUpperCase();
+ // Use exact position for end bracket to avoid issues with special characters
+ const lastBracketPos = clueText.lastIndexOf(']');
+ if (lastBracketPos !== -1) {
+ clueText = clueText.substring(0, lastBracketPos) + ` (${firstLetter})]`;
+ }
+ }
+ }
+
+ // Use more robust string manipulation to ensure the entire expression is highlighted
+ highlightedText = before + `<span class="active-clue">${clueText}</span>` + after;
+ }
+ });
+
+ return highlightedText;
+ }
+
+ /***************
+ HTML generation
+ ****************/
+
+ generateLoadingHTML() {
+ return `
+ <div class="loading">
+ Loading today's puzzle...
+ </div>
+ `;
+ }
+
+ /**
+ * Generates HTML for the input container based on current mode
+ * @returns {string} HTML markup
+ */
+ generateInputContainerHTML() {
+ if (this.useCustomKeyboard) {
+ if (this.inputMode === 'submit') {
+ return `
+ <div class="input-container">
+ <div class="message"></div>
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
+ <div class="custom-input" style="flex-grow: 1; height: 44px; display: flex; align-items: center;">
+ <span class="placeholder">type any answer...</span>
+ </div>
+ <button
+ class="mobile-submit-button"
+ style="height: 44px; background-color: #2563eb; color: white; padding: 0 1rem; border-radius: 6px; font-weight: 500; border: none;"
+ >
+ [enter]
+ </button>
+ </div>
+ <div class="custom-keyboard">
+ ${this.generateKeyboardButtonsHTML()}
+ </div>
+ </div>
+ `;
+ } else {
+ // Classic mode (auto-snap)
+ return `
+ <div class="input-container">
+ <div class="message"></div>
+ <div class="custom-input">
+ <span class="placeholder">start typing answers...</span>
+ </div>
+ <div class="custom-keyboard">
+ ${this.generateKeyboardButtonsHTML()}
+ </div>
+ </div>
+ `;
+ }
+ } else {
+ // Desktop
+ if (this.inputMode === 'submit') {
+ return `
+ <div class="input-container">
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;">
+ <input
+ type="text"
+ placeholder="type any answer..."
+ name="silly-joke"
+ class="answer-input"
+ autocomplete="off"
+ autocapitalize="off"
+ >
+ <button
+ class="submit-answer"
+ >
+ [enter]
+ </button>
+ </div>
+ <div class="message"></div>
+ </div>
+ `;
+ } else {
+ // Classic mode (auto-snap)
+ return `
+ <div class="input-container">
+ <input type="text" placeholder="Start typing answers..." name="silly-joke" class="answer-input" autocomplete="off" autocapitalize="off">
+ <div class="message"></div>
+ </div>
+ `;
+ }
+ }
+ }
+
+// Modified generateKeyboardButtonsHTML method
+// Modified generateKeyboardButtonsHTML method
+generateKeyboardButtonsHTML() {
+ const mainLayout = [
+ { keys: 'qwertyuiop', columns: 10 },
+ { keys: 'asdfghjkl', columns: 10 },
+ { keys: [
+ { key: '123', display: '123', small: true },
+ 'z', 'x', 'c', 'v', 'b', 'n', 'm',
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const altLayout = [
+ { keys: '1234567890', columns: 10 },
+ { keys: '-/:;()$&@"', columns: 10 },
+ { keys: [
+ { key: 'ABC', display: 'ABC', small: true },
+ '.', ',', '?', '!', "'", "=", "+",
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const rows = this.isAltKeyboard ? altLayout : mainLayout;
+ let html = ``;
+
+ rows.forEach((row, rowIndex) => {
+ // adding margin for middle row on default layout since it's only 9 keys vs, 10
+ const leftPadding = (rowIndex === 1 && !this.isAltKeyboard) ? 'margin-left: 5%' : '';
+ html += `<div class="keyboard-row" style="${leftPadding}">`;
+
+ const keys = Array.isArray(row.keys) ? row.keys : row.keys.split('');
+ keys.forEach(keyItem => {
+ if (typeof keyItem === 'string') {
+ html += this.generateKeyboardKeyHTML(keyItem);
+ } else {
+ html += this.generateSpecialKeyHTML(keyItem);
+ }
+ });
+
+ html += '</div>';
+ });
+
+ html += '</div>';
+ return html;
+}
+
+generateSpecialKeyHTML(keyItem) {
+ const fontSize = keyItem.small ? '0.875rem' : '1.125rem';
+ return `
+ <button class="keyboard-key" data-key="${keyItem.key}" style="
+ grid-column: ${keyItem.wide ? 'span 2' : 'span 1'};
+ font-size: ${fontSize};
+ ">
+ ${keyItem.display}
+ </button>
+ `;
+}
+
+ generateKeyboardKeyHTML(key) {
+ return `
+ <button class="keyboard-key" data-key="${key}">
+ ${key}
+ </button>
+ `;
+ }
+
+ /**
+ * Generates initial HTML structure for the game
+ * @returns {string} HTML markup
+ */
+ generateInitialHTML() {
+ const displayDate = this.currentPuzzleDate || this.getNYCDate();
+
+ return `
+ <div class="puzzle-container">
+ ${this.generateHeaderHTML(displayDate)}
+ <div class="puzzle-content">
+ ${this.generatePuzzleDisplayHTML()}
+ ${this.generateInputContainerHTML()}
+ ${this.generateStatsContainerHTML()}
+ ${this.generateSolvedExpressionsHTML()}
+ </div>
+ </div>
+ `;
+ }
+
+ generateHeaderHTML(displayDate) {
+ return `
+ <div class="puzzle-header">
+ <button class="info-button">!</button>
+ <h1>[Bracket Village]</h1>
+ <button class="help-button">?</button>
+ <div class="nav-container">
+ <button class="nav-button prev" ${!this.isMobile && 'disabled'}>&larr;</button>
+ <div class="puzzle-date">${this.formatNYCDate(displayDate)}</div>
+ <button class="nav-button next" ${!this.isMobile && 'disabled'}>&rarr;</button>
+ </div>
+ </div>
+ `;
+ }
+
+ generatePuzzleDisplayHTML() {
+ return `<div class="puzzle-display"></div>`;
+ }
+
+ generateStatsContainerHTML() {
+ return `
+ <div class="keystroke-stats" style="display: none;">
+ <div class="stats-content"></div>
+ <div class="share-buttons">
+ <button class="share-button copy">Copy Results</button>
+ <button class="share-button sms">Share via Text</button>
+ <button class="share-button reset">Reset Puzzle</button>
+ </div>
+ <div class="share-message" style="display: none;">Results copied to clipboard!</div>
+ </div>
+ `;
+ }
+
+ _generateSolvedExpressionItemHTML(expression, solution) {
+ return `
+ <div class="expression-item">
+ <span class="expression">[${expression}]</span> =
+ <span class="solution">${solution}</span>
+ </div>
+ `;
+ }
+
+ generateSolvedExpressionsHTML() {
+ // Convert Set to Array and reverse it to get most recent first
+ const expressionsList = Array.from(this.state.solvedExpressions)
+ .reverse()
+ .map(expression => this._generateSolvedExpressionItemHTML(
+ expression,
+ this.PUZZLE_DATA.solutions[expression]
+ ))
+ .join('');
+
+ return `
+ <div class="solved-expressions">
+ <h3></h3>
+ <div class="expressions-list">
+ ${expressionsList}
+ </div>
+ </div>
+ `;
+ }
+
+ generateCompletionMessage() {
+ return `
+ <div class="message success completion-message">
+ <span><strong>** Puzzle Solved! **</strong></span><br>
+ <a href="${this.PUZZLE_DATA.completionURL}"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="completion-url">
+ ${this.PUZZLE_DATA.completionURL}
+ </a>
+ </div>
+ `;
+ }
+
+ generateRankDisplay(stats) {
+ const margin = this.isMobile ? '0.5rem 0' : '1rem 0';
+ const padding = this.isMobile ? '1rem' : '1.5rem';
+
+ // Custom messages for certain ranks
+ let nextRankMessage = '';
+
+ if (stats.rank === 'Puppet Master') {
+ nextRankMessage = '<br><span class="next-rank">now I am become Death, the destroyer of worlds</span>';
+ } else if (stats.rank === 'Kingmaker') {
+ nextRankMessage = '<br><span class="next-rank">ah finally the very top</span>';
+ } else if (stats.rank === 'Mayor') {
+ nextRankMessage = '<br><span class="next-rank">nobody\'s my boss, right?</span>';
+ } else if (stats.rank === 'Power Broker') {
+ nextRankMessage = '<br><span class="next-rank">this has got to be it...</span>';
+ } else if (stats.nextRankName) {
+ nextRankMessage = `<br><span class="next-rank">Almost: ${stats.nextRankName} (${Math.ceil(stats.pointsToNextRank)} points needed)</span>`;
+ }
+
+ return `
+ <div class="rank-display" data-rank="${stats.rank}">
+ You are ${(stats.rank === 'Chief of Police' || stats.rank === 'Mayor') ? 'the' : 'a'} Bracket Village<br>
+ ${stats.rankEmoji} <b>${stats.rank}</b> ${stats.rankEmoji}
+ ${nextRankMessage}
+ <div class="share-hint">
+ ${this.isMobile ? '(tap to share via text)' : '(click to copy results)'}
+ </div>
+ </div>
+ `;
+ }
+
+
+ /**
+ * Updated generateStatItems method to show different stats based on mode
+ */
+ generateStatItems(stats) {
+ // Generate progress bar for score
+ const segments = 10;
+ const filledSegments = Math.round((stats.finalScore / 100) * segments);
+ const emptySegments = segments - filledSegments;
+
+ // Use purple squares for Puppet Master, otherwise use color based on score
+ let segmentEmoji;
+ if (stats.rank === 'Puppet Master') {
+ segmentEmoji = '\u{1F7EA}'; // Purple Square emoji
+ } else {
+ segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red
+ stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow
+ '\u{1F7E9}'; // Green
+ }
+
+ const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments);
+
+ // Score bar HTML
+ const scoreBarHTML = `
+ <div class="score-bar">
+ <div class="score-value">Score: ${stats.finalScore.toFixed(1)}</div>
+ <div class="progress-bar">${progressBar}</div>
+ ${this.inputMode === 'classic' ? '<div class="hard-mode-indicator" style="font-size: 1rem; text-align: center; margin-top: 5px; font-weight: bold;">\u{2620}\u{FE0F} hard mode!</div>' : ''}
+ </div>
+ `;
+
+ // For classic mode (keystroke-based)
+ if (this.inputMode === 'classic') {
+ return `
+ ${scoreBarHTML}
+ <div class="stat-items">
+ <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div>
+ <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div>
+ <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div>
+ <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div>
+ <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div>
+ <div class="stat-item"><b>Score breakdown</b>
+ <br>Base score (efficiency): ${stats.baseScore.toFixed(1)}
+ <br>Peek penalty: -${stats.peekPenalty.toFixed(1)}
+ ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''}
+ <br>Final score: ${stats.finalScore.toFixed(1)}
+ </div>
+ </div>
+ `;
+ }
+ // For submit mode (wrong-guess-based)
+ else {
+ return `
+ ${scoreBarHTML}
+ <div class="stat-items">
+ <div class="stat-item">\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}</div>
+ <div class="stat-item">\u{1F440} Clues peeked: ${this.state.peekedClues.size}</div>
+ <div class="stat-item">\u{1F6DF} Answers revealed: ${this.state.megaPeekedClues.size}</div>
+ <div class="stat-item"><b>Score breakdown</b>
+ <br>Base score: ${stats.baseScore.toFixed(1)}
+ ${stats.wrongGuessPenalty > 0 ? `<br>Wrong guess penalty: -${stats.wrongGuessPenalty.toFixed(1)}` : ''}
+ <br>Peek penalty: -${stats.peekPenalty.toFixed(1)}
+ ${stats.megaPeekPenalty > 0 ? `<br>Reveal penalty: -${stats.megaPeekPenalty.toFixed(1)}` : ''}
+ <br>Final score: ${stats.finalScore.toFixed(1)}
+ </div>
+ <div class="stat-item">Total keystrokes: ${this.state.totalKeystrokes}</div>
+ <div class="stat-item">Minimum keystrokes needed: ${this.state.minimumKeystrokes}</div>
+ <div class="stat-item">Excess keystrokes: ${this.state.totalKeystrokes - this.state.minimumKeystrokes}</div>
+ </div>
+ `;
+ }
+ }
+
+ /**************************
+ UI setup and configuration
+ ***************************/
+
+ /**
+ * Sets up puzzle display and help system
+ */
+ setupPuzzleDisplay() {
+
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) {
+ console.error('Puzzle display element not found');
+ return;
+ }
+
+ this.configurePuzzleDisplayLayout(puzzleDisplay);
+ this.setupHelpSystem();
+ this.setupInfoSystem();
+ this.setupClueClickHandlers(puzzleDisplay);
+
+ // Add header click handler
+ const header = this.root.querySelector('.puzzle-header h1');
+ if (header) {
+ header.addEventListener('click', async () => {
+ const todayDate = this.getNYCDate();
+ if (this.currentPuzzleDate !== todayDate) {
+ await this.navigateToPuzzle(todayDate);
+ }
+ });
+ }
+ }
+
+ /**
+ * Configures puzzle display layout based on device
+ * @param {HTMLElement} puzzleDisplay - The puzzle display element
+ */
+ configurePuzzleDisplayLayout(puzzleDisplay) {
+ const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+ puzzleDisplay.classList.add(isMobile ? 'puzzle-display-mobile' : 'puzzle-display-desktop');
+ }
+
+
+ /**
+ * Checks if the user has seen the current info version
+ * @returns {boolean} Whether the user has seen the current info
+ */
+ hasSeenInfo() {
+ // Check if the user has seen the current version of the info
+ return localStorage.getItem('bracketCityInfoSeen') === this.INFO_VERSION;
+ }
+
+ /**
+ * Marks the current info version as seen
+ */
+ markInfoAsSeen() {
+ // Store the current version instead of just 'true'
+ localStorage.setItem('bracketCityInfoSeen', this.INFO_VERSION);
+ }
+
+ /**
+ * Sets up the info system with an inline toggle switch
+ */
+ setupInfoSystem() {
+ // Define the current info version - increment this to reset for all users
+ this.INFO_VERSION = '4'; // Increment version for the new layout
+
+ const infoButton = this.root.querySelector('.info-button');
+ if (!infoButton) return;
+
+ // Check and apply the initial button state
+ if (!this.hasSeenInfo()) {
+ infoButton.classList.add('unseen-info');
+ }
+
+ // CSS for the iOS-style toggle
+ const toggleStyle = `
+ <style>
+ /* iOS Toggle Switch */
+ .inline-toggle {
+ position: relative;
+ display: inline-block;
+ width: 36px;
+ height: 20px;
+ vertical-align: middle;
+ margin: 0 6px;
+ }
+
+ .inline-toggle input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ .toggle-slider {
+ position: absolute;
+ cursor: pointer;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: #ccc;
+ transition: .3s;
+ border-radius: 34px;
+ }
+
+ .toggle-slider:before {
+ position: absolute;
+ content: "";
+ height: 16px;
+ width: 16px;
+ left: 2px;
+ bottom: 2px;
+ background-color: white;
+ transition: .2s;
+ border-radius: 50%;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.2);
+ }
+
+ input:checked + .toggle-slider {
+ background-color: #2563eb;
+ }
+
+ input:disabled + .toggle-slider {
+ background-color: #d1d5db;
+ cursor: not-allowed;
+ }
+
+ input:checked:disabled + .toggle-slider {
+ background-color: #93c5fd;
+ }
+
+ input:checked + .toggle-slider:before {
+ transform: translateX(16px);
+ }
+
+ .mode-disabled-message {
+ display: none;
+ }
+
+ .toggle-disabled .mode-disabled-message {
+ display: inline;
+ }
+ </style>
+ `;
+
+ // Build the info content with the toggle at the top
+
+ // old news
+ //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking a <mark style="background-color: #fff9c4;">clue</mark> lets you peek at the answer's first letter</div>
+ //<div style="margin: 0.9em 0; font-size: 0.85em;">* clicking <span class='help-icon'>?</span> takes you to an interactive tutorial</div>
+
+ const infoContent = `
+ ${toggleStyle}
+ <div id="toggle-container" style="text-align: center; margin: 0.4em 0 1em 0;">
+ <div style="display: flex; flex-direction: column; align-items: center; gap: 0;">
+ <div style="display: flex; align-items: center; justify-content: center; gap: 6px;">
+ <span style="font-weight: 500; font-size: 0.95em;">hard mode</span>
+ <label class="inline-toggle" onclick="event.stopPropagation();" style="margin: 0 0 0 2px;">
+ <input type="checkbox" id="mode-toggle" onclick="event.stopPropagation();">
+ <span class="toggle-slider"></span>
+ </label>
+ </div>
+ <div style="font-size: 0.7em; line-height: 1; margin-top: 1px; display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 4px;">
+ <span style="font-style: italic; color: #6b7280;">no submit button & every keystroke counts!</span>
+ <span class="mode-disabled-message" style="color: #ef4444; font-weight: 500;">
+ (can't change once a puzzle started)
+ </span>
+ </div>
+ </div>
+ </div>
+
+ <div style="margin: 0.9em 0; font-size: 0.85em;">* there is now a <mark style="background-color:rgba(255,255,0,0.2)">date picker</mark> - just click the date in the header and browse the archive</div>
+ `;
+
+ infoButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+
+ // Mark as seen when clicked
+ if (!this.hasSeenInfo()) {
+ this.markInfoAsSeen();
+ infoButton.classList.remove('unseen-info');
+ }
+
+ this.toggleHelp(infoContent);
+
+ // Add event listener to the toggle switch
+ setTimeout(() => {
+ const modeToggle = document.getElementById('mode-toggle');
+ const toggleContainer = document.getElementById('toggle-container');
+
+ if (!modeToggle || !toggleContainer) return;
+
+ // Always evaluate current puzzle state
+ const hasPuzzleStarted = this.isPuzzleStarted();
+
+ // Set the checked state to match the current inputMode
+ modeToggle.checked = this.inputMode === 'classic';
+
+ // Update the disabled state and container class based on current puzzle state
+ modeToggle.disabled = hasPuzzleStarted;
+ toggleContainer.className = hasPuzzleStarted ? 'toggle-disabled' : '';
+
+ if (!modeToggle.disabled) {
+ modeToggle.addEventListener('change', (e) => {
+ e.stopPropagation();
+
+ // Double-check puzzle state before changing mode
+ if (this.isPuzzleStarted()) {
+ // If puzzle has been started since info panel opened, disable toggle and show message
+ modeToggle.disabled = true;
+ toggleContainer.className = 'toggle-disabled';
+ // Reset toggle to match current mode
+ modeToggle.checked = this.inputMode === 'classic';
+ return;
+ }
+
+ // Update mode based on toggle state
+ // ON (checked) = classic mode, OFF (unchecked) = submit mode
+ const newMode = modeToggle.checked ? 'classic' : 'submit';
+ this.setInputMode(newMode);
+ });
+ }
+
+ // Make sure all interactive elements stop propagation
+ toggleContainer.addEventListener('click', (e) => {
+ // Only stop propagation if clicking on the toggle itself
+ if (e.target === modeToggle || e.target.closest('.inline-toggle')) {
+ e.stopPropagation();
+ }
+ });
+
+ // Add MutationObserver to update toggle state when puzzle state changes
+ this.setupToggleStateObserver(modeToggle, toggleContainer, isPuzzleStarted);
+ }, 100);
+ });
+ }
+
+ /**
+ * Checks if the user is a new player by examining localStorage
+ * @returns {boolean} True if this appears to be a new player
+ */
+ isNewPlayer() {
+ let count = 0;
+
+ // Count the number of localStorage keys that start with 'bracketCity'
+ for (let i = 0; i < localStorage.length; i++) {
+ const key = localStorage.key(i);
+ if (key && key.startsWith('bracketPuzzle')) {
+ count++;
+ }
+ }
+
+ // If more than one key is found, the player is not new
+ if (count > 1 || localStorage.getItem('tutorialSeen')) {
+ return false;
+ }
+
+ // Otherwise, the player is considered new
+ return true;
+ }
+
+ /**
+ * Sets up the help/tutorial system with special highlighting for new players
+ */
+ setupHelpSystem() {
+ const helpButton = this.root.querySelector('.help-button');
+ if (!helpButton) return;
+
+ // Check if this is a new player to determine if we should highlight the tutorial button
+ if (this.isNewPlayer()) {
+ helpButton.classList.add('new-player-highlight');
+ }
+
+ helpButton.addEventListener('click', (e) => {
+ e.stopPropagation();
+
+ // Remove the highlighting when clicked
+ helpButton.classList.remove('new-player-highlight');
+
+ if (confirm('Would you like to start the tutorial?')) {
+ localStorage.setItem('tutorialSeen', 'true');
+ this.saveState();
+ window.location.href = 'tutorial';
+ }
+ });
+ }
+
+ turnOffTutorialPulse() {
+ const helpButton = this.root.querySelector('.help-button');
+ if (helpButton) {
+ helpButton.classList.remove('new-player-highlight');
+ }
+ localStorage.setItem('tutorialSeen', 'true');
+ }
+
+ /**
+ * Toggles the help display with special handling for interactive elements
+ * @param {string} helpContent - The help content to display
+ */
+ toggleHelp(helpContent) {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Check if the puzzle is completed
+ const isPuzzleCompleted = puzzleDisplay.classList.contains('completed');
+
+ // Get the info button
+ const infoButton = this.root.querySelector('.info-button');
+
+ // Store current puzzle state when help is shown
+ if (!this.state.helpVisible) {
+ // Opening help
+ this.updateGameState({
+ helpVisible: true,
+ previousDisplay: puzzleDisplay.innerHTML
+ });
+
+ if (isPuzzleCompleted) {
+ // In completed state, just set content without scroll anchor
+ puzzleDisplay.innerHTML = helpContent;
+ // Don't auto-scroll in completed state
+ } else {
+ // Normal in-progress behavior with scroll anchor
+ puzzleDisplay.innerHTML = '<div id="help-scroll-anchor"></div>' + helpContent;
+
+ // Scroll to anchor - works reliably on iOS Safari
+ const scrollAnchor = document.getElementById('help-scroll-anchor');
+ if (scrollAnchor) {
+ scrollAnchor.scrollIntoView({block: 'start', behavior: 'auto'});
+ }
+ }
+
+ // Change the info button text from "!" to "X"
+ if (infoButton) {
+ infoButton.textContent = "X";
+ }
+
+ // Disable all inputs including submit button
+ this.disableInputsForHelp();
+
+ // Setup click handler for the puzzle display that closes help
+ // But don't close when clicking on interactive elements
+ puzzleDisplay.addEventListener('click', this.handleHelpClick = (event) => {
+ // Check if the click was on an interactive element
+ const isInteractiveElement =
+ event.target.tagName === 'INPUT' ||
+ event.target.tagName === 'BUTTON' ||
+ event.target.tagName === 'LABEL' ||
+ event.target.tagName === 'A' ||
+ event.target.classList.contains('toggle-container') ||
+ event.target.classList.contains('toggle-labels') ||
+ event.target.classList.contains('toggle-slider') ||
+ event.target.closest('label') ||
+ event.target.closest('a') ||
+ event.target.closest('.toggle-container');
+
+ // Only close help if it wasn't an interactive element
+ if (!isInteractiveElement) {
+ this.closeHelp();
+ }
+ });
+
+ } else {
+ this.closeHelp();
+ }
+ }
+
+ /**
+ * Closes the help panel and restores previous state
+ */
+ closeHelp() {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Change the info button text back from "X" to "!"
+ const infoButton = this.root.querySelector('.info-button');
+ if (infoButton) {
+ infoButton.textContent = "!";
+ }
+
+ // Remove the click handler to prevent memory leaks
+ if (this.handleHelpClick) {
+ puzzleDisplay.removeEventListener('click', this.handleHelpClick);
+ this.handleHelpClick = null;
+ }
+
+ // Closing help - restore previous state and re-render
+ this.updateGameState({
+ helpVisible: false,
+ previousDisplay: null
+ });
+
+ // Re-render the entire puzzle state
+ this.render();
+
+ // Re-enable input
+ const desktopInput = this.root.querySelector('.answer-input');
+ if (desktopInput) {
+ desktopInput.disabled = false;
+ }
+
+ // Re-enable submit button (desktop)
+ const submitButton = this.root.querySelector('.submit-answer');
+ if (submitButton) {
+ submitButton.disabled = false;
+ submitButton.style.opacity = '1';
+ submitButton.style.pointerEvents = 'auto';
+ }
+
+ // For mobile, re-enable keyboard and input
+ if (this.isMobile) {
+ // Restore custom input appearance
+ const customInput = this.root.querySelector('.custom-input');
+ if (customInput) {
+ customInput.style.opacity = '1';
+ customInput.style.pointerEvents = 'auto';
+ }
+
+ // Re-enable all keyboard keys
+ const keyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ keyboardKeys.forEach(key => {
+ key.disabled = false;
+ key.style.opacity = '1';
+ key.style.pointerEvents = 'auto';
+ });
+
+ // Re-enable mobile submit button
+ const mobileSubmitButton = this.root.querySelector('.mobile-submit-button');
+ if (mobileSubmitButton) {
+ mobileSubmitButton.disabled = false;
+ mobileSubmitButton.style.opacity = '1';
+ mobileSubmitButton.style.pointerEvents = 'auto';
+ }
+ }
+
+ // Restore focus when closing help (but only if not disabled)
+ if (desktopInput && !desktopInput.disabled) {
+ desktopInput.focus();
+ }
+ }
+
+ /**
+ * Sets up click handlers for puzzle clues
+ * @param {HTMLElement} puzzleDisplay - The puzzle display element
+ */
+ setupClueClickHandlers(puzzleDisplay) {
+ puzzleDisplay.addEventListener('click', (event) => {
+ // If help is showing, check if we clicked on a link before closing
+ if (this.state.helpVisible) {
+ // Check if the click was on a link (or its descendants)
+ const isLinkClick = event.target.tagName === 'A' ||
+ event.target.closest('a');
+
+ // Only close help if it wasn't a link click
+ if (!isLinkClick) {
+ this.toggleHelp();
+ }
+ return;
+ }
+
+ const clueElement = event.target.closest('.active-clue');
+ if (!clueElement) return;
+
+ // Find the clicked expression
+ const cleanText = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+ const cluePosition = Array.from(puzzleDisplay.querySelectorAll('.active-clue')).indexOf(clueElement);
+
+ if (cluePosition >= 0 && cluePosition < activeClues.length) {
+ const expression = activeClues[cluePosition].expression.trim();
+ this.toggleHintMode(expression);
+
+ // Return focus to input on desktop
+ if (!this.isMobile && this.answerInput) {
+ this.answerInput.focus();
+ }
+ }
+ });
+ }
+
+ setupCompletionState(puzzleDisplay) {
+ puzzleDisplay.classList.add('completed');
+
+ // scroll to bottom fix
+ document.body.style.overflow = 'auto';
+
+ if (this.isMobile) {
+ // Reduce bottom padding of puzzle display
+ puzzleDisplay.style.paddingBottom = '0.5rem';
+
+ // Adjust puzzle container styles for mobile
+ const container = this.root.querySelector('.puzzle-container');
+ if (container) {
+ Object.assign(container.style, {
+ position: 'relative',
+ height: '100vh',
+ overflowY: 'auto',
+ display: 'block'
+ });
+ }
+
+ // Reduce top margin and padding in the content area to decrease vertical space
+ const content = this.root.querySelector('.puzzle-content');
+ if (content) {
+ Object.assign(content.style, {
+ position: 'relative',
+ top: '1.3rem',
+ height: 'auto',
+ marginTop: '60px', // reduced from 85px
+ padding: '0.5rem',
+ paddingBottom: '1rem',
+ overflowY: 'visible'
+ });
+ }
+ }
+ }
+
+ attachCompletionHandlers(keystrokeStats) {
+ // Rank display click handler
+ const rankDisplay = keystrokeStats.querySelector('.rank-display');
+ if (rankDisplay) {
+ rankDisplay.addEventListener('click', () => {
+ if (this.isMobile) {
+ this.shareSMS();
+ } else {
+ this.shareResults();
+ }
+ });
+ }
+ }
+
+ setupShareButtons() {
+ const copyButton = this.root.querySelector('.share-button.copy');
+ const smsButton = this.root.querySelector('.share-button.sms');
+ const resetButton = this.root.querySelector('.share-button.reset');
+
+ copyButton.addEventListener('click', () => this.shareResults());
+
+ if (!/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) {
+ smsButton.style.display = 'none';
+ }
+ smsButton.addEventListener('click', () => this.shareSMS());
+ resetButton.addEventListener('click', () => this.resetPuzzle());
+
+ }
+
+ /**************************
+ Message system
+ ***************************/
+
+ /**
+ * Updates the game message with proper state management
+ * @param {string} text - Message text to display
+ * @param {string} type - Message type ('success' or 'error')
+ */
+ showMessage(text, type = 'success') {
+ const messageEl = this.root.querySelector('.message');
+ if (!messageEl) return;
+
+ // Update state
+ this.updateGameState({
+ message: text,
+ messageType: type
+ });
+
+ // Update message element
+ messageEl.textContent = text;
+ messageEl.className = `message ${type}`;
+ messageEl.style.display = 'block'; // Ensure message is visible
+
+ // Auto-clear ALL messages (both success and error)
+ setTimeout(() => {
+ // Only clear if this message is still showing
+ if (this.state.message === text) {
+ this.updateGameState({
+ message: '',
+ messageType: null
+ });
+ messageEl.textContent = '';
+ messageEl.className = 'message';
+ messageEl.style.display = 'none';
+ }
+ }, 1500);
+ }
+
+ /**
+ * Shows an error message with special handling
+ * @param {string} message - Error message to display
+ */
+ showErrorMessage(message) {
+ const messageEl = this.root.querySelector('.message') || document.createElement('div');
+
+ // If we need to create a new message element
+ if (!messageEl.parentNode) {
+ messageEl.className = 'message';
+ this.root.prepend(messageEl);
+ }
+
+ // Update state to track error
+ this.updateGameState({
+ message,
+ messageType: 'error',
+ lastError: {
+ message,
+ timestamp: Date.now()
+ }
+ });
+ }
+
+
+ /**************************
+ Share system
+ ***************************/
+
+ /**
+ * Modified generateShareText to only display mode when in classic/hard mode
+ */
+ generateShareText() {
+ const puzzleDate = this.formatNYCDate(this.currentPuzzleDate);
+ const efficiency = ((this.state.minimumKeystrokes / this.state.totalKeystrokes) * 100);
+
+ // Calculate rank and stats
+ const stats = this.rankCalculator.getDetailedStats(
+ efficiency,
+ this.state.peekedClues.size,
+ this.state.megaPeekedClues.size,
+ this.state.wrongGuesses || 0
+ );
+
+ // Generate progress bar
+ const segments = 10;
+ const filledSegments = Math.round((stats.finalScore / 100) * segments);
+ const emptySegments = segments - filledSegments;
+
+ // Use purple squares for Puppet Master, otherwise use color based on score
+ let segmentEmoji;
+ if (stats.rank === "Puppet Master") {
+ segmentEmoji = '\u{1F7EA}'; // Purple Square emoji
+ } else {
+ segmentEmoji = stats.finalScore < 25 ? '\u{1F7E5}' : // Red
+ stats.finalScore < 75 ? '\u{1F7E8}' : // Yellow
+ '\u{1F7E9}'; // Green
+ }
+
+ const progressBar = segmentEmoji.repeat(filledSegments) + '\u{2B1C}'.repeat(emptySegments);
+
+ // Build share text with specific line breaks to match desired layout
+ const shareItems = [
+ '[Bracket Village]',
+ puzzleDate,
+ '',
+ 'https://user.4574.co.uk/bv',
+ ''
+ ];
+
+ // Only show "hard mode" message if in classic mode
+ if (this.inputMode === 'classic') {
+ shareItems.push('\u{2620}\u{FE0F} hard mode!');
+ shareItems.push(''); // Add a line break between mode and rank
+ }
+
+ // Add rank
+ shareItems.push(`Rank: ${stats.rankEmoji} (${stats.rank})`);
+
+ // Only include mode-specific stats
+ // Replace it with this updated logic:
+ if (this.inputMode === 'classic' || stats.rank === 'Puppet Master') {
+ // Show keystroke stats in classic mode or for any Puppet Master
+ shareItems.push(`\u{1F3B9} Total Keystrokes: ${this.state.totalKeystrokes}`);
+ shareItems.push(`\u{1F3AF} Minimum Required: ${this.state.minimumKeystrokes}`);
+ } else {
+ // Submit mode stats
+ shareItems.push(`\u{274C} Wrong guesses: ${this.state.wrongGuesses || 0}`);
+ }
+
+ // Add peek and reveal counts only if they exist
+ if (this.state.peekedClues.size > 0) {
+ shareItems.push(`\u{1F440} Peeks: ${this.state.peekedClues.size}`);
+ }
+
+ if (this.state.megaPeekedClues.size > 0) {
+ shareItems.push(`\u{1F6DF} Answers Revealed: ${this.state.megaPeekedClues.size}`);
+ }
+
+ // Remove the input mode indicator - we now only show it for classic mode above the rank
+
+ // Add score and progress bar with an empty line before score
+ shareItems.push('');
+ shareItems.push(`Total Score: ${stats.finalScore.toFixed(1)}`);
+ shareItems.push(progressBar);
+
+ return shareItems.join('\n');
+ }
+
+ /**
+ * Handles sharing results via the user's preferred app using Web Share API
+ * Falls back to SMS URL scheme if Web Share API is not available
+ */
+ shareSMS() {
+ const shareText = this.generateShareText();
+
+ // Check if Web Share API is available
+ if (navigator.share) {
+ navigator.share({
+ title: 'Bracket Village Results',
+ text: shareText
+ })
+ .then(() => {
+ console.log('Successfully shared results');
+ })
+ .catch((error) => {
+ console.error('Error sharing results:', error);
+ // Fall back to SMS URL scheme if sharing fails
+ this.fallbackToSMS(shareText);
+ });
+ } else {
+ // Fall back to SMS URL scheme for older browsers
+ this.fallbackToSMS(shareText);
+ }
+ }
+
+ /**
+ * Fallback method to use SMS URL scheme when Web Share API is not available
+ * @param {string} shareText - The text to share
+ */
+ fallbackToSMS(shareText) {
+ const encodedText = encodeURIComponent(shareText);
+
+ // Use correct SMS URL scheme format
+ window.location.href = `sms:?&body=${encodedText}`;
+ }
+
+ /**
+ * Copies results to clipboard for desktop sharing
+ */
+shareResults() {
+ const shareText = this.generateShareText();
+
+ // Use navigator.clipboard API for modern browsers
+ if (navigator.clipboard) {
+ navigator.clipboard.writeText(shareText)
+ .then(() => {
+ // Show success message
+ const shareMessage = this.root.querySelector('.share-message');
+ if (shareMessage) {
+ shareMessage.style.display = 'block';
+ setTimeout(() => {
+ shareMessage.style.display = 'none';
+ }, 2000);
+ }
+ })
+ .catch(err => {
+ console.error('Failed to copy results: ', err);
+ // Show error or fallback to execCommand
+ this.fallbackCopyToClipboard(shareText);
+ });
+ } else {
+ // Fallback for older browsers
+ this.fallbackCopyToClipboard(shareText);
+ }
+}
+
+/**
+ * Fallback method to copy text to clipboard using execCommand
+ * @param {string} text - Text to copy
+ */
+fallbackCopyToClipboard(text) {
+ try {
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ textArea.style.position = 'fixed'; // Avoid scrolling to bottom
+ document.body.appendChild(textArea);
+ textArea.focus();
+ textArea.select();
+
+ const successful = document.execCommand('copy');
+ if (successful) {
+ // Show success message
+ const shareMessage = this.root.querySelector('.share-message');
+ if (shareMessage) {
+ shareMessage.style.display = 'block';
+ setTimeout(() => {
+ shareMessage.style.display = 'none';
+ }, 2000);
+ }
+ } else {
+ console.error('execCommand failed');
+ }
+
+ document.body.removeChild(textArea);
+ } catch (err) {
+ console.error('Fallback copy method failed: ', err);
+ }
+}
+
+ /**************************
+ Utility methods
+ ***************************/
+
+ /**
+ * Resets the input container to initial state
+ * @param {HTMLElement} inputContainer - The input container element
+ */
+ resetInputContainer(inputContainer) {
+ inputContainer.innerHTML = `
+ <input type="text" placeholder="type any answer..." class="answer-input" autocomplete="off" autocapitalize="off">
+ <div class="message"></div>
+ `;
+ // Re-setup input handlers for the new input element
+ this.setupInputHandlers();
+ }
+
+ /**
+ * Gets all required DOM elements
+ * @returns {Object} Object containing DOM elements
+ */
+ getDOMElements() {
+ return {
+ puzzleDisplay: this.root.querySelector('.puzzle-display'),
+ expressionsList: this.root.querySelector('.expressions-list'),
+ inputContainer: this.root.querySelector('.input-container'),
+ keystrokeStats: this.root.querySelector('.keystroke-stats'),
+ message: this.root.querySelector('.message')
+ };
+ }
+
+ /**
+ * Validates that all required elements exist
+ * @param {Object} elements - DOM elements object
+ * @returns {boolean} Whether all required elements exist
+ */
+ validateElements(elements) {
+ return Object.values(elements).every(element => element !== null);
+ }
+
+ /**
+ * Checks if the puzzle is complete
+ * @returns {boolean} Whether the puzzle is complete
+ */
+
+ isPuzzleComplete() {
+ return !this.state.displayState.includes('[');
+ }
+
+ cleanupEventListeners() {
+ const prevButton = this.root.querySelector('.nav-button.prev');
+ const nextButton = this.root.querySelector('.nav-button.next');
+ if (prevButton) prevButton.replaceWith(prevButton.cloneNode(true));
+ if (nextButton) nextButton.replaceWith(nextButton.cloneNode(true));
+ }
+
+ /**
+ * Creates and displays an announcement modal
+ * @param {string} title - The title of the announcement
+ * @param {string} message - The message content (can include HTML)
+ * @param {string} buttonText - The text for the dismiss button
+ * @param {string} modalId - Unique identifier for the modal
+ * @returns {boolean} Whether the modal was shown
+ */
+ showAnnouncementModal(title, message, buttonText = 'Got it', modalId) {
+ // Check if we should show this modal
+ if (!modalId || this.hasSeenAnnouncement(modalId)) {
+ return false;
+ }
+
+ // Create modal HTML
+ const modalHTML = `
+ <div class="announcement-modal-overlay" id="announcement-overlay">
+ <div class="announcement-modal">
+ <div class="announcement-modal-title">${title}</div>
+ <div class="announcement-modal-content">${message}</div>
+ <button class="announcement-modal-button" id="announcement-dismiss">${buttonText}</button>
+ </div>
+ </div>
+ `;
+
+ // Append to the body
+ document.body.insertAdjacentHTML('beforeend', modalHTML);
+
+ // Get DOM references
+ const overlay = document.getElementById('announcement-overlay');
+ const dismissButton = document.getElementById('announcement-dismiss');
+
+ // Add event listeners
+ dismissButton.addEventListener('click', () => {
+ this.dismissAnnouncementModal(modalId);
+ });
+
+ // Close on outside click too
+ overlay.addEventListener('click', (e) => {
+ if (e.target === overlay) {
+ this.dismissAnnouncementModal(modalId);
+ }
+ });
+
+ // Show the modal with a small delay
+ setTimeout(() => {
+ overlay.classList.add('visible');
+ }, 100);
+
+ return true;
+ }
+
+ /**
+ * Dismisses the announcement modal and marks it as seen
+ * @param {string} modalId - Unique identifier for the modal
+ */
+ dismissAnnouncementModal(modalId) {
+ const overlay = document.getElementById('announcement-overlay');
+
+ if (!overlay) return;
+
+ // Add fade-out animation
+ overlay.classList.remove('visible');
+
+ // Remove from DOM after animation completes
+ setTimeout(() => {
+ overlay.remove();
+
+ if (!this.isMobile) {
+ const inputElement = this.root.querySelector('.answer-input');
+ if (inputElement && !inputElement.disabled) {
+ inputElement.focus();
+ }
+ }
+
+ }, 300);
+
+ // Mark as seen
+ if (modalId) {
+ this.markAnnouncementAsSeen(modalId);
+ }
+ }
+
+ /**
+ * Checks if the user has seen a specific announcement
+ * @param {string} modalId - Unique identifier for the modal
+ * @returns {boolean} Whether the announcement has been seen
+ */
+ hasSeenAnnouncement(modalId) {
+ const storageKey = `bracketCityAnnouncement_${modalId}`;
+ return localStorage.getItem(storageKey) === 'seen';
+ }
+
+ /**
+ * Marks an announcement as seen in localStorage
+ * @param {string} modalId - Unique identifier for the modal
+ */
+ markAnnouncementAsSeen(modalId) {
+ const storageKey = `bracketCityAnnouncement_${modalId}`;
+ localStorage.setItem(storageKey, 'seen');
+ }
+
+ /**
+ * Helper method to display an important announcement
+ * @param {Object} options - Announcement options
+ * @param {string} options.title - The title of the announcement
+ * @param {string} options.message - The announcement message
+ * @param {string} options.buttonText - Text for the dismiss button
+ * @param {string} options.id - Unique identifier for this announcement
+ * @returns {boolean} Whether the announcement was shown
+ */
+ showImportantAnnouncement(options) {
+ const {
+ title = 'Important Announcement',
+ message = '',
+ buttonText = 'Got it',
+ id = 'default-announcement'
+ } = options;
+
+ return this.showAnnouncementModal(title, message, buttonText, id);
+ }
+}
diff --git a/static/bv/index.html b/static/bv/index.html
new file mode 100644
index 0000000..cecdf44
--- /dev/null
+++ b/static/bv/index.html
@@ -0,0 +1,30 @@
+
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <base href="/bv/">
+ <link rel="stylesheet" href="bracket.css">
+ <link rel="icon" type="image/png" href="bc-favicon.png">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>[Bracket Village]</title>
+ <meta name="description" content="Bracket Village is a nested clue puzzle game.">
+
+ <!-- Open Graph meta tags -->
+ <meta property="og:title" content="Bracket Village" />
+ <meta property="og:description" content="come visit bracket village" />
+ <meta property="og:image" content="https://bracket.city/bc-postcard.jpg" />
+ <meta property="og:url" content="https://bracket.city/" />
+ <meta property="og:type" content="website" />
+ <meta property="og:image:width" content="1200" />
+ <meta property="og:image:height" content="800" />
+
+</head>
+<body>
+ <div id="puzzle-root"></div>
+ <script src="bracket.js"></script>
+ <script src="puzzleencoder.js"></script>
+ <script>
+ const puzzle = new BracketPuzzle(document.getElementById('puzzle-root'));
+ </script>
+</body>
+</html>
diff --git a/static/bv/puzzleencoder.js b/static/bv/puzzleencoder.js
new file mode 100644
index 0000000..fcba62c
--- /dev/null
+++ b/static/bv/puzzleencoder.js
@@ -0,0 +1,83 @@
+// Utility functions for compact puzzle encoding/decoding
+const PuzzleEncoder = {
+ // Simple substitution cipher using base64
+ cipher: {
+ encode(str) {
+ return btoa(encodeURIComponent(str)).replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
+ },
+ decode(str) {
+ str = str.replace(/-/g, '+').replace(/_/g, '/');
+ // Add back padding if needed
+ while (str.length % 4) str += '=';
+ try {
+ return decodeURIComponent(atob(str));
+ } catch (e) {
+ console.error('Failed to decode:', e);
+ return null;
+ }
+ }
+ },
+
+ // Compress puzzle structure by replacing common patterns
+ compressPuzzle(puzzle) {
+ return puzzle
+ .replace(/\[\[/g, '<<') // Replace double brackets
+ .replace(/\]\]/g, '>>') // with angle brackets
+ .replace(/\[/g, '<') // Replace single brackets
+ .replace(/\]/g, '>') // with single angle brackets
+ .replace(/\s+/g, ' '); // Normalize spaces
+ },
+
+ // Decompress puzzle structure
+ decompressPuzzle(compressed) {
+ return compressed
+ .replace(/<<|〈〈/g, '[[')
+ .replace(/>>|〉〉/g, ']]')
+ .replace(/[<〈]/g, '[')
+ .replace(/[>〉]/g, ']');
+ },
+
+ // Compress solutions map into compact format
+ compressSolutions(solutions) {
+ const pairs = Object.entries(solutions)
+ .map(([expr, sol]) => `${expr}:${sol}`)
+ .join('|');
+ return pairs;
+ },
+
+ // Decompress solutions back into object
+ decompressSolutions(compressed) {
+ if (!compressed) return {};
+ return Object.fromEntries(
+ compressed.split('|')
+ .map(pair => pair.split(':'))
+ .filter(([k, v]) => k && v) // Filter out any invalid pairs
+ );
+ },
+
+ // Encode entire puzzle into compact URL-safe string
+ encodePuzzle(puzzleData) {
+ const compressed = {
+ p: this.compressPuzzle(puzzleData.initialPuzzle),
+ s: this.compressSolutions(puzzleData.solutions)
+ };
+ return this.cipher.encode(JSON.stringify(compressed));
+ },
+
+ // Decode puzzle from compact string
+ decodePuzzle(encoded) {
+ try {
+ const decoded = this.cipher.decode(encoded);
+ if (!decoded) return null;
+
+ const compressed = JSON.parse(decoded);
+ return {
+ initialPuzzle: this.decompressPuzzle(compressed.p),
+ solutions: this.decompressSolutions(compressed.s)
+ };
+ } catch (e) {
+ console.error('Failed to decode puzzle:', e);
+ return null;
+ }
+ }
+ }; \ No newline at end of file
diff --git a/static/bv/tutorial/index.html b/static/bv/tutorial/index.html
new file mode 100644
index 0000000..ac49d70
--- /dev/null
+++ b/static/bv/tutorial/index.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <base href="/bv/tutorial/" />
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Bracket Village</title>
+ <link rel="icon" type="image/png" href="../bc-favicon.png">
+ <!-- Tutorial CSS -->
+ <link rel="stylesheet" href="tutorial.css">
+</head>
+<body>
+ <!-- Main container for the game -->
+ <div id="bracket-city-container"></div>
+
+
+ <!-- Tutorial mode scripts -->
+ <script src="tutorial.js"></script>
+ <script src="tutorial-init.js"></script>
+
+</body>
+</html>
diff --git a/static/bv/tutorial/tutorial-init.js b/static/bv/tutorial/tutorial-init.js
new file mode 100644
index 0000000..ba0cc73
--- /dev/null
+++ b/static/bv/tutorial/tutorial-init.js
@@ -0,0 +1,22 @@
+startTutorialMode();
+
+ // Helper function to manually start the tutorial mode
+ function startTutorialMode() {
+ const container = document.getElementById('bracket-city-container');
+
+ if (container) {
+ // Clear any existing content
+ container.innerHTML = '';
+
+ // Initialize the tutorial
+ const tutorial = new BracketCityTutorial(container);
+
+ // Store the tutorial instance in case we need to access it later
+ window.bracketCityTutorial = tutorial;
+
+ return tutorial;
+ } else {
+ console.error('Could not find container element for Bracket Village tutorial');
+ return null;
+ }
+ }
diff --git a/static/bv/tutorial/tutorial.css b/static/bv/tutorial/tutorial.css
new file mode 100644
index 0000000..4f2aff0
--- /dev/null
+++ b/static/bv/tutorial/tutorial.css
@@ -0,0 +1,945 @@
+/* ===================
+ Root Variables and Resets
+ =================== */
+ :root {
+ --main-font: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ --mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ --highlight-color: #fefcbf;
+ }
+
+ *, *::before, *::after {
+ box-sizing: border-box;
+ }
+
+ /* ===================
+ Container Structure
+ =================== */
+ .puzzle-container {
+ width: 100%;
+ margin: 0 auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 0.5rem;
+ background: white;
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ @media (min-width: 1024px) {
+ .puzzle-container {
+ max-width: 42rem;
+ }
+ }
+
+ /* ===================
+ Header Styles
+ =================== */
+ /* Header Base Styles */
+ .puzzle-header {
+ background: white;
+ border-bottom: 1px solid #e2e8f0;
+ box-sizing: border-box;
+ padding: 0.6rem;
+ position: relative; /* Desktop default */
+ text-align: center;
+ z-index: 10;
+ }
+
+ /* Mobile-specific header styles */
+ @media (max-width: 640px) {
+ .puzzle-header {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ padding: max(0.4rem, env(safe-area-inset-top));
+ }
+ }
+
+ /* Header title */
+ .puzzle-header h1 {
+ margin: 0 0 0.5rem 0;
+ text-align: center;
+ font-size: clamp(2rem, 8vw, 3rem);
+ cursor: pointer;
+ font-family: var(--main-font);
+ font-variant: small-caps;
+ color: #1a202c;
+ font-weight: 600;
+ }
+
+ @media (max-width: 640px) {
+ .puzzle-header h1 {
+ margin: 0;
+ font-size: clamp(1.8rem, 7vw, 2.8rem);
+ }
+ }
+
+ /* Help button */
+ .puzzle-header .exit-button {
+ position: absolute;
+ top: 10px;
+ right: 20px;
+ background: none;
+ border: 2px solid #333;
+ border-radius: 50%;
+ width: 2.4rem;
+ height: 2.4rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ font-weight: bold;
+ font-size: 1.2rem;
+ color: #333;
+ }
+
+ @media (max-width: 640px) {
+ .puzzle-header .exit-button {
+ right: 10px;
+ }
+ }
+
+ /* Navigation container */
+ .puzzle-header .nav-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ }
+
+ /* Navigation buttons */
+ .puzzle-header .nav-button {
+ padding: 4px 12px;
+ border: none;
+ border-radius: 4px;
+ background: none;
+ cursor: pointer;
+ font-weight: bold;
+ font-size: 1.5rem;
+ color: #333;
+ }
+
+ .puzzle-header .nav-button:disabled {
+ opacity: 0.3;
+ cursor: default;
+ }
+
+ /* Puzzle date */
+ .puzzle-header .puzzle-date {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 600;
+ font-family: var(--main-font);
+ font-variant: small-caps;
+ color: #1a202c;
+ }
+
+ /* ===================
+ Main Content Area
+ =================== */
+ /* New rules for loading state */
+
+ .loading {
+ text-align: center;
+ padding: 2rem;
+ }
+
+ .puzzle-content {
+ padding: 0rem 1rem 1rem 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0rem;
+ width: 100%;
+ }
+
+ /* ===================
+ Puzzle Display
+ =================== */
+ .puzzle-display {
+ width: 100%;
+ min-height: 40vh;
+ max-height: 60vh;
+ background: #f1f5f9;
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ font-family: var(--mono-font);
+ font-size: 24px;
+ line-height: 1.5;
+ overflow-y: auto;
+ word-break: keep-all;
+ overflow-wrap: break-word;
+ hyphens: none;
+ transition: all 0.3s ease;
+ }
+
+ .puzzle-display.completed {
+ min-height: auto;
+ max-height: none;
+ height: auto;
+ }
+
+ /* ===================
+ Active Clue & Mark Styles
+ =================== */
+ .active-clue {
+ background-color: rgba(255, 255, 0, 0.2);
+ border-radius: 3px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ transition: background-color 0.3s ease;
+ cursor: pointer;
+ }
+
+ .active-clue:hover {
+ background-color: rgba(255, 255, 0, 0.3);
+ }
+
+ mark.solved {
+ background-color: rgba(0, 255, 0, 0.2);
+ border-radius: 3px;
+ padding: 2px 4px;
+ margin: -2px -4px;
+ }
+
+ mark {
+ background-color: transparent;
+ color: inherit;
+ }
+
+ /* ===================
+ Input Styles
+ =================== */
+ .input-container {
+ width: 100%;
+ margin: 10px 0 0 0;
+ position: relative;
+ }
+
+ .answer-input {
+ width: 100%;
+ padding: 16px;
+ font-size: 16px !important;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ margin-bottom: 0px;
+ box-sizing: border-box;
+ font-family: var(--mono-font);
+ }
+
+ /* ===================
+ Message Styles
+ =================== */
+ .message {
+ padding: 0.5rem;
+ margin: 0.5rem 0;
+ border-radius: 0.375rem;
+ text-align: center;
+ font-family: var(--mono-font);
+ display: none;
+ }
+
+ .message.success {
+ background-color: #c6f6d5;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1.2rem;
+ display: block;
+ padding: 20px;
+ line-height: 2rem;
+ }
+
+ .message.success .completion-link {
+ color: #2563eb;
+ text-decoration: underline;
+ }
+
+ .message.error {
+ background-color: #fee2e2;
+ color: #ef4444;
+ display: block;
+ }
+
+ /* ===================
+ Completion Message
+ =================== */
+ .completion-message {
+ margin: 0.5rem 0;
+ padding: 0rem;
+ border-radius: 0.375rem;
+ text-align: center;
+ font-family: var(--mono-font);
+ font-size: 1.2rem;
+ line-height: 1.5;
+ }
+
+ /* ===================
+ Solved Expressions
+ =================== */
+ .solved-expressions {
+ margin-top: 0rem;
+ }
+
+ .solved-expressions h3 {
+ font-size: 1.2rem;
+ font-weight: 500;
+ margin-bottom: 0.75rem;
+ font-family: var(--main-font);
+ color: #1a202c;
+ }
+
+ .expression-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1.2rem;
+ font-family: var(--mono-font);
+ line-height: 1.5;
+ }
+
+ .expression-item .solution {
+ font-weight: 600;
+ color: #1a202c;
+ }
+
+
+ /* ===================
+ Stats & Completion
+ =================== */
+ .stats-content {
+ margin-top: 0rem;
+ }
+
+ .stat-items {
+ margin: 0.5rem 0;
+ }
+
+ .stat-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ font-family: var(--main-font);
+ }
+
+ /* ===================
+ Share Button Styles
+ =================== */
+ .share-buttons {
+ margin-top: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ }
+
+ .share-button {
+ width: 100%;
+ padding: 0.75rem;
+ font-size: 1rem;
+ font-family: var(--main-font);
+ background: #1a202c;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ }
+
+ .share-button.reset {
+ background-color: #ff4444;
+ }
+
+ .share-button:hover {
+ background: #2d3748;
+ }
+
+ .share-message {
+ font-family: var(--main-font);
+ color: #1a202c;
+ text-align: center;
+ padding: 0.5rem;
+ margin-top: 0.5rem;
+ background: var(--highlight-color);
+ border-radius: 0.375rem;
+ }
+
+ /* ===================
+ Rank Display
+ =================== */
+ .rank-display {
+ font-family: var(--mono-font);
+ position: relative;
+ overflow: hidden;
+ padding: 1rem;
+ text-align: center;
+ font-size: 1.2em;
+ font-weight: 500;
+ border-radius: 0.375rem;
+ margin: 0rem 0rem 1rem 0rem;
+ border: 1px solid rgba(255,255,255,0.4);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.05);
+ cursor: pointer;
+ transition: transform 0.2s;
+ user-select: none;
+ }
+
+ .rank-display:active {
+ transform: scale(0.95);
+ }
+ .rank-display .next-rank {
+ font-size: 0.8em;
+ font-weight: normal;
+ }
+
+ .rank-display .share-hint {
+ font-size: 0.7em;
+ margin-top: 0.5em;
+ color: #666;
+ }
+
+ /* Rank gradients */
+ .rank-display[data-rank="Tourist"] { background: linear-gradient(135deg, #f0fdf4 0%, #6ee7b7 100%); }
+ .rank-display[data-rank="Commuter"] { background: linear-gradient(135deg, #fff7ed 0%, #fbd38d 100%); }
+ .rank-display[data-rank="Resident"] { background: linear-gradient(135deg, #f8fafc 0%, #cbd5e1 100%); }
+ .rank-display[data-rank="Council Member"] { background: linear-gradient(135deg, #fafaf9 0%, #d6d3d1 100%); }
+ .rank-display[data-rank="Chief of Police"] { background: linear-gradient(135deg, #f0f9ff 0%, #93c5fd 100%); }
+ .rank-display[data-rank="Mayor"] { background: linear-gradient(135deg, #fff1f2 0%, #fda4af 100%); }
+ .rank-display[data-rank="Power Broker"] { background: linear-gradient(135deg, #fefce8 0%, #92400e 100%); }
+ .rank-display[data-rank="Kingmaker"] { background: linear-gradient(135deg, #fffbeb 0%, #fcd34d 100%); }
+ .rank-display[data-rank="Puppet Master"] { background: linear-gradient(135deg, #faf5ff 0%, #d8b4fe 100%); }
+
+ /* ===================
+ Shimmer Animation
+ =================== */
+ @keyframes shimmer {
+ 0% { transform: translateX(-150%) rotate(45deg); }
+ 100% { transform: translateX(150%) rotate(45deg); }
+ }
+
+ .rank-display::after {
+ content: '';
+ position: absolute;
+ top: -50%;
+ left: -100%;
+ width: 300%;
+ height: 200%;
+ background: linear-gradient(
+ to bottom right,
+ rgba(255,255,255,0) 0%,
+ rgba(255,255,255,0.3) 50%,
+ rgba(255,255,255,0) 100%
+ );
+ transform: rotate(45deg);
+ animation: shimmer 4s infinite linear;
+ pointer-events: none;
+ }
+
+ /* ===================
+ Mobile Styles
+ =================== */
+ @media (max-width: 640px) {
+ body {
+ margin: 0;
+ padding: 0;
+ height: 100vh;
+ font-size: 14px;
+ overflow: hidden;
+ }
+
+ .message.success {
+ background-color: #c6f6d5;
+ color: #1a202c;
+ overflow-wrap: anywhere;
+ font-size: 1rem;
+ display: block;
+ padding: 10px;
+ line-height: 1.4rem;
+ margin-top: 0.2rem;
+ }
+
+ /* Mobile container setup */
+ .puzzle-container {
+ height: 100vh;
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ }
+
+ /* Mobile content area */
+ .puzzle-content {
+ position: absolute;
+ top: 70px;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ padding: 0;
+ gap: 0rem;
+ }
+
+ @font-face {
+ font-family: 'CustomMono';
+ /* Use ui-monospace for every character except underscore */
+ src: local("ui-monospace");
+ /* All Unicode except underscore (U+005F) */
+ unicode-range: U+0000-005E, U+0060-10FFFF;
+ }
+
+ @font-face {
+ font-family: 'CustomMono';
+ /* Use Menlo for underscore only */
+ src: local("Menlo");
+ unicode-range: U+005F;
+ }
+
+ .puzzle-display {
+ height: 155px;
+ padding: 0.5rem 1rem 1rem 1rem;
+ margin: 0;
+ font-size: 1.2rem;
+ background: #f1f5f9;
+ padding-bottom: 200px;
+ font-family: 'CustomMono', ui-monospace;
+ }
+
+ .puzzle-display.completed {
+ padding-bottom: 1rem !important;
+ }
+
+
+ .rank-display {
+ margin: 0rem 0.5rem 1rem 0.5rem;
+ }
+
+
+ /* Mobile input container */
+ .input-container {
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: white;
+ z-index: 10;
+ width: 100%;
+ border-top: 1px solid #e2e8f0;
+ box-shadow: 0 -2px 6px rgba(0,0,0,0.1);
+ padding: 0.5rem calc(0.5rem + env(safe-area-inset-left)) calc(0.5rem + env(safe-area-inset-bottom)) calc(0.5rem + env(safe-area-inset-right));
+ box-sizing: border-box;
+ }
+
+ .custom-input {
+ border: 1px solid #e2e8f0;
+ padding: 0.63rem;
+ border-radius: 6px;
+ margin-bottom: 0.125rem;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ font-size: 1.2rem;
+ min-height: 2.4rem;
+ background: white;
+ width: 100%;
+ color: #000;
+ }
+
+ .placeholder {
+ color: #9ca3af;
+ }
+
+ /* Mobile keyboard */
+ .custom-keyboard {
+ width: 100%;
+ padding: .25rem 0 0 0;
+ box-sizing: border-box;
+ background-color: white;
+ touch-action: none;
+ }
+
+ .keyboard-row {
+ display: grid;
+ grid-template-columns: repeat(10, 1fr);
+ gap: 0.02rem;
+ margin-bottom: 0.125rem;
+ justify-content: center;
+ width: 100%;
+ }
+
+ .keyboard-key {
+ height: 3.3rem;
+ min-height: 2.2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.2rem;
+ margin: 0.1rem 0.15rem 0.1rem 0.15rem;
+ cursor: pointer;
+ color: black;
+ background-color:rgb(233, 233, 233);
+ box-sizing: border-box;
+ touch-action: manipulation;
+ text-align: center;
+ font-size: 1.125rem;
+ font-weight: 600;
+ border: 1px solid rgb(200, 200, 200);
+ user-select: none;
+ -webkit-tap-highlight-color: transparent;
+ -webkit-appearance: none !important;
+ appearance: none !important;
+ border-radius: 4px !important;
+ outline: none !important;
+ -webkit-tap-highlight-color: transparent !important;
+ transition: background-color 0.1s ease-out,
+ transform 0.1s ease-out;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ }
+
+ .keyboard-key:active {
+ background-color: #94a3b8;
+ transform: translateY(2px);
+ }
+
+ .stat-item {
+ background: #f1f5f9;
+ padding: 0.75rem;
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+ line-height: 1.5;
+ }
+
+ /* Hide desktop elements */
+ .bottom-controls,
+ .reset-button,
+ .solved-expressions,
+ .keystroke-stats,
+ .share-button.copy {
+ display: none;
+ }
+
+ /* Mobile completion state */
+ .puzzle-container.completed .puzzle-content {
+ position: static;
+ height: auto;
+ overflow: visible;
+ margin-top: 85px;
+ padding: 1rem;
+ }
+
+ .puzzle-container.completed {
+ position: static;
+ height: auto;
+ min-height: 100vh;
+ overflow-y: auto;
+ }
+
+ .puzzle-display.completed .input-container {
+ display: none;
+ }
+
+ .puzzle-container.completed .solved-expressions,
+ .puzzle-container.completed .keystroke-stats {
+ display: block;
+ margin: 1rem;
+ }
+
+ .keystroke-stats {
+ margin-top: 0rem;
+ }
+ }
+
+ /* ===================
+ iOS-specific Styles
+ =================== */
+ @supports (-webkit-touch-callout: none) {
+ .keyboard-key:active {
+ background-color: #94a3b8 !important;
+ transform: translateY(2px) !important;
+ }
+ }
+
+/* Tutorial Mode Styles */
+.tutorial-container {
+ --tutorial-guidance-bg: #ffe8fa;
+ --tutorial-guidance-border: #4a6fa5;
+ --tutorial-highlight-border: #f6b73c;
+ --tutorial-completion-bg: #e9f7ef;
+ --tutorial-completion-border: #27ae60;
+ --tutorial-button-bg: #2980b9;
+ --tutorial-button-hover: #3498db;
+ }
+
+ /* Tutorial Guidance */
+ .tutorial-guidance {
+ background-color: #ffe8fa !important;
+ border-radius: 8px;
+ font-family: var(--main-font);
+ border-left: 4px solid #ab57a8;
+ border-right: 4px solid #ab57a8;
+ height: 130px !important;
+ overflow: hidden;
+ padding: 16px;
+ margin: 16px 0;
+ vertical-align: middle;
+ }
+
+ .small-break {
+ min-height: 1rem; /* adjust as needed */
+ }
+
+ @media (max-width: 640px) {
+ .small-break {
+ min-height: 0.8rem; /* adjust as needed */
+ }
+ }
+
+ .tutorial-guidance.emerge {
+ animation: emerge 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
+ }
+
+ @keyframes emerge {
+ 0% {
+ opacity: 0;
+ transform: scale(0.95);
+ }
+ 100% {
+ opacity: 1;
+ transform: scale(1);
+ }
+ }
+
+ .tutorial-guidance.highlight {
+ background-color: #fff8e5;
+ border-left-color: #f6b73c;
+ }
+
+ @media (max-width: 640px) {
+ .tutorial-guidance {
+ margin: 0.625rem 0.625rem 0.625rem 0.625rem !important; /* 10px */
+ /*padding: 1rem 0.3125rem !important; /* 16px 5px */
+ font-size: 0.9rem;
+ height: 140px !important;
+ vertical-align: middle;
+ }
+ }
+
+ /* Hide guidance when puzzle is completed */
+ .tutorial-guidance.completed,
+ .puzzle-display.completed ~ .tutorial-guidance {
+ /*display: none;*/
+ }
+
+ /* Tutorial Message */
+ .tutorial-message {
+ font-size: 16px;
+ }
+
+ .tutorial-message strong {
+ font-weight: 600;
+ }
+
+ .tutorial-message a {
+ text-decoration: none;
+ border-bottom: 1px solid;
+ padding-bottom: 1px;
+ transition: color 0.2s;
+ }
+
+ .tutorial-message a:hover {
+ opacity: 0.8;
+ }
+
+ .tutorial-message .emoji {
+ font-size: 1.2em;
+ }
+
+ @media (max-width: 640px) {
+ .tutorial-message {
+ font-size: 14px;
+ }
+ }
+
+ /* Tutorial Puzzle Display */
+ .tutorial-puzzle-display {
+ height: 155px !important;
+ min-height: 155px !important;
+ max-height: 155px !important;
+ overflow-y: auto;
+ background: #f1f5f9;
+ border-radius: 0.5rem;
+ padding: 1rem;
+ font-family: var(--mono-font);
+ line-height: 1.7;
+ word-break: keep-all;
+ overflow-wrap: break-word;
+ transition: none;
+ }
+
+ .tutorial-puzzle-display.completed {
+ height: 155px !important;
+ min-height: 155px !important;
+ max-height: 155px !important;
+ }
+
+ @media (max-width: 640px) {
+ .tutorial-puzzle-display {
+ height: 155px !important;
+ min-height: 155px !important;
+ max-height: 155px !important;
+ padding: 0.8rem;
+ }
+ }
+
+ /* Tutorial Highlights and Animations */
+ .spotlight {
+ background-color: #fff9c4;
+ box-shadow: 0 0 0 2px #fff9c4;
+ border-radius: 4px;
+ }
+
+ .correct {
+ background-color: rgba(0, 255, 0, 0.25);
+ box-shadow: 0 0 0 2px rgba(0, 255, 0, 0.2);
+ border-radius: 4px;
+ }
+
+ @keyframes pulse {
+ 0% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.7);
+ transform: scale(1);
+ }
+ 50% {
+ box-shadow: 0 0 0 6px rgba(255, 193, 7, 0);
+ transform: scale(1.05);
+ }
+ 100% {
+ box-shadow: 0 0 0 0 rgba(255, 193, 7, 0);
+ transform: scale(1);
+ }
+ }
+
+ @keyframes tap-hint {
+ 0% { transform: scale(1); }
+ 50% { transform: scale(1.05); }
+ 100% { transform: scale(1); }
+ }
+
+ .active-clue.tutorial-highlight {
+ box-shadow: 0 0 0 2px #ffc107;
+ animation: pulse 2.5s infinite;
+ background-color: rgba(255, 255, 0, 0.2);
+ font-weight: 500;
+ }
+
+ .active-clue.tap-hint {
+ animation: tap-hint 2s infinite;
+ }
+
+ /* Tutorial Completion */
+ .tutorial-completion {
+ background-color: #e9f7ef;
+ padding: 20px;
+ border-radius: 8px;
+ margin: 10px 10px 0px 10px;
+ text-align: center;
+ border: 2px solid #27ae60;
+ font-family: var(--mono-font);
+ animation: fade-in 0.5s ease-in-out;
+ }
+
+ .tutorial-completion-title {
+ font-size: 20px;
+ margin-bottom: 10px;
+ }
+
+ .tutorial-completion-text {
+ font-size: 16px;
+ }
+
+ .tutorial-completion-wrapper {
+ margin: 0px;
+ }
+
+ @keyframes fade-in {
+ from {
+ opacity: 0;
+ transform: translateY(-10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
+ /* Play Game Button */
+ #playGameButton {
+ margin-top: 15px;
+ padding: 10px 20px;
+ background-color: #2980b9;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 16px;
+ transition: background-color 0.2s, transform 0.1s;
+ }
+
+ #playGameButton:hover {
+ background-color: #3498db;
+ }
+
+ #playGameButton:active {
+ transform: scale(0.98);
+ }
+
+ /* First Letter Hint */
+ .first-letter-hint {
+ font-weight: bold;
+ color: #0c63e4;
+ }
+
+ /* Submit button and input wrapper */
+ .input-submit-wrapper {
+ display: flex;
+ gap: 0.5rem;
+ width: 100%;
+ }
+
+ .submit-answer {
+ padding: 0 1.5rem;
+ height: 60px;
+ background-color: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-weight: 500;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
+ white-space: nowrap;
+ font-size: 1rem;
+ }
+
+ .submit-answer:hover {
+ background-color: #1d4ed8;
+ }
+
+ .submit-answer:active {
+ background-color: #1e40af;
+ transform: scale(0.98);
+ }
+
+ .mobile-submit-button {
+ background-color: #2563eb !important;
+ color: white !important;
+ font-size: 1rem !important;
+ font-weight: 500 !important;
+ }
diff --git a/static/bv/tutorial/tutorial.js b/static/bv/tutorial/tutorial.js
new file mode 100644
index 0000000..33f9258
--- /dev/null
+++ b/static/bv/tutorial/tutorial.js
@@ -0,0 +1,1011 @@
+class BracketCityTutorial {
+ /**
+ * Constructor for the tutorial mode
+ * @param {HTMLElement} rootElement - The container element for the tutorial
+ */
+ constructor(rootElement) {
+ this.root = rootElement;
+
+ // Detect device type
+ this.isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
+
+ // Track keyboard layout for mobile
+ this.isAltKeyboard = false;
+
+ // Hard-coded tutorial puzzle data
+ this.PUZZLE_DATA = {
+ initialPuzzle: "[where [opposite of clean] dishes pile up] or [exercise in a [game played with a cue ball]]",
+ puzzleDate: "Tutorial Mode",
+ completionText: "🎉 Tutorial Complete! 🎉",
+ solutions: {
+ "exercise in a pool": "swim",
+ "game played with a cue ball": "pool",
+ "where dirty dishes pile up": "sink",
+ "opposite of clean": "dirty"
+ }
+ };
+
+ // Initialize the game state
+ this.state = {
+ displayState: this.PUZZLE_DATA.initialPuzzle,
+ solvedExpressions: new Set(),
+ solvedOrder: [], // New array to track order
+ message: '',
+ totalKeystrokes: 0,
+ activeClues: this.findActiveClues(this.PUZZLE_DATA.initialPuzzle),
+ hintModeClues: new Set(),
+ peekedClues: new Set(),
+ megaPeekedClues: new Set(),
+ tutorialStep: 0
+ };
+
+ // Tutorial guidance messages for each step
+ this.tutorialSteps = [
+ {
+ message: "In Bracket Village you can solve <strong>any clue</strong> just by submitting an answer<div class='small-break'></div>No need to click, just type the answer to any <span class='spotlight'>highlighted clue</span> and hit enter!<div class='small-break'></div>Keep guessing until you get one!",
+ highlight: "clue"
+ },
+ {
+ message: "Nice! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>You'll need to solve <span class='spotlight'>opposite of clean</span> to reveal its parent clue about dishes",
+ condition: expressions => expressions.size === 1 && expressions.has("game played with a cue ball"),
+ highlight: "none"
+ },
+ {
+ message: "Nice! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Clues are often <strong>nested</strong> within other clues<div class='small-break'></div>Now solve <span class='spotlight'>game played with a cue ball</span> to reveal the parent clue about exercise",
+ condition: expressions => expressions.size === 1 && expressions.has("opposite of clean"),
+ highlight: "none"
+ },
+ {
+ message: "Excellent! <span class='correct'>pool</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "opposite of clean",
+ highlight: "none"
+ },
+ {
+ message: "Excellent! <span class='correct'>dirty</span> is correct!<div class='small-break'></div>Now both <strong>parent clues</strong> are revealed, so they are both <span class='spotlight'>highlighted</span> and solvable<div class='small-break'></div>Go ahead and solve one",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && !expressions.has("where dirty dishes pile up") && !expressions.has("exercise in a pool") && this.state.solvedOrder[0] === "game played with a cue ball",
+ highlight: "none"
+ },
+ {
+ message: "Ok fine you got <span class='correct'>sink</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>game played with a cue ball</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...",
+ condition: expressions => expressions.size === 2 && expressions.has("opposite of clean") && expressions.has("where dirty dishes pile up") && !expressions.has("game played with a cue ball") && !expressions.has("exercise in a pool"),
+ highlight: "none"
+ },
+ {
+ message: "Ok fine you got <span class='correct'>swim</span> instead<div class='small-break'></div>You still need to solve <span class='spotlight'>opposite of clean</span><div class='small-break'></div>Looking at the <strong>parent clue</strong> text can help...",
+ condition: expressions => expressions.size === 2 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && !expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up"),
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>swim</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("exercise in a pool") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "exercise in a pool",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>sink</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("where dirty dishes pile up") && expressions.has("game played with a cue ball") && expressions.has("opposite of clean") && !expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "where dirty dishes pile up",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>dirty</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("exercise in a pool") && this.state.solvedOrder[2] === "opposite of clean",
+ highlight: "none"
+ },
+ {
+ message: "Yee-haw <span class='correct'>pool</span> is right. Only one more <span class='spotlight'>clue</span> to go...<div class='small-break'></div>Try clicking it to reveal the first letter, that's called a <strong>\"peek\"</strong><div class='small-break'></div>Then go ahead and finish!",
+ condition: expressions => expressions.size === 3 && expressions.has("opposite of clean") && expressions.has("game played with a cue ball") && expressions.has("where dirty dishes pile up") && this.state.solvedOrder[2] === "game played with a cue ball",
+ highlight: "none"
+ },
+ {
+ message: "<strong>Just to recap:</strong><div class='small-break'></div>* you can solve any <span class='spotlight'>highlighted clue</span> \u{2013} just type your guess and hit enter<div class='small-break'></div>* click once on a clue to <strong>peek</strong> at the first letter, twice to <span class='correct'>reveal</span> the answer",
+ condition: expressions => !this.state.displayState.includes('['),
+ highlight: "none",
+ isComplete: true
+ }
+ ];
+
+ // Initialize the UI
+ this.initializeGame();
+ }
+
+
+ /**
+ * Initializes the tutorial game
+ */
+ initializeGame() {
+ // Generate the initial HTML
+ this.root.innerHTML = this.generateInitialHTML();
+
+ // Setup input handlers
+ this.setupInputHandlers();
+
+ // Render the initial state
+ this.render();
+
+ // Setup tutorial guidance
+ this.updateTutorialGuidance();
+
+ // Setup clue click handlers for peeking
+ this.setupClueClickHandlers();
+ }
+
+ /**
+ * Generates the initial HTML for the tutorial
+ */
+ generateInitialHTML() {
+ return `
+ <div class="puzzle-container">
+ ${this.generateHeaderHTML()}
+ <div class="puzzle-content">
+ ${this.generateTutorialGuidanceHTML()}
+ ${this.generatePuzzleDisplayHTML()}
+
+ ${this.generateInputContainerHTML()}
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the header HTML
+ */
+ generateHeaderHTML() {
+ return `
+ <div class="puzzle-header">
+ <h1>[Bracket Village]</h1>
+ <button class="exit-button">X</button>
+ <div class="nav-container">
+ <div class="puzzle-date">How To Play</div>
+ </div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the puzzle display HTML with fixed height
+ */
+ generatePuzzleDisplayHTML() {
+ return `<div class="puzzle-display tutorial-puzzle-display"></div>`;
+ }
+
+ /**
+ * Generates the tutorial guidance container HTML
+ */
+ generateTutorialGuidanceHTML() {
+ return `
+ <div class="tutorial-guidance">
+ <div class="tutorial-message"></div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates the input container HTML based on device type
+ */
+ generateInputContainerHTML() {
+ if (this.isMobile) {
+ return `
+ <div class="input-container">
+ <div class="message"></div>
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem; margin-bottom: 0.5rem;">
+ <div class="custom-input" style="flex-grow: 1; height: 44px; display: flex; align-items: center;">
+ <span class="placeholder">type any answer...</span>
+ </div>
+ <button
+ class="mobile-submit-button"
+ style="height: 44px; background-color: #2563eb; color: white; padding: 0 1rem; border-radius: 6px; font-weight: 500; border: none;"
+ >
+ [submit]
+ </button>
+ </div>
+ <div class="custom-keyboard">
+ ${this.generateKeyboardButtonsHTML()}
+ </div>
+ </div>
+ `;
+ }
+
+ return `
+ <div class="input-container">
+ <div class="input-submit-wrapper" style="display: flex; gap: 0.5rem;">
+ <input
+ type="text"
+ placeholder="type any answer..."
+ class="answer-input"
+ autocomplete="off"
+ autocapitalize="off"
+ >
+ <button
+ class="submit-answer"
+ >
+ [submit]
+ </button>
+ </div>
+ <div class="message"></div>
+ </div>
+ `;
+ }
+
+ /**
+ * Generates mobile keyboard buttons HTML
+ */
+ generateKeyboardButtonsHTML() {
+ const mainLayout = [
+ { keys: 'qwertyuiop', columns: 10 },
+ { keys: 'asdfghjkl', columns: 10 },
+ { keys: [
+ { key: '123', display: '123', small: true },
+ 'z', 'x', 'c', 'v', 'b', 'n', 'm',
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const altLayout = [
+ { keys: '1234567890', columns: 10 },
+ { keys: '-/:;()$&@"', columns: 10 },
+ { keys: [
+ { key: 'ABC', display: 'ABC', small: true },
+ '.', ',', '?', '!', "'", "=", "+",
+ { key: 'backspace', display: '\u{232B}', wide: true }
+ ], columns: 10 }
+ ];
+
+ const rows = this.isAltKeyboard ? altLayout : mainLayout;
+
+ let html = ``;
+
+ rows.forEach((row, rowIndex) => {
+ // Adding margin for middle row on default layout since it's only 9 keys vs 10
+ const leftPadding = (rowIndex === 1 && !this.isAltKeyboard) ? 'margin-left: 5%' : '';
+ html += `<div class="keyboard-row" style="${leftPadding}">`;
+
+ const keys = Array.isArray(row.keys) ? row.keys : row.keys.split('');
+ keys.forEach(keyItem => {
+ if (typeof keyItem === 'string') {
+ html += this.generateKeyboardKeyHTML(keyItem);
+ } else {
+ html += this.generateSpecialKeyHTML(keyItem);
+ }
+ });
+
+ html += '</div>';
+ });
+
+ return html;
+ }
+
+ /**
+ * Generates a special keyboard key HTML
+ */
+ generateSpecialKeyHTML(keyItem) {
+ const fontSize = keyItem.small ? '0.875rem' : '1.125rem';
+
+ return `
+ <button class="keyboard-key" data-key="${keyItem.key}" style="
+ grid-column: ${keyItem.wide ? 'span 2' : 'span 1'};
+ font-size: ${fontSize};
+ ">
+ ${keyItem.display}
+ </button>
+ `;
+ }
+
+ /**
+ * Generates a regular keyboard key HTML
+ */
+ generateKeyboardKeyHTML(key) {
+ return `
+ <button class="keyboard-key" data-key="${key}">
+ ${key}
+ </button>
+ `;
+ }
+
+ /**
+ * Sets up input handlers for desktop or mobile
+ */
+ setupInputHandlers() {
+ if (this.isMobile) {
+ this.setupMobileInput();
+ } else {
+ this.setupDesktopInput();
+ }
+
+ // Setup exit button (formerly help button)
+ const exitButton = this.root.querySelector('.exit-button');
+ if (exitButton) {
+ exitButton.addEventListener('click', () => {
+ if (confirm('Are you sure you want to exit the tutorial?')) {
+ window.location.href = '/bv';
+ }
+ });
+ }
+ }
+
+ /**
+ * Sets up desktop input handling
+ */
+ setupDesktopInput() {
+ this.answerInput = this.root.querySelector('.answer-input');
+ const submitButton = this.root.querySelector('.submit-answer');
+
+ if (!this.answerInput || !submitButton) {
+ console.error('Required input elements not found');
+ return;
+ }
+
+ // Handle submit button click
+ submitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+
+ // Handle Enter key with visual feedback
+ this.answerInput.addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+
+ // Apply visual feedback style directly to the button
+ submitButton.style.transform = 'scale(0.95)';
+ submitButton.style.backgroundColor = '#1e40af';
+
+ // Process the submission
+ this.handleSubmission();
+
+ // Remove the styles after a short delay
+ setTimeout(() => {
+ submitButton.style.transform = '';
+ submitButton.style.backgroundColor = '';
+ }, 85); // Slightly longer for better visibility
+ }
+ });
+
+ this.answerInput.addEventListener('keydown', (e) => {
+ this.handleKeyDown(e);
+ });
+
+ this.answerInput.addEventListener('paste', (e) => {
+ e.preventDefault();
+ });
+
+ this.answerInput.focus();
+ }
+
+ /**
+ * Sets up mobile input handling
+ */
+ setupMobileInput() {
+ this.customInputValue = '';
+ this.customInputEl = this.root.querySelector('.custom-input');
+
+ if (!this.customInputEl) {
+ console.error('Custom input display element not found');
+ return;
+ }
+
+ // Clean up old placeholder if it exists
+ const oldPlaceholder = this.customInputEl.querySelector('.placeholder');
+ if (oldPlaceholder) {
+ oldPlaceholder.remove();
+ }
+
+ // Create fresh placeholder
+ const placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = 'type any answer...';
+ this.customInputEl.appendChild(placeholderEl);
+
+ // Set up the mobile submit button
+ const mobileSubmitButton = this.root.querySelector('.mobile-submit-button');
+ if (mobileSubmitButton) {
+ mobileSubmitButton.addEventListener('click', () => {
+ this.handleSubmission();
+ });
+ }
+
+ // Attach fresh click listeners to keyboard keys
+ const keyboardKeys = this.root.querySelectorAll('.keyboard-key');
+ keyboardKeys.forEach(keyEl => {
+ keyEl.addEventListener('click', () => {
+ const key = keyEl.getAttribute('data-key');
+ if (key === 'backspace') {
+ this.customInputValue = this.customInputValue.slice(0, -1);
+ } else if (key === '123' || key === 'ABC') {
+ this.isAltKeyboard = !this.isAltKeyboard;
+ // Re-render the keyboard
+ const keyboardContainer = this.root.querySelector('.custom-keyboard');
+ if (keyboardContainer) {
+ keyboardContainer.innerHTML = this.generateKeyboardButtonsHTML();
+ }
+ // Re-attach event listeners while preserving input
+ this.setupMobileInput();
+ return;
+ } else {
+ this.customInputValue += key;
+ this.state.totalKeystrokes++;
+ }
+
+ this.updateCustomInputDisplay();
+ });
+
+ // Prevent dragging/swiping on the key
+ keyEl.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ }, { passive: false });
+ });
+
+ // Enable :active states on iOS
+ document.addEventListener('touchstart', () => {}, false);
+
+ // Update display to show current input value
+ this.updateCustomInputDisplay();
+ }
+
+ handleSubmission() {
+ const input = this.isMobile ? this.customInputValue : this.answerInput.value;
+ if (!input || !input.trim()) return;
+
+ const normalizedInput = this.normalizeInput(input);
+ const match = this.findMatchingExpression(normalizedInput);
+
+ if (match) {
+ this.solveExpression(match);
+ }
+
+ // Clear input regardless of match
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.updateCustomInputDisplay();
+ } else {
+ this.answerInput.value = '';
+ this.answerInput.focus();
+ }
+ }
+ /**
+ * Updates the custom input display for mobile
+ */
+ updateCustomInputDisplay() {
+ if (!this.customInputEl) return;
+
+ // Find or create placeholder
+ let placeholderEl = this.customInputEl.querySelector('.placeholder');
+ if (!placeholderEl) {
+ placeholderEl = document.createElement('span');
+ placeholderEl.className = 'placeholder';
+ placeholderEl.style.color = '#9ca3af';
+ placeholderEl.textContent = 'start typing answers...';
+ }
+
+ // Clear the input
+ this.customInputEl.innerHTML = '';
+
+ // Show either placeholder or input value
+ if (this.customInputValue.length > 0) {
+ this.customInputEl.textContent = this.customInputValue;
+ } else {
+ this.customInputEl.appendChild(placeholderEl);
+ }
+ }
+
+ /**
+ * Sets up clue click handlers for peeking functionality
+ */
+ setupClueClickHandlers() {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ puzzleDisplay.addEventListener('click', (event) => {
+ const clueElement = event.target.closest('.active-clue');
+ if (!clueElement) return;
+
+ // Find the clicked expression
+ const cleanText = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+ const cluePosition = Array.from(puzzleDisplay.querySelectorAll('.active-clue')).indexOf(clueElement);
+
+ if (cluePosition >= 0 && cluePosition < activeClues.length) {
+ const expression = activeClues[cluePosition].expression.trim();
+ this.toggleHintMode(expression);
+
+ // Return focus to input on desktop
+ if (!this.isMobile && this.answerInput) {
+ this.answerInput.focus();
+ }
+ }
+ });
+ }
+
+ /**
+ * Shows the help dialog
+ */
+ showHelp() {
+ const helpContent = `
+ <div style="text-align: center; font-weight: bold; margin: 0 0 0.5em 0;">Welcome to the Bracket Village Tutorial</div>
+ <div style="margin: 0.9em 0;">* In Bracket Village, you solve <mark style="background-color: #fff9c4;">[active clues]</mark> by typing the answers</div>
+ <div style="margin: 0.9em 0;">* You can tap any <mark style="background-color: #fff9c4;">[active clue]</mark> to peek at its first letter, or tap again to reveal the full answer</div>
+ <div style="margin: 0.9em 0;">* This tutorial will guide you through the basics</div>
+ <div style="margin: 0.9em 0;">* Tap anywhere to continue</div>
+ `;
+
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Store the current content
+ this.previousDisplayContent = puzzleDisplay.innerHTML;
+
+ // Show help content
+ puzzleDisplay.innerHTML = helpContent;
+
+ // Add click handler to close help
+ const closeHandler = () => {
+ puzzleDisplay.innerHTML = this.previousDisplayContent;
+ puzzleDisplay.removeEventListener('click', closeHandler);
+ // Return focus to input on desktop
+ if (!this.isMobile && this.answerInput) {
+ this.answerInput.focus();
+ }
+ };
+
+ puzzleDisplay.addEventListener('click', closeHandler);
+ }
+
+ /**
+ * Main render method to update the UI
+ */
+ render() {
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+ if (!puzzleDisplay) return;
+
+ // Apply highlighting to active clues
+ const highlightedState = this.applyActiveClueHighlights(this.state.displayState);
+ puzzleDisplay.innerHTML = highlightedState;
+
+ // Check if puzzle is complete
+ if (this.isPuzzleComplete()) {
+ this.renderCompletionState();
+ }
+ }
+
+ /**
+ * Applies highlighting to active clues
+ * @param {string} text - Current puzzle text
+ * @returns {string} HTML with highlights applied
+ */
+ applyActiveClueHighlights(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const activeClues = this.findActiveClues(cleanText);
+
+ // Sort clues by start position in reverse order
+ activeClues.sort((a, b) => b.start - a.start);
+
+ let highlightedText = cleanText;
+
+ activeClues.forEach(clue => {
+ const expressionText = clue.expression.trim();
+ const before = highlightedText.slice(0, clue.start);
+ let clueText = highlightedText.slice(clue.start, clue.end);
+ const after = highlightedText.slice(clue.end);
+
+ // Handle different clue states
+ if (!clueText.includes('[') && !this.state.hintModeClues.has(expressionText)) {
+ // Already solved clue
+ highlightedText = before + clueText + after;
+ } else {
+ // Active or hint mode clue
+ if (this.state.hintModeClues.has(expressionText)) {
+ const solution = this.PUZZLE_DATA.solutions[expressionText];
+ // Show just the first letter
+ if (solution && !clueText.includes(`(`)) {
+ // Get the first letter of the solution
+ const firstLetter = solution.charAt(0).toUpperCase();
+ // Remove closing bracket, add first letter hint, re-add closing bracket
+ clueText = clueText.slice(0, -1) + ` (${firstLetter})]`;
+ }
+ }
+
+ // Ensure we're only wrapping the exact clue text with brackets included
+ highlightedText = before + `<span class="active-clue">${clueText}</span>` + after;
+ }
+ });
+
+ return highlightedText;
+ }
+
+ /**
+ * Renders the completion state while maintaining fixed height
+ */
+ renderCompletionState() {
+ const inputContainer = this.root.querySelector('.input-container');
+ const puzzleDisplay = this.root.querySelector('.puzzle-display');
+
+ if (!inputContainer || !puzzleDisplay) return;
+
+ // Hide input container
+ inputContainer.style.display = 'none';
+
+ // Add completion class but ensure height is preserved
+ puzzleDisplay.classList.add('completed');
+
+ // Add a tutorial completion message at the top
+ const completionHTML = `
+ <div class="tutorial-completion">
+ <div class="tutorial-completion-title">🎉 Tutorial Complete! 🎉</div>
+ <div class="tutorial-completion-text">Ready to play the full game?</div>
+ <button id="playGameButton">Play Bracket Village</button>
+ </div>
+ `;
+
+ // Get the puzzle content container
+ const puzzleContent = this.root.querySelector('.puzzle-content');
+
+ // Create a wrapper for the completion message
+ const completionWrapper = document.createElement('div');
+ completionWrapper.className = 'tutorial-completion-wrapper';
+ completionWrapper.innerHTML = completionHTML;
+
+ // Insert the completion message at the top of puzzle content
+ if (puzzleContent) {
+ puzzleContent.insertBefore(completionWrapper, puzzleContent.firstChild);
+ }
+
+ // Add event listener to the play button
+ const playButton = this.root.querySelector('#playGameButton');
+ if (playButton) {
+ playButton.addEventListener('click', () => {
+ window.location.href = '/bv';
+ });
+ }
+ }
+
+ /**
+ * Updates the tutorial guidance based on the current state
+ */
+ updateTutorialGuidance() {
+ const tutorialGuidance = this.root.querySelector('.tutorial-guidance');
+ if (!tutorialGuidance) return;
+
+ // Find the appropriate tutorial step
+ let currentStep = this.tutorialSteps[0]; // Default to first step
+
+ for (let i = this.tutorialSteps.length - 1; i >= 0; i--) {
+ const step = this.tutorialSteps[i];
+
+ // If this step has a condition and it's satisfied, use this step
+ if (step.condition && step.condition(this.state.solvedExpressions)) {
+ currentStep = step;
+ break;
+ }
+ }
+
+ // Clone and replace the guidance element to restart animation
+ const newGuidance = tutorialGuidance.cloneNode(true);
+ const messageElement = document.createElement('div');
+ messageElement.className = 'tutorial-message';
+ messageElement.innerHTML = currentStep.message;
+
+ newGuidance.innerHTML = ''; // Clear existing content
+ newGuidance.appendChild(messageElement);
+ newGuidance.classList.add('emerge');
+
+ tutorialGuidance.parentNode.replaceChild(newGuidance, tutorialGuidance);
+
+ // Remove all highlight classes first
+ const allClues = this.root.querySelectorAll('.active-clue');
+ allClues.forEach(clue => {
+ clue.classList.remove('tutorial-highlight', 'tap-hint');
+ });
+
+ // Add highlighting based on the current step
+ if (currentStep.highlight === 'clue') {
+ // Highlight active clues more prominently using CSS class
+ const activeClues = this.root.querySelectorAll('.active-clue');
+ activeClues.forEach(clue => {
+ clue.classList.add('tutorial-highlight');
+ });
+ } else if (currentStep.highlight === 'hint') {
+ // Add a subtle animation to suggest tapping a clue using CSS class
+ const activeClues = this.root.querySelectorAll('.active-clue');
+ if (activeClues.length > 0) {
+ activeClues[0].classList.add('tap-hint');
+ }
+ }
+ }
+
+ /**
+ * Determines if a keystroke should be counted
+ * @param {KeyboardEvent} event - The keyboard event
+ * @returns {boolean} Whether the keystroke should be counted
+ */
+ isCountableKeystroke(event) {
+ // Only count single printable characters
+ return event.key.length === 1 &&
+ /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/? ]$/.test(event.key);
+ }
+
+ /**
+ * Finds all active clues in the current puzzle state
+ * @param {string} puzzleText - Current puzzle text
+ * @returns {Array} Array of clue objects
+ */
+ findActiveClues(puzzleText) {
+ const activeClues = [];
+
+ function findClues(str, startOffset = 0) {
+ let i = 0;
+ while (i < str.length) {
+ if (str[i] === '[') {
+ const startIndex = i;
+ let bracketCount = 1;
+ let hasNestedBrackets = false;
+ i++;
+
+ let innerContent = '';
+ while (i < str.length && bracketCount > 0) {
+ if (str[i] === '[') {
+ bracketCount++;
+ hasNestedBrackets = true;
+ } else if (str[i] === ']') {
+ bracketCount--;
+ }
+ innerContent += str[i];
+ i++;
+ }
+
+ innerContent = innerContent.slice(0, -1);
+
+ if (!hasNestedBrackets) {
+ // Clean expression text of HTML markup
+ const cleanExpression = innerContent.replace(/<\/?[^>]+(>|$)/g, '');
+ activeClues.push({
+ start: startOffset + startIndex,
+ end: startOffset + i,
+ text: str.substring(startIndex, i),
+ expression: cleanExpression
+ });
+ }
+
+ if (hasNestedBrackets) {
+ findClues(innerContent, startOffset + startIndex + 1);
+ }
+ } else {
+ i++;
+ }
+ }
+ }
+
+ const cleanText = puzzleText.replace(/<\/?span[^>]*(>|$)/g, '');
+ findClues(cleanText);
+ return activeClues;
+ }
+
+ /**
+ * Finds available expressions that can be solved
+ * @param {string} text - Current puzzle text
+ * @returns {Array} Array of expression objects
+ */
+ findAvailableExpressions(text) {
+ const cleanText = text.replace(/<\/?span[^>]*(>|$)/g, '');
+ const results = [];
+ const regex = /\[([^\[\]]+?)\]/g;
+ const matchedPositions = new Set();
+ let match;
+
+ while ((match = regex.exec(cleanText)) !== null) {
+ const startIdx = match.index;
+ const endIdx = startIdx + match[0].length;
+
+ if (!matchedPositions.has(startIdx)) {
+ matchedPositions.add(startIdx);
+ results.push({
+ expression: match[1],
+ startIndex: startIdx,
+ endIndex: endIdx
+ });
+ }
+ }
+
+ return results;
+ }
+
+ /**
+ * Handles user input for solving expressions
+ * @param {string} input - User input string
+ */
+ handleInput(input) {
+ const normalizedInput = this.normalizeInput(input);
+ if (!normalizedInput) {
+ return;
+ }
+
+ const match = this.findMatchingExpression(normalizedInput);
+ if (!match) {
+ return;
+ }
+
+ this.solveExpression(match);
+ }
+
+ /**
+ * Normalizes and validates user input
+ * @param {string} input - Raw user input
+ * @returns {string|null} Normalized input or null if invalid
+ */
+ normalizeInput(input) {
+ if (!input?.trim()) {
+ return null;
+ }
+ return input.trim().toLowerCase();
+ }
+
+ /**
+ * Finds an unsolved expression matching the input
+ * @param {string} normalizedInput - Normalized user input
+ * @returns {Object|null} Matching expression info or null if not found
+ */
+ findMatchingExpression(normalizedInput) {
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {expression, startIndex, endIndex} of availableExpressions) {
+ const solution = this.PUZZLE_DATA.solutions[expression];
+
+ if (solution?.toLowerCase() === normalizedInput &&
+ !this.state.solvedExpressions.has(expression)) {
+ return {
+ expression,
+ solution,
+ startIndex,
+ endIndex
+ };
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Processes a correct solution and updates game state
+ * @param {Object} match - Expression match information
+ */
+ solveExpression(match) {
+ const { expression, solution, startIndex, endIndex } = match;
+
+ // Generate the new puzzle display state by replacing the solved expression
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ // Add to the solved expressions set
+ const updatedExpressions = new Set([...this.state.solvedExpressions, expression]);
+ this.state.solvedOrder.push(expression);
+ // Update game state
+ this.state.displayState = newDisplayState;
+ this.state.solvedExpressions = updatedExpressions;
+
+ // Clear the input based on device type
+ if (this.isMobile) {
+ this.customInputValue = '';
+ this.updateCustomInputDisplay();
+ } else {
+ if (this.answerInput) {
+ this.answerInput.value = '';
+ }
+ }
+
+ // Re-render the puzzle display
+ this.render();
+
+ // Update tutorial guidance
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Toggles hint mode for an expression
+ * @param {string} expression - The expression to toggle hints for
+ */
+ toggleHintMode(expression) {
+ // Early validation
+ if (!expression || this.state.megaPeekedClues.has(expression)) {
+ return;
+ }
+
+ // Handle mega peek (second click)
+ if (this.state.hintModeClues.has(expression)) {
+ this.handleMegaPeek(expression);
+ return;
+ }
+
+ // Handle first peek
+ this.handleFirstPeek(expression);
+ }
+
+ /**
+ * Handles the first peek at an expression (shows first letter)
+ * @param {string} expression - The expression to peek at
+ */
+ handleFirstPeek(expression) {
+ this.state.hintModeClues.add(expression);
+ this.state.peekedClues.add(expression);
+
+ this.render();
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Handles the mega peek action (reveals answer)
+ * @param {string} expression - The expression to reveal
+ */
+ handleMegaPeek(expression) {
+ const solution = this.PUZZLE_DATA.solutions[expression];
+ if (!solution) {
+ console.error('No solution found for expression:', expression);
+ return;
+ }
+
+ const newDisplayState = this.processSolutionReveal(expression, solution);
+ if (!newDisplayState) {
+ console.error('Could not find expression in puzzle state');
+ return;
+ }
+
+ // Update state
+ this.state.displayState = newDisplayState;
+ this.state.megaPeekedClues.add(expression);
+ this.state.solvedExpressions.add(expression);
+ this.state.hintModeClues.delete(expression);
+
+ // Re-render
+ this.render();
+
+ // Update tutorial guidance
+ this.updateTutorialGuidance();
+ }
+
+ /**
+ * Processes a solution reveal for megapeek
+ * @param {Object} expression - Expression to reveal
+ * @param {string} solution - Solution text
+ * @returns {string} New display state
+ */
+ processSolutionReveal(expression, solution) {
+ // Clean the display state first
+ const cleanState = this.state.displayState.replace(/<\/?[^>]+(>|$)/g, '');
+ const availableExpressions = this.findAvailableExpressions(cleanState);
+
+ for (const {startIndex, endIndex} of availableExpressions) {
+ const currentExpression = cleanState.slice(startIndex + 1, endIndex - 1).trim();
+
+ if (expression === currentExpression) {
+ const escapedSolution = solution
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;');
+
+ const newDisplayState =
+ cleanState.slice(0, startIndex) +
+ `<mark class="solved">${escapedSolution}</mark>` +
+ cleanState.slice(endIndex);
+
+ return newDisplayState;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Checks if the puzzle is complete
+ * @returns {boolean} Whether the puzzle is complete
+ */
+ isPuzzleComplete() {
+ return !this.state.displayState.includes('[');
+ }
+ }
+
+ // Export the class for use in other files
+ if (typeof module !== 'undefined' && module.exports) {
+ module.exports = { BracketCityTutorial };
+ }