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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"$schema": "https://unpkg.com/@changesets/config/schema.json",
"changelog": [
"@changesets/changelog-git",
"@changesets/changelog-github",
{
"repo": ""
"repo": "Todari/react-pixel-ui"
}
],
"commit": false,
Expand All @@ -15,4 +15,4 @@
"ignore": [
"@react-pixel-ui/demo"
]
}
}
37 changes: 37 additions & 0 deletions .changeset/core-audit-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@react-pixel-ui/core": minor
---

Core engine overhaul: real PNG compression, full CSS color support, gradient correctness, and graceful degradation.

**Performance**

- The PNG encoder now uses a built-in dependency-free DEFLATE (fixed Huffman + LZ77) instead of uncompressed stored blocks — generated data URLs are **30–100× smaller** (e.g. a 600×400 card went from ~78 KB to ~2.4 KB), with a stored-block fallback for incompressible data.
- `generatePixelArt` accepts `options.want: 'styles' | 'composite' | 'both'` so consumers skip generating the PNG they discard (~2× faster for gradient cases). Default `'both'` keeps backward compatibility.
- `generatePixelArt` results are cached in a small LRU keyed by inputs — hover/theme/re-render churn no longer regenerates identical art. Treat returned objects as immutable.

**Color parsing**

- All 148 CSS named colors are now supported (previously 14 — `lime`, `navy`, `teal`, `tomato`, `gold`, `rebeccapurple`, … no longer fail to parse).
- New: `color(srgb … )`, `color(srgb-linear …)`, and `color(display-p3 …)` — Chromium serializes computed `color-mix()` results as `color(srgb …)`, so `color-mix()` now works end-to-end through `<Pixel>`/`usePixelRef`.
- `rgb()`/`rgba()` patterns are anchored, so colors embedded in unsupported values (e.g. `conic-gradient(rgb(255,0,0), …)`) are no longer misparsed as a solid color.

**Gradients**

- Tailwind v4 interpolation hints are handled: `linear-gradient(to right in oklab, …)` no longer silently falls back to `to bottom`.
- Angle units `turn`, `rad`, and `grad` are converted correctly (previously only `deg` worked).
- Corner keywords (`to top right`, …) now resolve to the aspect-ratio-dependent "magic corner" angle per CSS spec via the new `resolveGradientAngle(gradient, width, height)` export, instead of a fixed 45° multiple.
- Double-position stops (`red 0% 50%`) expand into two stops, preserving hard color edges.
- Stops with `px` positions or one unparseable stop no longer corrupt the whole gradient (the `-1` position sentinel can no longer leak into sampling).

**Correctness & safety**

- `generatePixelShadow` no longer forces zero offsets to `pixelSize` — `box-shadow: 0 4px 0` stays a straight shadow instead of turning diagonal. Offsets snap to the nearest grid line; all-zero shadows are skipped entirely.
- `generateCompositePixelImage` keeps the interior **transparent** for border-only elements (previously it filled the content area with the border color), and returns `null` when a background is present but unparseable (`url()`, `conic-gradient()`, …) so consumers can leave the original styling untouched.
- `parseComputedStyles` resolves percentage `border-radius` (e.g. `border-radius: 50%` avatars) against the element size via a new optional `size` parameter, and handles elliptical two-value radii.
- `parseBoxShadow` detects the `inset` keyword anywhere in the serialization — real browsers put it at the END in computed styles, so inset shadows were previously converted into outer drop-shadows. Multiple shadows are split correctly and the first non-inset one is used.
- Every exported generator sanitizes `pixelSize` — `pixelSize: 0` no longer hangs the tab (infinite loop) or throws `RangeError`.

**Packaging**

- ESM consumers under `moduleResolution: node16/nodenext` now get the ESM declaration file (`index.d.mts`) via per-condition `exports` types — fixes the attw "Masquerading as CJS" failure and wrong CJS-interop type errors.
35 changes: 35 additions & 0 deletions .changeset/react-audit-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
"@react-pixel-ui/react": minor
---

React layer fixes: stable DOM across re-measures, resize awareness, StrictMode-safe observers, working responsive mode, and API hardening.

**`<Pixel>`**

