diff --git a/.changeset/fuzzy-pixels-coordinate.md b/.changeset/fuzzy-pixels-coordinate.md new file mode 100644 index 0000000..fbffd2d --- /dev/null +++ b/.changeset/fuzzy-pixels-coordinate.md @@ -0,0 +1,9 @@ +--- +"@react-pixel-ui/core": patch +"@react-pixel-ui/react": patch +--- + +Preserve existing parent filters when `usePixelRef` renders shadows, coordinate +filters shared by sibling instances, and reuse one root theme observer. + +Correct the published package documentation and PNG terminology. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceb59a2..8defc97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,56 @@ jobs: - name: Build run: pnpm build + + compatibility: + name: Package compatibility + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages and demo + run: pnpm build + + - name: Test React 18, React 19, and Next.js + run: pnpm test:compat + + browser: + name: Browser compatibility + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Setup Node.js 22 + uses: actions/setup-node@v5 + with: + node-version: 22 + package-manager-cache: false + + - name: Enable Corepack + run: corepack enable + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build packages and prerendered demo + run: pnpm build + + - name: Install browser runtimes + run: pnpm exec playwright install --with-deps chromium firefox webkit + + - name: Run browser tests + run: pnpm test:e2e diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32d084d..8919fd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: concurrency: ${{ github.workflow }}-${{ github.ref }} +permissions: + contents: write + pull-requests: write + id-token: write + jobs: release: name: Release @@ -15,10 +20,10 @@ jobs: - name: Checkout Repo uses: actions/checkout@v5 - - name: Setup Node.js 20.x + - name: Setup Node.js 22.x uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 22 registry-url: "https://registry.npmjs.org" # pnpm comes from corepack in the next step; without this, # setup-node@v5 tries to locate pnpm for caching and fails @@ -27,14 +32,12 @@ jobs: - name: Enable Corepack run: corepack enable + - name: Install npm with trusted publishing support + run: npm install --global "npm@^11.5.1" + - name: Install Dependencies run: pnpm install --frozen-lockfile - - name: Create .npmrc - run: | - echo email=rhymint@gmail.com > ~/.npmrc - echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" >> ~/.npmrc - - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 @@ -43,5 +46,3 @@ jobs: publish: pnpm release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/README.md b/README.md index c8d1843..25eb7b1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![bundle size](https://img.shields.io/bundlephobia/minzip/@react-pixel-ui/react)](https://bundlephobia.com/package/@react-pixel-ui/react) [![license](https://img.shields.io/npm/l/@react-pixel-ui/react)](https://github.com/Todari/react-pixel-ui/blob/main/LICENSE) -Any CSS to pixel art. Wrap your element with `` — Tailwind, inline styles, CSS modules all work. No Canvas, SSR compatible. +Turn supported CSS into pixel art. Wrap one rendered HTML element with `` — Tailwind, inline styles, and CSS modules work without Canvas and remain SSR compatible. [Demo](https://react-pixel-ui.vercel.app) | [npm](https://www.npmjs.com/package/@react-pixel-ui/react) | [GitHub](https://github.com/Todari/react-pixel-ui) @@ -69,7 +69,7 @@ import { Pixel } from '@react-pixel-ui/react'; |------|------|---------|-------------| | `size` | `number` | `4` | Pixel block size in CSS px. Larger = blockier. | | `enabled` | `boolean` | `true` | Toggle pixelation on/off | -| `children` | `ReactElement` | required | Single child element to pixelate | +| `children` | `ReactElement` | required | Single HTML element, or a component that forwards its ref to one | **Supported CSS properties:** - `background` / `background-color` — solid colors and gradients (`linear-gradient`, `radial-gradient`, `repeating-*`). Alpha-preserving. @@ -82,6 +82,11 @@ import { Pixel } from '@react-pixel-ui/react'; Attach to any element without wrapping. Best for third-party components or when you can't use a wrapper. +When a pixel shadow is present, the hook composes the generated +`drop-shadow()` on the element's parent so the child's staircase clip does +not cut it off. Existing parent filters are preserved, and multiple managed +children share the parent safely. + ```tsx import { usePixelRef } from '@react-pixel-ui/react'; @@ -132,7 +137,7 @@ function App() { | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `pixelSize` | `number` | `4` | Default pixel block size | -| `borderColor` | `string` | — | Default border color | +| `borderColor` | `string` | — | Default for `PixelBox`; CSS-reading APIs use the computed border color | ### `PixelBox` — Explicit props @@ -363,6 +368,11 @@ A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements rende the child via descendant selectors, trigger a parent re-render or use `usePixelRef`, which listens to `style` mutations on the managed element directly in addition to hover / focus / active / resize. +- **`usePixelRef` shadows and parent filters**: hard shadows are composed on + the managed element's parent to avoid clipping. The hook preserves + existing filters and coordinates sibling instances, but CSS selectors + that expect the parent's `filter` to be exactly `none` may still need an + isolated wrapper. ## Browser Compatibility @@ -372,8 +382,6 @@ A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements rende | `image-rendering: pixelated` | 41+ | 56+ (`crisp-edges`) | 10+ | 79+ | | `filter: drop-shadow()` | 18+ | 35+ | 6+ | 79+ | -**Overall: 97%+** global browser coverage. - ## TypeScript Fully typed. All components, hooks, and config objects have TypeScript definitions. @@ -391,7 +399,7 @@ import type { ``` packages/ - core/ # Pure CSS generators (zero browser dependency, SSR safe) + core/ # Framework-agnostic style generators (zero browser dependency, SSR safe) react/ # React hooks & components apps/ demo/ # Interactive demo + documentation site diff --git a/apps/demo/.eslintrc.js b/apps/demo/.eslintrc.js deleted file mode 100644 index b8580b0..0000000 --- a/apps/demo/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - "extends": [ - "@react-pixel-ui/eslint-config/react" - ] -}; diff --git a/apps/demo/e2e/landing.spec.ts b/apps/demo/e2e/landing.spec.ts new file mode 100644 index 0000000..dc17a79 --- /dev/null +++ b/apps/demo/e2e/landing.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from "@playwright/test"; + +test("renders the landing page with metadata and accessible controls", async ({ + page, +}) => { + const consoleErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") consoleErrors.push(message.text()); + }); + + await page.goto("/"); + + await expect( + page.getByRole("heading", { level: 1, name: "React Pixel UI" }), + ).toBeVisible(); + await expect(page.locator(".hero-badge")).toHaveText(/^v\d+\.\d+\.\d+$/); + await expect( + page.getByRole("slider", { name: "Pixel Size", exact: true }), + ).toBeVisible(); + await expect(page.locator('meta[property="og:image"]')).toHaveAttribute( + "content", + "https://react-pixel-ui.vercel.app/og.png", + ); + + const slider = page.getByRole("slider", { name: "Pixel Size", exact: true }); + await slider.focus(); + await expect(slider).toBeFocused(); + expect( + await slider.evaluate((element) => getComputedStyle(element).outlineStyle), + ).not.toBe("none"); + + await page + .getByRole("button", { name: "Copy code to clipboard" }) + .first() + .click(); + await expect( + page.getByRole("button", { name: "Copy code to clipboard" }).first(), + ).toHaveText("Copied"); + expect(consoleErrors).toEqual([]); +}); + +test("does not overflow a narrow viewport", async ({ page }) => { + await page.setViewportSize({ width: 360, height: 800 }); + await page.goto("/"); + + const dimensions = await page.evaluate(() => ({ + viewport: document.documentElement.clientWidth, + content: document.documentElement.scrollWidth, + })); + + expect(dimensions.content).toBeLessThanOrEqual(dimensions.viewport); + await expect( + page.getByRole("link", { name: "Try playground" }), + ).toBeVisible(); +}); diff --git a/apps/demo/eslint.config.js b/apps/demo/eslint.config.js new file mode 100644 index 0000000..3963625 --- /dev/null +++ b/apps/demo/eslint.config.js @@ -0,0 +1 @@ +module.exports = require('@react-pixel-ui/eslint-config/react'); diff --git a/apps/demo/index.html b/apps/demo/index.html index 0c9a9cc..fb19c30 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -4,7 +4,7 @@ React Pixel UI - CSS Pixel Art Library for React - + @@ -14,13 +14,22 @@ + + + + - + + + + + + @@ -33,7 +42,7 @@ "@id": "https://react-pixel-ui.vercel.app/#website", "url": "https://react-pixel-ui.vercel.app/", "name": "React Pixel UI", - "description": "Pure CSS pixel art library for React — no Canvas, SSR compatible.", + "description": "DOM and CSS pixel art library for React — no Canvas, SSR compatible.", "inLanguage": "en-US", "publisher": { "@id": "https://react-pixel-ui.vercel.app/#person" } }, diff --git a/apps/demo/package.json b/apps/demo/package.json index c1c12ce..a9d8168 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -5,10 +5,13 @@ "description": "Demo app for React Pixel UI", "scripts": { "dev": "vite", - "build": "vite build", + "build": "pnpm build:client && pnpm build:ssr && pnpm prerender", + "build:client": "vite build", + "build:ssr": "vite build --ssr src/entry-server.tsx --outDir dist/.ssr", + "prerender": "node scripts/prerender.mjs", "preview": "vite preview", - "lint": "eslint src --ext .ts,.tsx", - "lint:fix": "eslint src --ext .ts,.tsx --fix", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "type-check": "tsc --noEmit" }, "dependencies": { @@ -21,8 +24,8 @@ "@react-pixel-ui/eslint-config": "workspace:*", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^4.0.0", + "@vitejs/plugin-react": "^4.7.0", "typescript": "^5.0.0", - "vite": "^5.0.0" + "vite": "^6.4.3" } -} \ No newline at end of file +} diff --git a/apps/demo/public/llms.txt b/apps/demo/public/llms.txt index ddd9ac7..02042a8 100644 --- a/apps/demo/public/llms.txt +++ b/apps/demo/public/llms.txt @@ -1,24 +1,24 @@ # React Pixel UI -> React Pixel UI is a pure-CSS pixel art component library for React. Wrap any element with the `` component and its rendered output is automatically transformed into a low-resolution pixel-art look — no Canvas, no pre-rendering, SSR compatible. +> React Pixel UI is a DOM/CSS pixel-art component library for React. Wrap one rendered HTML element with `` and its supported computed styles are transformed into a low-resolution pixel-art look — no Canvas and SSR compatible. ## Why pure CSS -The pixelation is achieved with a combination of `clip-path`, RGBA PNG gradient masks, and `drop-shadow` filters — meaning the output is real DOM/CSS, so it works under server-side rendering and is accessible. +The pixelation uses `clip-path`, generated RGBA PNG data URLs, and `drop-shadow` filters. The child remains a real DOM element; it renders normally on the server and is enhanced after hydration. ## Usage ```jsx import { Pixel } from "@react-pixel-ui/react"; - + ``` -Anything inside `` (Tailwind, inline styles, CSS modules, even SVGs) is pixelated as-is. +The single child can be styled with Tailwind, inline styles, or CSS modules. React components work when they forward their ref to an HTML element. Unsupported backgrounds such as `url()` and `conic-gradient()` are preserved instead of being rewritten. ## Packages @@ -35,7 +35,7 @@ Anything inside `` (Tailwind, inline styles, CSS modules, even SVGs) is p - React 18+ - SSR: yes (Next.js, Remix, etc.) -- Browsers: any with `clip-path` and `drop-shadow` support (all modern browsers) +- Browsers: modern Chrome, Firefox, Safari, and Edge with `clip-path`, `filter: drop-shadow()`, and PNG data URL support ## Author diff --git a/apps/demo/public/og.png b/apps/demo/public/og.png new file mode 100644 index 0000000..063ac47 Binary files /dev/null and b/apps/demo/public/og.png differ diff --git a/apps/demo/scripts/prerender.mjs b/apps/demo/scripts/prerender.mjs new file mode 100644 index 0000000..abdfa1e --- /dev/null +++ b/apps/demo/scripts/prerender.mjs @@ -0,0 +1,25 @@ +import { readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +const outputDirectory = new URL('../dist/', import.meta.url); +const serverDirectory = new URL('.ssr/', outputDirectory); +const entryFile = (await readdir(serverDirectory)).find((file) => + /^entry-server\.(?:mjs|js)$/.test(file) +); + +if (!entryFile) { + throw new Error('The demo SSR bundle could not be found.'); +} + +const { render } = await import(pathToFileURL(new URL(entryFile, serverDirectory).pathname).href); +const htmlFile = new URL('index.html', outputDirectory); +const template = await readFile(htmlFile, 'utf8'); +const appHtml = render(); +const html = template.replace('
', `
${appHtml}
`); + +if (html === template) { + throw new Error('The demo root placeholder could not be found.'); +} + +await writeFile(htmlFile, html); +await rm(serverDirectory, { recursive: true, force: true }); diff --git a/apps/demo/src/App.css b/apps/demo/src/App.css index 9129eee..8dad683 100644 --- a/apps/demo/src/App.css +++ b/apps/demo/src/App.css @@ -1,5 +1,3 @@ -@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=Inter:wght@400;500;600;700&display=swap'); - :root { --bg-dark: #0f0e17; --bg-card: #1a1926; @@ -13,13 +11,39 @@ * { box-sizing: border-box; margin: 0; padding: 0; } +html { + scroll-behavior: smooth; +} + body { background: var(--bg-dark); color: var(--text); + color-scheme: dark; font-family: 'Inter', sans-serif; min-height: 100vh; } +.skip-link { + position: fixed; + z-index: 1000; + top: 12px; + left: 12px; + padding: 10px 14px; + color: #0f0e17; + background: var(--accent); + font-weight: 700; + transform: translateY(-160%); +} + +.skip-link:focus { + transform: translateY(0); +} + +:where(a, button, input, select, summary):focus-visible { + outline: 3px solid var(--accent); + outline-offset: 4px; +} + /* --- Hero --- */ .hero { text-align: center; @@ -53,10 +77,43 @@ body { color: var(--text-muted); font-size: 16px; max-width: 520px; - margin: 0 auto 40px; + margin: 0 auto 28px; line-height: 1.7; } +.hero-actions { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 40px; +} + +.hero-action { + display: inline-flex; + align-items: center; + min-height: 42px; + padding: 10px 16px; + color: var(--text); + border: 1px solid var(--border); + background: var(--bg-card); + text-decoration: none; + font-size: 13px; + font-weight: 700; + transition: border-color 0.2s, transform 0.2s; +} + +.hero-action:hover { + border-color: var(--accent); + transform: translateY(-2px); +} + +.hero-action-primary { + color: #0f0e17; + border-color: var(--accent); + background: var(--accent); +} + .hero-boxes { display: flex; justify-content: center; @@ -90,7 +147,7 @@ body { gap: 8px; } -.control-item span { +.control-item label { font-size: 12px; font-weight: 600; color: var(--text-muted); @@ -110,7 +167,6 @@ input[type="range"] { height: 6px; background: var(--border); border-radius: 3px; - outline: none; } input[type="range"]::-webkit-slider-thumb { @@ -122,9 +178,19 @@ input[type="range"]::-webkit-slider-thumb { cursor: pointer; } +input[type="range"]::-moz-range-thumb { + width: 18px; + height: 18px; + border: 0; + background: var(--accent); + border-radius: 0; + cursor: pointer; +} + /* --- Sections --- */ .section { margin-bottom: 48px; + scroll-margin-top: 24px; } .section-label { @@ -197,21 +263,60 @@ input[type="range"]::-webkit-slider-thumb { } /* --- Code block --- */ +.code-shell { + position: relative; + margin-top: 16px; +} + .code-block { background: #0d0c14; border: 1px solid var(--border); border-radius: 8px; - padding: 20px 24px; + padding: 52px 24px 20px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 13px; line-height: 1.7; color: var(--text-muted); overflow-x: auto; - margin-top: 16px; + margin-top: 0; white-space: pre; word-break: keep-all; } +.code-block code { + display: block; +} + +.copy-button { + position: absolute; + z-index: 1; + top: 10px; + right: 10px; + min-width: 64px; + padding: 6px 10px; + border: 1px solid var(--border); + background: var(--bg-card); + color: var(--text); + font: 600 11px/1.2 'Inter', sans-serif; + cursor: pointer; +} + +.copy-button:hover { + border-color: var(--accent); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .code-block .keyword { color: #c792ea; } .code-block .string { color: #c3e88d; } .code-block .component { color: #82aaff; } @@ -282,6 +387,19 @@ input[type="range"]::-webkit-slider-thumb { color: var(--text-muted); } +.copyright { + padding-bottom: 24px; + margin-top: 12px; + color: var(--text-muted); + text-align: center; + font-size: 12px; +} + +.copyright a { + color: inherit; + text-decoration: underline; +} + /* --- Playground --- */ .playground { display: grid; @@ -459,9 +577,11 @@ input[type="range"]::-webkit-slider-thumb { input[type="range"] { width: 100%; } .hero { padding: 48px 16px 40px; } .hero p { font-size: 15px; padding: 0 8px; } + .hero-actions { margin-bottom: 32px; } + .hero-action { flex: 1 1 140px; justify-content: center; } .section { margin-bottom: 40px; } .section-title { font-size: 20px; margin-bottom: 16px; } - .code-block { padding: 16px; font-size: 12px; } + .code-block { padding: 48px 16px 16px; font-size: 12px; } .install-bar { padding: 14px 16px; margin-top: 32px; } .install-bar code { font-size: 12px; } @@ -497,6 +617,16 @@ input[type="range"]::-webkit-slider-thumb { .showcase-grid { gap: 16px; } } +@media (prefers-reduced-motion: reduce) { + html { scroll-behavior: auto; } + *, *::before, *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} + @media (max-width: 380px) { .hero { padding: 36px 12px 32px; } .hero h1 { font-size: 18px; } diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index 7f82c20..fe06c79 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,70 +1,132 @@ -import { useState } from 'react'; -import { Pixel, usePixelRef, PixelButton, PixelConfigProvider } from '@react-pixel-ui/react'; -import { trackEvent } from './ga'; -import './App.css'; +import { useState } from "react"; +import { + Pixel, + usePixelRef, + PixelButton, + PixelConfigProvider, +} from "@react-pixel-ui/react"; +import { trackEvent } from "./ga"; +import "./App.css"; + +const GITHUB_URL = "https://github.com/Todari/react-pixel-ui"; +const NPM_URL = "https://www.npmjs.com/package/@react-pixel-ui/react"; function App() { const [pixelSize, setPixelSize] = useState(6); // Playground state const [pgPixelSize, setPgPixelSize] = useState(6); - const [pgBg, setPgBg] = useState('linear-gradient(135deg, #ff6b6b, #4ecdc4)'); + const [pgBg, setPgBg] = useState("linear-gradient(135deg, #ff6b6b, #4ecdc4)"); const [pgRadius, setPgRadius] = useState(20); const [pgBorder, setPgBorder] = useState(3); - const [pgBorderColor, setPgBorderColor] = useState('#2d3436'); + const [pgBorderColor, setPgBorderColor] = useState("#2d3436"); const [pgWidth, setPgWidth] = useState(300); const [pgHeight, setPgHeight] = useState(120); const [pgShadowX, setPgShadowX] = useState(4); const [pgShadowY, setPgShadowY] = useState(4); - const [pgShadowColor, setPgShadowColor] = useState('#2d3436'); + const [pgShadowColor, setPgShadowColor] = useState("#2d3436"); return ( - - {/* Hero */} -
-
v2.0
-

React Pixel UI

-

- Wrap any element with <Pixel> and your CSS becomes pixel art. - Tailwind, inline styles, CSS modules — all supported. No Canvas. -

-
- {[ - { bg: 'linear-gradient(135deg, #ff8906, #e53170)', bc: '#b8441a', label: '' }, - { bg: 'linear-gradient(135deg, #a29bfe, #6c5ce7)', bc: '#4a3580', label: 'Gradient' }, - { bg: 'linear-gradient(135deg, #55efc4, #00b894)', bc: '#007a5e', label: 'Shadow' }, - ].map((item, i) => ( - -
- {item.label} -
-
- ))} -
-
- -
- {/* Global pixel size control */} -
-
- Pixel Size - {pixelSize}px - setPixelSize(Number(e.target.value))} /> + <> + + Skip to content + + + {/* Hero */} +
+
v{__REACT_PIXEL_UI_VERSION__}
+

React Pixel UI

+

+ Wrap any element with <Pixel> and your CSS + becomes pixel art. Tailwind, inline styles, CSS modules — all + supported. No Canvas. +

+ +
+ {[ + { + bg: "linear-gradient(135deg, #ff8906, #e53170)", + bc: "#b8441a", + label: "", + }, + { + bg: "linear-gradient(135deg, #a29bfe, #6c5ce7)", + bc: "#4a3580", + label: "Gradient", + }, + { + bg: "linear-gradient(135deg, #55efc4, #00b894)", + bc: "#007a5e", + label: "Shadow", + }, + ].map((item, i) => ( + +
+ {item.label} +
+
+ ))} +
+
+ +
+ {/* Global pixel size control */} +
+
+ + + {pixelSize}px + + setPixelSize(Number(e.target.value))} + /> +
-
- {/* Install */} -
-
Get Started
-

Installation

- {`# npm + {/* Install */} +
+
Get Started
+

