Compare commits

..

1 Commits

  1. 144
      README.md
  2. 240
      package-lock.json
  3. 5
      package.json
  4. 177
      src/backend/components/boardgame.tsx
  5. 8
      src/backend/components/counter.tsx
  6. 6
      src/backend/main/index.tsx
  7. 2
      src/backend/utils.ts
  8. 95
      src/frontend/components/board-game.ts
  9. 22
      src/frontend/components/index.ts
  10. 77
      src/frontend/components/progressive-form.ts
  11. 8
      src/frontend/components/reactive-button.ts
  12. 6
      src/frontend/components/reactive-span.ts
  13. 97
      src/frontend/static/style.css
  14. 38
      src/frontend/utils/dom-utils.ts
  15. 2
      src/frontend/utils/query-tracking-utils.ts
  16. 8
      src/jsx-augmentations.ts
  17. 36
      src/shared/display.ts
  18. 3
      tsconfig.json

@ -34,17 +34,10 @@ Source: https://www.programmfabrik.de/en/assignment-frontend-backend-developer-j
## Features
* Does not have any client-side dependencies, does not use any third-party libraries on the client.
* Works in modern browsers (which includes at least 96% overall and at least 98% desktop userbase;
includes Chrome since 2017, Firefox since 2018, Safari since 2017)
as a single-page application, in offline mode.
* Works in older browsers / browsers with JS disabled (which, together with the previous point,
covers 99.9%+ overall userbase, including text-based browsers and presumably IE6 and probably IE5)
as a plain old multi-page application (requiring internet connection).
Basically it has almost perfect backwards compatibility
(compatible with all browsers that fully support HTML4).
* Doesn't use browser detection / sniffing, instead gracefully degrading depending on the feature set available.
* Works in modern browsers as a single-page application, in offline mode.
* Works in older browsers / browsers with JS disabled as a plain old multi-page application (requiring internet connection).
* Hopefully more accessible than a typical single-page application.
* Supports multiple games on the same page, playable at the same time, concurrently and independently.
* Supports multiple games on the same page, playable at the same time.
* Supports different game rules (the provided main page has two games with different rule sets).
* Supports custom board size.
* The computer opponent is unbeatable.
@ -91,9 +84,6 @@ I wanted to minimise the size of the resulting frontend application,
and the best way to reduce the number of dependencies on frontend is to not have any dependencies,
to write vanilla JS.
Also, I wanted to make this work everywhere (or almost everywhere),
in the spirit of GOV.UK public services: https://shkspr.mobi/blog/2021/01/the-unreasonable-effectiveness-of-simple-html/
I've been hearing a lot about Web Components lately,
so I decided to use this as an opportunity to learn more about them.
But there are some major design choices to be made with Web Components too,
@ -274,22 +264,28 @@ Listed in `package.json`, installed with `npm ci`, not vendored.
#### Dev dependencies (build/compile-time)
* `tap`, a test framework (https://node-tap.org/).
* `typescript`, because it's so much easier to write even small projects in TS than in JS.
* `@tsconfig/strictest`, to avoid having to enable all the strict TS compiler options manually.
* `tap`, a test framework.
* `typescript`, because it's so much easier to write even small projects in TS than in JS
(fixed at 5.7-rc because
(1) for code to run in browser as-is without a bundler, I'm using ES modules;
(2) for ts-node (used by node-tap) to work properly, imports need to be with `.ts` extension;
(3) for frontend to work, imports need to be with compiled `.js` extension,
and rewriting import extensions was only implemented in TS 5.7:
https://devblogs.microsoft.com/typescript/announcing-typescript-5-7-rc/#path-rewriting-for-relative-paths
which is not yet stable).
* `@tsconfig/strictest`, to avoid
* `eslint`, for linting.
* `prettier`, to ensure common code style.
* `typescript-eslint`, for type-aware and type-checked linting.
* `copyfiles`, to copy static assets to the `dist` directory so that they can be next to compiled JS for frontend.
* `rimraf`, to cleanup `dist` directory before building.
* `typed-query-selector`, because https://github.com/microsoft/TypeScript/issues/29037 is not addressed yet.
#### Backend dependencies (runtime)
* `express`, because I needed a minimal server framework to handle requests and serve static files.
* `body-parser`, to handle POST requests in express.
* `preact`, `preact-render-to-string`, to render intermediate JSX code to HTML
(because I'm using JSX as a template language on backend) (https://preactjs.com/).
(because I'm using JSX as a template language on backend).
#### Frontend dependencies (runtime)
@ -297,105 +293,21 @@ None.
## Supported browser engines
There are three main fully functional modes:
1. Main simple offline-based JS-only mode, using modern web standards;
2. More elaborate (almost) offline-based (almost) JS-only mode (game works offline,
but demo counters on the top of the page (separate from the game) only work online
because I don't have the motivation to redesign `ReactiveButton` and `ReactiveSpan`),
using modern web standards _without_ custom built-in elements
(which means old, but not too old Blink-based browsers like Chromium 54-66,
and all modern WebKit-based browsers like Safari 10.1+);
3. Online-based (plain old client-server) mode available in most browsers even with JS disabled.
The main required features are:
* ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type,
without them no JS will load, and this demo will fall back to `mode 3`
(can be worked around by using bundlers, but this will only affect Chromium-based browsers from 54 (2016) to 60 (2017),
so it's not worth it to change the entire build setup just for better UX in these browsers).
* Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define,
without them this demo will fall back to `mode 3`.
* Custom built-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is,
without them this demo will fall back to `mode 2`.
* Templates: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template,
without them `mode 1` or `mode 2` will break, but they're in baseline HTML,
so if they're not supported, neither custom elements nor ES modules are not supported either,
and this demo will fall back to `mode 3` anyway.
* `:scope` CSS selector, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
* `MutationObserver`, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
* `:not` CSS selector for critical UI features, needed in all modes
(hiding and showing different messages and buttons depending on the game state):
https://developer.mozilla.org/en-US/docs/Web/CSS/:not.
* `hidden` attribute or inline `style="display: none"` support in `mode 3` to not display templates.
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
Therefore, for desktop browsers:
* **Gecko** (Firefox): checked and works in v132, both with JS enabled (`mode 1`) and with JS disabled (`mode 3`).
Should work in `mode 1` starting with Firefox 63 (2018); should fall back plain old client-server mode in all versions,
although the design in Firefox 51 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* **Blink** (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work in `mode 1`.
Should work in `mode 1` starting with Chrome 67 (2018); should fall back to `mode 2` in Chrome plain old client-server mode in all versions;
should fall back to `mode 3` with JS disabled,
although the design in Chrome 56 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* **WebKit** (e.g. qutebrowser, Konqueror, Epiphany and notably Safari): superficialy checked in [BadWolf](https://hacktivis.me/projects/badwolf) 1.3 / webkit2gtk-4.1 2.46.3,
basic functionality seems to work both with JS enabled (in `mode 2`) and with JS disabled (in `mode 3`).
Should work with JS enabled in `mode 2` in all browsers supporting custom elements (e.g. in Safari 10.1 (2017) and newer);
should fall back to `mode 3` in older browsers or with JS disabled,
except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional).
* **Servo**: checked, does **not** work in nightly as of 2024-11-20 and as of 2024-11-25 (even progressive form doesn't work),
and its developer tools host seemingly crashes on this page
(at least on the borrowed Windows machine; Servo is not packaged for Alpine, and its regular Linux builds don't run on Alpine).
I decided to _not_ disable dynamic features in Servo because:
1. Servo is not production-ready, its users know that it is not suitable to be used as a daily driver;
2. This is likely a bug in servo, it will be fixed in the future, so
3. Gating offline JS mode for non-servo browsers only will mean that in the future, when servo is fixed,
this demo will only work in client-server mode, just because servo once had a bug. Not ideal.
(Also not even attempting to use dynamic features in servo will make it more difficult to debug the problem in it.)
* **EdgeHTML** (classic MS Edge before they migrated to Chromium): not checked, should fall back to `mode 3` in all versions,
but the page will not look very pretty in Edge before 16 (2017).
* **Trident** (Internet Explorer): not checked, should fall back to `mode 3`,
UI should be functional but not pretty.
* **elinks**: checked on 0.18.0, fully functional (in `mode 3`):
* elinks just like links did not not support disabled buttons in 0.17.1.1 or earlier,
but this is fixed in https://github.com/rkd77/elinks/issues/341 (merged in 0.18.0).
* elinks did not support `hidden` attribute and by default does not support `display: none` in 0.17.1.1 or earlier,
so it will display the content of all (unused) templates.
Support for `hidden` attribute is already implemented in https://github.com/rkd77/elinks/issues/341 (merged in 0.18.0),
and will automatically work in 0.18.0; in older versions, the user should add
`set document.css.ignore_display_none = 0` to their elinks config.
* **links**: checked on 2.30, mostly functional (in `mode 3):
* Links doesn't support disabled buttons
so all board fields are always clickable (even if they're already filled),
this does not cause any functional issues (clicking on the occupied field,
or clicking on an empty field when the game is over, does not change anything),
but still that's a poor UX.
* **lynx**: checked on 2.9.2, somewhat functional in `mode 3` (mostly broken but can be used by those who wish):
* lynx just like links and elinks does not support disabled buttons;
* It does not support neither `hidden` attribute nor styles, so all (unused) templates are displayed;
* It displays the `value` of submit buttons along with their text, which means that, for example,
in every cell of the board along with its state (X/O/_) it also displays full serialized state of the next board.
* **w3m**: checked on 0.5.3.20230718 (the newest version that my distro has to offer),
broken due to https://git.sr.ht/~rkta/w3m/commit/5d9c728592de1c053e064e8db4452b0bface58e0,
might work in newer versions (but newer versions are not pacaged in Alpine).
So presumably, according to caniuse.com,
* `mode 1` should work in 88% desktop browsers and 80% total browsers
(with Safari being the notable exception contributing 9% on desktop and 18% overall);
* Fallback to `mode 2` (which is just as functional but with a bit more convoluted JS code)
should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
* The demo should at least fall back to functional `mode 3` (client-server) with the functional UI
in all more or less mainstream browsers of the last 25 years (including presumably Internet Explorer 6 and NetFront 3, not tested),
and in some non-mainstream ones, including text browsers such as links and elinks (checked) but notably not lynx;
* All this is achieved almost without code duplication.
* Gecko (Firefox): works in v132, both with JS enabled (offline mode) and with JS disabled (plain old client-server mode).
* Blink (e.g. Chromium and Chromium-based browsers): not checked, should work in reasonably recent versions,
both with JS enabled (offline mode) and with JS disabled (plain old client-server mode).
* WebKit (e.g. qutebrowser and notably Safari): not checked; should **not** work in offline mode,
because WebKit does not fully implement decade-old standard which Gecko and Blink supported since 2018:
https://github.com/WebKit/standards-positions/issues/97.
Everything should still work in online mode, falling back on client-server communication.
* Servo: checked, does **not** work in nightly as of 2024-11-20, and I don't have an opportunity to figure out why
(it's not packaged for Alpine (...yet), their regular Linux builds don't work on Alpine,
so I had to check it on someone else's Windows machine).
## Known issues and quirks
* The game does not work offline fully on client (i.e. without falling back on client-server communication)
in WebKit-based browsers (such as qutebrowser or Safari), see above.
* Playing against the computer opponent is only supported for boards under 12 squares (i.e. 4x3 or 3x4 max).
* The computer player moves might be counterintuitive at times,
and it might be difficult for a human player to get to lose the game,
@ -410,6 +322,10 @@ So presumably, according to caniuse.com,
* Implement error handling and handling of incorrect / malformed game states (since they come from the client).
* Improve UI for large boards (disable autoplayers when board is too large,
hide "enable autoplayer" buttons, provide clear indication to the user instead).
* Target older ES versions on the frontend,
so that offline-only game can work in all browsers fully supporting Web Components
(i.e. Blink-based and Firefox not older than 2018);
right now ES2022 is targeted.
* Figure out better API for game board,
`board.get(row, column)` and `board.get(row, column)` seemed to be a good way to encapsulate readonly state,
but they became very inconvenient as the codebase grew and became more complex,

240
package-lock.json generated

@ -27,9 +27,8 @@
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"tap": "^21.0.1",
"typed-query-selector": "^2.12.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0"
"typescript": "^5.7.1-rc",
"typescript-eslint": "^8.15.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
@ -1485,6 +1484,21 @@
"node": ">=0.3.1"
}
},
"node_modules/@tapjs/typescript/node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@tapjs/worker": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@tapjs/worker/-/worker-4.0.0.tgz",
@ -1734,17 +1748,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.16.0.tgz",
"integrity": "sha512-5YTHKV8MYlyMI6BaEG7crQ9BhSc8RxzshOReKwZwRWN0+XvvTOm+L/UYLCYxFpfwYuAAqhxiq4yae0CMFwbL7Q==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz",
"integrity": "sha512-+zkm9AR1Ds9uLWN3fkoeXgFppaQ+uEVtfOV62dDmsy9QCNqlRHWNEck4yarvRNrvRcHQLGfqBNui3cimoz8XAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.16.0",
"@typescript-eslint/type-utils": "8.16.0",
"@typescript-eslint/utils": "8.16.0",
"@typescript-eslint/visitor-keys": "8.16.0",
"@typescript-eslint/scope-manager": "8.15.0",
"@typescript-eslint/type-utils": "8.15.0",
"@typescript-eslint/utils": "8.15.0",
"@typescript-eslint/visitor-keys": "8.15.0",
"graphemer": "^1.4.0",
"ignore": "^5.3.1",
"natural-compare": "^1.4.0",
@ -1767,17 +1781,45 @@
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ts-api-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz",
"integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.16.0.tgz",
"integrity": "sha512-D7DbgGFtsqIPIFMPJwCad9Gfi/hC0PWErRRHFnaCWoEDYi5tQUDiJCTmGUbBiLzjqAck4KcXt9Ayj0CNlIrF+w==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.15.0.tgz",
"integrity": "sha512-7n59qFpghG4uazrF9qtGKBZXn7Oz4sOMm8dwNWDQY96Xlm2oX67eipqcblDj+oY1lLCbf1oltMZFpUso66Kl1A==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/scope-manager": "8.16.0",
"@typescript-eslint/types": "8.16.0",
"@typescript-eslint/typescript-estree": "8.16.0",
"@typescript-eslint/visitor-keys": "8.16.0",
"@typescript-eslint/scope-manager": "8.15.0",
"@typescript-eslint/types": "8.15.0",
"@typescript-eslint/typescript-estree": "8.15.0",
"@typescript-eslint/visitor-keys": "8.15.0",
"debug": "^4.3.4"
},
"engines": {
@ -1797,14 +1839,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.16.0.tgz",
"integrity": "sha512-mwsZWubQvBki2t5565uxF0EYvG+FwdFb8bMtDuGQLdCCnGPrDEDvm1gtfynuKlnpzeBRqdFCkMf9jg1fnAK8sg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.15.0.tgz",
"integrity": "sha512-QRGy8ADi4J7ii95xz4UoiymmmMd/zuy9azCaamnZ3FM8T5fZcex8UfJcjkiEZjJSztKfEBe3dZ5T/5RHAmw2mA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.16.0",
"@typescript-eslint/visitor-keys": "8.16.0"
"@typescript-eslint/types": "8.15.0",
"@typescript-eslint/visitor-keys": "8.15.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1815,14 +1857,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.16.0.tgz",
"integrity": "sha512-IqZHGG+g1XCWX9NyqnI/0CX5LL8/18awQqmkZSl2ynn8F76j579dByc0jhfVSnSnhf7zv76mKBQv9HQFKvDCgg==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.15.0.tgz",
"integrity": "sha512-UU6uwXDoI3JGSXmcdnP5d8Fffa2KayOhUUqr/AiBnG1Gl7+7ut/oyagVeSkh7bxQ0zSXV9ptRh/4N15nkCqnpw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.16.0",
"@typescript-eslint/utils": "8.16.0",
"@typescript-eslint/typescript-estree": "8.15.0",
"@typescript-eslint/utils": "8.15.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.3.0"
},
@ -1842,10 +1884,38 @@
}
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/ts-api-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz",
"integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.16.0.tgz",
"integrity": "sha512-NzrHj6thBAOSE4d9bsuRNMvk+BvaQvmY4dDglgkgGC0EW/tB3Kelnp3tAKH87GEwzoxgeQn9fNGRyFJM/xd+GQ==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.15.0.tgz",
"integrity": "sha512-n3Gt8Y/KyJNe0S3yDCD2RVKrHBC4gTUcLTebVBXacPy091E6tNspFLKRXlk3hwT4G55nfr1n2AdFqi/XMxzmPQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -1857,14 +1927,14 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.16.0.tgz",
"integrity": "sha512-E2+9IzzXMc1iaBy9zmo+UYvluE3TW7bCGWSF41hVWUE01o8nzr1rvOQYSxelxr6StUvRcTMe633eY8mXASMaNw==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.15.0.tgz",
"integrity": "sha512-1eMp2JgNec/niZsR7ioFBlsh/Fk0oJbhaqO0jRyQBMgkz7RrFfkqF9lYYmBoGBaSiLnu8TAPQTwoTUiSTUW9dg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"@typescript-eslint/types": "8.16.0",
"@typescript-eslint/visitor-keys": "8.16.0",
"@typescript-eslint/types": "8.15.0",
"@typescript-eslint/visitor-keys": "8.15.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -1911,17 +1981,45 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/ts-api-utils": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.0.tgz",
"integrity": "sha512-032cPxaEKwM+GT3vA5JXNzIaizx388rhsSW79vGRNGXfRRAdEAn2mvk36PvK5HnOchyWZ7afLEXqYCvPCrzuzQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.16.0.tgz",
"integrity": "sha512-C1zRy/mOL8Pj157GiX4kaw7iyRLKfJXBR3L82hk5kS/GyHcOFmy4YUq/zfZti72I9wnuQtA/+xzft4wCC8PJdA==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.15.0.tgz",
"integrity": "sha512-k82RI9yGhr0QM3Dnq+egEpz9qB6Un+WLYhmoNcvl8ltMEededhh7otBVVIDDsEEttauwdY/hQoSsOv13lxrFzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@typescript-eslint/scope-manager": "8.16.0",
"@typescript-eslint/types": "8.16.0",
"@typescript-eslint/typescript-estree": "8.16.0"
"@typescript-eslint/scope-manager": "8.15.0",
"@typescript-eslint/types": "8.15.0",
"@typescript-eslint/typescript-estree": "8.15.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -1940,13 +2038,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.16.0.tgz",
"integrity": "sha512-pq19gbaMOmFE3CbL0ZB8J8BFCo2ckfHBfaIsaOZgBIF4EoISJIdLX5xRhd0FGB0LlHReNRuzoJoMGpTjq8F2CQ==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.15.0.tgz",
"integrity": "sha512-h8vYOulWec9LhpwfAdZf2bjr8xIp0KNKnpgqSz0qqYYKAW/QZKw3ktRndbiAtUz4acH4QLQavwZBYCc0wulA/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.16.0",
"@typescript-eslint/types": "8.15.0",
"eslint-visitor-keys": "^4.2.0"
},
"engines": {
@ -6835,19 +6933,6 @@
"node": ">= 8"
}
},
"node_modules/ts-api-utils": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.1.tgz",
"integrity": "sha512-5RU2/lxTA3YUZxju61HO2U6EoZLvBLtmV2mbTvqyu4a/7s7RmJPT+1YekhMVsQhznRWk/czIwDUg+V8Q9ZuG4w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
},
"peerDependencies": {
"typescript": ">=4.2.0"
}
},
"node_modules/tshy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tshy/-/tshy-3.0.2.tgz",
@ -6913,6 +6998,20 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/tshy/node_modules/typescript": {
"version": "5.6.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz",
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@ -6974,17 +7073,10 @@
"node": ">= 0.6"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==",
"version": "5.7.1-rc",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.1-rc.tgz",
"integrity": "sha512-d6m+HT78uZtyUbXbUyIvuJ6kXCTSJEfy+2pZSUwt9d6JZ0kOMNDwhIILfV5FnaxMwVa48Yfw4sK0ISC4Qyq5tw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@ -6996,15 +7088,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.16.0.tgz",
"integrity": "sha512-wDkVmlY6O2do4V+lZd0GtRfbtXbeD0q9WygwXXSJnC1xorE8eqyC2L1tJimqpSeFrOzRlYtWnUp/uzgHQOgfBQ==",
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.15.0.tgz",
"integrity": "sha512-wY4FRGl0ZI+ZU4Jo/yjdBu0lVTSML58pu6PgGtJmCufvzfV565pUF6iACQt092uFOd49iLOTX/sEVmHtbSrS+w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.16.0",
"@typescript-eslint/parser": "8.16.0",
"@typescript-eslint/utils": "8.16.0"
"@typescript-eslint/eslint-plugin": "8.15.0",
"@typescript-eslint/parser": "8.15.0",
"@typescript-eslint/utils": "8.15.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

@ -31,9 +31,8 @@
"prettier": "^3.3.3",
"rimraf": "^6.0.1",
"tap": "^21.0.1",
"typed-query-selector": "^2.12.0",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0"
"typescript": "^5.7.1-rc",
"typescript-eslint": "^8.15.0"
},
"dependencies": {
"body-parser": "^1.20.3",

@ -1,12 +1,6 @@
import { sequence } from "../../shared/utils/array-utils.ts";
import { BoardgameStateType, CurrentOutcome } from "../../shared/datatypes/types.ts";
import {
ButtonValues,
getButtonValues,
getCellDisplayData,
getDisplayStates,
getSlotTemplateNames,
} from "../../shared/display.ts";
import { ButtonValues, getButtonValues, getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
import { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
const getSubmitAttributes = (key: string, targetState: BoardgameStateType) => ({
@ -52,112 +46,81 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
const buttonValues = getButtonValues(gameState);
const templates = {
"state-draw": "Draw",
"state-win-x": "Player X won",
"state-win-o": "Player O won",
"state-move-x": "It's X move",
"state-move-o": "It's O move",
"state-uninitialized": "",
"configuration-x-autoplayer": (
<>
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</>
),
"configuration-x-manual": (
<>
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</>
),
"configuration-o-autoplayer": (
<>
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</>
),
"configuration-o-manual": (
<>
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</>
),
"game-cell": <button {...getSubmitAttributes(key, gameState)} />,
};
const slotTemplateNames = getSlotTemplateNames(gameState, currentOutcome);
const getSlotParams = (slotName: keyof typeof slotTemplateNames) => ({
class: slotName,
"data-current-template": slotTemplateNames[slotName],
children: [templates[slotTemplateNames[slotName]]],
});
const gameClasses = getDisplayStates(gameState, currentOutcome);
const gameActiveClassNames = Object.entries(gameClasses)
.filter(([, value]) => value)
.map(([name]) => name);
return (
<board-game data-track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<progressive-form-wrapper>
<form method="post" is="progressive-form">
<table class="game-board-table">
<tbody class="game-board">
{sequence(gameState.rows).map((row) => (
<tr>
{sequence(gameState.columns).map((column) => (
<td data-current-template="game-cell">
{getCellHtml({ key, gameState, currentOutcome, row, column })}
</td>
))}
</tr>
))}
</tbody>
</table>
<p {...getSlotParams("state-slot")} />
<p {...getSlotParams("configuration-x-slot")} />
<p {...getSlotParams("configuration-o-slot")} />
<p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span>Start new game</span>
</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-row", { key, buttonValues })}>Add row</button>
{" / "}
<button {...getGenericButtonAttributes("remove-row", { key, buttonValues })}>Remove row</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-column", { key, buttonValues })}>Add column</button>
{" / "}
<button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button>
</p>
</form>
</progressive-form-wrapper>
<div hidden style="display: none">
Looks like your browser does not support neither `hidden` HTML attribute nor (very old) inline `style="display:
none"` CSS (are you using lynx or very old links?) So be a good bean and do the browser job yourself by ignoring
this text and what comes next. (Also if you're on elinks 0.17.1 or earlier, adding `set
document.css.ignore_display_none = 0` to your elinks config might help.)
{Object.entries(templates).map(([key, value]) => (
<template name={key}>{value}</template>
))}
OK now you can stop ignoring.
</div>
<board-game track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<form method="post" is="progressive-form">
<p class="when-outcome-undecided">
Current player: <span class="current-player-name">{gameState.currentPlayerName}</span>
</p>
<table class="game-board-table">
<tbody class="game-board">
{sequence(gameState.rows).map((row) => (
<tr>
{sequence(gameState.columns).map((column) => (
<td>{getCellHtml({ key, gameState, currentOutcome, row, column })}</td>
))}
</tr>
))}
</tbody>
</table>
<p>
<span class="when-outcome-winx">Player X won</span>
<span class="when-outcome-wino">Player O won</span>
<span class="when-outcome-draw">Draw</span>
</p>
<p class="when-autoplayer-x-disabled">
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</p>
<p class="when-autoplayer-x-enabled">
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p class="when-autoplayer-o-disabled">
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</p>
<p class="when-autoplayer-o-enabled">
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span class="when-game-not-in-progress">Start game</span>
<span class="when-game-in-progress">Restart game</span>
</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-row", { key, buttonValues })}>Add row</button>
{" / "}
<button {...getGenericButtonAttributes("remove-row", { key, buttonValues })}>Remove row</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-column", { key, buttonValues })}>Add column</button>
{" / "}
<button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button>
</p>
</form>
</board-game>
);
};

@ -2,19 +2,19 @@ import type { Request } from "express";
import { safeGetQueryValue } from "../utils.ts";
export const getCounterHtml = (req: Request, key: string) => {
const counter = parseInt(safeGetQueryValue(req, key) ?? "0", 10);
const counter = parseInt(safeGetQueryValue(req, "a") ?? "0", 10);
return (
<>
<form method="post" is="progressive-form">
<button type="submit" name={key} value={`${counter - 1}`} is="reactive-button" data-track={key} data-delta="-1">
<button type="submit" name={key} value={`${counter - 1}`} is="reactive-button" track={key} delta="-1">
-
</button>{" "}
<button type="submit" name={key} value={`${counter + 1}`} is="reactive-button" data-track={key} data-delta="+1">
<button type="submit" name={key} value={`${counter + 1}`} is="reactive-button" track={key} delta="+1">
+
</button>
</form>{" "}
Value of "{key}":{" "}
<span class="counter" is="reactive-span" data-track={key}>
<span class="counter" is="reactive-span" track={key}>
{counter}
</span>
</>

@ -4,8 +4,6 @@ import { handleBoardgame } from "./boardgame-handler.ts";
import { getCounterHtml } from "../components/counter.tsx";
export const mainPageHandler: RequestHandler = (req, res) => {
console.log(`Handling main page: ${req.url}`);
const boardThree = handleBoardgame(req, res, "tictactoe-three-1", "tictactoe-three");
if (!boardThree) {
// No return value from handleBoardgame means that it redirected user to another URL,
@ -22,7 +20,6 @@ export const mainPageHandler: RequestHandler = (req, res) => {
res,
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
{/*
We need to use ".js" here instead of ".ts" (and "incorrect" path)
because we're loading the compiled file on frontend, from path relative to the root page,
@ -39,9 +36,6 @@ export const mainPageHandler: RequestHandler = (req, res) => {
<br />
It works both with JS and without JS; it does not require network connection when JS is used; and it
supports history navigation (you can go back & forward and see how the state changes).
<br />
Note that, for simplicity reasons, this falls back to client-server mode in WebKit-based browsers (e.g.
Safari).
</p>
<ul>
<li>{getCounterHtml(req, "a")}</li>

@ -36,5 +36,5 @@ export const rewriteQueryParamsWith = (req: Request, res: Response, newParams: R
newQuery.set(param, value);
}
res.redirect(303, `?${newQuery.toString()}`);
res.redirect(`?${newQuery.toString()}`);
};

@ -1,21 +1,13 @@
import type {} from "typed-query-selector/strict.d.ts";
import { BoardgameState } from "../../shared/datatypes/boardgame-state.ts";
import { getTargetGameState } from "../../shared/gameplay/boardgame.ts";
import { CurrentOutcome } from "../../shared/datatypes/types.ts";
import {
ButtonValues,
getButtonValues,
getCellDisplayData,
getDisplayStates,
getSlotTemplateNames,
} from "../../shared/display.ts";
import { ButtonValues, getButtonValues, getCellDisplayData, getDisplayStates } from "../../shared/display.ts";
import { GameVariantName, gamesRules } from "../../shared/game-variants/index.ts";
import { ensureColumnsNumber, ensureRowsNumber, ensureSlotTemplate } from "../utils/dom-utils.ts";
import { replaceLocation } from "../utils/navigation-utils.ts";
import { TrackingTools } from "../utils/query-tracking-utils.ts";
import { updateWithQueryParams } from "../utils/url-utils.ts";
class BoardGameComponent extends HTMLElement {
export class BoardGameComponent extends HTMLElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null, key: string) {
@ -32,52 +24,83 @@ class BoardGameComponent extends HTMLElement {
return;
}
const currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided;
const slotTemplateNames = getSlotTemplateNames(gameState, currentOutcome);
// Technically we're mixing code and data here, but that's OK
// because both slotName and templateName originate from getSlotTemplateNames and is not actually arbitrary
for (const [slotName, templateName] of Object.entries(slotTemplateNames)) {
for (const slot of Array.from(this.querySelectorAll(`.${slotName}`))) {
ensureSlotTemplate(slot, templateName, () => this.querySelector(`template[name="${templateName}"]`));
for (const playerNameElement of this.querySelectorAll(".current-player-name")) {
if ((playerNameElement as HTMLElement).innerText !== gameState.currentPlayerName) {
(playerNameElement as HTMLElement).innerText = gameState.currentPlayerName;
}
}
const buttonValues = getButtonValues(gameState);
for (const button of Array.from(this.querySelectorAll("button"))) {
for (const className of Array.from(button.classList)) {
if (Object.prototype.hasOwnProperty.call(buttonValues, className)) {
for (const button of this.querySelectorAll("button")) {
for (const className of button.classList) {
if (Object.hasOwn(buttonValues, className)) {
button.value = buttonValues[className as keyof ButtonValues].serialize();
}
}
}
const currentOutcome = gameState.board ? rules.getBoardOutcome(gameState.board) : CurrentOutcome.Undecided;
const displayStates = getDisplayStates(gameState, currentOutcome);
for (const [className, shouldEnable] of Object.entries(displayStates)) {
this.classList.toggle(className, shouldEnable);
}
for (const tbody of Array.from(this.querySelectorAll("tbody.game-board"))) {
ensureRowsNumber(tbody, gameState.rows);
for (const tbodyUntyped of this.querySelectorAll("tbody.game-board")) {
const tbody = tbodyUntyped as HTMLTableSectionElement;
while (gameState.rows < tbody.rows.length) {
tbody.rows[0]?.remove();
}
while (gameState.rows > tbody.rows.length) {
tbody.insertRow();
}
for (let rowNumber = 0; rowNumber < tbody.rows.length; rowNumber++) {
const row = tbody.rows[rowNumber];
if (!row) {
continue;
}
for (const row of Array.from(tbody.rows)) {
ensureColumnsNumber(row, gameState.columns);
while (gameState.columns < row.cells.length) {
row.cells[0]?.remove();
}
for (const cell of Array.from(row.cells)) {
ensureSlotTemplate(cell, "game-cell", () => this.querySelector(`template[name="game-cell"]`));
while (gameState.columns > row.cells.length) {
row.insertCell();
}
for (let columnNumber = 0; columnNumber < row.cells.length; columnNumber++) {
const cell = row.cells[columnNumber];
if (!cell) {
continue;
}
if (!cell.childNodes.length) {
const button = document.createElement("button");
button.type = "submit";
button.name = key;
cell.append(button);
}
const { isDisabled, nextGameState, text, className } = getCellDisplayData({
gameState,
currentOutcome,
row: row.sectionRowIndex,
column: cell.cellIndex,
row: rowNumber,
column: columnNumber,
});
for (const button of Array.from(cell.querySelectorAll("button"))) {
for (const button of cell.querySelectorAll("button")) {
button.value = nextGameState.serialize();
button.disabled = isDisabled;
button.innerText = text;
button.className = className;
if (button.disabled !== isDisabled) {
button.disabled = isDisabled;
}
if (button.innerText !== text) {
button.innerText = text;
}
if (button.className !== className) {
button.className = className;
}
}
}
}
@ -96,7 +119,3 @@ class BoardGameComponent extends HTMLElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerBoardGame = () => {
window.customElements.define("board-game", BoardGameComponent);
};

@ -1,13 +1,19 @@
import { registerBoardGame } from "./board-game.ts";
import { registerProgressiveForm } from "./progressive-form.ts";
import { registerReactiveButton } from "./reactive-button.ts";
import { registerReactiveSpan } from "./reactive-span.ts";
import { BoardGameComponent } from "./board-game.ts";
import { ProgressiveForm } from "./progressive-form.ts";
import { ReactiveButton } from "./reactive-button.ts";
import { ReactiveSpan } from "./reactive-span.ts";
export const initializeWebComponents = () => {
if ((window as Partial<typeof window>).customElements?.define) {
registerBoardGame();
registerProgressiveForm();
registerReactiveButton();
registerReactiveSpan();
// We need to define customized built-in elements first,
// because WebKit (Safari) is not standard-compliant[1] and doesn't support them,
// so hopefully these calls will throw, leaving us with all custom elements or none,
// instead of defining autonomous custom elements and skipping customized built-in elements.
// [1]: https://github.com/WebKit/standards-positions/issues/97
customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
customElements.define("reactive-button", ReactiveButton, { extends: "button" });
customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
customElements.define("board-game", BoardGameComponent);
}
};

@ -1,22 +1,12 @@
import { goToLocation } from "../utils/navigation-utils.ts";
import { updateWithQueryParams } from "../utils/url-utils.ts";
// Needed for backwards compatibility with browsers that predate FormData as iterator
const getFormDataEntries = (formData: FormData) => {
const result: [string, FormDataEntryValue][] = [];
formData.forEach((value, key) => {
result.push([key, value]);
});
return result;
};
const submitListener = function (this: HTMLFormElement, e: SubmitEvent) {
goToLocation(updateWithQueryParams(new URL(this.action), getFormDataEntries(new FormData(this, e.submitter))));
goToLocation(updateWithQueryParams(new URL(this.action), new FormData(this, e.submitter)));
e.preventDefault();
};
class ProgressiveForm extends HTMLFormElement {
export class ProgressiveForm extends HTMLFormElement {
connectedCallback() {
if (this.method.toUpperCase() !== "POST") {
throw new Error(
@ -26,67 +16,4 @@ class ProgressiveForm extends HTMLFormElement {
this.addEventListener("submit", submitListener);
}
disconnectedCallback() {
this.removeEventListener("submit", submitListener);
}
}
// Only needed in browsers that support custom elements but not custom built-in elements
class ProgressiveFormWrapper extends HTMLElement {
private readonly watchedForms: Set<HTMLFormElement> = new Set<HTMLFormElement>();
private observer: MutationObserver | null = null;
private handleChanges() {
const currentForms = new Set(Array.from(this.querySelectorAll(':scope > form[is="progressive-form"]')));
// we need to dereference the set here because we're going to modify it
for (const watchedForm of [...this.watchedForms]) {
if (!currentForms.has(watchedForm)) {
watchedForm.removeEventListener("submit", submitListener);
this.watchedForms.delete(watchedForm);
}
}
for (const currentForm of currentForms) {
if (!this.watchedForms.has(currentForm)) {
this.watchedForms.add(currentForm);
currentForm.addEventListener("submit", submitListener);
}
}
}
connectedCallback() {
this.handleChanges();
const observer = new MutationObserver(this.handleChanges.bind(this));
observer.observe(this, {
childList: true,
});
this.observer = observer;
}
disconnectedCallback() {
this.observer?.disconnect();
for (const watchedForm of this.watchedForms) {
watchedForm.removeEventListener("submit", submitListener);
}
this.watchedForms.clear();
}
}
export const registerProgressiveForm = () => {
window.customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
const form = document.createElement("form", { is: "progressive-form" });
if (!(form instanceof ProgressiveForm)) {
// We're probably on WebKit based browsers (e.g. Safari 10.1+), or somewhat old Blink-based (e.g. Chromium 54-66),
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is for more details.
// Fall back to using ProgressiveFormWrapper.
console.log("Progressive form is unsupported");
window.customElements.define("progressive-form-wrapper", ProgressiveFormWrapper);
}
};

@ -1,10 +1,10 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts";
class ReactiveButton extends HTMLButtonElement {
export class ReactiveButton extends HTMLButtonElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
const delta = parseInt(this.getAttribute("data-delta") ?? "0", 10);
const delta = parseInt(this.getAttribute("delta") ?? "0", 10);
this.value = (parseInt(newValue ?? "0", 10) + delta).toString();
}
@ -20,7 +20,3 @@ class ReactiveButton extends HTMLButtonElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerReactiveButton = () => {
window.customElements.define("reactive-button", ReactiveButton, { extends: "button" });
};

@ -1,6 +1,6 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts";
class ReactiveSpan extends HTMLSpanElement {
export class ReactiveSpan extends HTMLSpanElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
@ -19,7 +19,3 @@ class ReactiveSpan extends HTMLSpanElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerReactiveSpan = () => {
window.customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
};

@ -9,17 +9,6 @@ body {
grid-column-gap: 5vmin;
}
@media (width < 50em) {
body {
grid-template-columns: 1fr;
grid-template-rows: min-content min-content min-content;
grid-template-areas:
"counters"
"game1"
"game2";
}
}
section.counters {
grid-area: counters;
}
@ -41,51 +30,55 @@ li form {
display: inline;
}
table.game-board-table {
border: solid 7px transparent;
border-collapse: collapse;
}
table.game-board-table tbody.game-board td {
border: solid 6px black;
padding: 3px;
}
/* Colors for wins taken from jspaint.app where I drew icons for X and Y */
.outcome-winx table.game-board-table,
.outcome-winx table.game-board-table tbody.game-board td {
border-color: #804000;
}
board-game {
&:not(.outcome-winx) .when-outcome-winx,
&:not(.outcome-wino) .when-outcome-wino,
&:not(.outcome-draw) .when-outcome-draw,
&:not(.outcome-undecided) .when-outcome-undecided,
&:not(.autoplayer-x-enabled) .when-autoplayer-x-enabled,
&:not(.autoplayer-x-disabled) .when-autoplayer-x-disabled,
&:not(.autoplayer-o-enabled) .when-autoplayer-o-enabled,
&:not(.autoplayer-o-disabled) .when-autoplayer-o-disabled,
&:not(.game-in-progress) .when-game-in-progress,
&:not(.game-not-in-progress) .when-game-not-in-progress {
display: none;
}
.outcome-wino table.game-board-table,
.outcome-wino table.game-board-table tbody.game-board td {
border-color: #800080;
}
table.game-board-table {
border: solid 7px transparent;
border-collapse: collapse;
.outcome-draw table.game-board-table {
border-color: black;
}
tbody.game-board {
td {
border: solid 6px black;
padding: 3px;
}
tbody.game-board button,
tbody.game-board button:disabled,
tbody.game-board button:hover {
width: min(4em, 15vmin);
height: min(4em, 15vmin);
border: none;
background-color: transparent;
color: transparent;
}
button {
&,
&:disabled,
&:hover {
width: 4em;
height: 4em;
border: none;
background-color: transparent;
color: transparent;
}
tbody.game-board button.square-X {
background-image: url("x.png");
background-size: 100% 100%;
}
&.square-X {
background-image: url("x.png");
background-size: 100% 100%;
}
tbody.game-board button.square-O {
background-image: url("o.png");
background-size: 100% 100%;
}
&.square-O {
background-image: url("o.png");
background-size: 100% 100%;
}
tbody.game-board button.square-_:enabled {
cursor: pointer;
&.square-_:enabled {
cursor: pointer;
}
}
}
}
}

@ -1,38 +0,0 @@
export const ensureSlotTemplate = (
slot: Element,
templateName: string,
getTemplate: () => HTMLTemplateElement | null,
) => {
if (slot.getAttribute("data-current-template") !== templateName) {
while (slot.lastChild) {
slot.removeChild(slot.lastChild);
}
const template = getTemplate();
if (template) {
slot.appendChild(template.content.cloneNode(true));
}
slot.setAttribute("data-current-template", templateName);
}
};
export const ensureRowsNumber = (tbody: HTMLTableSectionElement, targetRowsNumber: number) => {
while (targetRowsNumber < tbody.rows.length) {
tbody.deleteRow(0);
}
while (targetRowsNumber > tbody.rows.length) {
tbody.insertRow();
}
};
export const ensureColumnsNumber = (row: HTMLTableRowElement, targetColumnsNumber: number) => {
while (targetColumnsNumber < row.cells.length) {
row.cells[0]?.remove();
}
while (targetColumnsNumber > row.cells.length) {
row.insertCell();
}
};

@ -27,7 +27,7 @@ export class TrackingTools<
constructor(private readonly element: TElement) {}
private setTrackerIfNeeded() {
const trackKey = this.element.getAttribute("data-track");
const trackKey = this.element.getAttribute("track");
if (trackKey != this.currentTrackKey) {
if (this.clearTracker) {
this.clearTracker();

@ -1,10 +1,16 @@
declare module "preact/jsx-runtime" {
// eslint-disable-next-line @typescript-eslint/no-namespace -- preact uses namespaces, so we have too
namespace JSX {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface HTMLAttributes {
// custom attribute used by query-tracker web components
delta?: string;
track?: string;
}
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface IntrinsicElements {
"board-game": HTMLAttributes;
"progressive-form-wrapper": HTMLAttributes;
}
}
}

@ -1,5 +1,4 @@
import { BoardgameStateType, CurrentOutcome, Player, SquareState, formatSquareState } from "./datatypes/types.ts";
import { unreachableString } from "./utils/utils.ts";
export const getDisplayStates = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => ({
"outcome-winx": currentOutcome === CurrentOutcome.WinX,
@ -62,38 +61,3 @@ export const getButtonValues = (gameState: BoardgameStateType) => ({
});
export type ButtonValues = ReturnType<typeof getButtonValues>;
export const getGameStateTemplateName = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) => {
switch (currentOutcome) {
case CurrentOutcome.Draw:
return "state-draw";
case CurrentOutcome.WinX:
return "state-win-x";
case CurrentOutcome.WinO:
return "state-win-o";
case CurrentOutcome.Undecided:
switch (gameState.currentPlayer) {
case Player.X:
return "state-move-x";
case Player.O:
return "state-move-o";
case null:
return "state-uninitialized";
default:
throw new Error(`Unsupported current player: ${unreachableString(gameState.currentPlayer)}`);
}
default:
throw new Error(`Unsupported current outcome: ${unreachableString(currentOutcome)}`);
}
};
export const getSlotTemplateNames = (gameState: BoardgameStateType, currentOutcome: CurrentOutcome) =>
({
"state-slot": getGameStateTemplateName(gameState, currentOutcome),
"configuration-x-slot": gameState.autoPlayers.has(Player.X)
? "configuration-x-autoplayer"
: "configuration-x-manual",
"configuration-o-slot": gameState.autoPlayers.has(Player.O)
? "configuration-o-autoplayer"
: "configuration-o-manual",
}) as const;

@ -6,9 +6,8 @@
"forceConsistentCasingInFileNames": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"lib": ["DOM", "ES6"],
"module": "NodeNext",
"noEmit": true,
"target": "ES6"
"target": "ES2022"
}
}

Loading…
Cancel
Save