aboutsummaryrefslogtreecommitdiff
path: root/static/bs
diff options
context:
space:
mode:
authorNat Lasseter <user@4574.co.uk>2025-03-31 16:20:12 +0100
committerNat Lasseter <user@4574.co.uk>2025-03-31 16:20:12 +0100
commit1a4fca51bf12a38e45220113347199e283c12751 (patch)
tree0b5b2e48be5a2e79f8417a9224fba35efc438643 /static/bs
parent9e498357dfac69a3037933b46746a470fb9c8491 (diff)
[bracket shitty] add tutorial
Diffstat (limited to 'static/bs')
-rw-r--r--static/bs/bracket.js2
-rw-r--r--static/bs/tutorial/tutorial-init.js22
-rw-r--r--static/bs/tutorial/tutorial.css1014
-rw-r--r--static/bs/tutorial/tutorial.js1011
4 files changed, 2048 insertions, 1 deletions
diff --git a/static/bs/bracket.js b/static/bs/bracket.js
index 8e346f1..d3284b7 100644
--- a/static/bs/bracket.js
+++ b/static/bs/bracket.js
@@ -3522,7 +3522,7 @@ generateSpecialKeyHTML(keyItem) {
if (confirm('Would you like to start the tutorial?')) {
localStorage.setItem('tutorialSeen', 'true');
this.saveState();
- window.location.href = '/tutorial';
+ window.location.href = 'tutorial';
}
});
}
diff --git a/static/bs/tutorial/tutorial-init.js b/static/bs/tutorial/tutorial-init.js
new file mode 100644
index 0000000..ade15a5
--- /dev/null
+++ b/static/bs/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 Shitty tutorial');
+ return null;
+ }
+ }
diff --git a/static/bs/tutorial/tutorial.css b/static/bs/tutorial/tutorial.css
new file mode 100644
index 0000000..f0e9d9a
--- /dev/null
+++ b/static/bs/tutorial/tutorial.css
@@ -0,0 +1,1014 @@
+/* ===================
+ 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;
+ }
+
+ /* ===================
+ Email Form Styles
+ =================== */
+
+ .email-signup-container {
+ margin: 1rem 0.5rem 0.5rem 0.5rem;
+ }
+
+ .email-signup-form label {
+ display: block;
+ margin-bottom: 0.75rem;
+ font-weight: 500;
+ text-align: center;
+ font-family: var(--mono-font);
+ font-size: 1.1rem;
+ }
+
+ .email-signup-form label span
+ {
+ background-color: rgba(255, 255, 0, 0.2); padding: 0.2rem 0.4rem;
+ }
+
+ .email-input-container {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .email-signup-form input {
+ flex: 1;
+ padding: 0.5rem;
+ border: 1px solid #ccc;
+ border-radius: 0.375rem;
+ font-size: 16px;
+ gap: 0.5rem
+ }
+
+ .email-signup-form button {
+ padding: 0.5rem 1rem;
+ background-color: #2563eb;
+ color: white;
+ border: none;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ font-weight: 500;
+ font-size: 16px;
+ white-space: nowrap;
+ }
+ .email-signup-message {
+ border-radius: 0.375rem;
+ margin-top: 0.75rem;
+ text-align: center;
+ font-family: var(--mono-font);
+ }
+
+ .email-signup-message .success {
+ padding: 1rem;
+ background-color: #ecfdf5;
+ border: 1px solid #6ee7b7;
+ border-radius: 0.375rem;
+ color: #065f46;
+ }
+
+ .email-signup-message .error {
+ padding: 1rem;
+ background-color: #fef2f2;
+ border: 1px solid #fca5a5;
+ border-radius: 0.375rem;
+ color: #991b1b;
+ }
+
+ /* ===================
+ 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;
+ } \ No newline at end of file
diff --git a/static/bs/tutorial/tutorial.js b/static/bs/tutorial/tutorial.js
new file mode 100644
index 0000000..3e89fdc
--- /dev/null
+++ b/static/bs/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 City 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 City]</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 = '/';
+ }
+ });
+ }
+ }
+
+ /**
+ * 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 City Tutorial</div>
+ <div style="margin: 0.9em 0;">* In Bracket City, 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 City</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 = '/';
+ });
+ }
+ }
+
+ /**
+ * 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 };
+ }