Installation

+ {`# npm npm install @react-pixel-ui/react # pnpm @@ -72,49 +134,78 @@ pnpm add @react-pixel-ui/react # yarn yarn add @react-pixel-ui/react`} -

- Requires React 18+. @react-pixel-ui/core is installed automatically. -

-
+

+ Requires React 18+. @react-pixel-ui/core is installed + automatically. +

+
- {/* Primary API */} -
-
Primary API
-

<Pixel> — Wrap any element

-

- Wrap any element and its CSS is automatically converted to pixel art. - Works with inline styles, Tailwind, CSS modules. -

-
-
-

Original CSS

-
- Normal div -
-
-
-

Wrapped with <Pixel>

- -
- Pixel Art! + {/* Primary API */} +
+
Primary API
+

<Pixel> — Wrap any element

+

+ Wrap any element and its CSS is automatically converted to pixel + art. Works with inline styles, Tailwind, CSS modules. +

+
+
+

Original CSS

+
+ Normal div
- +
+
+

Wrapped with <Pixel>

+ +
+ Pixel Art! +
+
+
-
- {`import { Pixel } from '@react-pixel-ui/react'; + {`import { Pixel } from '@react-pixel-ui/react';
Pixel Art!
`}
-
+ - {/* Hook API */} -
-
Hook API
-

usePixelRef — Ref-based

-

- For maximum flexibility. Attach to any element without a wrapper. -

-
- -
- {`const pixelRef = usePixelRef({ pixelSize: 6 }); + {/* Hook API */} +
+
Hook API
+

usePixelRef — Ref-based

+

+ For maximum flexibility. Attach to any element without a wrapper. +

+
+ +
+ {`const pixelRef = usePixelRef({ pixelSize: 6 });
> Content
`}
-
+
- {/* Modern color spaces */} -
-
Color
-

oklch, hsl, and translucency

-

- Full RGBA alpha preserved end-to-end. Any CSS color form (including - CSS Color 4 oklch()) works in gradients and borders. -

-
-
-

oklch gradient

- -
- oklch + hsl -
-
-
-
-

Translucent fade

- -
- RGBA preserved -
-
+ {/* Modern color spaces */} +
+
Color
+

oklch, hsl, and translucency

+

+ Full RGBA alpha preserved end-to-end. Any CSS color form + (including CSS Color 4 oklch()) works in gradients + and borders. +

+
+
+

oklch gradient

+ +
+ oklch + hsl +
+
+
+
+

Translucent fade

+ +
+ RGBA preserved +
+
+
-
-
+ - {/* Playground */} -
-
Interactive
-

Playground

-
-
- {/* No `key` hack needed — now re-measures when the + {/* Playground */} +
+
Interactive
+

Playground

+
+
+ {/* No `key` hack needed — now re-measures when the child's style prop changes via useLayoutEffect deps. */} - -
- Playground -
-
-
-
- - - - - - - - - - + +
+ Playground +
+
+
+
+ + + + + + + + + + +
-
- {` + {`
Content
`}
-
+ - {/* Showcase */} -
-
Gallery
-

Showcase

-
- {[ - { bg: '#dfe6e9', bc: '#2d3436', r: 16, bw: 3, color: '#2d3436', label: 'Card' }, - { bg: 'linear-gradient(180deg, #a29bfe, #6c5ce7)', bc: '#4a3580', r: 12, bw: 2, color: '#fff', label: 'Purple' }, - { bg: 'linear-gradient(135deg, #fd79a8, #e84393)', bc: '#b8256e', r: 16, bw: 3, color: '#fff', label: 'Pink', shadow: true }, - { bg: 'linear-gradient(135deg, #2d3436, #636e72)', bc: '#636e72', r: 16, bw: 2, color: '#dfe6e9', label: 'Dark', shadow: true }, - { bg: 'linear-gradient(90deg, #55efc4, #0984e3)', bc: '#007a5e', r: 20, bw: 3, color: '#fff', label: 'Ocean' }, - { bg: 'linear-gradient(45deg, #ff8906, #e53170)', bc: '#b8441a', r: 8, bw: 2, color: '#fff', label: 'Sunset' }, - { bg: '#ffeaa7', bc: '#e17055', r: 12, bw: 3, color: '#2d3436', label: 'Warm' }, - { bg: 'linear-gradient(180deg, #74b9ff, #0984e3)', bc: '#0652DD', r: 24, bw: 3, color: '#fff', label: 'Sky' }, - ].map((item, i) => ( - - -
- {item.label} -
-
-
- ))} -
-
+ {/* Showcase */} +
+
Gallery
+

Showcase

+
+ {[ + { + bg: "#dfe6e9", + bc: "#2d3436", + r: 16, + bw: 3, + color: "#2d3436", + label: "Card", + }, + { + bg: "linear-gradient(180deg, #a29bfe, #6c5ce7)", + bc: "#4a3580", + r: 12, + bw: 2, + color: "#fff", + label: "Purple", + }, + { + bg: "linear-gradient(135deg, #fd79a8, #e84393)", + bc: "#b8256e", + r: 16, + bw: 3, + color: "#fff", + label: "Pink", + shadow: true, + }, + { + bg: "linear-gradient(135deg, #2d3436, #636e72)", + bc: "#636e72", + r: 16, + bw: 2, + color: "#dfe6e9", + label: "Dark", + shadow: true, + }, + { + bg: "linear-gradient(90deg, #55efc4, #0984e3)", + bc: "#007a5e", + r: 20, + bw: 3, + color: "#fff", + label: "Ocean", + }, + { + bg: "linear-gradient(45deg, #ff8906, #e53170)", + bc: "#b8441a", + r: 8, + bw: 2, + color: "#fff", + label: "Sunset", + }, + { + bg: "#ffeaa7", + bc: "#e17055", + r: 12, + bw: 3, + color: "#2d3436", + label: "Warm", + }, + { + bg: "linear-gradient(180deg, #74b9ff, #0984e3)", + bc: "#0652DD", + r: 24, + bw: 3, + color: "#fff", + label: "Sky", + }, + ].map((item, i) => ( + + +
+ {item.label} +
+
+
+ ))} +
+
- {/* Buttons */} -
-
Components
-

PixelButton

-
- Primary - Secondary - Danger -
-
+ {/* Buttons */} +
+
Components
+

PixelButton

+
+ + Primary + + + Secondary + + + Danger + +
+
- {/* Usage Guide */} -
-
Documentation
-

Usage Guide

+ {/* Usage Guide */} +
+
Documentation
+

Usage Guide

-

1. Basic Usage

-

- Wrap any element with <Pixel>. It reads computed CSS and converts - {' '}background, border-radius, border, and box-shadow to pixel art. -

- {`import { Pixel } from '@react-pixel-ui/react'; +

1. Basic Usage

+

+ Wrap any element with <Pixel>. It reads + computed CSS and converts background,{" "} + border-radius, border, and{" "} + box-shadow to pixel art. +

+ {`import { Pixel } from '@react-pixel-ui/react';
@@ -344,11 +686,12 @@ yarn add @react-pixel-ui/react`}
`}
-

2. Hook API

-

- Use usePixelRef when you need a ref-based approach without wrapping. -

- {`import { usePixelRef } from '@react-pixel-ui/react'; +

2. Hook API

+

+ Use usePixelRef when you need a ref-based approach + without wrapping. +

+ {`import { usePixelRef } from '@react-pixel-ui/react'; function MyComponent() { const ref = usePixelRef({ pixelSize: 6 }); @@ -364,21 +707,23 @@ function MyComponent() { ); }`} -

3. Global Config

-

- Set defaults for all <Pixel> and usePixelRef instances. -

- {`import { PixelConfigProvider } from '@react-pixel-ui/react'; +

3. Global Config

+

+ Set defaults for all <Pixel> and{" "} + usePixelRef instances. +

+ {`import { PixelConfigProvider } from '@react-pixel-ui/react'; `} -

4. Explicit Components

-

- PixelBox and PixelButton take explicit props instead of reading CSS. -

- {`import { PixelBox } from '@react-pixel-ui/react'; +

4. Explicit Components

+

+ PixelBox and PixelButton take explicit + props instead of reading CSS. +

+ {`import { PixelBox } from '@react-pixel-ui/react'; `} -
+
- {/* When to use what */} -
-
Guide
-

When to use what

-
-
- Use caseAPIWhy -
-
- Existing styled elements - <Pixel> - Reads CSS automatically, zero config -
-
- Third-party components - usePixelRef - Attach via ref, no wrapper div -
-
- Full manual control - PixelBox - Explicit props, no CSS reading -
-
- Quick buttons - PixelButton - Ready-to-use variants + {/* When to use what */} +
+
Guide
+

When to use what

+
+
+ Use case + API + Why +
+
+ Existing styled elements + + <Pixel> + + Reads CSS automatically, zero config +
+
+ Third-party components + + usePixelRef + + Attach via ref, no wrapper div +
+
+ Full manual control + + PixelBox + + Explicit props, no CSS reading +
+
+ Quick buttons + + PixelButton + + Ready-to-use variants +
-
-
+ - {/* FAQ */} -
-
Help
-

FAQ

-
-
trackEvent('toggle_faq', { question: 'gradient not pixelated' })}> - Gradient looks smooth, not pixelated? -

Increase pixelSize. At size 2, blocks are 2x2 CSS pixels — too small on Retina screens. Try 6 or higher.

-
-
trackEvent('toggle_faq', { question: 'tailwind support' })}> - Does it work with Tailwind CSS? -

Yes. <Pixel> reads getComputedStyle which resolves Tailwind classes into final CSS values. Just wrap your element.

-
-
trackEvent('toggle_faq', { question: 'supported css properties' })}> - What CSS properties are converted? -