- The drop-shadow wrapper no longer remounts the child DOM node on every re-measure (theme toggle, style change). Once a shadow wrapper exists it stays in the tree (`display: contents` while measuring), so uncontrolled `<input>` values, focus, and selection survive.
- A `ResizeObserver` now watches the child: responsive/percentage-width elements re-measure when their size changes (stale absolute-px clip-paths are regenerated), and elements that mount at zero size (inside `display: none` tabs/accordions) get pixel art as soon as they become visible — previously they never did.
- Backgrounds the engine can't pixelate (`url()` images, `conic-gradient()`) are left untouched instead of being replaced with `background: none` + a transparent PNG (elements no longer go invisible or flash solid red).
- Author `box-shadow` is only cleared when it is actually replaced by a pixel shadow — inset-only shadows are preserved.

**`usePixelRef`**

- The style observer is created inside an effect instead of the ref callback. Under React 18 StrictMode (the Vite/Next dev default) the simulated remount previously disconnected the observer permanently, freezing all hover/focus/resize updates in development.
- Borderless elements with a `box-shadow` get their pixelated drop-shadow again — the hook only read `wrapperStyle.filter`, which the composer only sets when a border wrapper is needed, so shadows were silently deleted. When the element has no clip-path the filter now applies to the element itself instead of its parent.
- Same graceful-degradation behavior as `<Pixel>` for unparseable backgrounds.

**`<PixelBox>`**

- `responsive` mode actually works now: the component no longer pins its own measured fallback (`200×100`) as inline px width/height, which made the ResizeObserver only ever read back the forced size. Size the box with your own CSS (`style={{ width: '100%', height: 120 }}`) and the pixel art follows it. Measurement uses the border box.
- `className`, `style`, and other HTML props now consistently land on the **root** element (previously `style` landed on the inner content div when a border was used, so `margin` etc. silently did nothing).

**`<PixelButton>`**

- Unknown `variant` values fall back to `primary` with a dev warning instead of crashing with a `TypeError`.
- The rendered `<button>` defaults to `type="button"` so dropping it into a form no longer submits the form; an explicit `type` prop still wins.

**Misc**

- `PixelConfigProvider` no longer crashes when rendered without a `config` prop (it is now optional).
- `usePixelArt` memoizes by the radius values, so inline per-corner arrays (`borderRadius={[8,8,0,0]}`) no longer regenerate the art on every parent re-render; it also skips generating the unused composite PNG.
- `PixelBox`/`PixelButton` expose proper `displayName`s in React DevTools (no more `PixelBox2`).
- ESM consumers under `moduleResolution: node16/nodenext` get the ESM declaration file via per-condition `exports` types, and the `"use client"` banner no longer shifts source maps by one line.
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ jobs:
node-version: [20, 22]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
# pnpm comes from corepack in the next step; without this,
# setup-node@v5 tries to locate pnpm for caching and fails
package-manager-cache: false

- name: Enable Corepack
run: corepack enable
Expand All @@ -38,7 +41,7 @@ jobs:
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_OUTPUT

- name: Cache pnpm store
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
Expand Down
16 changes: 7 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,22 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v4
uses: actions/checkout@v5

- name: Setup Node.js 20.x
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 20
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
package-manager-cache: false

- name: Enable Corepack
run: corepack enable

- name: Install Dependencies
run: pnpm install
run: pnpm install --frozen-lockfile

- name: Create .npmrc
run: |
Expand All @@ -36,14 +39,9 @@ jobs:
id: changesets
uses: changesets/action@v1
with:
# This expects you to have a script called release which does a build for your packages and calls changeset publish
# `release` builds and tests the packages, then runs `changeset publish`
publish: pnpm release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Send a Slack notification if a publish happens
if: steps.changesets.outputs.published == 'true'
# You can do something when a publish happens.
run: my-slack-bot send-notification --message "A new version of ${GITHUB_REPOSITORY} was published!"
64 changes: 4 additions & 60 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,64 +1,8 @@
# Changelog

