Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Contributing to SQLwak

Thanks for helping make SQLwak better! The most valuable contributions are new
levels, clearer task descriptions, and bug reports about queries that were
graded incorrectly.

## Development

```bash
npm install
npm run dev # http://localhost:3000
```

Before opening a PR, make sure all four CI gates pass locally:

```bash
npm run lint
npm run typecheck
npm test
npm run build
```

## How validation works

A level is solved when the user's query returns the same result as the level's
`solutionQuery`, both executed against the seeded in-memory SQLite database
(`src/lib/seed.ts`). The comparison (`src/lib/validator.ts`):

- only permits a single read-only `SELECT`/`WITH` statement;
- requires row order **only** when the solution query has a top-level
`ORDER BY` (a per-level `orderMatters` field can override this);
- matches column names case-insensitively, strings exactly, and numbers with a
relative tolerance.

## Authoring a level

Levels live in `src/data/levels/` (one file per epoch, combined in
`index.ts`). When adding one:

1. **Append to the correct epoch file** with the next sequential `id`. Epochs
must stay contiguous — the test suite enforces this.
2. **Write the description as a business request**, ending with an explicit
contract: which columns to return (use backticked names matching the
solution's aliases), and — if order matters — the exact ordering, phrased
like "ordered by `balance` descending".
3. **Only add `ORDER BY` to the solution when the description asks for it.**
If the description doesn't mention ordering, leave the solution unordered;
the validator then accepts any row order.
4. **`seedQuery`** is a scaffold with blanks, not a near-answer. Keep clause
keywords on their own lines with a single trailing space where the user
should type.
5. **`hint`** should name the technique (e.g. "use `LAG() OVER`"), not the
answer.
6. **Run `npm test`.** The suite executes every solution against the real
seed and fails if a solution errors, returns zero rows, or if a seed
scaffold already passes validation.

If a level needs new data, extend `src/lib/seed.ts` — and check existing
levels still pass, since they share the seed.

## Code style

Match the surrounding code. UI work should respect the design principles in
`PRODUCT.md` (terminal-first, WCAG AA contrast, reduced-motion alternatives).
19 changes: 16 additions & 3 deletions IMPROVEMENT_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,22 @@ Implemented in this PR:
unused types, unused `p5` dependency, no-op callbacks, and the `sqlawk` name typo removed
(3.3); seed extracted to `src/lib/seed.ts` for Node test reuse.

Still open: level 49 simplification and seed-blank audit (1.6), store tests (2.4),
`levels.ts` split and build-time expected results (3.4), Pillar 4 UX/a11y work, and Pillar 5
docs (LICENSE choice is the owner's call).
Implemented in the follow-up PR:

- **Remainder of 1.6:** level 49 rewritten with the `AVG(x²) − AVG(x)²` variance identity
(no more correlated subqueries); seed-scaffold whitespace normalized.
- **2.4:** store tests for XP accrual, streak, navigation history, and reset — which surfaced
and fixed three real bugs (XP farming by replaying levels, `currentLevel` advancing past the
final level, and back-navigation never consuming history).
- **3.4 (partial):** `levels.ts` split into per-epoch modules under `src/data/levels/`.
- **Pillar 4:** escalating disclosure (expected result *shape* revealed after 3 failed
attempts), dialog semantics/focus/Escape handling for the modal and level navigator,
aria-labels on icon buttons, and a stacking responsive layout below the `lg` breakpoint.
- **Pillar 5:** MIT LICENSE, CONTRIBUTING.md with a level-authoring guide.

Still open: build-time expected results (3.4), the expected-vs-actual value diff (4.1 — the
shape reveal shipped; a value-level diff is a deliberate product decision), and a deeper
mobile/touch pass (4.4).

---

Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 martinl5

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ npm run build # Production build

## Contributing

Issues and PRs welcome! Ideas for new levels, challenge themes, or schema additions are especially appreciated.
Issues and PRs welcome! Ideas for new levels, challenge themes, or schema additions are especially appreciated — see [CONTRIBUTING.md](CONTRIBUTING.md) for the level-authoring guide.

## License

[MIT](LICENSE)

---

Expand Down
2 changes: 2 additions & 0 deletions src/components/FlockStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function FlockStatus({ onOpenLevelNavigator }: FlockStatusProps)
className="p-1.5 transition-colors hover:opacity-70"
style={{ color: 'var(--lcb-muted)', background: 'rgba(255,255,255,0.04)', border: '1px solid var(--lcb-border)', borderRadius: 4 }}
title="Browse levels"
aria-label="Browse levels"
>
<List className="w-3.5 h-3.5" />
</button>
Expand All @@ -47,6 +48,7 @@ export default function FlockStatus({ onOpenLevelNavigator }: FlockStatusProps)
cursor: canGoBack ? 'pointer' : 'not-allowed',
}}
title="Previous level"
aria-label="Go to previous level"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
Expand Down
7 changes: 4 additions & 3 deletions src/components/GameProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,14 @@ export default function GameProvider() {
)}