background, background-image (linear/radial/repeating gradients), border-radius, border, box-shadow. Other properties (color, font, padding) are preserved as-is.

-
-
trackEvent('toggle_faq', { question: 'ssr nextjs compatible' })}> - Is it SSR / Next.js compatible? -

Yes. Core uses pure math (no Canvas/DOM). Elements render normally on the server and pixelate after hydration.

-
-
trackEvent('toggle_faq', { question: 'typescript support' })}> - TypeScript support? -

Fully typed. Import types: PixelArtConfig, PixelShadowConfig, BorderRadii, etc.

-
-
-
+ {/* FAQ */} +
+
Help
+

FAQ

+
+
+ trackEvent("toggle_faq", { + question: "gradient not pixelated", + }) + } + > + Gradient looks smooth, not pixelated? +

+ Increase pixelSize. At size 2, blocks are 2x2 CSS + pixels — too small on Retina screens. Try 6 or higher. +

+
+
+ trackEvent("toggle_faq", { question: "tailwind support" }) + } + > + Does it work with Tailwind CSS? +

+ Yes. <Pixel> reads{" "} + getComputedStyle which resolves Tailwind classes + into final CSS values. Just wrap your element. +

+
+
+ trackEvent("toggle_faq", { + question: "supported css properties", + }) + } + > + What CSS properties are converted? +

