diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index dcec89a..2c90d4e 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -29,10 +29,40 @@ jobs:
- name: Validate HTML
run: html-validate index.html
+ # ── Playwright responsive / layout tests ─────────────────────
+ test:
+ name: Responsive Layout Tests
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Install Playwright browsers
+ run: npx playwright install chromium --with-deps
+
+ - name: Run Playwright tests
+ run: npx playwright test --reporter=list
+
+ - name: Upload Playwright report on failure
+ if: failure()
+ uses: actions/upload-artifact@v4
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 7
+
# ── Deploy (only on push to main) ────────────────────────────
deploy:
name: Deploy to GitHub Pages
- needs: validate
+ needs: [validate, test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
@@ -61,3 +91,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+
diff --git a/css/style.css b/css/style.css
index 393b315..d0d67a7 100644
--- a/css/style.css
+++ b/css/style.css
@@ -12,10 +12,10 @@
--chess-panel-padding: 4rem; /* total horizontal padding within chess panel (2 × 1rem + margins) */
- /* Board size: fills the 70 % chess panel minus padding, capped at 720 px.
+ /* Board size: fills the 68 % chess panel minus padding, capped at 720 px.
Also constrained by viewport height (subtracting ~280px for header 56px +
player bars ~80px + controls ~40px + move history ~100px + gaps ~4px). */
- --board-sz: clamp(300px, min(calc(70vw - var(--chess-panel-padding) * 2), calc(100vh - 280px)), 720px);
+ --board-sz: clamp(300px, min(calc(68vw - var(--chess-panel-padding) * 2), calc(100vh - 280px)), 720px);
--text-primary: #e6edf3;
--text-secondary: #8b949e;
@@ -166,8 +166,8 @@ a { color: var(--accent-blue); }
align-items: center;
padding: 1rem;
gap: .6rem;
- flex: 0 0 70%;
- width: 70%;
+ flex: 0 0 68%;
+ width: 68%;
min-width: 0;
overflow-y: auto;
scrollbar-width: thin;
@@ -405,22 +405,25 @@ a { color: var(--accent-blue); }
/* ── SQL Panel ──────────────────────────────────────────────── */
.sql-panel {
- flex: 0 0 30%;
- width: 30%;
+ flex: 0 0 32%;
+ width: 32%;
+ min-width: 300px;
display: flex;
flex-direction: column;
border-left: 1px solid var(--bg-border);
background: var(--sql-bg);
overflow: hidden;
- min-width: 0;
transition: width .25s ease, flex .25s ease, opacity .2s;
}
.sql-panel.hidden-panel {
flex: 0 0 0;
width: 0;
+ /* Override the base min-width (300 px) so the panel collapses fully */
+ min-width: 0;
opacity: 0;
pointer-events: none;
border-left: none;
+ overflow: hidden;
}
.sql-panel-header {
@@ -459,13 +462,16 @@ a { color: var(--accent-blue); }
/* SQL content area */
.sql-content {
flex: 1;
+ min-height: 0;
overflow-y: auto;
+ overflow-x: hidden;
padding: .75rem 1rem;
display: flex;
flex-direction: column;
gap: .75rem;
scroll-behavior: smooth;
scrollbar-width: thin;
+ scrollbar-color: var(--bg-border) transparent;
}
.sql-placeholder {
@@ -782,7 +788,9 @@ input:checked + .slider::before { transform: translateX(18px); }
font-size: .78rem;
line-height: 1.6;
padding: .6rem .75rem;
- resize: none;
+ resize: vertical;
+ min-height: 7.5rem;
+ max-height: 20rem;
outline: none;
transition: background .15s;
}
@@ -845,41 +853,164 @@ input:checked + .slider::before { transform: translateX(18px); }
.sql-input-section { display: flex; }
}
+/* ── Responsive: Tablet (≤ 1024 px) ────────────────────────── */
+@media (max-width: 1024px) {
+ .sql-panel {
+ flex: 0 0 36%;
+ width: 36%;
+ min-width: 260px;
+ }
+ .chess-panel {
+ flex: 0 0 64%;
+ width: 64%;
+ }
+ :root {
+ --board-sz: clamp(280px,
+ min(calc(64vw - var(--chess-panel-padding) * 2), calc(100vh - 280px)),
+ 640px);
+ }
+}
+
+/* ── Responsive: Mobile landscape / narrow (≤ 900 px) ──────── */
@media (max-width: 900px) {
- .app-main { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
+ .app-main {
+ flex-direction: column;
+ overflow: hidden;
+ }
.chess-panel {
- flex: none;
+ flex: 0 0 auto;
width: 100%;
+ max-height: 58vh;
min-height: auto;
- overflow-y: visible;
+ overflow-y: auto;
+ scrollbar-width: thin;
}
.sql-panel {
- flex: none;
+ flex: 1 1 auto;
width: 100%;
+ min-width: 0;
+ min-height: 40vh;
border-left: none;
border-top: 1px solid var(--bg-border);
- height: 45vh;
- min-height: 260px;
+ overflow: hidden;
}
.sql-panel.hidden-panel {
- height: 0;
+ flex: 0 0 0;
min-height: 0;
+ height: 0;
border-top: none;
+ overflow: hidden;
+ }
+
+ .sql-move-input {
+ min-height: 5.5rem;
}
/* Mobile: limit by both viewport width and height to handle short screens */
:root { --board-sz: min(90vw, 440px, calc(55vh)); }
}
+/* ── Responsive: Mobile portrait (≤ 600 px) ────────────────── */
+@media (max-width: 600px) {
+ .chess-panel {
+ max-height: 54vh;
+ padding: .6rem;
+ }
+
+ .sql-panel {
+ min-height: 44vh;
+ }
+
+ .sql-move-input {
+ min-height: 5rem;
+ font-size: .75rem;
+ }
+
+ .sql-panel-header {
+ padding: .4rem .75rem;
+ }
+
+ .sql-input-bar {
+ padding: .45rem .75rem;
+ }
+
+ :root { --board-sz: min(90vw, 380px, calc(50vh)); }
+}
+
+/* ── Responsive: Small phone (≤ 480 px) ────────────────────── */
@media (max-width: 480px) {
.app-header { padding: 0 .75rem; }
.header-logo .logo-text { display: none; }
- .chess-panel { padding: .5rem; }
+ .chess-panel { padding: .5rem; gap: .4rem; max-height: 52vh; }
.header-controls { gap: .3rem; }
- :root { --board-sz: min(92vw, 360px); }
+ .sql-panel { min-height: 46vh; }
+
+ .sql-move-input { min-height: 4.5rem; }
+
+ :root { --board-sz: min(92vw, 340px, calc(48vh)); }
#btnToggleSQL #sqlToggleLabel { display: none; }
}
+
+/* ── Responsive: Wide screens (≥ 1440 px) ──────────────────── */
+@media (min-width: 1440px) {
+ .sql-panel {
+ flex: 0 0 28%;
+ width: 28%;
+ min-width: 340px;
+ }
+ .chess-panel {
+ flex: 0 0 72%;
+ width: 72%;
+ }
+ :root {
+ --board-sz: clamp(400px,
+ min(calc(72vw - var(--chess-panel-padding) * 2), calc(100vh - 280px)),
+ 760px);
+ }
+}
+
+/* ── Responsive: Very wide screens (≥ 1920 px) ─────────────── */
+@media (min-width: 1920px) {
+ .sql-panel {
+ flex: 0 0 26%;
+ width: 26%;
+ min-width: 400px;
+ }
+ .chess-panel {
+ flex: 0 0 74%;
+ width: 74%;
+ }
+ .sql-move-input {
+ min-height: 9rem;
+ }
+}
+
+/* ── Responsive: Short landscape (height < 500 px) ─────────── */
+/* e.g. phones in landscape orientation */
+@media (max-height: 500px) {
+ .chess-panel {
+ max-height: 50vh;
+ }
+ .sql-panel {
+ min-height: 48vh;
+ }
+ .sql-move-input {
+ min-height: 3rem;
+ max-height: 6rem;
+ }
+ .sql-input-bar {
+ min-height: 36px;
+ padding: .25rem .75rem;
+ }
+ .sql-input-actions {
+ padding: .25rem .5rem;
+ }
+ .sql-panel-header {
+ padding: .35rem .75rem;
+ }
+}
+
diff --git a/index.html b/index.html
index 5539676..ded5f90 100644
--- a/index.html
+++ b/index.html
@@ -101,7 +101,7 @@
id="sqlMoveInput"
class="sql-move-input"
spellcheck="false"
- rows="3"
+ rows="6"
placeholder="UPDATE chess_piece
SET position = 'e4'
WHERE position = 'e2';
-- or shorthand: e2 e4">
diff --git a/package.json b/package.json
index 2542c90..74ccad6 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,12 @@
{
"dependencies": {
"chess.js": "^0.10.3"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.59.1"
+ },
+ "scripts": {
+ "test": "playwright test",
+ "test:report": "playwright show-report"
}
}
diff --git a/playwright.config.js b/playwright.config.js
new file mode 100644
index 0000000..4ada4f2
--- /dev/null
+++ b/playwright.config.js
@@ -0,0 +1,61 @@
+// @ts-check
+const { defineConfig, devices } = require('@playwright/test');
+const path = require('path');
+
+module.exports = defineConfig({
+ testDir: './tests',
+ timeout: 30_000,
+ retries: 0,
+ reporter: [['list'], ['html', { open: 'never' }]],
+
+ use: {
+ /* Serve index.html directly via file:// so no server is needed */
+ baseURL: 'file://' + path.resolve(__dirname, 'index.html'),
+ headless: true,
+ screenshot: 'only-on-failure',
+ video: 'off',
+ },
+
+ projects: [
+ /* Desktop sizes */
+ {
+ name: 'desktop-1920x1080',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1920, height: 1080 } },
+ },
+ {
+ name: 'desktop-1440x900',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 } },
+ },
+ {
+ name: 'desktop-1280x800',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 800 } },
+ },
+ /* Tablet sizes */
+ {
+ name: 'tablet-1024x768',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 1024, height: 768 } },
+ },
+ {
+ name: 'tablet-portrait-768x1024',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 768, height: 1024 } },
+ },
+ /* Mobile sizes */
+ {
+ name: 'mobile-portrait-390x844',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 390, height: 844 } },
+ },
+ {
+ name: 'mobile-landscape-844x390',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 844, height: 390 } },
+ },
+ {
+ name: 'mobile-small-360x640',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 360, height: 640 } },
+ },
+ /* Ultrawide */
+ {
+ name: 'ultrawide-2560x1080',
+ use: { ...devices['Desktop Chrome'], viewport: { width: 2560, height: 1080 } },
+ },
+ ],
+});
diff --git a/test-results/.last-run.json b/test-results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/test-results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git a/tests/responsive.spec.js b/tests/responsive.spec.js
new file mode 100644
index 0000000..6c5da90
--- /dev/null
+++ b/tests/responsive.spec.js
@@ -0,0 +1,259 @@
+// @ts-check
+const { test, expect } = require('@playwright/test');
+const path = require('path');
+
+const PAGE_URL = 'file://' + path.resolve(__dirname, '..', 'index.html');
+
+/**
+ * Helper: open the page, dismiss the setup modal by playing as guest,
+ * then return the page for further assertions.
+ */
+async function openAndStartGame(page) {
+ await page.goto(PAGE_URL);
+ // Dismiss setup modal
+ await page.click('#btnPlayAsGuest');
+}
+
+// ── SQL Panel visibility ────────────────────────────────────────────────────
+
+test.describe('SQL panel — visible on page load', () => {
+ test('SQL panel is rendered and not hidden', async ({ page }) => {
+ await openAndStartGame(page);
+ const sqlPanel = page.locator('#sqlPanel');
+ await expect(sqlPanel).toBeVisible();
+ await expect(sqlPanel).not.toHaveClass(/hidden-panel/);
+ });
+
+ test('SQL panel header is visible', async ({ page }) => {
+ await openAndStartGame(page);
+ await expect(page.locator('.sql-panel-header')).toBeVisible();
+ });
+
+ test('SQL panel contains at least one SQL block after game start', async ({ page }) => {
+ await openAndStartGame(page);
+ const blocks = page.locator('.sql-block');
+ await expect(blocks).toHaveCount(1);
+ });
+});
+
+// ── Query textarea ──────────────────────────────────────────────────────────
+
+test.describe('SQL query textarea', () => {
+ test('textarea is visible', async ({ page }) => {
+ await openAndStartGame(page);
+ await expect(page.locator('#sqlMoveInput')).toBeVisible();
+ });
+
+ test('textarea has rows=6', async ({ page }) => {
+ await openAndStartGame(page);
+ const rows = await page.locator('#sqlMoveInput').getAttribute('rows');
+ expect(Number(rows)).toBeGreaterThanOrEqual(6);
+ });
+
+ test('textarea has sufficient rendered height (≥ 80 px)', async ({ page }) => {
+ await openAndStartGame(page);
+ const height = await page.locator('#sqlMoveInput').evaluate(
+ (el) => el.getBoundingClientRect().height
+ );
+ expect(height).toBeGreaterThanOrEqual(80);
+ });
+
+ test('textarea is pre-filled with the default SQL sample', async ({ page }) => {
+ await openAndStartGame(page);
+ const value = await page.locator('#sqlMoveInput').inputValue();
+ expect(value.toLowerCase()).toContain('update chess_piece');
+ });
+
+ test('textarea allows vertical resize (resize != none)', async ({ page }) => {
+ await openAndStartGame(page);
+ const resize = await page.locator('#sqlMoveInput').evaluate(
+ (el) => window.getComputedStyle(el).resize
+ );
+ expect(resize).not.toBe('none');
+ });
+});
+
+// ── SQL content area scrollability ─────────────────────────────────────────
+
+test.describe('SQL content area — scrollability', () => {
+ test('sql-content has overflow-y scroll or auto', async ({ page }) => {
+ await openAndStartGame(page);
+ const overflow = await page.locator('#sqlContent').evaluate(
+ (el) => window.getComputedStyle(el).overflowY
+ );
+ expect(['auto', 'scroll']).toContain(overflow);
+ });
+
+ test('body does NOT scroll (overflow hidden)', async ({ page }) => {
+ await openAndStartGame(page);
+ const overflow = await page.locator('body').evaluate(
+ (el) => window.getComputedStyle(el).overflow
+ );
+ expect(overflow).toBe('hidden');
+ });
+
+ test('sql-content has positive scrollHeight (content is present)', async ({ page }) => {
+ await openAndStartGame(page);
+ const sh = await page.locator('#sqlContent').evaluate((el) => el.scrollHeight);
+ expect(sh).toBeGreaterThan(0);
+ });
+});
+
+// ── Layout correctness at various viewports ─────────────────────────────────
+
+test.describe('Layout at current viewport', () => {
+ test('chess panel and SQL panel are side-by-side on wide screens', async ({ page, viewport }) => {
+ if (!viewport || viewport.width < 1024) test.skip();
+ await openAndStartGame(page);
+
+ const chessBox = await page.locator('#chessPanel').boundingBox();
+ const sqlBox = await page.locator('#sqlPanel').boundingBox();
+ expect(chessBox).not.toBeNull();
+ expect(sqlBox).not.toBeNull();
+
+ // On wide screens both panels should be in the same row (similar top values)
+ expect(Math.abs((chessBox?.y ?? 0) - (sqlBox?.y ?? 0))).toBeLessThan(10);
+ // SQL panel should be to the right of chess panel
+ expect((sqlBox?.x ?? 0)).toBeGreaterThan((chessBox?.x ?? 0));
+ });
+
+ test('SQL panel stacks below chess panel on narrow screens', async ({ page, viewport }) => {
+ if (!viewport || viewport.width >= 900) test.skip();
+ await openAndStartGame(page);
+
+ const chessBox = await page.locator('#chessPanel').boundingBox();
+ const sqlBox = await page.locator('#sqlPanel').boundingBox();
+ expect(chessBox).not.toBeNull();
+ expect(sqlBox).not.toBeNull();
+
+ // SQL panel should be below chess panel (larger Y)
+ expect((sqlBox?.y ?? 0)).toBeGreaterThan((chessBox?.y ?? 0));
+ });
+
+ test('no horizontal page overflow', async ({ page }) => {
+ await openAndStartGame(page);
+ const bodyWidth = await page.evaluate(() => document.body.scrollWidth);
+ const viewWidth = await page.evaluate(() => window.innerWidth);
+ // Allow up to 1 px rounding difference
+ expect(bodyWidth).toBeLessThanOrEqual(viewWidth + 1);
+ });
+
+ test('chess board is visible and square', async ({ page }) => {
+ await openAndStartGame(page);
+ const box = await page.locator('#board').boundingBox();
+ expect(box).not.toBeNull();
+ if (box) {
+ expect(box.width).toBeGreaterThan(100);
+ // Width and height should be within 2 px of each other
+ expect(Math.abs(box.width - box.height)).toBeLessThanOrEqual(2);
+ }
+ });
+
+ test('SQL panel header does not overflow its container', async ({ page }) => {
+ await openAndStartGame(page);
+ const panelBox = await page.locator('#sqlPanel').boundingBox();
+ const headerBox = await page.locator('.sql-panel-header').boundingBox();
+ if (panelBox && headerBox) {
+ expect(headerBox.width).toBeLessThanOrEqual(panelBox.width + 1);
+ }
+ });
+
+ test('SQL input section is fully visible within the SQL panel', async ({ page, viewport }) => {
+ // On very short landscape screens (height < 500 px) the panel is compact by design;
+ // the user can still interact — skip strict bounds check in that scenario.
+ if (viewport && viewport.height < 500) test.skip();
+ await openAndStartGame(page);
+ const panelBox = await page.locator('#sqlPanel').boundingBox();
+ const inputBox = await page.locator('#sqlInputSection').boundingBox();
+ if (panelBox && inputBox) {
+ // Input section top should be within the panel
+ expect(inputBox.y).toBeGreaterThanOrEqual(panelBox.y - 1);
+ // Input section bottom should be within the panel
+ expect(inputBox.y + inputBox.height).toBeLessThanOrEqual(panelBox.y + panelBox.height + 1);
+ }
+ });
+});
+
+// ── Toggle SQL panel ────────────────────────────────────────────────────────
+
+test.describe('Toggle SQL panel', () => {
+ test('SQL panel hides when toggle button is clicked', async ({ page }) => {
+ await openAndStartGame(page);
+ await page.click('#btnToggleSQL');
+ await expect(page.locator('#sqlPanel')).toHaveClass(/hidden-panel/);
+ });
+
+ test('SQL panel reappears when toggle button is clicked again', async ({ page }) => {
+ await openAndStartGame(page);
+ await page.click('#btnToggleSQL');
+ await page.click('#btnToggleSQL');
+ await expect(page.locator('#sqlPanel')).not.toHaveClass(/hidden-panel/);
+ });
+
+ test('chess panel expands when SQL panel is hidden', async ({ page, viewport }) => {
+ if (!viewport || viewport.width < 1024) test.skip();
+ await openAndStartGame(page);
+
+ const widthBefore = await page.locator('#chessPanel').evaluate(
+ (el) => el.getBoundingClientRect().width
+ );
+ await page.click('#btnToggleSQL');
+ // Wait for the CSS transition to finish (width change detectable via polling)
+ await expect.poll(async () =>
+ page.locator('#chessPanel').evaluate((el) => el.getBoundingClientRect().width)
+ ).toBeGreaterThan(widthBefore);
+ });
+});
+
+// ── SQL block content ───────────────────────────────────────────────────────
+
+test.describe('SQL block content', () => {
+ test('initial SQL block contains CREATE TABLE statements', async ({ page }) => {
+ await openAndStartGame(page);
+ const codeText = await page.locator('.sql-block .sql-code').first().textContent();
+ expect(codeText?.toUpperCase()).toContain('CREATE TABLE');
+ });
+
+ test('a chess move appends a new SQL block', async ({ page }) => {
+ await openAndStartGame(page);
+ const before = await page.locator('.sql-block').count();
+
+ // Click e2 then e4 to make a move
+ await page.locator('.sq[data-square="e2"]').click();
+ await page.locator('.sq[data-square="e4"]').click();
+
+ const after = await page.locator('.sql-block').count();
+ expect(after).toBeGreaterThan(before);
+ });
+
+ test('Clear SQL button removes all SQL blocks', async ({ page }) => {
+ await openAndStartGame(page);
+ // Make a move first
+ await page.locator('.sq[data-square="e2"]').click();
+ await page.locator('.sq[data-square="e4"]').click();
+ await page.click('#btnClearSQL');
+ await expect(page.locator('.sql-block')).toHaveCount(0);
+ });
+});
+
+// ── Run SQL via textarea ────────────────────────────────────────────────────
+
+test.describe('Execute SQL move via textarea', () => {
+ test('typing a shorthand move and running it executes the move', async ({ page }) => {
+ await openAndStartGame(page);
+ await page.locator('#sqlMoveInput').fill('e2 e4');
+ await page.click('#btnRunSQL');
+ // After a successful move the error should be hidden
+ await expect(page.locator('#sqlRunError')).toHaveClass(/hidden/);
+ });
+
+ test('invalid SQL shows an error message', async ({ page }) => {
+ await openAndStartGame(page);
+ await page.locator('#sqlMoveInput').fill('not valid sql');
+ await page.click('#btnRunSQL');
+ const errorEl = page.locator('#sqlRunError');
+ await expect(errorEl).not.toHaveClass(/hidden/);
+ const text = await errorEl.textContent();
+ expect(text?.trim().length).toBeGreaterThan(0);
+ });
+});