{/* UI overlay */}
<div className="relative z-10 h-full flex">
<div className="relative z-10 h-full flex flex-col lg:flex-row overflow-y-auto lg:overflow-hidden">
{/* Left: SQL Editor */}
<div className="w-[460px] h-full p-3 pr-2">
<div className="w-full lg:w-[460px] h-[75vh] lg:h-full flex-shrink-0 p-3 lg:pr-2">
<SQLPanel />
</div>

{/* Right: Schema / Data + status */}
<div className="w-[420px] h-full p-3 pl-2 flex flex-col gap-2">
<div className="w-full lg:w-[420px] h-[75vh] lg:h-full flex-shrink-0 p-3 lg:pl-2 flex flex-col gap-2">
{/* Tab bar */}
<div
className="flex"
Expand All @@ -235,6 +235,7 @@ export default function GameProvider() {
<button
key={tab}
onClick={() => setActiveRightTab(tab)}
aria-pressed={activeRightTab === tab}
className="flex-1 py-2 text-xs tracking-widest uppercase transition-colors"
style={{
fontFamily: 'var(--font-ibm-plex-mono)',
Expand Down
15 changes: 15 additions & 0 deletions src/components/LevelNavigator.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use client';

import { useEffect } from 'react';
import { useGameStore } from '@/store/useGameStore';
import { levels } from '@/data/levels';
import { Check, X } from 'lucide-react';
Expand All @@ -21,6 +22,13 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps)

const handleSelect = (id: number) => { setCurrentLevel(id); onClose(); };

useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isOpen, onClose]);

if (!isOpen) return null;

const epochs = ['Foundational', 'Intermediate', 'Advanced', 'Expert'] as const;
Expand All @@ -36,6 +44,9 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps)

