From 8c71db7477bd18839e7a571b6dd8d78492153ff2 Mon Sep 17 00:00:00 2001 From: Saeed ET Date: Fri, 16 Oct 2026 20:36:34 +0330 Subject: [PATCH 1/4] feat: refresh Classic and add five poker table themes --- .github/workflows/ci.yml | 4 +- .github/workflows/publish.yml | 4 +- CHANGELOG.md | 7 + CONTRIBUTING.md | 6 +- README.md | 31 +- apps/playground/e2e/table-themes.spec.ts | 112 + apps/playground/src/main.tsx | 58 +- apps/playground/src/style.css | 33 + docs/api.md | 10 +- docs/architecture.md | 4 +- docs/design/README.md | 2 + docs/design/table-theme-specification.json | 4523 +++++++++++++++++ docs/design/table-themes.md | 11 + docs/status.md | 8 +- package.json | 3 +- packages/core/src/table.ts | 40 +- packages/react/src/index.ts | 2 + packages/react/src/table-skin-types.ts | 47 + packages/react/src/table-skins.generated.ts | 4503 ++++++++++++++++ packages/react/src/table-surface.tsx | 262 + packages/react/src/table.test.tsx | 120 + packages/react/src/table.tsx | 115 +- packages/react/type-tests/contracts.tsx | 3 + tooling/assets/reference/tables/classic.png | Bin 0 -> 276151 bytes .../reference/tables/royal-burgundy.png | Bin 0 -> 269827 bytes .../table-wordmarks/classic-wordmark.svg | 51 + .../emerald-art-deco-wordmark.svg | 72 + .../midnight-racetrack-wordmark.svg | 51 + .../table-wordmarks/neon-night-wordmark.svg | 28 + .../table-wordmarks/ocean-slate-wordmark.svg | 37 + .../table-wordmarks/royal-burgundy-crown.svg | 13 + .../royal-burgundy-wordmark.svg | 37 + tooling/assets/tables.mjs | 52 + tooling/package/build.mjs | 2 +- tooling/package/consumers.mjs | 8 +- tooling/package/verify.mjs | 1 + 36 files changed, 10141 insertions(+), 119 deletions(-) create mode 100644 apps/playground/e2e/table-themes.spec.ts create mode 100644 docs/design/table-theme-specification.json create mode 100644 docs/design/table-themes.md create mode 100644 packages/react/src/table-skin-types.ts create mode 100644 packages/react/src/table-skins.generated.ts create mode 100644 packages/react/src/table-surface.tsx create mode 100644 packages/react/src/table.test.tsx create mode 100644 tooling/assets/reference/tables/classic.png create mode 100644 tooling/assets/reference/tables/royal-burgundy.png create mode 100644 tooling/assets/source/table-wordmarks/classic-wordmark.svg create mode 100644 tooling/assets/source/table-wordmarks/emerald-art-deco-wordmark.svg create mode 100644 tooling/assets/source/table-wordmarks/midnight-racetrack-wordmark.svg create mode 100644 tooling/assets/source/table-wordmarks/neon-night-wordmark.svg create mode 100644 tooling/assets/source/table-wordmarks/ocean-slate-wordmark.svg create mode 100644 tooling/assets/source/table-wordmarks/royal-burgundy-crown.svg create mode 100644 tooling/assets/source/table-wordmarks/royal-burgundy-wordmark.svg create mode 100644 tooling/assets/tables.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 116701b..79f0097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,8 +21,8 @@ jobs: node-version-file: .node-version cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm generate:ranks && pnpm prepare:assets - - run: git diff --exit-code -- packages/assets/generated packages/assets/outlined packages/assets/src/generated + - run: pnpm generate:ranks && pnpm prepare:assets && pnpm generate:tables + - run: git diff --exit-code -- packages/assets/generated packages/assets/outlined packages/assets/src/generated packages/react/src/table-skins.generated.ts - run: pnpm check - run: pnpm test:visual - run: pnpm package:check diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 135ca6c..b3d2f46 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,8 +28,8 @@ jobs: package-manager-cache: false - run: node tooling/package/release-guard.mjs - run: pnpm install --frozen-lockfile - - run: pnpm generate:ranks && pnpm prepare:assets - - run: git diff --exit-code -- packages/assets/generated packages/assets/outlined packages/assets/src/generated + - run: pnpm generate:ranks && pnpm prepare:assets && pnpm generate:tables + - run: git diff --exit-code -- packages/assets/generated packages/assets/outlined packages/assets/src/generated packages/react/src/table-skins.generated.ts - run: pnpm check - run: pnpm test:visual - run: pnpm package:release diff --git a/CHANGELOG.md b/CHANGELOG.md index d72c9d7..6144270 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased — 0.3.0 + +- Add five Penpot table themes: Midnight Racetrack, Royal Burgundy, Neon Night, Emerald Art Deco and Ocean Slate, alongside the refined Classic design. +- Add `PokerTable.theme`, theme names and labels while preserving all 2/6/8/9-seat overlay anchors and the Classic default. +- Preserve gradients, stitching, brass studs, felt patterns, lighting and glow. Convert decorative lettering to SVG paths with no runtime font dependency. +- Add a theme picker and six-design gallery to the playground, plus theme, color and shadow customization documentation. + ## 0.2.2 — 2026-10-15 - Align the separate chip rim with the updated Penpot master: a continuous 1px inner border above every edge insert, using shared design constants on single chips and stack layers. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5f90079..7063fc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,8 @@ # Contributing Use the Node and pnpm versions pinned in the repository. Run `pnpm install`, then -`pnpm check`. After building, install Chromium with `pnpm exec playwright install -chromium` and run `pnpm test:e2e`. +`pnpm check`. After building, install browsers with `pnpm exec playwright install +chromium webkit` and run `pnpm test:e2e`. Keep the core free of React. Preserve separate original and generated assets. Approved Penpot visuals must not be redesigned to accommodate implementation. @@ -17,4 +17,6 @@ Do not publish to npm without explicit maintainer approval. After asset changes, run `pnpm generate:ranks`, `pnpm prepare:assets`, and `pnpm test:visual`. Generated modules are committed; CI detects regeneration drift. Run `pnpm package:check` after a successful build. `pnpm package:pack && pnpm test:consumers` installs the actual tarball into isolated React 18 and Next.js build fixtures (requires network access). +After table design changes, capture the current component masters and outline decorative lettering before updating the sources. Run `pnpm generate:tables` to reproduce the typed skins. Keep font files and live text out of table artwork, preserve overlay anchors and verify all six themes. See [table preparation](docs/design/table-themes.md). + Default builds remain private. Only after explicit maintainer approval, `pnpm package:release` prepares a public manifest and validated tarball. Publication is a separate action and is never part of `build`, `check`, `package:pack` or `package:release`. diff --git a/README.md b/README.md index 4f8215a..eeab4d9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![FeltForge playground showing a six-seat poker table with community cards, chips, and markers](https://raw.githubusercontent.com/saeedet/feltforge/main/docs/images/feltforge-playground-table.png) -Composable, accessible React primitives for playing cards and poker interfaces. Standard and compact mobile cards, three court collections, two Jokers, customizable card and chip shadows, crisp chips and proportionally scaled tables, built from a Penpot design system. +Composable, accessible React primitives for playing cards and poker interfaces. Standard and compact mobile cards, three court collections, two Jokers, customizable card and chip shadows, crisp chips and six proportionally scaled table themes, built from a Penpot design system. **[Try the playground](https://saeedet.github.io/feltforge/)** · [Source and release notes](https://github.com/saeedet/feltforge). No game rules, betting engine, network service or real-money logic are included. @@ -26,7 +26,7 @@ pnpm build pnpm dev ``` -The playground lets you explore all 52 ordinary cards, court collections, Jokers, backs, custom chips, bounded stacks, markers and 2/6/8/9-seat layouts. In the Cards tab, the “Give it a spin” preview supports click flips, drag/flick rotation, hover tilt and keyboard controls, with reduced-motion support. +The playground lets you explore all 52 ordinary cards, court collections, Jokers, backs, custom chips, bounded stacks, markers and six table themes in 2/6/8/9-seat layouts. The Poker table tab includes a theme picker and a gallery of all six designs. In the Cards tab, the “Give it a spin” preview supports click flips, drag/flick rotation, hover tilt and keyboard controls, with reduced-motion support. The [hosted playground](https://saeedet.github.io/feltforge/) is deployed from `main` after CI passes. Its source stays in `apps/playground` and is not included in the npm package. @@ -145,6 +145,7 @@ import { PokerTable, ChipStack, PokerMarker } from 'feltforge'; } renderBet={() => } renderMarker={({ index }) => @@ -154,6 +155,31 @@ import { PokerTable, ChipStack, PokerMarker } from 'feltforge'; />; ``` +Choose from the updated **Classic**, **Midnight Racetrack**, **Royal Burgundy**, **Neon Night**, **Emerald Art Deco**, and **Ocean Slate** designs. Omit `theme` for Classic. Every theme includes its Penpot rail, felt, lighting and decorative details; lettering and symbols are SVG paths with no font dependency. + +| Theme | `theme` value | +| ------------------ | -------------------- | +| Classic | `classic` | +| Midnight Racetrack | `midnight-racetrack` | +| Royal Burgundy | `royal-burgundy` | +| Neon Night | `neon-night` | +| Emerald Art Deco | `emerald-art-deco` | +| Ocean Slate | `ocean-slate` | + +`TABLE_THEMES`, `TABLE_THEME_LABELS` and the `TableTheme` type are exported from `feltforge`, `feltforge/table` and `feltforge/core` for building your own picker. + +Override a theme's rail, inner rail, outline, felt or betting line with the existing CSS tokens. Overrides replace that material's paint with your color while retaining the design's shading and details: + +```tsx + +``` + +Tables include SVG shadows and, in Neon Night, glow. Set `style={{ '--table-shadow': 'none' }}` to disable these effects, or replace them with a CSS filter such as `drop-shadow(0 3px 6px rgba(0,0,0,.3))`. Filter lengths use the 1000 × 600 table coordinate system and scale with the table. This token applies to each shadowed layer, including brass studs and neon lines. + The table retains a 5:3 aspect ratio. Seat, bet, marker and pot overlays use normalized anchors and retain their own dimensions. `TableAnchor` supports custom overlays; `getTableLayout()` exposes the same geometry independently of React. Seat 0 is bottom centre, then clockwise. Markers use the approved 125px inward / 55px tangent formula and stored signs. 800px or wider is recommended for a complete table interface. Narrow applications should adapt or scroll their overlays; the library does not shrink an entire interface as one SVG scene. @@ -181,6 +207,7 @@ See [API and theme tokens](https://github.com/saeedet/feltforge/blob/main/docs/a ```sh pnpm generate:ranks # Check pinned fonts and reproduce outline paths pnpm prepare:assets # Validate, normalize, optimize and generate modules +pnpm generate:tables # Reproduce table skins and outlined lettering pnpm check # Format, lint, type contracts, unit tests and build pnpm test:visual # Compare SVG assets with authoritative sources pnpm package:check # Inspect exports, consumer bundles and npm dry run diff --git a/apps/playground/e2e/table-themes.spec.ts b/apps/playground/e2e/table-themes.spec.ts new file mode 100644 index 0000000..2f8d00a --- /dev/null +++ b/apps/playground/e2e/table-themes.spec.ts @@ -0,0 +1,112 @@ +import { expect, test } from '@playwright/test'; + +const themes = [ + 'classic', + 'midnight-racetrack', + 'royal-burgundy', + 'neon-night', + 'emerald-art-deco', + 'ocean-slate', +]; +test('all six table themes preserve overlays and local SVG references', async ({ + page, +}) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + await page.goto('/'); + await page.getByRole('button', { name: 'Poker table', exact: true }).click(); + const main = page.locator('.table-preview > [data-table-theme]'); + await expect(page.getByLabel('Table themes').getByRole('button')).toHaveCount( + 6, + ); + for (const theme of themes) { + await page.getByLabel('Table theme', { exact: true }).selectOption(theme); + await expect(main).toHaveAttribute('data-table-theme', theme); + await expect(main.locator('.community-card')).toHaveCount(5); + await expect(main.locator('.seat')).toHaveCount(6); + await expect(main.locator('svg').first().locator('text,image')).toHaveCount( + 0, + ); + const missing = await page.evaluate(() => { + const ids = Array.from(document.querySelectorAll('[id]'), (el) => el.id); + const missing: string[] = []; + for (const el of document.querySelectorAll('svg *')) + for (const attr of [ + 'fill', + 'stroke', + 'clip-path', + 'filter', + 'href', + 'xlink:href', + ]) { + const value = el.getAttribute(attr) ?? ''; + const matches = Array.from( + value.matchAll( + attr.endsWith('href') ? /^#(.+)/g : /url\(#([^)"]+)/g, + ), + (m) => m[1]!, + ); + for (const id of matches) if (!ids.includes(id)) missing.push(id); + } + return missing; + }); + expect(missing).toEqual([]); + await main.screenshot({ path: `test-results/table-${theme}.png` }); + for (const seats of [2, 8, 9, 6]) { + await page + .getByLabel('Seats', { exact: true }) + .selectOption(String(seats)); + await expect(main.locator('.seat')).toHaveCount(seats); + await expect(main).toHaveAttribute('data-table-theme', theme); + } + } + await page.getByRole('button', { name: 'Neon Night', exact: true }).click(); + await expect(page.getByLabel('Table theme', { exact: true })).toHaveValue( + 'neon-night', + ); + await page + .getByRole('button', { name: 'Flip community A', exact: true }) + .press('Enter'); + await expect( + page.getByRole('button', { name: 'Flip community A', exact: true }), + ).toHaveAttribute('aria-pressed', 'true'); + await page + .getByLabel('Table themes') + .screenshot({ path: 'test-results/table-theme-gallery.png' }); + await page.setViewportSize({ width: 390, height: 844 }); + await page + .getByLabel('Table themes') + .screenshot({ path: 'test-results/table-theme-gallery-narrow.png' }); + expect( + await page.evaluate(() => document.documentElement.scrollWidth), + ).toBeLessThanOrEqual(391); + expect(errors).toEqual([]); +}); + +test('table material and shadow tokens override the themed artwork', async ({ + page, +}) => { + await page.goto('/?tab=table#explore'); + await page + .getByLabel('Table theme', { exact: true }) + .selectOption('neon-night'); + const table = page.locator('.table-preview > [data-table-theme]'); + await table.evaluate((el) => { + (el as HTMLElement).style.setProperty('--table-felt', '#123456'); + (el as HTMLElement).style.setProperty('--table-line', '#abcdef'); + (el as HTMLElement).style.setProperty('--table-shadow', 'none'); + }); + await expect( + table.locator('[data-table-layer="Felt"] > g > rect').first(), + ).toHaveCSS('fill', 'rgb(18, 52, 86)'); + await expect( + table.locator('[data-table-layer="Neon Betting Line"] > g > rect').last(), + ).toHaveCSS('stroke', 'rgb(171, 205, 239)'); + const effects = table.locator('g[filter]'); + expect(await effects.count()).toBeGreaterThan(0); + expect( + await effects.evaluateAll((elements) => + elements.every((el) => getComputedStyle(el).filter === 'none'), + ), + ).toBe(true); +}); diff --git a/apps/playground/src/main.tsx b/apps/playground/src/main.tsx index 8f044af..5437e96 100644 --- a/apps/playground/src/main.tsx +++ b/apps/playground/src/main.tsx @@ -9,13 +9,20 @@ import { PokerMarker, PokerTable, } from '@feltforge/react'; -import { RANKS, SUITS, COMPACT_CARD } from '@feltforge/core'; +import { + RANKS, + SUITS, + COMPACT_CARD, + TABLE_THEMES, + TABLE_THEME_LABELS, +} from '@feltforge/core'; import type { Rank, Suit, CourtRank, CourtStyle, TableLayout, + TableTheme, CompactCardSize, } from '@feltforge/core'; import type { ThemeStyle } from '@feltforge/react'; @@ -104,7 +111,11 @@ function App() { '0 4px 16px rgba(35, 86, 62, .3)', ); const compact = COMPACT_CARD[compactSize]; - const [tab, setTab] = useState('Cards'), + const [tab, setTab] = useState(() => + new URLSearchParams(document.location.search).get('tab') === 'table' + ? 'Poker table' + : 'Cards', + ), [style, setStyle] = useState('persian'), [court, setCourt] = useState(null), [jokers, setJokers] = useState([]), @@ -118,6 +129,7 @@ function App() { [cap, setCap] = useState(5), [color, setColor] = useState('#0c7a7c'); const [layout, setLayout] = useState(6), + [tableTheme, setTableTheme] = useState('classic'), [tableWidth, setTableWidth] = useState(1000), [dealer, setDealer] = useState(0); useEffect(() => { @@ -784,6 +796,22 @@ function App() { {tab === 'Poker table' && (
+