diff --git a/.changeset/config.json b/.changeset/config.json index dd5f529..f28c030 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -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, @@ -15,4 +15,4 @@ "ignore": [ "@react-pixel-ui/demo" ] -} \ No newline at end of file +} diff --git a/.changeset/core-audit-improvements.md b/.changeset/core-audit-improvements.md new file mode 100644 index 0000000..7c44a58 --- /dev/null +++ b/.changeset/core-audit-improvements.md @@ -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 ``/`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. diff --git a/.changeset/react-audit-improvements.md b/.changeset/react-audit-improvements.md new file mode 100644 index 0000000..1c0ba59 --- /dev/null +++ b/.changeset/react-audit-improvements.md @@ -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. + +**``** + +- 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 `` 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 `` for unparseable backgrounds. + +**``** + +- `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). + +**``** + +- Unknown `variant` values fall back to `primary` with a dev warning instead of crashing with a `TypeError`. +- The rendered ` + + + + + ); + } + const { getByTestId } = render(); + const before = getByTestId('field'); + // Type into the input — uncontrolled value lives on the DOM node + (before as HTMLInputElement).value = 'typed'; + + // Trigger a re-measure cycle (style prop change → phase 1 → phase 2) + act(() => { + getByTestId('toggle').click(); + }); + + const after = getByTestId('field'); + expect(after).toBe(before); // same DOM node — no remount + expect((after as HTMLInputElement).value).toBe('typed'); + }); +}); diff --git a/packages/react/src/components/Pixel.tsx b/packages/react/src/components/Pixel.tsx index ac354ba..c2bf1c9 100644 --- a/packages/react/src/components/Pixel.tsx +++ b/packages/react/src/components/Pixel.tsx @@ -43,6 +43,13 @@ export function Pixel({ const childRef = useRef(null); const warnedRef = useRef(false); const [artState, setArtState] = useState(null); + // Bumped to force a re-measure even when artState is already null + // (e.g. the child was zero-sized at mount and just became visible). + const [measureToken, setMeasureToken] = useState(0); + // Once a shadow wrapper has been rendered, keep the wrapper in the tree + // (display: contents when inactive) so the child DOM node is never + // remounted by a structure change — remounts wipe input state and focus. + const hadShadowWrapperRef = useRef(false); // Derive a stable signature from child props so React-driven // style/className changes trigger re-measurement. @@ -103,6 +110,43 @@ export function Pixel({ }; }, [enabled]); + // Re-measure when the child's size changes (responsive layouts, font + // loads, content edits) or when it first becomes non-zero (mounted + // inside display:none). The generated clip-path uses absolute px + // coordinates, so stale art would be visibly wrong after a resize. + useEffect(() => { + if (!enabled) return; + if (typeof ResizeObserver === 'undefined') return; + const el = childRef.current; + if (!el) return; + + let rafId: number | null = null; + const observer = new ResizeObserver(() => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + const node = childRef.current; + if (!node) return; + const w = node.offsetWidth; + const h = node.offsetHeight; + if (artState) { + if (w !== artState.width || h !== artState.height) { + setArtState(null); + setMeasureToken((t) => t + 1); + } + } else if (w > 0 && h > 0) { + setMeasureToken((t) => t + 1); + } + }); + }); + observer.observe(el); + + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + observer.disconnect(); + }; + }, [enabled, artState]); + // Read computed styles when artState is null (before pixel art is applied) useIsomorphicLayoutEffect(() => { if (artState !== null) return; // already computed @@ -133,26 +177,44 @@ export function Pixel({ return; } - const computed = getComputedStyle(el); - const artConfig = parseComputedStyles(computed, pixelSize); // Always measure the OUTER (border-box) dimensions. We preserve the // border width via `border-color: transparent` below, which keeps the // element's outer box unchanged regardless of box-sizing. const width = el.offsetWidth; const height = el.offsetHeight; + const computed = getComputedStyle(el); + const artConfig = parseComputedStyles(computed, pixelSize, { + width, + height, + }); + if (width > 0 && height > 0) { setArtState({ config: artConfig, width, height }); } - }, [artState, pixelSize, enabled]); + }, [artState, measureToken, pixelSize, enabled]); // Memoize the generated pixel art so parent re-renders don't regenerate - // the composite BMP on every frame. + // the composite PNG on every frame. const result: PixelArtStyles | null = useMemo(() => { if (!artState) return null; - return generatePixelArt(artState.width, artState.height, artState.config); + return generatePixelArt(artState.width, artState.height, artState.config, { + want: 'composite', + }); }, [artState]); + // Derive the shadow filter before any early return so the + // wrapper-stickiness effect below can observe it. + const shadowFilter: string | undefined = result + ? ((result.wrapperStyle.filter ?? result.contentStyle.filter) as + | string + | undefined) + : undefined; + + useEffect(() => { + if (shadowFilter) hadShadowWrapperRef.current = true; + }, [shadowFilter]); + if (!isValidElement(children) || !enabled) return children; // Read the child's existing ref in a React 18 + 19 compatible way: @@ -176,9 +238,15 @@ export function Pixel({ } }; + // Keep the wrapper div in the tree once it has appeared so the child + // keeps its position (and DOM node) across phase changes. + const keepWrapper = hadShadowWrapperRef.current; + const passthroughWrap = (node: React.ReactElement) => + keepWrapper ?
{node}
: node; + // Phase 1: no pixel art yet — render child as-is with ref to measure if (!artState || !result) { - return cloneElement(children, { ref: mergedRef } as never); + return passthroughWrap(cloneElement(children, { ref: mergedRef } as never)); } // Phase 2: apply pixel art via style override. @@ -186,19 +254,17 @@ export function Pixel({ // layout shift), we set `border-color: transparent` to preserve the box, // and use `background-origin: border-box` so the composite image covers // the border area. - const pixelStyle: React.CSSProperties = { - background: 'none', - borderColor: 'transparent', - borderRadius: 0, - boxShadow: 'none', - }; + const pixelStyle: React.CSSProperties = {}; if (result.clipPath !== 'none') { pixelStyle.clipPath = result.clipPath; } - // Use composite image (border + gradient baked into one PNG) if (result.compositeImage) { + // Composite PNG bakes background + border into one image. + pixelStyle.background = 'none'; + pixelStyle.borderColor = 'transparent'; + pixelStyle.borderRadius = 0; pixelStyle.backgroundImage = result.compositeImage; pixelStyle.backgroundSize = '100% 100%'; pixelStyle.backgroundRepeat = 'no-repeat'; @@ -206,13 +272,15 @@ export function Pixel({ pixelStyle.backgroundClip = 'border-box'; pixelStyle.imageRendering = 'pixelated'; } else if (result.contentStyle.background) { - pixelStyle.background = result.contentStyle.background as string; + // No composite means the background couldn't be pixelated (url() + // image, conic-gradient, ...) — keep the original background and + // border so the element degrades gracefully instead of vanishing. + pixelStyle.borderRadius = 0; } - if (result.wrapperStyle.filter) { - pixelStyle.filter = result.wrapperStyle.filter as string; - } else if (result.contentStyle.filter) { - pixelStyle.filter = result.contentStyle.filter as string; + if (shadowFilter) { + // The author box-shadow is replaced by a pixelated drop-shadow filter. + pixelStyle.boxShadow = 'none'; } const childProps = children.props as { style?: React.CSSProperties }; @@ -227,13 +295,11 @@ export function Pixel({ // resolves to 0 in some browsers → the element collapses). Pinning the // wrapper to the measured dimensions breaks the cycle while still // allowing `max-width: 100%` to cap at the containing block. - const shadowFilter = pixelStyle.filter; if (shadowFilter) { - delete pixelStyle.filter; return (
{ expect(div.getAttribute('data-custom')).toBe('yes'); }); }); + +describe('PixelBox — responsive mode', () => { + it('does not pin its own px width/height in responsive mode', () => { + const { container } = render( + , + ); + const root = container.firstChild as HTMLElement; + // The component must not force the 200x100 fallback — user CSS rules + expect(root.style.width).toBe('100%'); + expect(root.style.height).not.toBe('100px'); + }); + + it('pins explicit width/height in fixed mode (default)', () => { + const { container } = render( + , + ); + const root = container.firstChild as HTMLElement; + expect(root.style.width).toBe('120px'); + expect(root.style.height).toBe('60px'); + }); + + it('puts the user style on the ROOT element when a border is used', () => { + const { container } = render( + , + ); + const root = container.firstChild as HTMLElement; + expect(root.style.margin).toBe('8px'); + }); +}); diff --git a/packages/react/src/components/PixelBox.tsx b/packages/react/src/components/PixelBox.tsx index 816d8b4..8aedba4 100644 --- a/packages/react/src/components/PixelBox.tsx +++ b/packages/react/src/components/PixelBox.tsx @@ -7,9 +7,9 @@ import { usePixelConfig } from '../context/PixelConfigProvider'; export interface PixelBoxProps extends React.HTMLAttributes { /** Pixel grid size in CSS px. Falls back to PixelConfigProvider default. */ pixelSize?: number; - /** Width in CSS px (required unless responsive is true) */ + /** Width in CSS px (ignored when responsive is true) */ width?: number; - /** Height in CSS px (required unless responsive is true) */ + /** Height in CSS px (ignored when responsive is true) */ height?: number; /** Border radius — single value or per-corner [tl, tr, br, bl] */ borderRadius?: number | [number, number, number, number]; @@ -21,16 +21,29 @@ export interface PixelBoxProps extends React.HTMLAttributes { background?: string; /** Hard pixel shadow */ shadow?: PixelShadowConfig; - /** Enable responsive mode (auto-detect size via ResizeObserver) */ + /** + * Responsive mode: the box follows the size your CSS gives it + * (className/style — e.g. width: '100%'), detected via ResizeObserver. + * The component does not pin its own width/height in this mode. + */ responsive?: boolean; } +/** Remove the generated px width/height so user CSS drives the layout */ +function stripSize(style: React.CSSProperties): React.CSSProperties { + const { width: _width, height: _height, ...rest } = style; + return rest; +} + /** * Generic container with pixel art styling. * * Renders staircase corners via clip-path, stepped gradients, * and hard pixel shadows. When a border is specified, automatically * uses a wrapper div for the border effect. + * + * The user `className`, `style`, and other props always land on the + * ROOT element (the wrapper when a border is used). */ export const PixelBox = forwardRef( function PixelBox( @@ -53,65 +66,70 @@ export const PixelBox = forwardRef( const config = usePixelConfig(); const pixelSize = pixelSizeProp ?? config.pixelSize; - // Responsive size detection + // Responsive size detection — measures the size user CSS produced const { ref: sizeRef, size: detectedSize } = useResponsiveSize( pixelSize, responsive, ); - const width = responsive && detectedSize ? detectedSize.width : (widthProp ?? 200); - const height = responsive && detectedSize ? detectedSize.height : (heightProp ?? 100); + const width = responsive + ? (detectedSize?.width ?? 0) + : (widthProp ?? 200); + const height = responsive + ? (detectedSize?.height ?? 0) + : (heightProp ?? 100); + const hasSize = width > 0 && height > 0; - const { - wrapperStyle, - contentStyle, - needsWrapper, - } = usePixelArt(width, height, { - pixelSize, - borderRadius, - borderWidth, - borderColor: borderColor ?? config.borderColor, - backgroundColor: background, - shadow, - }); + const { wrapperStyle, contentStyle, needsWrapper } = usePixelArt( + width, + height, + { + pixelSize, + borderRadius, + borderWidth, + borderColor: borderColor ?? config.borderColor, + backgroundColor: background, + shadow, + }, + ); - // Merge user style with generated content style - const mergedContentStyle: React.CSSProperties = { - ...contentStyle, - ...style, + const mergedRef = (node: HTMLDivElement | null) => { + if (typeof ref === 'function') ref(node); + else if (ref) { + (ref as React.MutableRefObject).current = node; + } + if (responsive) { + (sizeRef as React.MutableRefObject).current = + node; + } }; + // Root style: generated styles + user style (user wins). In responsive + // mode the generated px width/height are stripped so the box keeps + // following its container; before the first measurement only the user + // style is applied. + const generatedRootStyle = needsWrapper ? wrapperStyle : contentStyle; + const rootStyle: React.CSSProperties = hasSize + ? { + ...(responsive ? stripSize(generatedRootStyle) : generatedRootStyle), + ...style, + } + : { ...style }; + if (needsWrapper) { return ( -
{ - // Forward both refs - if (typeof ref === 'function') ref(node); - else if (ref) (ref as React.MutableRefObject).current = node; - if (responsive) (sizeRef as React.MutableRefObject).current = node; - }} - style={wrapperStyle} - {...rest} - > -
- {children} -
+
+
{children}
); } return ( -
{ - if (typeof ref === 'function') ref(node); - else if (ref) (ref as React.MutableRefObject).current = node; - if (responsive) (sizeRef as React.MutableRefObject).current = node; - }} - style={mergedContentStyle} - {...rest} - > +
{children}
); }, ); + +PixelBox.displayName = 'PixelBox'; diff --git a/packages/react/src/components/PixelButton.test.tsx b/packages/react/src/components/PixelButton.test.tsx index ebcf88a..4942172 100644 --- a/packages/react/src/components/PixelButton.test.tsx +++ b/packages/react/src/components/PixelButton.test.tsx @@ -39,3 +39,25 @@ describe('PixelButton', () => { expect(btn.disabled).toBe(true); }); }); + +describe('PixelButton — robustness', () => { + it('falls back to primary on an unknown variant instead of crashing', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { getByRole } = render( + // @ts-expect-error intentional: plain-JS callers can pass anything + oops, + ); + expect(getByRole('button')).toBeInTheDocument(); + expect(warn.mock.calls.flat().join(' ')).toContain('Unknown PixelButton variant'); + warn.mockRestore(); + }); + + it('defaults to type="button" so it does not submit forms', () => { + const { getByRole } = render(Go); + expect((getByRole('button') as HTMLButtonElement).type).toBe('button'); + }); + + it('exposes a stable displayName for DevTools', () => { + expect(PixelButton.displayName).toBe('PixelButton'); + }); +}); diff --git a/packages/react/src/components/PixelButton.tsx b/packages/react/src/components/PixelButton.tsx index c535602..867b6c2 100644 --- a/packages/react/src/components/PixelButton.tsx +++ b/packages/react/src/components/PixelButton.tsx @@ -59,7 +59,18 @@ export const PixelButton = forwardRef( ) { const config = usePixelConfig(); const pixelSize = pixelSizeProp ?? config.pixelSize; - const variantStyle = VARIANT_STYLES[variant]; + // Tolerate unknown variant values from plain-JS callers instead of + // crashing the render with a TypeError. + const variantStyle = VARIANT_STYLES[variant] ?? VARIANT_STYLES.primary; + if ( + process.env.NODE_ENV !== 'production' && + !(variant in VARIANT_STYLES) + ) { + // eslint-disable-next-line no-console + console.warn( + `[react-pixel-ui] Unknown PixelButton variant "${variant}" — falling back to "primary".`, + ); + } const { wrapperStyle, contentStyle, needsWrapper } = usePixelArt( width, @@ -91,11 +102,14 @@ export const PixelButton = forwardRef( [style], ); + // type="button" by default so dropping a PixelButton into a
+ // doesn't submit it; a user-provided type in `rest` still wins. if (needsWrapper) { return (