{/* Drawer */}
<div
role="dialog"
aria-modal="true"
aria-label="Level navigator"
className="absolute right-0 top-0 bottom-0 w-[360px] flex flex-col"
style={{ background: 'var(--lcb-panel)', borderLeft: '1px solid var(--lcb-border)' }}
>
Expand All @@ -54,6 +65,8 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps)
</div>
<button
onClick={onClose}
aria-label="Close level navigator"
autoFocus
className="p-1 transition-opacity hover:opacity-60"
style={{ color: 'var(--lcb-muted)' }}
>
Expand Down Expand Up @@ -112,6 +125,8 @@ export default function LevelNavigator({ isOpen, onClose }: LevelNavigatorProps)
: 'var(--lcb-muted)',
}}
title={level.title}
aria-label={`Level ${level.id}: ${level.title}${done ? ' (completed)' : ''}`}
aria-current={current ? 'true' : undefined}
>
{done && !current && (
<Check className="w-2.5 h-2.5 absolute top-0.5 left-0.5" style={{ color: '#22c55e' }} />
Expand Down
6 changes: 6 additions & 0 deletions src/components/LevelUpModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export default function LevelUpModal() {

{/* Card */}
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="level-up-title"
initial={{ scale: 0.88, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.88, opacity: 0, y: 16 }}
Expand Down Expand Up @@ -96,6 +99,7 @@ export default function LevelUpModal() {
Query Accepted
</motion.p>
<motion.h2
id="level-up-title"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
Expand Down Expand Up @@ -192,6 +196,7 @@ export default function LevelUpModal() {

{/* Continue button */}
<motion.button
autoFocus
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.56 }}
Expand All @@ -211,6 +216,7 @@ export default function LevelUpModal() {
{/* Close */}
<button
onClick={handleClose}
aria-label="Close"
className="absolute top-3 right-3 p-1 transition-opacity hover:opacity-60"
style={{ color: 'var(--lcb-muted)' }}
>
Expand Down
24 changes: 23 additions & 1 deletion src/components/SQLPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import Editor, { Monaco } from '@monaco-editor/react';
import { Play, Lightbulb, X, Anchor } from 'lucide-react';
import { useGameStore } from '@/store/useGameStore';
import { validateQuery } from '@/lib/validator';
import { validateQuery, getExpectedShape } from '@/lib/validator';
import { levels } from '@/data/levels';
import { epochOf, xpFor, EPOCH_RANK } from '@/lib/progression';

Expand All @@ -21,6 +21,7 @@ const SQL_SNIPPETS = [
export default function SQLPanel() {
const [query, setQuery] = useState('');
const [error, setError] = useState<string | null>(null);
const [failedAttempts, setFailedAttempts] = useState(0);
const [doubloonAmt, setDoubloonAmt] = useState<number | null>(null);
const editorRef = useRef<unknown>(null);
const monacoRef = useRef<Monaco | null>(null);
Expand Down Expand Up @@ -105,9 +106,11 @@ export default function SQLPanel() {
} else {
setError(result.message);
setQueryResult(result.userResult || null);
setFailedAttempts((n) => n + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Query execution failed');
setFailedAttempts((n) => n + 1);
} finally {
setIsExecuting(false);
}
Expand All @@ -132,11 +135,16 @@ export default function SQLPanel() {

useEffect(() => {
setQuery('');
setFailedAttempts(0);
const editor = editorRef.current as { focus?: () => void } | null;
if (editor?.focus) setTimeout(() => editor.focus?.(), 50);
}, [currentLevel]);

const epoch = epochOf(currentLevel);
// Escalating disclosure: after three failed attempts, reveal the expected
// result's shape (columns + row count) without revealing its values.
const expectedShape =
failedAttempts >= 3 && level ? getExpectedShape(level.solutionQuery) : null;

return (
<div
Expand Down Expand Up @@ -206,6 +214,20 @@ export default function SQLPanel() {
</p>
</div>
)}
{expectedShape && (
<div
className="mt-2 px-3 py-2"
style={{ border: '1px solid rgba(96,165,250,0.25)', background: 'rgba(96,165,250,0.05)', borderRadius: 3 }}
>
<p className="text-xs font-semibold uppercase tracking-widest mb-1" style={{ color: '#60a5fa', fontFamily: 'var(--font-ibm-plex-mono)' }}>
Harbour Master&apos;s Dossier
</p>
<p className="text-xs leading-5" style={{ color: 'var(--lcb-white)', opacity: 0.8, fontFamily: 'var(--font-ibm-plex-mono)' }}>
The expected report has <span style={{ color: '#60a5fa' }}>{expectedShape.rows}</span> row{expectedShape.rows === 1 ? '' : 's'} with column{expectedShape.columns.length === 1 ? '' : 's'}:{' '}
<span style={{ color: '#60a5fa' }}>{expectedShape.columns.join(', ')}</span>
</p>
</div>
)}
</div>

{/* ── Quick snippets ───────────────────────────────────────────────── */}
Expand Down
Loading
Loading