+ background, background-image{" "} + (linear/radial/repeating gradients),{" "} + border-radius, border,{" "} + box-shadow. Other properties (color, font, + padding) are preserved as-is. +

+
+
+ trackEvent("toggle_faq", { + question: "ssr nextjs compatible", + }) + } + > + Is it SSR / Next.js compatible? +

+ Yes. Core uses pure math (no Canvas/DOM). Elements render + normally on the server and pixelate after hydration. +

+
+
+ trackEvent("toggle_faq", { question: "typescript support" }) + } + > + TypeScript support? +

+ Fully typed. Import types: PixelArtConfig,{" "} + PixelShadowConfig, BorderRadii, etc. +

+
+
+
- {/* How it works */} -
-
Under the hood
-

How it works

-
- - - - - - -
-
+ {/* How it works */} +
+
Under the hood
+

How it works

+
+ + + + + + +
+
- {/* Footer */} - -
- © 2025 Todari — Released under the MIT License -
-
- + {/* Footer */} + + + + ); } -function ShowcaseItem({ label, children }: { label: string; children: React.ReactNode }) { +function ShowcaseItem({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { return (
{children} @@ -497,14 +975,28 @@ function ShowcaseItem({ label, children }: { label: string; children: React.Reac ); } -function FeatureCard({ icon, title, desc }: { icon: string; title: string; desc: string }) { +function FeatureCard({ + icon, + title, + desc, +}: { + icon: string; + title: string; + desc: string; +}) { const icons: Record = { - auto: '\u2728', clip: '\u2702', image: '\u25A6', shadow: '\u2592', - observe: '\u21BB', ssr: '\u2601', + auto: "\u2728", + clip: "\u2702", + image: "\u25A6", + shadow: "\u2592", + observe: "\u21BB", + ssr: "\u2601", }; return (
-
{icons[icon] || '\u25A0'}
+

{title}

{desc}

@@ -513,34 +1005,84 @@ function FeatureCard({ icon, title, desc }: { icon: string; title: string; desc: /** Simple JSX/TS syntax highlighter */ function Code({ children }: { children: string }) { + const [copied, setCopied] = useState(false); const highlight = (code: string) => { const tokens: Array<{ text: string; cls: string }> = []; - const re = /(\/\/[^\n]*|'[^']*'|"[^"]*"|`[^`]*`|\b(?:import|from|export|default|const|let|var|function|return|if|else)\b|<\/?[A-Z]\w*|<\/?[a-z]\w*|\b\d+\b|\w+(?==))/g; + const re = + /(\/\/[^\n]*|'[^']*'|"[^"]*"|`[^`]*`|\b(?:import|from|export|default|const|let|var|function|return|if|else)\b|<\/?[A-Z]\w*|<\/?[a-z]\w*|\b\d+\b|\w+(?==))/g; let last = 0; let m: RegExpExecArray | null; while ((m = re.exec(code)) !== null) { - if (m.index > last) tokens.push({ text: code.slice(last, m.index), cls: '' }); + if (m.index > last) + tokens.push({ text: code.slice(last, m.index), cls: "" }); const t = m[0]; - let cls = ''; - if (t.startsWith('//')) cls = 'comment'; - else if (t.startsWith("'") || t.startsWith('"') || t.startsWith('`')) cls = 'string'; - else if (/^<\/?\s*[A-Z]/.test(t)) cls = 'component'; - else if (/^<\/?\s*[a-z]/.test(t)) cls = 'tag'; - else if (/^(import|from|export|default|const|let|var|function|return|if|else)$/.test(t)) cls = 'keyword'; - else if (/^\d+$/.test(t)) cls = 'number'; - else if (re.lastIndex < code.length && code[re.lastIndex] === '=') cls = 'prop'; + let cls = ""; + if (t.startsWith("//")) cls = "comment"; + else if (t.startsWith("'") || t.startsWith('"') || t.startsWith("`")) + cls = "string"; + else if (/^<\/?\s*[A-Z]/.test(t)) cls = "component"; + else if (/^<\/?\s*[a-z]/.test(t)) cls = "tag"; + else if ( + /^(import|from|export|default|const|let|var|function|return|if|else)$/.test( + t, + ) + ) + cls = "keyword"; + else if (/^\d+$/.test(t)) cls = "number"; + else if (re.lastIndex < code.length && code[re.lastIndex] === "=") + cls = "prop"; tokens.push({ text: t, cls }); last = re.lastIndex; } - if (last < code.length) tokens.push({ text: code.slice(last), cls: '' }); + if (last < code.length) tokens.push({ text: code.slice(last), cls: "" }); return tokens; }; + const copy = async () => { + try { + await navigator.clipboard.writeText(children); + } catch { + const textarea = document.createElement("textarea"); + textarea.value = children; + textarea.setAttribute("readonly", ""); + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + textarea.remove(); + } + setCopied(true); + trackEvent("copy_code"); + window.setTimeout(() => setCopied(false), 1600); + }; + return ( -
- {highlight(children).map((t, i) => - t.cls ? {t.text} : t.text - )} +
+ +
+        
+          {highlight(children).map((t, i) =>
+            t.cls ? (
+              
+                {t.text}
+              
+            ) : (
+              t.text
+            ),
+          )}
+        
+      
+ + {copied ? "Code copied to clipboard" : ""} +
); } @@ -548,14 +1090,23 @@ function Code({ children }: { children: string }) { function RefHookDemo({ pixelSize }: { pixelSize: number }) { const pixelRef = usePixelRef({ pixelSize }); return ( -
+
usePixelRef
); diff --git a/apps/demo/src/entry-server.tsx b/apps/demo/src/entry-server.tsx new file mode 100644 index 0000000..cf831db --- /dev/null +++ b/apps/demo/src/entry-server.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { renderToString } from "react-dom/server"; +import App from "./App"; +import "./index.css"; + +export function render() { + return renderToString( + + + , + ); +} diff --git a/apps/demo/src/ga.ts b/apps/demo/src/ga.ts index 44e33f2..9e1464a 100644 --- a/apps/demo/src/ga.ts +++ b/apps/demo/src/ga.ts @@ -7,8 +7,8 @@ declare global { const GA_ID = import.meta.env.VITE_GA_ID; -if (GA_ID) { - const script = document.createElement('script'); +if (GA_ID && typeof document !== "undefined") { + const script = document.createElement("script"); script.async = true; script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`; document.head.appendChild(script); @@ -20,12 +20,15 @@ if (GA_ID) { // eslint-disable-next-line prefer-rest-params window.dataLayer.push(arguments); }; - window.gtag('js', new Date()); - window.gtag('config', GA_ID); + window.gtag("js", new Date()); + window.gtag("config", GA_ID); } -export function trackEvent(eventName: string, params?: Record) { - if (typeof window !== 'undefined' && typeof window.gtag === 'function') { - window.gtag('event', eventName, params ?? {}); +export function trackEvent( + eventName: string, + params?: Record, +) { + if (typeof window !== "undefined" && typeof window.gtag === "function") { + window.gtag("event", eventName, params ?? {}); } } diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index d5799d4..f388698 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -1,15 +1,23 @@ -import './ga'; -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; -import './index.css'; - -const root = ReactDOM.createRoot( - document.getElementById('root') as HTMLElement -); +import "./ga"; +import React from "react"; +import { createRoot, hydrateRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; -root.render( +const app = ( -); \ No newline at end of file +); + +const root = document.getElementById("root"); + +if (!root) { + throw new Error("React Pixel UI demo root element was not found."); +} + +if (root.hasChildNodes()) { + hydrateRoot(root, app); +} else { + createRoot(root).render(app); +} diff --git a/apps/demo/src/vite-env.d.ts b/apps/demo/src/vite-env.d.ts index 11f02fe..d10f6ed 100644 --- a/apps/demo/src/vite-env.d.ts +++ b/apps/demo/src/vite-env.d.ts @@ -1 +1,3 @@ /// + +declare const __REACT_PIXEL_UI_VERSION__: string; diff --git a/apps/demo/vercel.json b/apps/demo/vercel.json new file mode 100644 index 0000000..c10ed9b --- /dev/null +++ b/apps/demo/vercel.json @@ -0,0 +1,29 @@ +{ + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; script-src 'self' https://www.googletagmanager.com; connect-src 'self' https://www.google-analytics.com https://region1.google-analytics.com; img-src 'self' data: https://www.google-analytics.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests" + }, + { + "key": "Referrer-Policy", + "value": "strict-origin-when-cross-origin" + }, + { + "key": "Permissions-Policy", + "value": "camera=(), microphone=(), geolocation=()" + }, + { + "key": "X-Content-Type-Options", + "value": "nosniff" + }, + { + "key": "X-Frame-Options", + "value": "DENY" + } + ] + } + ] +} diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 470a176..f0bc262 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -1,10 +1,21 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; +import { readFileSync } from "node:fs"; +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +const reactPackage = JSON.parse( + readFileSync( + new URL("../../packages/react/package.json", import.meta.url), + "utf8", + ), +) as { version: string }; export default defineConfig({ plugins: [react()], + define: { + __REACT_PIXEL_UI_VERSION__: JSON.stringify(reactPackage.version), + }, server: { port: 3000, - open: true - } -}); \ No newline at end of file + open: false, + }, +}); diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 6896100..550943b 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -18,7 +18,7 @@ changelogs: ### New Features -- **Pure CSS output** — Uses `clip-path: polygon()`, BMP data URL gradients, and `filter: drop-shadow()` +- **DOM/CSS output** — Uses `clip-path: polygon()`, PNG data URL gradients, and `filter: drop-shadow()` without Canvas - **SSR compatible** — All computation is pure math, zero browser API dependency - **`PixelBox` component** — Drop-in pixel art container with automatic wrapper div for borders - **`PixelButton` component** — Pre-styled button with `primary`, `secondary`, `danger` variants @@ -29,7 +29,7 @@ changelogs: - **`PixelConfigProvider`** — React context for global defaults (pixelSize, borderColor) - **Per-corner border radius** — `borderRadius={[tl, tr, br, bl]}` array syntax - **Pixel grid snapping** — `borderWidth` and `borderRadius` auto-snap to `pixelSize` multiples -- **2D pixel gradients** — BMP data URL with `image-rendering: pixelated` for true square blocks +- **2D pixel gradients** — RGBA PNG data URL with `image-rendering: pixelated` for true square blocks - **Hard pixel shadows** — `drop-shadow(blur=0)` follows clip-path contour ### Migration from v1.x diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..bc60bf2 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,34 @@ +# Releasing + +Releases are managed by Changesets and `.github/workflows/release.yml`. +Merging a release commit to `main` publishes both public packages after lint, +type-check, unit tests, and builds pass. + +## npm trusted publishing + +Both `@react-pixel-ui/core` and `@react-pixel-ui/react` use the same GitHub +Actions trusted publisher in npm: + +- Repository owner: `Todari` +- Repository: `react-pixel-ui` +- Workflow: `release.yml` +- Environment: leave empty unless the workflow later adds one + +The workflow grants `id-token: write`, uses Node 22 and npm 11.5.1+, and both +package manifests enable provenance. Publishing uses short-lived GitHub OIDC +credentials, so the workflow does not read a long-lived npm token. + +## Local verification + +```bash +pnpm lint +pnpm type-check +pnpm test +pnpm build +pnpm test:compat +pnpm test:e2e +pnpm audit --prod +``` + +Do not run `pnpm changeset publish` locally unless a manual release is +intentional. diff --git a/package.json b/package.json index e55e43c..c4f1398 100644 --- a/package.json +++ b/package.json @@ -15,24 +15,41 @@ "format": "prettier --write \"**/*.{ts,tsx,md,json}\"", "format:check": "prettier --check \"**/*.{ts,tsx,md,json}\"", "test": "turbo run test", + "test:compat": "node scripts/package-smoke.mjs 18.3.1 && node scripts/package-smoke.mjs 19.2.8 && node scripts/next-smoke.mjs", + "test:e2e": "playwright test", "setup": "pnpm install && pnpm build", "version": "changeset version", - "release": "pnpm build && pnpm test && changeset publish" + "release": "pnpm lint && pnpm type-check && pnpm test && pnpm build && changeset publish" }, "devDependencies": { "@changesets/changelog-github": "^0.5.1", "@changesets/cli": "^2.29.7", + "@playwright/test": "^1.62.1", "@types/node": "^20.0.0", - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "^8.0.0", + "eslint": "^9.39.5", "prettier": "^3.2.5", - "turbo": "^2.3.3", + "turbo": "^2.9.14", "typescript": "^5.0.0" }, "packageManager": "pnpm@9.9.0", + "pnpm": { + "overrides": { + "@babel/core": "7.29.6", + "brace-expansion@1": "1.1.16", + "esbuild": "0.25.0", + "flatted": "3.4.2", + "glob@10": "10.5.0", + "js-yaml@3": "3.15.0", + "minimatch@3": "3.1.5", + "minimatch@9": "9.0.7", + "picomatch@2": "2.3.2", + "picomatch@4": "4.0.4", + "postcss": "8.5.18", + "rollup": "4.59.0" + } + }, "workspaces": [ "packages/*", "apps/*" ] -} \ No newline at end of file +} diff --git a/packages/core/.eslintrc.js b/packages/core/.eslintrc.js deleted file mode 100644 index 8def65f..0000000 --- a/packages/core/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - "extends": [ - "@react-pixel-ui/eslint-config/library" - ] -}; diff --git a/packages/core/README.md b/packages/core/README.md index b1220d5..07bb649 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,429 +1,160 @@ -# React Pixel UI +# @react-pixel-ui/core -[![npm version](https://img.shields.io/npm/v/@react-pixel-ui/react)](https://www.npmjs.com/package/@react-pixel-ui/react) -[![bundle size](https://img.shields.io/bundlephobia/minzip/@react-pixel-ui/react)](https://bundlephobia.com/package/@react-pixel-ui/react) -[![license](https://img.shields.io/npm/l/@react-pixel-ui/react)](https://github.com/Todari/react-pixel-ui/blob/main/LICENSE) +[![npm version](https://img.shields.io/npm/v/@react-pixel-ui/core)](https://www.npmjs.com/package/@react-pixel-ui/core) +[![license](https://img.shields.io/npm/l/@react-pixel-ui/core)](https://github.com/Todari/react-pixel-ui/blob/main/LICENSE) -Any CSS to pixel art. Wrap your element with `` — Tailwind, inline styles, CSS modules all work. No Canvas, SSR compatible. +Framework-agnostic pixel-art style engine used by +[`@react-pixel-ui/react`](https://www.npmjs.com/package/@react-pixel-ui/react). +It generates staircase polygons, pixelated gradients, composite RGBA PNG data +URLs, and hard shadows without Canvas or runtime dependencies. -[Demo](https://react-pixel-ui.vercel.app) | [npm](https://www.npmjs.com/package/@react-pixel-ui/react) | [GitHub](https://github.com/Todari/react-pixel-ui) +Use the React package when you want components and hooks. Install this package +directly when you are building another framework adapter or applying the +generated styles yourself. ## Install ```bash -npm install @react-pixel-ui/react +npm install @react-pixel-ui/core # or -pnpm add @react-pixel-ui/react -# or -yarn add @react-pixel-ui/react -``` - -Requires **React 18+**. `@react-pixel-ui/core` is installed automatically. - -## Quick Start - -```tsx -import { Pixel } from '@react-pixel-ui/react'; - -function App() { - return ( - -
- Pixel Art! -
-
- ); -} -``` - -That's it. `` reads your CSS and converts `background`, `border-radius`, `border`, and `box-shadow` into pixel art. - -## APIs - -### `` — Wrap any element (Recommended) - -```tsx -import { Pixel } from '@react-pixel-ui/react'; - -// Tailwind - -
- Works with Tailwind -
-
- -// Inline styles - -
- Works with inline styles -
-
-``` - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `size` | `number` | `4` | Pixel block size in CSS px. Larger = blockier. | -| `enabled` | `boolean` | `true` | Toggle pixelation on/off | -| `children` | `ReactElement` | required | Single child element to pixelate | - -**Supported CSS properties:** -- `background` / `background-color` — solid colors and gradients (`linear-gradient`, `radial-gradient`, `repeating-*`). Alpha-preserving. -- `border-radius` — converted to staircase corners (supports per-corner `[tl, tr, br, bl]`) -- `border` — pixel art border with staircase corners. Box size is preserved via `border-color: transparent` (no layout shift, even with `box-sizing: content-box`). -- `box-shadow` — converted to hard drop-shadow (no blur) -- **Reactive updates**: the child's `className` / `style` props and theme classes on `` / `` (Tailwind dark mode, etc.) are automatically observed — no manual re-render needed. - -### `usePixelRef` — Ref-based hook - -Attach to any element without wrapping. Best for third-party components or when you can't use a wrapper. - -```tsx -import { usePixelRef } from '@react-pixel-ui/react'; - -function MyComponent() { - const pixelRef = usePixelRef({ pixelSize: 6 }); - - return ( -
- Pixelated via ref -
- ); -} -``` - -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `pixelSize` | `number` | `4` | Pixel block size | -| `enabled` | `boolean` | `true` | Toggle pixelation | -| `observeHover` | `boolean` | `true` | Re-compute on `:hover` | -| `observeFocus` | `boolean` | `true` | Re-compute on `:focus` | -| `observeActive` | `boolean` | `true` | Re-compute on `:active` | - -### `PixelConfigProvider` — Global defaults - -Set default `pixelSize` for all `` and `usePixelRef` instances in the tree. - -```tsx -import { PixelConfigProvider } from '@react-pixel-ui/react'; - -function App() { - return ( - - {/* All components default to size 6 */} - - - ); -} -``` - -| Config Key | Type | Default | Description | -|------------|------|---------|-------------| -| `pixelSize` | `number` | `4` | Default pixel block size | -| `borderColor` | `string` | — | Default border color | - -### `PixelBox` — Explicit props - -Use when you want direct control instead of auto-reading CSS. - -```tsx -import { PixelBox } from '@react-pixel-ui/react'; - - - Content - -``` - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `width` | `number` | `200` | Element width in px | -| `height` | `number` | `100` | Element height in px | -| `pixelSize` | `number` | `4` | Pixel block size | -| `borderRadius` | `number \| [number, number, number, number]` | — | Corner radius. Array = `[topLeft, topRight, bottomRight, bottomLeft]` | -| `borderWidth` | `number` | — | Border thickness (auto-snapped to pixelSize grid) | -| `borderColor` | `string` | — | Any CSS color | -| `background` | `string` | — | CSS color or gradient string | -| `shadow` | `{ x: number, y: number, color: string }` | — | Hard pixel shadow | -| `responsive` | `boolean` | `false` | Auto-detect size via ResizeObserver | - -### `PixelButton` — Pre-styled button - -```tsx -import { PixelButton } from '@react-pixel-ui/react'; - -Click me -``` - -| Prop | Type | Default | Description | -|------|------|---------|-------------| -| `variant` | `'primary' \| 'secondary' \| 'danger'` | `'primary'` | Color theme | -| `width` | `number` | `160` | Button width | -| `height` | `number` | `48` | Button height | -| `borderRadius` | `number` | `8` | Corner radius | -| `pixelSize` | `number` | from context | Pixel block size | -| `shadow` | `{ x, y, color }` | auto | Pixel shadow | - -## When to use what - -| Use case | API | Why | -|----------|-----|-----| -| Existing styled elements | `` | Reads CSS automatically, zero config | -| Third-party components | `usePixelRef` | Attach via ref, no wrapper div | -| Full manual control | `PixelBox` | Explicit props, no CSS reading | -| Pre-built buttons | `PixelButton` | Ready-to-use with variants | - -## How It Works - -| Feature | CSS Technique | -|---------|---------------| -| Staircase corners | `clip-path: polygon()` — Bresenham circle algorithm generates stepped polygon | -| Pixel gradients | Composite PNG data URL + `image-rendering: pixelated` — 2D grid sampling per block with full RGBA alpha | -| Pixel borders | Border color + gradient baked into single PNG with staircase shapes | -| Hard shadows | `filter: drop-shadow(blur=0)` — follows clip-path contour | -| Auto-detection | `getComputedStyle()` reads any CSS → converted to pixel art config | - -## Recipes - -### Dynamic pixel size - -```tsx -function PixelSlider() { - const [size, setSize] = useState(6); - - return ( - <> - setSize(+e.target.value)} /> - -
- Size: {size}px -
-
- - ); +pnpm add @react-pixel-ui/core +``` + +## Quick start + +```ts +import { generatePixelArt } from "@react-pixel-ui/core"; + +const result = generatePixelArt( + 240, + 120, + { + pixelSize: 6, + borderRadius: 18, + borderWidth: 3, + borderColor: "#2d3436", + backgroundColor: "linear-gradient(135deg, #ff6b6b, #4ecdc4)", + shadow: { x: 6, y: 6, color: "rgba(0, 0, 0, 0.35)" }, + }, + { want: "composite" }, +); + +element.style.clipPath = result.clipPath; + +if (result.compositeImage) { + element.style.backgroundImage = `url("${result.compositeImage}")`; + element.style.backgroundSize = "100% 100%"; + element.style.imageRendering = "pixelated"; } ``` -### Per-corner radius - -```tsx - -
- Asymmetric corners -
-
-``` - -### Modern color spaces (oklch / hsl) - -```tsx -// Gradient stops can use any supported color form. - -
- oklch + hsl -
-
+`generatePixelArt()` is pure and keeps a small internal LRU cache. Treat the +returned style objects as immutable. + +## Main API + +```ts +generatePixelArt( + width: number, + height: number, + config: PixelArtConfig, + options?: { want?: 'styles' | 'composite' | 'both' } +): PixelArtStyles +``` + +`want` lets adapters skip PNG work they do not need: + +- `styles`: `wrapperStyle` and `contentStyle` +- `composite`: one border/background PNG plus fallback styles +- `both`: all outputs; the default for backward compatibility + +`PixelArtConfig` supports: + +| Field | Type | Description | +| ----------------- | -------------------------------------------- | ------------------------------------- | +| `pixelSize` | `number` | CSS-pixel grid size | +| `borderRadius` | `number \| [number, number, number, number]` | Uniform or per-corner radii | +| `borderWidth` | `number` | Border width, snapped to the grid | +| `borderColor` | `string` | Supported CSS color | +| `backgroundColor` | `string` | Supported color or gradient | +| `shadow` | `{ x, y, color }` | Hard shadow with grid-snapped offsets | + +The result includes `wrapperStyle`, `contentStyle`, `needsWrapper`, +`clipPath`, `innerClipPath`, and `compositeImage`. + +## Tree-shakeable utilities + +```ts +import { + generateStaircasePolygon, + generatePolygonPoints, + parseBorderRadius, + generateSteppedGradient, + generatePixelGradient, + generateCompositePixelImage, + generatePixelShadow, + parseComputedStyles, + parseColor, + interpolateColor, + colorToCSS, + parseGradient, + resolveGradientAngle, + snapToGrid, + snapToGridCeil, + sanitizePixelSize, +} from "@react-pixel-ui/core"; +``` + +The individual exports cover: + +- staircase `clip-path` generation and radius parsing +- CSS stepped gradients or compressed RGBA PNG gradient data URLs +- a composite PNG containing background, border, and transparent corners +- zero-blur `drop-shadow()` values +- CSS color and gradient parsing/interpolation +- pixel-grid math + +`parseComputedStyles()` accepts a `CSSStyleDeclaration` supplied by a browser +adapter. The core package itself does not read `window`, `document`, or +`getComputedStyle`. + +```ts +const config = parseComputedStyles( + getComputedStyle(element), + 6, + element.getBoundingClientRect(), +); ``` -### Translucent gradients - -```tsx -// Alpha is preserved end-to-end via the RGBA composite PNG. - -
- Fades from translucent to opaque -
-
-``` - -### Tailwind dark mode - -```tsx -// watches / class changes automatically. -// Toggle a `.dark` class on and the pixel art re-renders -// with the new computed colors. - -
- Auto-adapts to theme -
-
-``` - -### Next.js (App Router) - -```tsx -// app/page.tsx — server component importing works out of the box -import { Pixel } from '@react-pixel-ui/react'; - -export default function Page() { - return ( - -
- SSR compatible -
-
- ); -} -``` - -> The published bundle starts with `"use client"`, so Next.js treats `@react-pixel-ui/react` -> as a client module automatically — you don't need to add the directive yourself. -> `` renders its child on the server and upgrades to pixel art after hydration. - -## FAQ - -**Q: Why does my gradient look smooth instead of pixelated?** -A: Check that `pixelSize` is large enough to see distinct blocks. At `size={2}`, blocks are 2x2 CSS pixels — very small on high-DPI screens. Try `size={6}` or higher. - -**Q: Why is the border missing at diagonal corners?** -A: Make sure you're using `` or `usePixelRef` (v2.0.1+). These use composite PNG rendering where border + gradient are baked together with correct staircase shapes. - -**Q: Does it work with Tailwind CSS?** -A: Yes. `` reads `getComputedStyle` which resolves Tailwind classes into final CSS values. Tailwind dark mode toggling a class on `` is detected automatically and the pixel art re-renders. - -**Q: What CSS properties are supported?** -A: `background-color`, `background-image` (linear/radial/repeating gradients), `border-radius`, `border`, `box-shadow`. Other properties (color, font, padding, etc.) are preserved as-is. - -**Q: Is it SSR compatible?** -A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements render normally on the server and get pixelated on hydration. - ## Supported CSS values -- **Colors**: named colors, `#rgb[a]` / `#rrggbb[aa]`, `rgb[a]()` (comma or - modern slash syntax), `hsl[a]()` (comma or slash), and `oklch()` / `oklab()` - are all parsed natively. `color-mix()` and `var(--token)` rely on the - browser normalizing them to `rgb()` via `getComputedStyle` — which works - transparently on the `` / `usePixelRef` path since those read - computed styles from the DOM. -- **Gradients**: `linear-gradient`, `radial-gradient`, and their - `repeating-*` variants. Stops may use any supported color form including - `oklch()`. -- **`box-shadow`**: the *first* non-inset shadow is converted into a hard - pixel `drop-shadow`. Additional shadows and inset shadows are ignored by - design (pixel art uses a single hard shadow). -- **Alpha**: translucent colors and gradient stops are preserved end-to-end - via the composite PNG RGBA encoder. - -## Known limitations - -- **`` explicit `background` prop**: unlike `` which reads - computed styles, `` takes the raw string you pass. It understands - hex, named, `rgb()`, `hsl()`, and `oklch()` but not `color-mix()` or - `var(--token)` (there's no DOM resolution step). -- **Dynamic children via ancestor selectors**: `` observes the child's - React props (`className`, `style`) and the `` / `` theme - classes. If an unrelated *middle* ancestor toggles a class that changes - the child via descendant selectors, trigger a parent re-render or use - `usePixelRef`, which listens to `style` mutations on the managed element - directly in addition to hover / focus / active / resize. - -## Browser Compatibility - -| Feature | Chrome | Firefox | Safari | Edge | -|---------|--------|---------|--------|------| -| `clip-path: polygon()` | 55+ | 54+ | 10+ | 79+ | -| `image-rendering: pixelated` | 41+ | 56+ (`crisp-edges`) | 10+ | 79+ | -| `filter: drop-shadow()` | 18+ | 35+ | 6+ | 79+ | - -**Overall: 97%+** global browser coverage. - -## TypeScript - -Fully typed. All components, hooks, and config objects have TypeScript definitions. - -```tsx -import type { - PixelArtConfig, - PixelArtStyles, - PixelShadowConfig, - BorderRadii, -} from '@react-pixel-ui/react'; -``` +- named colors, hex, modern and legacy `rgb()` / `hsl()` +- `oklab()`, `oklch()`, and `color()` for supported color spaces +- linear, radial, and repeating gradients +- angle units, corner directions, interpolation hints, and RGBA alpha -## Project Structure +Unsupported backgrounds such as `url()`, `conic-gradient()`, and unresolved +custom properties return no generated image. Adapters should retain the +original background so the element degrades gracefully. -``` -packages/ - core/ # Pure CSS generators (zero browser dependency, SSR safe) - react/ # React hooks & components -apps/ - demo/ # Interactive demo + documentation site -``` +Only the first non-inset box shadow is represented by the high-level engine. +The generated shadow has no blur by design. -## Development +## Runtime and TypeScript -```bash -pnpm setup # Install + build -pnpm dev --filter=@react-pixel-ui/demo # Run demo at localhost:3000 -pnpm build && pnpm type-check # Build & verify -``` +- ESM and CommonJS builds +- bundled TypeScript declarations +- no React dependency +- no Canvas dependency +- no runtime dependency on browser globals +- safe to import during SSR -## Contributing +## Related links -PRs welcome. Please open an issue first to discuss larger changes. +- [React components and hooks](https://www.npmjs.com/package/@react-pixel-ui/react) +- [Interactive demo](https://react-pixel-ui.vercel.app) +- [Source and issues](https://github.com/Todari/react-pixel-ui) ## License [MIT](./LICENSE) © [Todari](https://github.com/Todari) - ---- - -# React Pixel UI (한국어) - -CSS 스타일을 자동으로 픽셀아트로 변환하는 React 라이브러리. - -```bash -npm install @react-pixel-ui/react -``` - -```tsx -import { Pixel } from '@react-pixel-ui/react'; - -// 어떤 스타일이든 로 감싸면 픽셀 아트로 변환 - -
- 자동으로 픽셀화! -
-
-``` - -Tailwind, 인라인 스타일, CSS 모듈 모두 지원. Canvas 없음, SSR 호환. - -자세한 API 문서는 영어 섹션을 참고하세요. diff --git a/packages/core/eslint.config.js b/packages/core/eslint.config.js new file mode 100644 index 0000000..4aec14e --- /dev/null +++ b/packages/core/eslint.config.js @@ -0,0 +1 @@ +module.exports = require('@react-pixel-ui/eslint-config/library'); diff --git a/packages/core/package.json b/packages/core/package.json index 0be0ab4..996bf34 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -45,14 +45,15 @@ "README.md" ], "publishConfig": { - "access": "public" + "access": "public", + "provenance": true }, "scripts": { "build": "tsup", "dev": "tsup --watch", "clean": "rm -rf dist", - "lint": "eslint src --ext .ts,.tsx", - "lint:fix": "eslint src --ext .ts,.tsx --fix", + "lint": "eslint src", + "lint:fix": "eslint src --fix", "type-check": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" @@ -62,6 +63,6 @@ "@react-pixel-ui/eslint-config": "workspace:*", "tsup": "^8.0.0", "typescript": "^5.0.0", - "vitest": "^1.6.0" + "vitest": "^3.2.6" } } diff --git a/packages/core/src/composer.ts b/packages/core/src/composer.ts index 9b9e2fd..f65e9c1 100644 --- a/packages/core/src/composer.ts +++ b/packages/core/src/composer.ts @@ -224,13 +224,13 @@ function generatePixelArtUncached( } interface ResolvedBackground { - /** True if this is a pixel gradient BMP (needs image-rendering: pixelated) */ + /** True if this is a pixel gradient PNG (needs image-rendering: pixelated) */ isPixelImage: boolean; /** CSS value — either a color string or a url("data:...") */ value: string; } -/** Resolve background — generate pixel gradient BMP for gradients, pass through solid colors */ +/** Resolve background — generate pixel gradient PNG for gradients, pass through solid colors */ function resolveBackground( backgroundColor: string | undefined, pixelSize: number, @@ -243,14 +243,14 @@ function resolveBackground( backgroundColor.includes('linear-gradient') || backgroundColor.includes('radial-gradient') ) { - const bmpUrl = generatePixelGradient( + const pngUrl = generatePixelGradient( backgroundColor, pixelSize, width, height, ); - if (bmpUrl) { - return { isPixelImage: true, value: bmpUrl }; + if (pngUrl) { + return { isPixelImage: true, value: pngUrl }; } } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 69d90a5..da33da6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -37,7 +37,7 @@ export interface PixelArtStyles { clipPath: string; /** Inner clip-path polygon string (null if no border) */ innerClipPath: string | null; - /** Composite BMP data URL with border + gradient baked in (for single-element rendering) */ + /** Composite PNG data URL with border + gradient baked in (for single-element rendering) */ compositeImage: string | null; } diff --git a/packages/eslint-config/library.js b/packages/eslint-config/library.js index 56244f1..8ab5d11 100644 --- a/packages/eslint-config/library.js +++ b/packages/eslint-config/library.js @@ -1,32 +1,36 @@ -/** - * Shared ESLint config for pure TypeScript library packages. - * No React, no browser globals — just TS with type-aware defaults. - */ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - }, - plugins: ['@typescript-eslint'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - ], - env: { - node: true, - browser: true, - es2022: true, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'warn', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, +const js = require('@eslint/js'); +const globals = require('globals'); +const tseslint = require('typescript-eslint'); + +module.exports = tseslint.config( + { + ignores: [ + '**/dist/**', + '**/node_modules/**', + '**/*.config.ts', + '**/*.config.js', ], - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', - 'no-empty': ['error', { allowEmptyCatch: true }], }, - ignorePatterns: ['dist', 'node_modules', '*.config.ts', '*.config.js'], -}; + js.configs.recommended, + tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.node, + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + } +); diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json index cdf8ad1..028b748 100644 --- a/packages/eslint-config/package.json +++ b/packages/eslint-config/package.json @@ -11,19 +11,17 @@ "scripts": { "clean": "rm -rf dist" }, - "dependencies": { - "@typescript-eslint/eslint-plugin": "^6.0.0", - "@typescript-eslint/parser": "^6.0.0", - "eslint": "^8.0.0", - "eslint-plugin-react": "^7.0.0", - "eslint-plugin-react-hooks": "^4.0.0", - "eslint-plugin-import": "^2.0.0", - "eslint-plugin-jsx-a11y": "^6.0.0" - }, "devDependencies": { + "@eslint/js": "^9.39.5", + "eslint": "^9.39.5", + "eslint-plugin-jsx-a11y": "^6.10.2", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^7.1.1", + "globals": "^17.8.0", + "typescript-eslint": "^8.65.0", "typescript": "^5.0.0" }, "peerDependencies": { - "eslint": "^8.0.0" + "eslint": "^9.0.0" } -} \ No newline at end of file +} diff --git a/packages/eslint-config/react.js b/packages/eslint-config/react.js index f56835c..d3e0ece 100644 --- a/packages/eslint-config/react.js +++ b/packages/eslint-config/react.js @@ -1,48 +1,36 @@ -/** - * Shared ESLint config for React + TypeScript packages. - * Adds react and react-hooks rules on top of the library config. - */ -module.exports = { - root: true, - parser: '@typescript-eslint/parser', - parserOptions: { - ecmaVersion: 2022, - sourceType: 'module', - ecmaFeatures: { jsx: true }, +const jsxA11y = require('eslint-plugin-jsx-a11y'); +const react = require('eslint-plugin-react'); +const reactHooks = require('eslint-plugin-react-hooks'); +const library = require('./library'); + +module.exports = [ + ...library, + { + files: ['**/*.{ts,tsx}'], + plugins: { + react, + 'react-hooks': reactHooks, + 'jsx-a11y': jsxA11y, + }, + settings: { + react: { version: 'detect' }, + }, + rules: { + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + ...jsxA11y.configs.recommended.rules, + 'react/prop-types': 'off', + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + // The library intentionally builds imperative DOM adapters around refs. + // These compiler-oriented rules reject ref composition and style writes + // even though they run in layout effects/callbacks, not during render. + 'react-hooks/refs': 'off', + 'react-hooks/immutability': 'off', + }, }, - plugins: ['@typescript-eslint', 'react', 'react-hooks'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:react/jsx-runtime', - ], - env: { - browser: true, - node: true, - es2022: true, + { + ignores: ['**/vite-env.d.ts'], }, - settings: { - react: { version: 'detect' }, - }, - rules: { - '@typescript-eslint/no-unused-vars': [ - 'warn', - { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, - ], - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-non-null-assertion': 'off', - 'react/prop-types': 'off', - 'react-hooks/rules-of-hooks': 'error', - 'react-hooks/exhaustive-deps': 'warn', - 'no-empty': ['error', { allowEmptyCatch: true }], - }, - ignorePatterns: [ - 'dist', - 'node_modules', - '*.config.ts', - '*.config.js', - 'vite-env.d.ts', - ], -}; +]; diff --git a/packages/react/.eslintrc.js b/packages/react/.eslintrc.js deleted file mode 100644 index b8580b0..0000000 --- a/packages/react/.eslintrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - "extends": [ - "@react-pixel-ui/eslint-config/react" - ] -}; diff --git a/packages/react/README.md b/packages/react/README.md index b1220d5..25eb7b1 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -4,7 +4,7 @@ [![bundle size](https://img.shields.io/bundlephobia/minzip/@react-pixel-ui/react)](https://bundlephobia.com/package/@react-pixel-ui/react) [![license](https://img.shields.io/npm/l/@react-pixel-ui/react)](https://github.com/Todari/react-pixel-ui/blob/main/LICENSE) -Any CSS to pixel art. Wrap your element with `` — Tailwind, inline styles, CSS modules all work. No Canvas, SSR compatible. +Turn supported CSS into pixel art. Wrap one rendered HTML element with `` — Tailwind, inline styles, and CSS modules work without Canvas and remain SSR compatible. [Demo](https://react-pixel-ui.vercel.app) | [npm](https://www.npmjs.com/package/@react-pixel-ui/react) | [GitHub](https://github.com/Todari/react-pixel-ui) @@ -69,7 +69,7 @@ import { Pixel } from '@react-pixel-ui/react'; |------|------|---------|-------------| | `size` | `number` | `4` | Pixel block size in CSS px. Larger = blockier. | | `enabled` | `boolean` | `true` | Toggle pixelation on/off | -| `children` | `ReactElement` | required | Single child element to pixelate | +| `children` | `ReactElement` | required | Single HTML element, or a component that forwards its ref to one | **Supported CSS properties:** - `background` / `background-color` — solid colors and gradients (`linear-gradient`, `radial-gradient`, `repeating-*`). Alpha-preserving. @@ -82,6 +82,11 @@ import { Pixel } from '@react-pixel-ui/react'; Attach to any element without wrapping. Best for third-party components or when you can't use a wrapper. +When a pixel shadow is present, the hook composes the generated +`drop-shadow()` on the element's parent so the child's staircase clip does +not cut it off. Existing parent filters are preserved, and multiple managed +children share the parent safely. + ```tsx import { usePixelRef } from '@react-pixel-ui/react'; @@ -132,7 +137,7 @@ function App() { | Config Key | Type | Default | Description | |------------|------|---------|-------------| | `pixelSize` | `number` | `4` | Default pixel block size | -| `borderColor` | `string` | — | Default border color | +| `borderColor` | `string` | — | Default for `PixelBox`; CSS-reading APIs use the computed border color | ### `PixelBox` — Explicit props @@ -165,7 +170,10 @@ import { PixelBox } from '@react-pixel-ui/react'; | `borderColor` | `string` | — | Any CSS color | | `background` | `string` | — | CSS color or gradient string | | `shadow` | `{ x: number, y: number, color: string }` | — | Hard pixel shadow | -| `responsive` | `boolean` | `false` | Auto-detect size via ResizeObserver | +| `responsive` | `boolean` | `false` | Follow the size your CSS gives the box (detected via ResizeObserver) instead of `width`/`height` props. Size it with `style`/`className` (e.g. `style={{ width: '100%', height: 120 }}`). | + +`className`, `style`, and other HTML props always land on the **root** +element — the wrapper `
` when a border is used. ### `PixelButton` — Pre-styled button @@ -184,6 +192,9 @@ import { PixelButton } from '@react-pixel-ui/react'; | `pixelSize` | `number` | from context | Pixel block size | | `shadow` | `{ x, y, color }` | auto | Pixel shadow | +The rendered `