## 2.0.0 (2026-04-03)
Release notes are maintained per package by [changesets](https://github.com/changesets/changesets):

### Breaking Changes
- [`@react-pixel-ui/react` CHANGELOG](./packages/react/CHANGELOG.md)
- [`@react-pixel-ui/core` CHANGELOG](./packages/core/CHANGELOG.md)

- **Removed Canvas-based rendering** — No more `html2canvas` dependency, no `document.createElement('canvas')` calls
- **Removed `usePixelCSS` hook** — Replaced by `usePixelArt` hook and `PixelBox` component
- **Removed CSS string input** — New API uses explicit props instead of CSS string parsing
- **New API surface** — All exports changed (see Migration Guide below)

### New Features

- **Pure CSS output** — Uses `clip-path: polygon()`, BMP data URL gradients, and `filter: drop-shadow()`
- **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
- **`usePixelArt` hook** — Low-level hook returning `wrapperStyle`, `contentStyle`, `needsWrapper`
- **`useStaircaseClip` hook** — Generate clip-path polygon for staircase corners only
- **`useSteppedGradient` hook** — Convert CSS gradient to stepped bands only
- **`useResponsiveSize` hook** — ResizeObserver-based auto-sizing with pixel grid snapping
- **`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
- **Hard pixel shadows** — `drop-shadow(blur=0)` follows clip-path contour

### Migration from v1.x

**Before (v1):**
```tsx
import { usePixelCSS } from '@react-pixel-ui/react';

const { pixelStyle } = usePixelCSS(`
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
border: 2px solid #333;
border-radius: 12px;
`, { width: 280, height: 120, pixelSize: 4 });

<div style={pixelStyle}>Content</div>
```

**After (v2):**
```tsx
import { PixelBox } from '@react-pixel-ui/react';

<PixelBox
width={280} height={120} pixelSize={4}
borderRadius={12} borderWidth={2} borderColor="#333"
background="linear-gradient(45deg, #ff6b6b, #4ecdc4)"
>
Content
</PixelBox>
```

Or with the hook:
```tsx
import { usePixelArt } from '@react-pixel-ui/react';

const { wrapperStyle, contentStyle, needsWrapper } = usePixelArt(280, 120, {
pixelSize: 4, borderRadius: 12, borderWidth: 2,
borderColor: '#333', backgroundColor: 'linear-gradient(45deg, #ff6b6b, #4ecdc4)',
});
```
Migrating from v1? See the [v1 → v2 migration guide](./docs/MIGRATION.md).
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,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 `<div>` when a border is used.

### `PixelButton` — Pre-styled button

Expand All @@ -184,6 +187,9 @@ import { PixelButton } from '@react-pixel-ui/react';
| `pixelSize` | `number` | from context | Pixel block size |
| `shadow` | `{ x, y, color }` | auto | Pixel shadow |

The rendered `<button>` defaults to `type="button"` (it won't submit a
surrounding form); pass `type="submit"` explicitly when you want that.

## When to use what

| Use case | API | Why |
Expand Down Expand Up @@ -322,27 +328,35 @@ A: Yes. The core package uses pure math (no Canvas, no DOM APIs). Elements rende

## 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 `<Pixel>` / `usePixelRef` path since those read
computed styles from the DOM.
- **Colors**: all 148 CSS named colors, `#rgb[a]` / `#rrggbb[aa]`,
`rgb[a]()` (comma or modern slash syntax), `hsl[a]()` (comma or slash),
`oklch()` / `oklab()`, and `color(srgb | srgb-linear | display-p3 ...)`
are parsed natively. `color-mix()` and `var(--token)` are resolved by the
browser via `getComputedStyle` on the `<Pixel>` / `usePixelRef` path
(Chromium serializes `color-mix()` results as `color(srgb ...)`, which is
supported).
- **Gradients**: `linear-gradient`, `radial-gradient`, and their
`repeating-*` variants. Stops may use any supported color form including
`oklch()`.
`repeating-*` variants — including Tailwind v4's interpolation hints
(`to right in oklab`), `turn`/`rad`/`grad` angle units, double-position
stops, and aspect-ratio-correct corner keywords (`to top right`). 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.
via the composite PNG RGBA encoder (compressed with a built-in
dependency-free deflate — data URLs stay in the low-KB range).
- **Graceful degradation**: backgrounds the engine can't pixelate
(`url()` images, `conic-gradient()`, unresolved `var()`) are left
completely untouched — the element keeps its original styling and only
the staircase clip-path is applied.

## Known limitations

- **`<PixelBox>` explicit `background` prop**: unlike `<Pixel>` which reads
computed styles, `<PixelBox>` 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).
hex, named, `rgb()`, `hsl()`, `oklch()`, and `color()` but not
`color-mix()` or `var(--token)` (there's no DOM resolution step).
- **Dynamic children via ancestor selectors**: `<Pixel>` observes the child's
React props (`className`, `style`) and the `<html>` / `<body>` theme
classes. If an unrelated *middle* ancestor toggles a class that changes
Expand Down
1 change: 0 additions & 1 deletion debug-test.js

This file was deleted.

71 changes: 71 additions & 0 deletions docs/MIGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# v1 → v2 Migration Guide

v2.0.0 (2026-04-03) was a full rewrite. This guide covers what changed

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

릴리스 날짜를 확인하세요.

문서에 2026-04-03이 v2.0.0 릴리스 날짜로 명시되어 있습니다. 현재 6월이고 PR이 아직 open 상태이므로, 실제 릴리스 시 날짜를 업데이트해야 할 수 있습니다. 또는 플레이스홀더를 사용하는 것도 고려하세요.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/MIGRATION.md` at line 3, The migration note currently hard-codes the
release date string "v2.0.0 (2026-04-03)" in MIGRATION.md; update this to either
the final release date when known or replace the literal date with a placeholder
token (e.g., "v2.0.0 (RELEASE_DATE)") so the document won't become stale while
the PR is open, and ensure any CI/docs generation that consumes this file can
substitute the real date later.

and how to migrate. For ongoing release notes, see the per-package
changelogs:

- [`@react-pixel-ui/react` CHANGELOG](../packages/react/CHANGELOG.md)
- [`@react-pixel-ui/core` CHANGELOG](../packages/core/CHANGELOG.md)

## What changed in 2.0.0

### Breaking Changes

- **Removed Canvas-based rendering** — No more `html2canvas` dependency, no `document.createElement('canvas')` calls
- **Removed `usePixelCSS` hook** — Replaced by `usePixelArt` hook and `PixelBox` component
- **Removed CSS string input** — New API uses explicit props instead of CSS string parsing
- **New API surface** — All exports changed (see Migration Guide below)

### New Features

- **Pure CSS output** — Uses `clip-path: polygon()`, BMP data URL gradients, and `filter: drop-shadow()`
- **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
- **`usePixelArt` hook** — Low-level hook returning `wrapperStyle`, `contentStyle`, `needsWrapper`
- **`useStaircaseClip` hook** — Generate clip-path polygon for staircase corners only
- **`useSteppedGradient` hook** — Convert CSS gradient to stepped bands only
- **`useResponsiveSize` hook** — ResizeObserver-based auto-sizing with pixel grid snapping
- **`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
- **Hard pixel shadows** — `drop-shadow(blur=0)` follows clip-path contour

### Migration from v1.x

**Before (v1):**
```tsx
import { usePixelCSS } from '@react-pixel-ui/react';

const { pixelStyle } = usePixelCSS(`
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
border: 2px solid #333;
border-radius: 12px;
`, { width: 280, height: 120, pixelSize: 4 });

<div style={pixelStyle}>Content</div>
```

**After (v2):**
```tsx
import { PixelBox } from '@react-pixel-ui/react';

<PixelBox
width={280} height={120} pixelSize={4}
borderRadius={12} borderWidth={2} borderColor="#333"
background="linear-gradient(45deg, #ff6b6b, #4ecdc4)"
>
Content
</PixelBox>
```

Or with the hook:
```tsx
import { usePixelArt } from '@react-pixel-ui/react';

const { wrapperStyle, contentStyle, needsWrapper } = usePixelArt(280, 120, {
pixelSize: 4, borderRadius: 12, borderWidth: 2,
borderColor: '#333', backgroundColor: 'linear-gradient(45deg, #ff6b6b, #4ecdc4)',
});
```
Loading