diff --git a/.changeset/animate-height-to-auto.md b/.changeset/animate-height-to-auto.md new file mode 100644 index 00000000..d3a8c22b --- /dev/null +++ b/.changeset/animate-height-to-auto.md @@ -0,0 +1,15 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Animate `height` to `auto` + +A motion `height` now takes `"auto"` at either end of the animation. `auto` is +the height the content takes, and only layout knows that number, so the element +measures its content every frame and interpolates against the measurement. An +animation that opens a panel follows content that changes while it runs. + +The measurement runs at the width the element really gets, whether that width +comes from a declared length, from `flex`, from a percentage or from a stretched +cross axis. Text wraps the way it will on screen. diff --git a/.changeset/bend-toward-content-that-grows.md b/.changeset/bend-toward-content-that-grows.md new file mode 100644 index 00000000..d6c1dbde --- /dev/null +++ b/.changeset/bend-toward-content-that-grows.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Keep a `height` animation smooth when the content changes while it runs. + +A `motion` height with `auto` at an end resolves against the content every +frame. Content that grew part way through moved the height with it, so a box +opening toward two rows jumped when two more arrived. The animation now keeps +the height that was on screen and bends the rest of the curve toward the new +content, ending when it would have ended. + +At rest, `auto` still follows the content at once. diff --git a/.changeset/class-name-prop.md b/.changeset/class-name-prop.md new file mode 100644 index 00000000..982bf247 --- /dev/null +++ b/.changeset/class-name-prop.md @@ -0,0 +1,33 @@ +--- +"@gpuix/react": minor +--- + +Add a `className` prop and a resolver seam for it. + +`className` is `string | undefined` on every element, so `clsx` and `cn` need no +special handling. GPUIX ships no resolver. A root registers one: + +```ts +createRoot(renderer, { resolveClassName }) +``` + +The resolver reads one class token, such as `p-4`, and returns the `StyleDesc` +it declares, or `null` for a token it does not know. `@gpuix/tailwind` will be +one. Without a resolver a `className` does nothing and the root warns once. + +A declaration in `style` beats one from a class, key by key, and in the hover +and active states too. [CSS Style Attributes][spec] gives the attribute "a +specificity higher than any selector", so an element with +`style={{ backgroundColor: "red" }}` stays red under a `hover:bg-blue-500` +class, the way a browser keeps it red. + +Caching is per token. `clsx("p-4", a && "bg-blue-500", b && "text-lg")` writes up +to eight strings from three tokens, and the resolver sees three. A bounded cache +over whole strings sits in front of it, so a repeated string skips both the split +and the merge. + +`hideInstance` and `unhideInstance` now send the class-derived style as well. +React drives that pair for Suspense, and before this an element that suspended +came back with only its inline style. + +[spec]: https://www.w3.org/TR/css-style-attr/#cascading diff --git a/.changeset/clip-rounded-scroll-boxes.md b/.changeset/clip-rounded-scroll-boxes.md new file mode 100644 index 00000000..a482eed0 --- /dev/null +++ b/.changeset/clip-rounded-scroll-boxes.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": patch +--- + +Clip a rounded scroll box at its corner arcs, and end the scroll range at the far edge of the content. + +A box that hides or scrolls its overflow used to clip its children to a +rectangle, so a child could poke out over a rounded corner by a few +pixels. Such a box now clips on the GPU with the same rounded shape it +paints. The scroll range used to count the start-side padding twice, +which let a padded scroll box scroll sideways past its content and show +a bar it did not need. The range now ends at the far edge of the +children plus the end-side padding, as in CSS, and an absolutely +positioned child ends the range at its own edge. diff --git a/.changeset/close-a-height-from-where-it-is.md b/.changeset/close-a-height-from-where-it-is.md new file mode 100644 index 00000000..33f84a34 --- /dev/null +++ b/.changeset/close-a-height-from-where-it-is.md @@ -0,0 +1,16 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Collapse a `height: auto` animation from the height on screen. + +Opening a panel animated to the height the content takes, but closing it snapped +shut in one frame. Every frame of the closing animation measured zero. + +A motion height now carries a number of pixels and a share of the height the +content takes. `"auto"` is the whole share, a length is none of it, and a frame +between the two is part of each, so `"auto"` and a length are the same kind of +value. A collapse starts from the height that is on screen, and pressing the +button again part way through turns back from the frame it reached instead of +jumping. diff --git a/.changeset/corner-shape.md b/.changeset/corner-shape.md new file mode 100644 index 00000000..5250e739 --- /dev/null +++ b/.changeset/corner-shape.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add `corner-shape` from CSS Borders 4. + +`cornerShape` takes `round`, `squircle`, `square`, `bevel`, `scoop`, `notch` +or `superellipse(K)`, one to four values like `borderRadius`. The per-corner +and per-side longhands, their logical names, and the `corner*` shorthands +that pair a radius with a shape are read too. The quad shader draws each +corner as a superellipse, so a squircle costs the same as a circle. Motion +interpolates `cornerShape` in the half-corner space the spec names. Shadows +and image corners still follow the round shape. diff --git a/.changeset/css-lengths-and-calc.md b/.changeset/css-lengths-and-calc.md new file mode 100644 index 00000000..7065b3f6 --- /dev/null +++ b/.changeset/css-lengths-and-calc.md @@ -0,0 +1,17 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Read a `lineHeight` string the way CSS reads it, and accept `calc()` in any +length. + +A JS number keeps the old meaning, so `lineHeight: 20` is still 20 px. A string +follows CSS: `"2.5"` is a multiple of the font size, `"250%"` is the same +multiple, and `"24px"` is 24 px. Anything at or below zero declares nothing. + +Every length also takes `calc()`, `min()`, `max()` and `clamp()`, folded by +lightningcss while the value parses. `rem` becomes pixels first, against the +window rem size, so `calc(1rem + 4px)` reaches a single number. This is what +makes the Tailwind spacing scale work, because every step in it is +`calc(var(--spacing) * n)`. diff --git a/.changeset/cursor-keywords.md b/.changeset/cursor-keywords.md new file mode 100644 index 00000000..35701f83 --- /dev/null +++ b/.changeset/cursor-keywords.md @@ -0,0 +1,11 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Read every CSS `cursor` keyword and show the I-beam over selectable text. + +`cursor` only knew `pointer` and `default`. It now maps `text`, `grab`, +`not-allowed`, the resize directions and the other keywords to their native +cursors. Selectable text shows the I-beam when no ancestor sets a cursor, +which is what `cursor: auto` does on the web. diff --git a/.changeset/custom-properties-and-var.md b/.changeset/custom-properties-and-var.md new file mode 100644 index 00000000..18da7d44 --- /dev/null +++ b/.changeset/custom-properties-and-var.md @@ -0,0 +1,18 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Support CSS custom properties in the `style` prop. A `--name` key declares a value for the element and everything below it, and `var(--name)` reads it, with fallbacks including the empty one Tailwind writes as `var(--tw-ring-inset,)`. A missing variable with no fallback drops the declaration, which is what CSS calls invalid at computed-value time. + +Support `currentColor`. It reads the computed `color`, whether the element declares it or an ancestor does. + +Take text in every numeric style field. `padding`, `borderWidth`, `fontSize` and the other 33 numeric fields now accept `number | string`, so `8`, `"8px"` and `"var(--pad)"` all mean the same thing. A unit the renderer cannot read, such as `2rem`, drops the declaration instead of painting the number as pixels. + +Type custom properties with a pattern index signature, so `"-pad"` is a type error rather than a name that silently never resolves. `hover` and `active` reject them, because a state has no cascade of its own to declare into. + +Keep the resolved-style cache correct without giving up on elements that use no variable. A resolution that reads nothing inherited holds under every cascade, so only a `var()` or `currentColor` reader is ever invalidated by an ancestor. + +Stop re-resolving an element that has no `style` prop. The reconciler skips the call for an empty style at mount but sends `{}` on every update, so the first update on every unstyled element read as a change. + +Typecheck the `style` prop rules. `src/__tests__` was excluded from every tsconfig, so nothing checked the type assertions. `bun run typecheck` covers them now, and CI runs it. diff --git a/.changeset/eased-gradients.md b/.changeset/eased-gradients.md new file mode 100644 index 00000000..6afbb122 --- /dev/null +++ b/.changeset/eased-gradients.md @@ -0,0 +1,15 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Ease the mix between two gradient stops. + +An `` between two colour stops bends the mix, following the +CSSWG proposal in csswg-drafts issue 1332: `linear-gradient(to top, black, +ease-in-out, transparent)`. `ease`, `ease-in`, `ease-out`, `ease-in-out` and +`cubic-bezier()` are read. The easing paints as the GPUI colour hint whose +curve crosses one half at the same place, so the paint agrees with the easing +at both ends and at the half-way point. A straight fade to transparent looks +dense near the solid stop and thin near the clear one. An eased one reads as +one smooth fall-off. diff --git a/.changeset/index-and-child-selectors.md b/.changeset/index-and-child-selectors.md new file mode 100644 index 00000000..1ac6c5f7 --- /dev/null +++ b/.changeset/index-and-child-selectors.md @@ -0,0 +1,27 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Index and child selector conditions from a class + +A class resolver can now return `selectors`, a list of `{ on, style }` rules. +The spellings form a closed set. `:first-child`, `:last-child`, +`:nth-child(odd)`, `:nth-child(even)` and `:only-child` read the position of +the element among its siblings. The walk knows that position at build time, so +they cost no event and no measurement. `& > *` and `& > :not(:last-child)` sit +on the parent and style its direct children, which is what `space-y-*` and +`divide-*` compile to. `& *` reaches the whole subtree. An unknown spelling +warns once and drops. + +Every rule applies with specificity zero, as `:where()` does on the web. A +declaration the child makes itself wins over a rule from the parent, and the +`style` prop wins over an index rule key by key. Two tokens on the same +selector merge into one rule, the later token winning. The `style` prop type +excludes `selectors`, because a style attribute holds declarations, not +selectors. + +Two places sit outside the tree walk. A virtual-list row builds on its own, so +it has no child position and the index conditions do not apply to it. A custom +element resolves its own style, so the rules of a parent stop at its border. +Verified on macOS. The Windows and Linux paths did not run here. diff --git a/.changeset/key-on-a-built-in-tag.md b/.changeset/key-on-a-built-in-tag.md new file mode 100644 index 00000000..49fe8c06 --- /dev/null +++ b/.changeset/key-on-a-built-in-tag.md @@ -0,0 +1,15 @@ +--- +"@gpuix/react": patch +--- + +Accept `key` on a built-in tag under `jsxImportSource: "@gpuix/react"`. + +`` failed to typecheck. TypeScript reads +`JSX.IntrinsicAttributes` for a component tag but not for a built-in one, so +`key` has to sit in the props of each tag. React does the same for every DOM +tag. The props of a tag stay closed, so a name that is not a prop is still an +error. + +`jsx-runtime.d.ts` also imported its types with no file extension, which +`moduleResolution: "nodenext"` cannot resolve. Under `skipLibCheck` that import +became `any` and every tag took any prop at all. The import now names the file. diff --git a/.changeset/layer-effects.md b/.changeset/layer-effects.md new file mode 100644 index 00000000..8e99b766 --- /dev/null +++ b/.changeset/layer-effects.md @@ -0,0 +1,23 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add `filter`, `backdrop-filter`, `mask-image`, `mix-blend-mode`, `background-blend-mode` and `overscroll-behavior`. + +An element with any of the first four paints itself and its children into a +texture of its own, and the GPU paints that texture over the frame with the +effect. `blur()` is a two-pass Gaussian on a shrunk texture, so a wide blur +costs the same as a narrow one. `drop-shadow()` blurs the alpha of the +element's picture and paints it under the element in its colour. The other +filter functions fold into one colour matrix. `url()` is the one function +not painted. `mask-image` takes a gradient and keeps each pixel by its +alpha. Over a `backdrop-filter: blur()` the alpha scales the blur radius +instead, so an eased mask gives a progressive blur like the iOS 26 +navigation bar. CSS would fade a sharp copy over the blur there. All sixteen blend modes of Compositing and Blending 1 plus +`plus-lighter` are painted. `background-image` now paints over +`background-color` instead of replacing it. A scroll box keeps a wheel event +it can scroll with, and `overscroll-behavior: contain` keeps it at the end +of the box too. The Metal, DirectX and wgpu renderers all paint the +effects. Only WebGL, which has no storage buffers, paints the content of +an effect layer with no effect. diff --git a/.changeset/linear-gradient-backgrounds.md b/.changeset/linear-gradient-backgrounds.md new file mode 100644 index 00000000..533fc476 --- /dev/null +++ b/.changeset/linear-gradient-backgrounds.md @@ -0,0 +1,13 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Paint `linear-gradient()` backgrounds. + +`background` and the new `backgroundImage` take a `linear-gradient()` with +an angle, a side or a corner, up to eight colour stops, percentage positions +and colour hints. The engine fixes the stops up the way CSS Images 3 says and +the quad shader paints them, so a gradient costs one quad like a flat colour. +`backgroundImage` wins over `backgroundColor`, since a box paints one fill. +Stop lengths, radial and conic gradients are not read yet. diff --git a/.changeset/motion-keeps-the-style-cache.md b/.changeset/motion-keeps-the-style-cache.md new file mode 100644 index 00000000..a104afd9 --- /dev/null +++ b/.changeset/motion-keeps-the-style-cache.md @@ -0,0 +1,19 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Keep the resolved-style cache for an element that animates. + +A motion frame drives eight numbers: `width`, `height`, `top`, `right`, +`bottom`, `left`, `borderRadius` and `opacity`. It used to drive them by +copying the element's whole style, writing the numbers into the copy and +resolving that copy. Every declaration the element made was reparsed on every +frame of the animation to change one value. + +None of the eight reads a custom property, `currentColor` or the font size, so +each one now lands on the element after the cached resolution does. An animated +element resolves its style once, the same as any other element. + +Custom elements resolve a style themselves, so a motion frame still reaches +them folded into one. diff --git a/.changeset/read-colours-with-lightningcss.md b/.changeset/read-colours-with-lightningcss.md new file mode 100644 index 00000000..ca1db249 --- /dev/null +++ b/.changeset/read-colours-with-lightningcss.md @@ -0,0 +1,35 @@ +--- +"@gpuix/native": minor +--- + +Read every colour with the same CSS parser the rest of the engine uses. + +GPUIX held two CSS parsers. Colour went through csscolorparser, and every other +value went through lightningcss. The two agreed on most colours and disagreed at +the edges, which is where the specifications matter most. Colour now goes through +lightningcss as well, and csscolorparser is gone. + +Three colour syntaxes work that did not before: + +- `color-mix()`, which Tailwind writes for every opacity modifier such as + `bg-red-500/50` +- `light-dark()`, which reads the appearance of the window +- `color()`, such as `color(display-p3 1 0 0)` + +`currentColor` now resolves wherever it sits, including inside `light-dark()`. +Before, only a value that was exactly `currentColor` resolved, and a nested one +made the whole declaration invalid. + +Four syntaxes no longer parse, because no CSS specification defines any of them: + +- `hsv()` and `hsva()` +- `hwba()` +- hex with no leading `#`, such as `ff0000ff` + +A declaration that uses one of these is invalid, so the property keeps the value +it would have had with no declaration at all. Write `hwb()` instead of `hwba()`, +and add the `#` to a bare hex colour. There is no CSS replacement for `hsv()`, +so convert the value to `hsl()` or `hwb()`. + +Alpha on `rgb()`, `hsl()` and `hwb()` now rounds to 8 bits, because that is how +lightningcss holds an sRGB colour. The wider colour spaces keep the exact value. diff --git a/.changeset/read-styles-without-buffering.md b/.changeset/read-styles-without-buffering.md new file mode 100644 index 00000000..5d971d1f --- /dev/null +++ b/.changeset/read-styles-without-buffering.md @@ -0,0 +1,26 @@ +--- +"@gpuix/native": patch +--- + +Read a `style` prop without buffering it, and stop carrying it by value. + +`StyleDesc` used `#[serde(flatten)]` to collect custom properties, and `Numeric` +and `FontWeightValue` used `#[serde(untagged)]`. Each of those makes serde read +the whole value into an intermediate tree before it looks at one field, and +every `setStyle` call paid for it. All three now have a hand written +`Deserialize`. A macro declares `StyleDesc` and its reader from one field list, +so the name JS writes and the name Rust reads come from the same literal. + +The struct is 1,728 bytes, so the read now writes straight into a box rather +than building on the stack and copying. Measured over 200,000 parses: + +| shape | before | after | no flatten, no untagged | +| --- | --- | --- | --- | +| two fields | 320 ns | 84 ns | 74 ns | +| eleven fields | 531 ns | 399 ns | 341 ns | + +A retained element holds that box instead of the struct, which takes it from +2,000 bytes to 280. A tree of 10,000 elements was carrying 17 MB of mostly +empty styles. Each op in a batched mutation shrank the same way. + +The wire format is unchanged. diff --git a/.changeset/render-takes-a-class-resolver.md b/.changeset/render-takes-a-class-resolver.md new file mode 100644 index 00000000..85ca3f31 --- /dev/null +++ b/.changeset/render-takes-a-class-resolver.md @@ -0,0 +1,17 @@ +--- +"@gpuix/react": minor +--- + +Pass a class resolver to `render()`. + +`createRoot` took `resolveClassName`, but `render()` did not, so an application +that wanted `className` had to open the window and build the root by hand. +`render()` now takes the same root options: + +```ts +render(, { resolveClassName, title: "Demo" }) +``` + +A `Root` also carries the `renderer` it draws on, so an application that lets +`render()` open the window can still reach the handle afterwards. Inside the +tree, `useGpuixRequired()` gives the same renderer. diff --git a/.changeset/resolve-styles-once.md b/.changeset/resolve-styles-once.md new file mode 100644 index 00000000..ccfd2612 --- /dev/null +++ b/.changeset/resolve-styles-once.md @@ -0,0 +1,10 @@ +--- +"@gpuix/native": minor +"@gpuix/react": patch +--- + +Resolve each element style once instead of on every frame. GPUI rebuilds its element tree on every frame, so the renderer used to run all 52 style branches again for styles that had not changed since the last update from React. The resolved style is now kept on the retained element and dropped when the style changes. + +Apply the `visibility` style. It reached the native side but nothing read it, so `visibility: "hidden"` did nothing. + +Keep the element style when React hides an element. `hideInstance` replaced the whole style with `visibility: "hidden"`, which dropped the layout box and every other style on the element. The `hover` and `active` styles are dropped while an element is hidden, so neither can paint an element React asked to hide. diff --git a/.changeset/scrollbars.md b/.changeset/scrollbars.md new file mode 100644 index 00000000..9bd86225 --- /dev/null +++ b/.changeset/scrollbars.md @@ -0,0 +1,34 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Paint scrollbars on scroll boxes, and add `scrollbar-width`, `scrollbar-color` and `scrollbar-gutter`. + +A box with `overflow: scroll` or `overflow: auto` now gets a scrollbar +on each axis it scrolls. The OS picks the kind of bar, as a browser does. +When the OS auto-hides scrollbars, an overlay bar floats over the content, +shows for a second after a scroll and fades out, and reserves no space. +Otherwise a classic bar sits in a 15px gutter that the layout reserves. +`overflow: scroll` keeps the classic bar at all times and `auto` shows it +only while the content overflows. A drag of the thumb scrolls, a click in +the track moves one page, and the thumb widens under the mouse. +`scrollbar-width: thin` narrows the bar and `none` removes it. +`scrollbar-color` sets the thumb and the track. `scrollbar-gutter: stable` +reserves the gutter of a classic bar even while the content fits, and +`stable both-edges` reserves one at the start of the axis too. +`overflow: auto` used to do nothing and `clip` now clips like `hidden`. +`GPUIX_SCROLLBARS=overlay` or `classic` in the environment overrides the +OS choice, for tests. + +A bar paints after the whole frame, above any effect a sibling of the +content paints, so a blurred sticky header does not cover it. When one +axis of `overflow` computes to `visible` or `clip` and the other axis +scrolls, the first becomes `auto` or `hidden`, as in CSS. + +`scrollIntoView(elementId, block, inline)` on the renderer scrolls every +scroll box around an element until the element shows. `block` and +`inline` take `start`, `center`, `end` or `nearest`, with the web +defaults. `scroll-margin` on the target keeps space around it, and +`scroll-padding` on a scroll box keeps space inside the box, each as one +value or as the one-to-four shorthand. diff --git a/.changeset/shader-eased-gradients.md b/.changeset/shader-eased-gradients.md new file mode 100644 index 00000000..32e2bcf2 --- /dev/null +++ b/.changeset/shader-eased-gradients.md @@ -0,0 +1,11 @@ +--- +"@gpuix/native": patch +--- + +Solve a gradient easing per fragment in the shader. + +The easing between two colour stops painted as the GPUI colour hint that +crosses one half at the same place, which agrees with the easing only at both +ends and at the half-way point. The bumped GPUI carries the easing control +points to the GPU, and the shader solves the curve at every fragment, so the +whole curve paints exactly. diff --git a/.changeset/sizes-read-css-lengths.md b/.changeset/sizes-read-css-lengths.md new file mode 100644 index 00000000..c804d747 --- /dev/null +++ b/.changeset/sizes-read-css-lengths.md @@ -0,0 +1,21 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Read `width` and `height` the way CSS reads them. + +`width`, `height`, `minWidth`, `minHeight`, `maxWidth` and `maxHeight` used to +take a number, a percentage or `"auto"` and nothing else, so `"200px"`, +`"6rem"`, `"calc(100px + 2rem)"` and `"var(--size)"` were all rejected. They now +go through the same length parser as `padding`, `gap` and `fontSize`, and keep +the percentage and `"auto"` they always took. + +A value the parser cannot read used to throw out of `setStyle` and lose every +other property written in the same commit, so one bad size painted an element +with no style at all. It now drops the one declaration and leaves the rest +alone, which is what a browser does with a declaration it cannot parse. + +The values resolve when the style resolves rather than when it is read off the +wire, so a size can name a custom property and follow it when the property +changes. diff --git a/.changeset/split-the-renderer-by-subject.md b/.changeset/split-the-renderer-by-subject.md new file mode 100644 index 00000000..51160b98 --- /dev/null +++ b/.changeset/split-the-renderer-by-subject.md @@ -0,0 +1,12 @@ +--- +"@gpuix/native": patch +--- + +Split the renderer into modules by subject. + +`renderer.rs` held the napi binding, the GPUI view, the frame walk, the virtual +list state and the batch parser in one 3,358 line file. The frame walk now lives +in `renderer/frame.rs`, the retained state of one virtual list in +`renderer/virtual_list.rs`, and the batch parser in `renderer/batch.rs`. + +Nothing changed about what any of it does. diff --git a/.changeset/sync-env-overrides-under-bun.md b/.changeset/sync-env-overrides-under-bun.md new file mode 100644 index 00000000..c1eaf3ad --- /dev/null +++ b/.changeset/sync-env-overrides-under-bun.md @@ -0,0 +1,14 @@ +--- +"@gpuix/native": patch +"@gpuix/react": patch +--- + +Push `process.env` overrides through to the Rust side under Bun. + +Rust reads overrides such as `GPUIX_SCROLLBARS` at paint. Node writes a +`process.env` assignment through to `setenv`, but Bun only updates its JS +snapshot, so a test that set the variable after start had no effect under +`bun test`. The native module now exports `syncEnvVar`, and the test +renderer copies the known overrides across before every frame flush. The +values land in an override map, not in the real environment, because +`setenv` races `getenv` on the dedicated UI thread of Windows and Linux. diff --git a/.changeset/view-transitions.md b/.changeset/view-transitions.md new file mode 100644 index 00000000..7eced04c --- /dev/null +++ b/.changeset/view-transitions.md @@ -0,0 +1,31 @@ +--- +"@gpuix/native": minor +"@gpuix/react": minor +--- + +Add the View Transitions API. + +`startViewTransition(renderer, update, options)` captures every element that +carries a `viewTransitionName`, applies the React update synchronously, and +animates each name from its old place to its new one. The renderer clones the +named subtrees before the update and paints the frozen copies over the live +tree while the transition runs, so the leaving screen stays visible under, or +over, the arriving one. + +Options take a duration, a delay, and an ease per name, plus `translateX`, +`translateY`, `opacity` and `blur` ranges for the old side and the new side. +Percent lengths resolve against the size of the named element, so +`translateX: ["100%", "0%"]` slides a screen in from the right at any width. +A name with no options crossfades. A name that only enters animates against +its own bounds. A name that only leaves paints its frozen copy over the tree +while the `old` side runs, without the clip of its former ancestors. + +The `motion` prop takes a `blur` field too: a `filter: blur()` sigma in +pixels that interpolates like `opacity`. + +The new side moves through the motion channel, so the live element and its +hitboxes move together, and input lands where the screen paints. The frozen +copy takes fresh ids where the live tree still uses them, so a surviving +element and its copy never share GPUI element state. + +Limits in this version: the frozen copy takes no input. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6401587..ba6fdbbe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,14 +136,14 @@ jobs: COMPILE_TARGET: ${{ matrix.settings.bunTarget }} COMPILE_SKIP_ICONS: "1" COMPILE_SKIP_APP: "1" - run: bun compile-chat.ts + run: bun compile.ts chat.tsx --name "GPUIX Chat" --id dev.gpuix.chat --icon assets/icons/openai-mark.svg --tint "#10a37f" # A release asset is served as raw bytes, so a download loses the # executable bit and arrives with no extension: macOS and Linux users # got a file the OS refused to open. `.tar.gz` keeps the mode and names # itself. Windows already ships a `.exe`, which needs no unpacking. - name: Package chat example - working-directory: examples/dist + working-directory: examples/dist/chat shell: bash env: NAME: example-chat-${{ matrix.settings.target }} @@ -161,7 +161,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: example-chat-${{ matrix.settings.target }} - path: examples/dist/example-chat-${{ matrix.settings.target }}.* + path: examples/dist/chat/example-chat-${{ matrix.settings.target }}.* if-no-files-found: error test: @@ -191,6 +191,12 @@ jobs: run: bun run build working-directory: packages/react + # After the build, never before it. The script cleans and rebuilds + # `dist`, and a plain `tsc` build fails with TS5055 when `dist` exists. + - name: Typecheck React package + run: bun run typecheck + working-directory: packages/react + - name: Run example tests run: bun run test working-directory: examples diff --git a/.gitmodules b/.gitmodules index a5311aa3..2856ccbf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "zed"] path = zed - url = https://github.com/remorses/zed.git - branch = gpuix + url = git@github.com:mateo-m/zed.git + branch = gpui-effect-layers diff --git a/docs/css-and-classname-plan.md b/docs/css-and-classname-plan.md new file mode 100644 index 00000000..86f393ad --- /dev/null +++ b/docs/css-and-classname-plan.md @@ -0,0 +1,988 @@ +# CSS values, the cascade, and `className` + +Status: draft, waiting for sign-off. Branch: `feat/css-values-and-classname`. + +Revision 2. An architecture review of revision 1 changed eight decisions. The section +"What revision 2 changed" lists them, with the reason for each. + +## What this adds + +GPUIX today takes a finished style object from JavaScript. Every length is a pixel number. +Every colour is a string that `parse_color` reads once. Nothing is left to work out. + +This document specifies four layers: + +0. `gpuix-css`, a crate that parses CSS values and resolves `var()`. It does not link gpui. +1. A resolved style seam in `gpuix-native`. One function turns a style plus its inherited + environment into a `gpui::StyleRefinement`, and the result is cached per element. +2. A `className` prop in `@gpuix/react`, resolved through one function and one root option. +3. `@gpuix/tailwind`, a thin adapter that turns Tailwind classes into CSS declarations. + +The goal is not "make Tailwind work". The goal is to make GPUIX understand CSS, so that +Tailwind works because it emits CSS. + +## What the code does today + +These facts come from gpuix at `9f0fb6d` and the pinned zed fork at `4d80927`. Read them +before you design against them. + +### GPUIX + +- `className` is in `RESERVED_PROPS` at `packages/react/src/reconciler/host-config.ts:134`. + The reconciler drops it without a message. +- `apply_styles` at `renderer.rs:2971-3277` is 307 lines and 52 `if let Some` branches. + It runs for every element on every frame. +- `renderer.rs:2571` already calls `apply_styles(refinement, hover_style)` on a bare + `StyleRefinement`, because `apply_styles` is generic over `E: Styled`. +- `RetainedElement` (`retained_tree.rs:13-31`) holds `style: Option` and + `subtree_revision: u64`. +- `RetainedTree::set_style` (`retained_tree.rs:170-181`) compares the old style against the new + one and marks the element changed only when they differ. +- `packages/native/src/color.rs` uses `csscolorparser 0.8.3`. Named colours, hex, `rgb()`, + `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()` and relative `from` syntax all parse. + `color-mix()` does not. +- `DimensionValue` in `style.rs` accepts a number, `"N%"` or `"auto"`, and applies to `width`, + `height` and the min and max variants only. Every other length field is a bare `f64`. +- `StyleDesc.boxShadow` holds one `BoxShadowValue`, not a list. +- `StyleDesc.background` is only a fallback colour for `backgroundColor` (`renderer.rs:3150`). +- `Inherited` (`renderer.rs:1833-1868`) carries two fields, `selectable` and `selection_wash`. +- `renderer.rs` is 3,683 lines. +- Four call sites send a style: `host-config.ts:125` (`sendStyle`), `:369` (`commitUpdate`), + `:395` (`hideInstance`), `:399` (`unhideInstance`). `setStyle` replaces, never merges. + +### GPUI, in the pinned fork + +- `Styled::style(&mut self) -> &mut StyleRefinement` (`crates/gpui/src/styled.rs:24`). +- `Style` derives `Refineable`, with `#[refineable(Debug, PartialEq, Serialize, Deserialize, + JsonSchema)]` (`crates/gpui/src/style.rs:178`). So `StyleRefinement` compares, serializes and + deserializes. +- `Refineable::Refinement` is itself `Refineable` with the same `Refinement` type + (`crates/refineable/src/refineable.rs:30`). Refinements merge into refinements. +- Every variant API takes the same type (`crates/gpui/src/elements/div.rs`): + `hover` at 806, `group_hover` at 816, `focus` at 1213, `active` at 1500, + `group_active` at 1509, `group_drag_over` at 1150, all + `impl FnOnce(StyleRefinement) -> StyleRefinement`. `group(name)` at 737. + There is no `group_focus`. +- `Style.padding` is `Edges`. `margin` and `inset` are `Edges`. + `gap` is `Size`. `flex_basis` is `Length`. `border_widths` is + `Edges`. Percentages, rems and `auto` already work in all of them. + GPUIX flattens each one to a pixel number and throws the capability away. +- `linear_gradient(angle, from, to)` takes exactly two colour stops. No radial, no conic. +- taffy is pinned `=0.13.0` and has calc. GPUI's `Length` has no calc variant, so nothing + reaches taffy's resolver. + +## What revisions 2 and 3 changed + +| # | Change | Reason | +|---|--------|--------| +| 1 | `resolve()` returns a `gpui::StyleRefinement`, cached per element. `apply_styles` is deleted. | Revision 1 stacked CSS work on a 52-branch per-frame scan, then set a 2% budget to contain it. GPUI already has the type and the merge. | +| 2 | ~~One `when` list replaces `hover`, `active`, `focus`, `groupHover`, `groupActive`, `groupFocus` and `media`.~~ **Reversed in revision 3.** `style` carries no conditions at all. | Revision 2 replaced seven fields with one list. Revision 3 removed the list. A CSS style attribute holds declarations, so a condition belongs in a class. See layer 2. | +| 3 | `gpuix-css` is its own crate and does not depend on gpui. | Revision 1 put pure value tests behind Metal, the zed submodule and a macOS runner. | +| 4 | `hideInstance` joins the call sites that funnel through one style function. | Revision 1 named three of four. The missing one is the only one that destroys state. | +| 5 | The resolver is a `createRoot` option, not a global. `invalidateClassNameCache` is gone. | One adapter is a hypothetical seam. A global also serialises tests that vitest runs in parallel. | +| 6 | Rust keeps `StyleDesc`. Every numeric field deserializes through one `Numeric` type, and colour fields stay strings. | The full move to declarations-only is real but it is not this branch. See follow-ups. | +| 7 | The steady-state gate asserts a counter, not a duration. | A 2% wall-clock band on a CI runner is noise. It would be muted, and a muted gate reads as coverage. | +| 8 | The cascade lives in `packages/native/src/cascade.rs`. | `renderer.rs` is already 3,683 lines. | + +Revision 4 widens layer 4. The goal is Tailwind in its entirety. `first:`, `last:`, `odd:`, +`even:` and `only:` become index conditions, and the walk evaluates them from the child index. +`space-x-*`, `divide-*`, `*:` and `**:` become child conditions, and they flow down with +`Inherited`. Every variant that still drops now names its prerequisite. See "What still +drops, and why". + +## Layer 0: the `gpuix-css` crate + +New crate at `packages/native/css`, named `gpuix-css`. It depends on `lightningcss` with +`default-features = false`, and on nothing else. It must not depend on gpui. + +`gpuix-native` adds it as a path dependency. `csscolorparser` is removed. + +### Why a separate crate + +`gpuix-native` links gpui, gpui_platform, gpui_macos, core-text, core-graphics, fifteen +tree-sitter grammars and the whole zed submodule. The value tests in this specification are +pure functions: a CSS string goes in, a value comes out. Inside `gpuix-native` they need a +Metal toolchain, the zed checkout and a macOS runner, and CI runs macos-latest only. + +In their own crate they run on Linux in milliseconds with `cargo test -p gpuix-css`. + +This seam is real, not hypothetical. Two adapters already sit on it: the `style` prop and +`className`. + +### Interface + +```rust +pub fn parse(property: &str, value: &str) -> Result; +pub fn substitute(unparsed: &Unparsed, vars: &Vars) -> Result; + +pub enum Parsed { + Ready(Property<'static>), // no var(), folded now + Pending(Unparsed), // contains var(), finish later +} +``` + +Three items. Everything else in the crate is private. + +### What lightningcss provides + +- `Property::parse_string(property_id, input, options)` parses a declaration into a typed value. +- `Property::Unparsed(UnparsedProperty)` holds a value that contains `var()`. + `UnparsedProperty::substitute_variables(&self, vars)` finishes it. +- `Property::Custom(CustomProperty)` holds a `--x` declaration. +- `CssColor` covers every CSS colour grammar, plus `color-mix()` and `currentColor`. + `Calc` folds during the parse. +- `MediaQuery` and `MediaCondition` parse. There is no evaluator, because lightningcss is a + compiler. Write that in `gpuix-css` and keep it pure: it takes a size, not a window. + +Tailwind v4 ships the same library. `@tailwindcss/node@4.3.3` depends on `lightningcss@1.32.0`. + +Do not gate the crate behind a cargo feature. Measure the `.node` size before and after, and +put both numbers in the pull request body. + +### What folds now, and what does not + +A value with no `var()` folds the moment it arrives, once, forever: + +``` +padding: calc(1rem + 2px) -> Ready +background-color: oklch(...) -> Ready +width: calc(1 / 2 * 100%) -> Ready, 50% +``` + +A value that mentions `var()` becomes `Pending`, because it depends on the element's ancestors. + +`calc()` that mixes a percentage with a length is rejected. Name the expression in the error: + +``` +calc(100% - 20px) -> error: mixed percentage and length in calc, not supported +``` + +taffy can do this. GPUI cannot express it without a calc variant on `Length` in the zed fork, +and every future upstream sync would carry that patch. It is a follow-up. + +### Supported functions + +Ship `calc()`, `min()`, `max()`, `clamp()`, `var()`, `color-mix()`, `linear-gradient()`. + +Leave out `radial-gradient()`, `conic-gradient()`, `env()`, `attr()`, `image-set()`. + +`linear-gradient()` maps to `gpui::linear_gradient(angle, from, to)`, which takes exactly two +stops. If a gradient declares more, keep the first and the last, drop the rest, warn once. + +### Units + +Keep `rem` symbolic, so a `rem` reaches GPUI as `AbsoluteLength::Rems`. A change to the window +rem size then reflows with no style re-resolution. + +A `calc()` that mixes `rem` with `px` must fold, so it takes the rem size as a parameter. That +is the one place `rem` stops being live. Say so in the reference documentation. + +`border-*` is `AbsoluteLength` in GPUI, so it takes `px` and `rem` and rejects percentages. +Report that as an error rather than rounding to zero. + +## Layer 1: the resolved style seam + +### The problem this replaces + +`apply_styles` is a shallow module. Its interface is 307 lines long, because you cannot know +what it does without reading all of it. It runs per element per frame, scanning 52 optional +fields that are almost always `None`. + +Revision 1 added CSS parsing, variable substitution and media evaluation on top of that loop. + +### The seam + +```rust +// packages/native/src/style/resolve.rs +pub fn resolve(style: &StyleDesc, env: &Cascade) -> StyleRefinement; +``` + +One function. It returns GPUI's own type. + +This works because `Styled::style()` returns `&mut StyleRefinement`, `Style` derives +`Refineable`, and a `Refinement` is itself `Refineable`, so refinements merge into refinements. +`renderer.rs:2571` already calls `apply_styles` on a bare `StyleRefinement`, so the pattern is +in the codebase already. It was never named. + +### The cache + +Add one field to `RetainedElement`: + +```rust +pub resolved: Option, + +pub struct Resolved { + refinement: StyleRefinement, + variants: Vec<(Condition, StyleRefinement)>, + /// The cascade generation that produced this. Compare before reuse. + generation: u64, +} +``` + +`RetainedTree::set_style` already compares the old style against the new one +(`retained_tree.rs:173`). Clear `resolved` in the branch that already exists. There is no new +invalidation point to invent. + +Per frame, an element that has not changed does: + +```rust +el.style().refine(&cached.refinement); +``` + +That is a merge of set fields. No branch scan, no parsing, no substitution. + +### Applying variants + +Every GPUI variant API takes `impl FnOnce(StyleRefinement) -> StyleRefinement`, so a cached +variant refinement applies directly: + +```rust +for (condition, refinement) in &cached.variants { + el = match condition { + Condition::Hover => el.hover(|_| refinement.clone()), + Condition::Active => el.active(|_| refinement.clone()), + Condition::Focus => el.focus(|_| refinement.clone()), + Condition::Group { name, state: GroupState::Hover } => el.group_hover(name, |_| refinement.clone()), + Condition::Group { name, state: GroupState::Active } => el.group_active(name, |_| refinement.clone()), + // A media condition is not a GPUI variant. It is evaluated during the walk. + Condition::Media { .. } => el.style().refine(refinement), + // An index condition is not one either. The walk knows the child index + // and the child count, and merges when the test holds. + Condition::Index { .. } => el.style().refine(refinement), + }; +} +``` + +Use GPUI's plain `focus`, not `focus_visible` or `in_focus`. Tailwind's `focus:` is the +unqualified one. + +A media condition is not a GPUI variant. Evaluate it against the window size during the walk +and merge the refinement when it matches. + +An index condition works the same way. The retained tree stores children in order, and the +walk visits each child with its index and the child count. `first`, `last`, `odd`, `even` and +`only` are tests on those two numbers, so they need no selector engine. A list mutation +changes the numbers, and the next frame re-evaluates them, the same way a resize re-evaluates +a media condition. + +### Delete `apply_styles` + +The 307-line function becomes the private body of `resolve`, converted to write into a +`StyleRefinement` rather than to chain builder calls on `E: Styled`. Nothing else calls it. + +Note the counterintuitive result: the body does not shrink much at first. The win is that it +runs once per style change instead of once per element per frame, that its output is a value +you can compare and serialize, and that the cascade and the variants get a return type. + +## Layer 1b: the cascade + +New module: `packages/native/src/cascade.rs`. `Inherited` moves here from `renderer.rs:1833`. + +```rust +impl Cascade { + pub fn root(theme: &Theme, window: Size) -> Self; + pub fn descend(&self, style: Option<&StyleDesc>) -> Self; + pub fn resolve(&self, style: &StyleDesc) -> Resolved; +} +``` + +Three methods hide inheritance, the variable map and condition evaluation. The tree walk in +`renderer.rs` calls `descend` going down and `resolve` at each node, and learns nothing about +any of it. + +### Inheritance + +**GPUI already does this, and revision 2 said the opposite.** Revision 2 claimed a `color` on a +div does not reach a nested ``, called the fix a visible behaviour change, and asked for a +changeset naming it. That is wrong. It was written from reading `apply_styles`, which has no +inheritance in it, without checking what GPUI does underneath. + +A `div` pushes its text style onto a window stack at `div.rs:1840`, and `window.text_style()` +composes the whole stack. A `` with no style of its own paints with the nearest ancestor +declaration. `SelectableText` already documents its dependence on this at +`packages/native/src/text/paint.rs:155`. + +Measured, not reasoned. Each row declares the property on the ancestor, on the text itself, and +nowhere, then compares the three screenshots byte for byte. + +| Property | Ancestor against nothing | Ancestor against text | +| --- | --- | --- | +| `color` | 0.02 | identical | +| `fontSize` | 0.01 | identical | +| `fontWeight` | 0.01 | identical | +| `fontFamily` | 0.43 | identical | +| `lineHeight` | 0.86 | identical | +| `textAlign` | 0.48 | identical | + +Lower means more different. The left column shows the declaration does something. The right +column shows the ancestor and the text produce the same pixels, which is inheritance. + +`packages/react/src/__tests__/inheritance.test.tsx` pins all six, plus a nested case where the +nearer ancestor wins. The behaviour comes from the pinned fork rather than from this repository, +so a fork bump could remove it silently. That is what the test is for. + +So there is no inheritance work for text properties, no behaviour change, and nothing for a +changeset to name. What is left of the original list: + +- `userSelect` and `selectionColor` already inherit through `Cascade`, which is the old + `Inherited` struct moved out of `renderer.rs`. +- `cursor` does not inherit. It is not in GPUI's `TextStyle`, and a screenshot cannot see a + cursor, so this needs a different test before it is worth building. Left out of this branch. +- Custom properties are the real work, and the rest of this section covers them. + +A note on why the earlier claim survived review: every reviewer, including this one, read the +GPUIX code and stopped there. The behaviour lives one layer down. + +### Custom properties + +**Built. What follows is what shipped, not a proposal.** + +Declare them in `style`, exactly as on the web: + +```tsx +
+
+
+``` + +Type them with a template literal pattern index signature: + +```ts +export interface StyleDesc { + [key: `--${string}`]: string | number | undefined + // ...the existing keys +} +``` + +Verified with `tsc --strict`: `"-pad"` is rejected because it does not match the pattern, and +`color: 42` stays an error. React's own `CSSProperties` uses an open index signature, which +lets every typo through. + +Resolution runs in three steps. + +1. Serde collects every key that is not a known field into `StyleDesc.custom` through + `#[serde(flatten)]`. `declared_variables` keeps the `--` names and sorts them. +2. During the walk, `descend` layers the node's variables over the inherited map. It runs + before the node's own style resolves, so a declaration is in scope for the `var()` beside it. +3. `Scope::value` substitutes textually, and the existing value parsers read the result as if + the author had written it in place. + +Substitution is textual and does not go through `gpuix-css`. The plan routed it through typed +`Property` values, which would mean parsing and re-emitting every inline style. Substituting +text keeps `StyleDesc` typed, keeps `gpuix-css` off the per-element path, and matches what CSS +says a custom property is: text, held uninterpreted until a property reads it. + +Three rules keep this fast. + +The variable map is shared by pointer. A node that declares no variables passes the parent's map +down unchanged, and a node that redeclares the value it already has keeps the same pointer too. + +`Resolved.cascade` holds the cascade a resolution read, or `None` when it read nothing +inherited. `None` is the common case, and a resolution marked `None` survives every cascade +change. Only an element that used `var()` or `currentColor` is ever invalidated by an ancestor. +The key is the whole `Cascade` rather than the variable map alone, so an ancestor changing +`userSelect` also invalidates a `var()` reader below it. That is one pointer to compare instead +of two, and both changes are rare. + +`descend` is memoized per element on the parent cascade pointer, and the root cascade is +memoized on the theme. Without both, a declaration would build a new `Arc` on every frame and +the whole subtree below it would re-resolve on every frame, which is exactly what the cache +exists to stop. `packages/react/src/__tests__/custom-properties.test.tsx` pins this with a +counter: ten frames over twenty readers under one declaration must add zero resolutions. + +While wiring this, a second cache thrash turned up and is fixed. `sendStyle` skips the napi call +for an empty style at mount, but `commitUpdate` always sends `{}`. So the first update on every +element with no `style` prop read as a change and resolved a style with nothing in it. +`set_style` now stores `None` for a style that declares nothing, which makes the two paths agree. + +Order inside one element does not matter. `var()` resolves against the element's final set of +custom properties, not against the position of the declaration. Tailwind depends on this: +`text-sm` emits `line-height: var(--tw-leading, var(--text-sm--line-height))` while `leading-6` +emits `--tw-leading`, and both land on the same element in either order. + +`var()` supports a fallback, including an empty one. Tailwind emits `var(--tw-ring-inset,)`. + +### currentColor + +**Built.** `Cascade` tracks the computed `color` and `Scope::color` resolves the keyword against +it. Tailwind needs this because `ring-*` emits `var(--tw-ring-color, currentcolor)`. + +The root starts at `gpui::black()`, which is what `TextStyle::default` uses, so the cascade's +copy of the colour and GPUI's own text style stack agree without setting a colour on the root +wrapper. Setting one would change how every unstyled `` paints, and that is a separate +decision. + +Two limits. Only a bare `currentColor` resolves, so one nested inside `color-mix()` falls +through to the colour parser and fails there. And `color: currentColor` declares nothing, +because CSS computes it to `inherit`. + +### Window media queries + +**Not built.** Nothing emits a media condition until `className` lands in layer 3, and building +an evaluator with no producer would repeat the mistake that `group` was. The design below stands. + +`gpuix-css` parses the condition. `Cascade` holds the window size and evaluates it. + +On a resize, bump the cascade generation and re-resolve. This is the same invalidation the +variable cascade already needs, so it costs no new machinery. + +Treat `@media (hover: hover)` as always true. Tailwind wraps every `hover:` utility in it, and +a desktop window always has a pointer. + +Container queries stay out of this branch. A container query makes style depend on layout and +layout depend on style, and terminating that loop needs real containment rules. + +## Layer 2: `StyleDesc` changes + +### Numeric fields take text + +**Built.** Every numeric field on `StyleDesc` was `Option`, which cannot hold +`var(--pad)`. All 36 now deserialize through one `Numeric` type: + +```rust +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum Numeric { + Number(f64), + Text(String), +} +``` + +In TypeScript that is `number | string`. A bare number still means pixels, so `8`, `"8px"` and +`"var(--pad)"` all declare the same padding. `Scope::length` hands the text to +`gpuix_css::length`, which reads a number, a `px` or `rem` length, a percentage, and any +`calc()`, `min()`, `max()` or `clamp()` over them. A unit it cannot fold drops the declaration, +because painting 2 pixels for `2vw` is worse than painting nothing. The unitless fields such as +`opacity` and `flexGrow` widened too, since `var()` is legal in any property and a field left +as `f64` would reject it. + +### Lengths, `calc()` and `rem` + +**Built.** `packages/native/css/src/length.rs` reads one length and returns pixels, a fraction +or a bare number. lightningcss does the parsing, which means `calc()`, `min()`, `max()` and +`clamp()` fold while the value parses, with no evaluator of ours in the middle. Two shapes need +handling before the handoff. + +A bare `1.5` reads as `1.5px` in lightningcss, because CSS quirks mode says so. A number is +read first and never reaches the parser. + +`rem` is rewritten to pixels before parsing, against the window rem size. lightningcss holds +`rem` as a relative length and will not add it to a `px`, so `calc(1rem + 4px)` would come back +unfolded. This deviates from what layer 3 planned, which was to keep `rem` symbolic all the way +to `AbsoluteLength::Rems` so a rem size change reflowed with no re-resolution. Folding early +costs that: the root cascade keys on the window rem size, so a `set_rem_size` call re-resolves +every style that reads a rem. Nothing in GPUIX calls it today. + +### A `lineHeight` string is a multiple, a number is pixels + +**Built.** A JS number keeps the old GPUIX meaning, so `lineHeight: 20` is 20 px, as in React +Native. A string follows CSS: a bare number in a string is a multiple of the font size and +reaches GPUI as `gpui::relative(n)`. A percentage is the same multiple. A length keeps its +unit. Zero or less declares nothing. + +`packages/react/src/__tests__/css-lengths.test.tsx` pins all of these against a wrapped +paragraph, with a differ check first, so a line height that quietly did nothing would fail +rather than pass. + +### Reading a style without buffering it + +**Built.** `Numeric` and `FontWeightValue` were `#[serde(untagged)]` and `StyleDesc` was +`#[serde(flatten)]`. Each of those makes serde read the whole value into an intermediate tree +before it looks at one field, and every `setStyle` paid for it. All three now have a hand +written `Deserialize`. A `style_desc!` macro declares `StyleDesc` and its reader from one field +list, so the name JS writes and the name Rust reads come from the same literal. The wire format +did not change, and one test reads what `Serialize` writes against the names the reader knows, +which fails if the two halves ever disagree. + +That left the cost of the struct itself. `StyleDesc` is 1,728 bytes, so the parse spent more +time moving it than reading it. `StyleDesc::from_json_boxed` writes into a `Box` from the +start, through the same `fill` the ordinary `Deserialize` uses, so the two cannot disagree. +Measured over 200,000 parses: + +| shape | before | hand written | into a box | no flatten, no untagged | +| --- | --- | --- | --- | --- | +| two fields | 320 ns | 178 ns | 84 ns | 74 ns | +| eleven fields | 531 ns | 466 ns | 399 ns | 341 ns | + +A `RetainedElement` holds that box rather than the struct, which takes it from 2,000 bytes to +280. A tree of 10,000 elements was carrying 17 MB of styles that were mostly empty. `BatchOp` +shrank the same way, since one `SetStyle` variant made every op in a batch as wide as a style. + +### `style` holds declarations, never selectors + +Revision 2 proposed a `when` list on `StyleDesc`, holding conditions such as hover, active, +group and media, plus a `group` field to name an ancestor. Revision 3 removes both. They were +built, tested and then deleted. + +[CSS Style Attributes][css-style-attr] defines the attribute value as + +> the syntax of the contents of a CSS declaration block (excluding the delimiting braces) + +and nothing else. It cannot express `:hover`, it cannot express +`.card:hover &`, and it cannot express `@media`. Every condition in CSS comes from a selector or +an at-rule, and both of those live in a stylesheet reached through a class. GPUIX must behave +the same way, so `style` carries no conditions and every condition arrives through `className`. + +The `group` field was the clearer mistake. It copied Tailwind's model, where `group` is a class +name rather than a property, and it duplicated `className` before `className` shipped. Naming an +ancestor is what a class already does. + +`hover` and `active` stay on `StyleDesc` because they predate this plan and removing them now +would leave no way to express a hover until layer 3 lands. They are the two exceptions, they get +no siblings, and they are candidates for removal in the release that ships `className`. That +removal is a breaking change and needs its own sign-off. + +### What GPUI actually models as style + +Worth recording, because it took a trait-by-trait read of the pinned fork to establish and it +decides where conditions belong. + +| Method | Trait | +| --- | --- | +| `group`, `hover`, `focus`, `in_focus`, `group_hover` | `InteractiveElement` | +| `active`, `group_active` | `StatefulInteractiveElement` | +| every layout, colour and text setter | `Styled` | + +Not one conditional method is on `Styled`, and `Styled::style()` returns only the base +refinement. GPUI does not treat a condition as a style property either. It treats it as +interactivity. The existing `style.hover` already conflated the two before this plan started. + +Three more facts from the same read, so layer 3 does not rediscover them: + +- `hover()` holds `debug_assert!(hover_style.is_none(), "hover style already set")`. Two rules + that resolve to the same condition on one element must merge into a single call, or a debug + build panics. +- There is no `group_focus`. Tailwind's `group-focus:` has nothing to map onto. +- `focus()` needs `track_focus` and a focus handle. A plain `div` owns neither, so `:focus` + needs focus-handle plumbing before it can work. + +### Resolved conditions + +`Resolved` currently holds `base`, `hover` and `active`. Layer 3 replaces those two fields with +`Vec<(Condition, StyleRefinement)>`, because a stylesheet can produce any number of conditions +and can produce the same one twice. `Condition` is an internal type built by the selector +parser. It never crosses the FFI boundary, so it needs no serde and no unknown-kind variant. + +## Layer 3: the `className` seam + +### The prop + +**Built.** `className?: string` on the shared `Props` base. Both `jsx-runtime.d.ts` and +`jsx-dev-runtime.d.ts` map every intrinsic element to `Props`, so they needed no edit. + +`string | undefined` is the whole type, so `clsx` and `cn` work with no special handling. + +### What a resolver returns + +**Built, and different from what this section first planned.** The plan had the resolver +return declarations, `Array<{ on: Condition | null; declarations: Array<[string, string]> }>`. +It returns a `StyleDesc` instead: + +```ts +export type ClassNameResolver = (token: string) => StyleDesc | null +``` + +Declarations would have put a CSS property name to `StyleDesc` key table in TypeScript, next to +the one Rust already has, and the two would drift. `StyleDesc` already carries `hover` and +`active`, so the shape loses nothing that `setStyle` can carry today. The adapter owns every +piece of CSS knowledge and layer 3 is a merge with no table in it. + +The cost is that only `hover` and `active` are reachable from a class. Focus, group and media +conditions need the resolved-style seam of layer 1 rather than `setStyle`, which takes a +`StyleDesc`. Layer 4 warns and drops them, as it already does for `group-focus:`. + +### One function, four call sites + +```ts +function computeStyle(props: Props, container: Container): StyleDesc +``` + +**Built.** All four call sites use it: `sendStyle`, `commitUpdate`, `hideInstance` and +`unhideInstance`. + +`hideInstance` is the one revision 1 missed, and it is the only one that destroys state. + +It used to send `{ visibility: "hidden" }` over the inline style, and `setStyle` replaces +rather than merges. That round-tripped only because `props.style` was the sole source of truth. +With `className` it is not, so hiding an element would discard every class-derived style and +`unhideInstance` would restore only the inline prop. React drives that pair for Suspense, so +the symptom is content that unstyles itself after it suspends. + +`hideInstance` now sends `computeStyle(props, container)` with `visibility` overridden, and +with `hover` and `active` dropped, or a hover style that sets `visibility` would paint an +element React asked to hide. `host-config-style.test.tsx` pins both. + +### Registering a resolver + +**Built.** The resolver is an option on the root, not a global: + +```ts +createRoot(renderer, { resolveClassName }) +``` + +`createTestRoot` takes the same options, so a test registers a resolver over a fixed table. + +One adapter means a hypothetical seam, and this specification plans exactly one, Tailwind v4. +It already puts v3 behind a different seam, `TailwindEngine`. A global buys nothing here. + +A global costs something. Global mutable state plus a global cache means two tests with +different `appearance` settings cannot run at once, and vitest runs files concurrently, and +`createTestRoot()` drives a real renderer. + +There is no precedent for a global setter in this package. `packages/react/src/index.ts` +exports `createRenderer`, `render` and `resetRender`, and `src` contains no global setter at all. + +If no resolver is set and an element has a `className`, do nothing and print one development +warning. Never throw. + +`invalidateClassNameCache()` does not exist. The cache's lifetime is the root's. Exporting a +manual invalidation would make the caller responsible for knowing when the cache is stale, +which is knowledge the module owns. + +### Precedence + +**Built.** [CSS Style Attributes][css-style-attr] settles this, and it is stricter than it first looks: + +> These declarations are considered to have author origin and a specificity higher than any +> selector. + +So `style` beats `className` always, key by key, and it beats a conditional rule too. Given +`style={{ backgroundColor: "red" }}` and a class that sets `background-color` on hover, the +element stays red while hovered. A browser behaves the same way, and only `!important` changes +it. GPUIX has no `!important`. + +That is a constraint on the resolver, not a note. A condition resolved from `className` must not +write a key the `style` prop already set, or the element will change colour on hover where CSS +says it must not. + +`motion` keeps overwriting its eight numeric keys every frame, ahead of both. + +A conflict inside one class string is the adapter's problem. There is no specificity and no +selector engine among classes. That rule is flat: last write wins. + +[css-style-attr]: https://www.w3.org/TR/css-style-attr/#cascading + +### The cache + +**Built.** Cache one class token, not one class string. + +`clsx("p-4", isActive && "bg-blue-500", isLarge && "text-lg")` produces up to eight strings from +three tokens. Five toggles produce thirty-two. A token cache stores the tokens. + +A cached token holds the `StyleDesc` the resolver returned, or `null` for a token it rejected, +so an unknown class is asked about once. + +A bounded cache over whole strings sits in front, 256 entries, least recently used out first. +It matters because the same string usually repeats between two frames, and then neither the +split nor the merge runs. The token cache under it is unbounded, because the set of tokens an +application uses is fixed by its source code while the set of strings grows with every +combination of conditional classes. Both live on the root. + +One test drives five class strings built from three tokens and asserts the resolver saw exactly +those three. + +## Layer 4: `@gpuix/tailwind` + +### Version and modularity + +Target Tailwind v4 only. npm `latest` is `4.3.3`. v3 lives on as `v3-lts` at `3.4.19` and needs +entirely different code, because v4 has no JavaScript config. + +Build the seam anyway: + +```ts +interface TailwindEngine { + resolve(classes: string[]): CachedToken[] +} +``` + +Default it to `@gpuix/tailwind/v4`. Put the v4 code behind the interface from the first commit, +so adding v3 later is a new file rather than a refactor. + +### Loading + +```ts +const resolveClassName = await createTailwindResolver({ css: "./src/app.css" }) +createRoot(container, { resolveClassName }) +``` + +`__unstable__loadDesignSystem` is async, but resolution during a commit is synchronous, so the +application awaits the resolver before it mounts. A lazy resolver would render the first frame +unstyled. + +Options accept `{ css: path }` or `{ source: "...css text..." }`. The path form reads the file +and resolves `@import` through the filesystem, which works under `bun --hot`. + +A packaged application has no `node_modules`, so the path form will not work there. That is a +named follow-up. + +### Resolving a class + +``` +class string -> ds.getClassOrder() -> ds.candidatesToAst() -> walk -> declarations +``` + +`getClassOrder` gives Tailwind's real precedence, which sorts by property rather than by source +order, and matches what a browser produces. Apply in that order, last write wins. + +A user who wants `tailwind-merge` semantics runs `twMerge()` on the string first. The adapter +does not depend on it. + +An unknown class returns an empty array from `candidatesToAst`. That is the support test. + +Send `var()` through untouched. Rust owns the cascade. A value the adapter pre-flattened would +not respond to an ancestor that overrides the variable. + +### Harvesting `@property` + +The AST contains `@property` rules with `initial-value`. Collect them into the element's +variable defaults before the class declarations apply. + +This is not optional. A bare `border` emits `border-style: var(--tw-border-style)` and the value +lives only in `@property --tw-border-style { initial-value: solid }`. Without the harvest, +`border`, `shadow-*`, `ring-*` and `space-*` all resolve to nothing. + +### Variant mapping + +Every variant becomes a `Condition`: + +| Tailwind | `Condition` | +|----------------------|----------------------------------------------------| +| `hover:` | `{ kind: "hover" }` | +| `active:` | `{ kind: "active" }` | +| `focus:` | `{ kind: "focus" }` | +| `group-hover/name:` | `{ kind: "group", name, state: "hover" }` | +| `group-active/name:` | `{ kind: "group", name, state: "active" }` | +| `sm: md: lg:` | `{ kind: "media", query }` | +| `max-lg: min-lg:` | `{ kind: "media", query }` | +| `first:` `last:` `only:` | `{ kind: "index", test }` | +| `odd:` `even:` | `{ kind: "index", test }` | + +An unnamed group uses `""`. + +Flatten `dark:` at resolve time from an `appearance: "dark" | "light"` option. Key the cache by +appearance and clear it when the value flips. + +### Child conditions + +`space-x-*`, `divide-*` and the `*:` variant compile to a selector on the children. +`space-x-*` and `divide-*` produce `:where(& > :not(:last-child))`, and `*:` produces +`& > *`. The class sits on the parent, and the declarations apply to the children. + +The rules cross the FFI in one wire field. `StyleDesc` gains `selectors`, a list of +`{ on, style }` pairs, and the resolver is its only writer. The `style` prop type excludes +it. The spellings form a closed set: `:first-child`, `:last-child`, `:nth-child(odd)`, +`:nth-child(even)`, `:only-child`, `& > *`, `& > :not(:last-child)` and `& *`. An unknown +spelling warns once and drops. + +The rules for the children ride in the walk context (`BuildCtx`), not in `Inherited`. +`Inherited` keys the resolution cache by pointer, so a refinement that changes per parent +would clear the cache of every child on every frame. The walk context costs nothing there, +because the rules apply at paint and never touch a cached resolution. Direct rules reach +one level and swap out at each depth. Descendant rules stack for the whole subtree. A rule +applies before the child's own declarations. `:where()` has specificity zero, so the +child's own declarations must win, and this order gives exactly that. + +Two places sit outside the walk. A virtual-list row builds on its own, so it has no child +position and the index conditions do not apply to it. A custom element resolves its own +`StyleDesc`, so the rules of a parent stop at its border. + +### What still drops, and why + +The target is the whole of Tailwind, because the goal of this plan is CSS, and Tailwind emits +CSS. A variant the plan cannot build yet gets a named follow-up with its prerequisite, never a +permanent drop. Until its follow-up ships, the resolver warns once and drops: `group-focus:`, +`peer-*`, `has-*`, `motion-safe:`, `print:`. + +| Variant | Prerequisite | +| --- | --- | +| `group-focus:` | GPUI has `group_hover` (`div.rs:816`) and `group_active` (`div.rs:1509`) but no `group_focus`. Track focus per group in GPUIX, or add the method upstream. | +| `peer-*` | The hover or focus state of an earlier sibling. The walk visits siblings in order, so it can carry the state of the peers it already passed. It needs the same state store as `group-focus:`. | +| `has-*` | The state of a descendant, which the walk has not reached yet. Read the state of the last frame, one frame late. A browser pays a comparable invalidation pass. | +| `motion-safe:` | The OS reduce-motion setting. GPUIX does not read it today. | +| `print:` | A print target. GPUIX does not print. | + +`group-hover` is Tailwind's spelling, but the meaning is plain CSS: an ancestor in `:hover` plus +a descendant combinator. GPUI has `group(name)` and `group_hover(name, f)` natively. The +`Condition` names the CSS idea, not the Tailwind one. + +### Reporting unsupported input + +One option, `tolerance: "warn" | "error"`, default `"warn"`. `error` throws at resolve time. + +The resolver always exposes `getUnsupported()`, whatever the tolerance is. It returns both +unknown classes and declarations that no GPUI style can hold, so a test asserts on one thing. + +## Deviations from CSS + +List these in the package README as well. + +- No specificity and no selector engine. Precedence is flat and last write wins. The + structural pseudo-classes do not need one. The walk reads the child index. +- `calc()` cannot mix a percentage with a length. +- A gradient has at most two colour stops. No radial or conic gradients. +- No `radial-gradient()`, `conic-gradient()`, `env()`, `attr()` or `image-set()`. +- No container queries. +- No transforms, transitions or CSS animations. Use `motion` instead. +- No `text-decoration`, no `z-index`. +- `border` sets `border-width` only. There is no border style beyond a solid fill. +- Percentage padding, margin and inset work. Percentage border width does not. +- `peer-*` and `has-*` wait on sibling and descendant state. See "What still drops, and why". +- Variant nesting is one level deep. +- `style` keeps `hover` and `active`, which a CSS style attribute cannot express. They predate + this plan. They gain no siblings, and they are candidates for removal in the release that + ships `className`. +- `group-focus` has no GPUI equivalent. `group-hover` and `group-active` do. + +## Tests + +Five tiers. The first three need no GPU. + +### `gpuix-css` value tables + +`cargo test -p gpuix-css`, on any platform, in milliseconds. Table-driven: a CSS value string +in, a `Parsed` out. Cover `calc`, every unit, `color-mix`, gradients, `var()` fallbacks +including the empty one, media conditions, and malformed input. + +Port the existing `color.rs` tests unchanged. They already cover named colours, every colour +function family and relative syntax, so they become the proof that moving from `csscolorparser` +to `lightningcss` changed nothing. + +### Resolution snapshots + +`StyleRefinement` derives `Serialize` and `PartialEq` (`style.rs:178`), so a resolution test is +a value comparison with no window, no view and no GPU: + +```rust +assert_eq!(resolve(&style, &cascade), expected); +``` + +Snapshot the serialized refinement for the wider cases. This is the tier that revision 1 could +not have, because `apply_styles` had no return value. + +Cover: inheritance through three levels, a variable overridden in a subtree, a variable with an +empty fallback, a media condition on both sides of its boundary, and every `Condition` variant. + +### The reconciler + +`computeStyle` is a pure function, so test it directly. Cover the hide and unhide round trip +named in layer 3, className and style precedence, and the token cache under `clsx`-style input. + +### Adapter snapshots and the coverage floor + +Fixture files of class strings by category in `packages/tailwind/test/fixtures/`. Snapshot the +resolver's output, and print the matching `ds.candidatesToCss()` output inside the same snapshot +so a reviewer sees the CSS the mapping came from without running anything. + +Hand-write edge cases: negative values, opacity modifiers, condition merging, `@theme` overrides, +and `text-sm` with `leading-6` in both orders. + +Then walk all 23,337 entries of `ds.getClassList()`, resolve each one, and assert that the +supported fraction never drops below a number committed in the repository. On failure, print +every class that became unsupported. + +This is the test that makes the suite hard to fake. A change that quietly breaks a utility +family fails here even when no snapshot covers it. + +### Pixels + +`comparePixels` pairs through `createTestRoot()`. Cover: a `className` and the equivalent +`style` render the same pixels, an inherited `color` reaches a nested ``, a variable +override changes only its subtree, and a resize crosses a media query boundary. + +## Performance gates + +The reason to use GPUI instead of a browser is speed. A change that makes GPUIX slower has +failed, whatever else it does. + +### Gate on counters, not on durations + +The cache design makes a falsifiable claim: on a frame where nothing changed, zero styles are +resolved. Count the calls and assert the count. + +```rust +assert_eq!(stats.style_resolutions, 0); // steady state +assert_eq!(stats.style_resolutions, 1); // after one setStyle +assert_eq!(stats.style_resolutions, 412); // subtree under a changed variable +``` + +This is deterministic, it runs anywhere, and it fails for the exact reason a wall-clock gate +would be reaching for. It also names which elements re-resolved, which a timing number never +does. + +A 2% wall-clock band on a shared macOS runner is noise. Thermal state and the GPU scheduler move +frame time more than that. Such a gate gets muted, and a muted gate reads as coverage. + +The counter mechanism is half built. `renderer.rs:1085` and `:1105` already expose +`reset_debug_frame_overlay_stats` and `get_debug_frame_overlay_stats`, and there is a commit +titled "Add a chat performance regression test and overlay draw stats." Add one field. + +### Gated + +| Gate | Threshold | +|------|-----------| +| `style_resolutions` per steady-state frame, 10k elements with `var()` styles | exactly 0 | +| `style_resolutions` after one `setStyle` | exactly 1 | +| `style_resolutions` after one root variable change | exactly the subtree size | +| Mount, the existing 10k-row benchmark | within 10% of the baseline | + +### Reported, not gated + +Steady-state frame time, theme-change time and resize time. Record the baseline on `main` +before the first commit, and put both numbers in every pull request body. Read them. Do not +fail the build on them. + +## Follow-ups + +Write these into the specification. Do not build them now. + +- **Declarations as the only input to Rust.** Once Rust owns the mapping, `StyleDesc` in Rust is + a second hand-maintained mirror of CSS. Deleting it and sending declarations for the `style` + prop as well would concentrate the mapping in one place. It is staged out of this branch for + two reasons: the typed serde path is measurably cheaper than parsing CSS text for inline + styles that carry dynamic numbers, and the migration touches every element type. Revisit once + the counters from the performance section exist to measure it honestly. +- **`!important`.** Layer 3 gives `style` a specificity higher than any selector, per + [CSS Style Attributes][css-style-attr]. That leaves a class with no way to override an inline + declaration, because in the cascade an important author declaration is the only thing that + outranks a normal inline one, and an important inline declaration outranks that in turn. This + is not hypothetical: Tailwind's `bg-red-500!` compiles to `!important`, so every such utility + is silently lost against any `style` prop touching the same key. Needs an importance flag on + each declaration and four cascade levels rather than two. Decide before layer 4 ships, because + adding importance later changes which declaration wins and is therefore breaking. +- **Conditions past hover and active.** A resolver returns a `StyleDesc`, which carries those + two and nothing else, so focus, group and media conditions cannot reach an element from a + class. Reaching them means a second napi call carrying the layer 1 `Resolved` shape rather + than a `StyleDesc`. Layer 4 warns and drops them until then. +- Container queries. Needs containment rules to terminate the layout and style loop. +- Layout-time `calc()`. Needs a calc variant on GPUI's `Length` in the zed fork, wired to + taffy's `calc_resolver`. +- Theme resolution for a packaged application with no `node_modules`. +- A Tailwind v3 engine behind the existing interface. +- `group_focus` in GPUI, which would make Tailwind's `group-focus:` resolvable. +- Radial and conic gradients, and gradients with more than two stops. Needs upstream GPUI work. +- Splitting `renderer.rs`. This branch removes the cascade and style application from it. The + remaining 3,000 lines still hold the napi surface, window setup, the GPUI view, virtual lists, + element builders, events and batch parsing. That split is its own piece of work. + +## Delivery + +One branch, `feat/css-values-and-classname`, for the prototype. Split it into sequenced pull +requests once the gates pass. + +Land in this order. The first two carry no new dependency and no behaviour change, so they can +merge to `main` on their own even if the rest slips. + +1. **The resolved style seam.** `resolve()` returns a `StyleRefinement`, cached on + `RetainedElement`, invalidated where `set_style` already compares. Delete `apply_styles`. + Add the `style_resolutions` counter. No new dependency. +2. **The `hideInstance` fix.** Failing test first. It is a real bug today, waiting only for a + second source of style. +3. **The `gpuix-css` crate.** Split it before writing it. Retrofitting a crate seam is the + expensive version. +4. **The `StyleDesc` widening.** Done, minus the conditions. `style` carries declarations only, + so `hover` and `active` gain no siblings and every other condition waits for layer 3. +5. **The cascade.** `cascade.rs`, inheritance, custom properties, media queries. +6. **The `className` seam.** +7. **`@gpuix/tailwind`.** + +Add a changeset. Never edit `CHANGELOG.md` by hand. Never publish locally. diff --git a/examples/chat.test.tsx b/examples/chat.test.tsx index 773028d5..be5ee508 100644 --- a/examples/chat.test.tsx +++ b/examples/chat.test.tsx @@ -166,9 +166,11 @@ describeNative('chat example', () => { const transcript = renderer.findByType('virtual-list')[0] expect(transcript).toBeDefined() + // Every row declares `width: "100%"`, and the retained style now reports + // back what was written rather than a number it read the percentage as. expect( transcript.children.map((id) => renderer.getElement(id)?.style.width) - ).toEqual(Array(transcript.children.length).fill(1)) + ).toEqual(Array(transcript.children.length).fill('100%')) const painted = renderer.getPaintedText() diff --git a/examples/compile-chat.ts b/examples/compile.ts similarity index 54% rename from examples/compile-chat.ts rename to examples/compile.ts index e1f347bd..b1aeb075 100644 --- a/examples/compile-chat.ts +++ b/examples/compile.ts @@ -1,25 +1,53 @@ /** - * Compile the GPUIX chat example into a standalone Bun binary. - * On macOS also wraps it in a .app so Finder and Dock can show a custom icon. + * Compile one example into a standalone Bun binary. + * + * The binary holds the script, the Bun runtime and the native `.node`, so it + * runs on a machine with nothing installed. On macOS it is wrapped in a `.app` + * so Finder and the Dock can show it. + * + * bun compile.ts chat.tsx --name "GPUIX Chat" --id dev.gpuix.chat --icon assets/icons/openai-mark.svg --tint "#10a37f" + * bun compile.ts demo.tsx --name "GPUIX Demo" --id dev.gpuix.demo + * + * The icon needs `rsvg-convert` (librsvg) and, on Windows, `magick`. When + * either is missing the icon is skipped and the app takes the system default. * * CI sets COMPILE_OUT, COMPILE_TARGET, COMPILE_SKIP_ICONS, COMPILE_SKIP_APP. */ import { spawnSync } from 'node:child_process' import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' +import { parseArgs } from 'node:util' import { fileURLToPath } from 'node:url' const ROOT = path.dirname(fileURLToPath(import.meta.url)) -const DIST = path.join(ROOT, 'dist') -const SVG = path.join(ROOT, 'assets', 'icons', 'openai-mark.svg') + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + name: { type: 'string' }, + id: { type: 'string' }, + icon: { type: 'string' }, + tint: { type: 'string', default: '#10a37f' }, + }, +}) + +const entry = positionals[0] +if (!entry || !values.name || !values.id) { + console.error('usage: bun compile.ts --name --id [--icon ] [--tint ]') + process.exit(2) +} + +const ENTRY = path.resolve(ROOT, entry) +const SLUG = path.basename(entry, path.extname(entry)) +const APP_NAME = values.name +const BUNDLE_ID = values.id +const DIST = path.join(ROOT, 'dist', SLUG) const PNG = path.join(DIST, 'app-icon.png') const ICO = path.join(DIST, 'app-icon.ico') const ICNS = path.join(DIST, 'app-icon.icns') const COMPILE_TARGET = process.env.COMPILE_TARGET -const WINDOWS = - process.platform === 'win32' || (COMPILE_TARGET ?? '').includes('windows') +const WINDOWS = process.platform === 'win32' || (COMPILE_TARGET ?? '').includes('windows') const BINARY = path.join(DIST, outputName()) -const APP_NAME = 'GPUIX Chat' const APP_BUNDLE = path.join(DIST, `${APP_NAME}.app`) function outputName(): string { @@ -27,28 +55,16 @@ function outputName(): string { if (requested) { return WINDOWS && !requested.endsWith('.exe') ? `${requested}.exe` : requested } - return WINDOWS ? 'chat.exe' : 'chat' + return WINDOWS ? `${SLUG}.exe` : SLUG } function log(message: string): void { - console.log(`[compile-chat] ${message}`) -} - -function hasCommand(command: string): boolean { - const result = spawnSync(command, ['--version'], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }) - return result.status === 0 + console.log(`[compile] ${message}`) } -function run(command: string, args: string[], opts: { cwd?: string } = {}): void { +function run(command: string, args: string[]): void { log(`run: ${command} ${args.join(' ')}`) - const result = spawnSync(command, args, { - cwd: opts.cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - }) + const result = spawnSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) const stdout = result.stdout.trim() const stderr = result.stderr.trim() if (stdout && command !== 'sips') console.log(stdout) @@ -58,41 +74,36 @@ function run(command: string, args: string[], opts: { cwd?: string } = {}): void } } -async function buildIcons(): Promise { - if (process.env.COMPILE_SKIP_ICONS === '1') { - log('skipping icons') - return - } - if (!hasCommand('rsvg-convert') || !hasCommand('magick')) { - log('rsvg-convert or magick missing, skipping icons') - return - } +/// Whether the icon can be built here. Returns the reason when it cannot. +function iconBlocker(): string | null { + if (process.env.COMPILE_SKIP_ICONS === '1') return 'COMPILE_SKIP_ICONS is set' + if (!values.icon) return 'no --icon given' + if (!Bun.which('rsvg-convert')) return 'rsvg-convert is not installed' + if (process.platform === 'win32' && !Bun.which('magick')) return 'magick is not installed' + return null +} - log(`building icons from ${path.relative(ROOT, SVG)}`) - const svg = (await Bun.file(SVG).text()).replace( - 'fill="currentColor"', - 'fill="#ffffff"', - ) +async function buildIcons(): Promise { + const blocker = iconBlocker() + if (blocker) { + log(`skipping the icon: ${blocker}`) + return false + } + const source = path.resolve(ROOT, values.icon!) + log(`building icons from ${path.relative(ROOT, source)}`) + const svg = (await Bun.file(source).text()).replace('fill="currentColor"', 'fill="#ffffff"') const whiteSvg = path.join(DIST, 'app-icon.svg') await Bun.write(whiteSvg, svg) - run('rsvg-convert', [ - '-w', - '1024', - '-h', - '1024', - '--background-color', - '#10a37f', - whiteSvg, - '-o', - PNG, - ]) + run('rsvg-convert', ['-w', '1024', '-h', '1024', '--background-color', values.tint!, whiteSvg, '-o', PNG]) log(`wrote ${path.relative(ROOT, PNG)}`) - run('magick', [PNG, '-define', 'icon:auto-resize=256,128,64,48,32,16', ICO]) - log(`wrote ${path.relative(ROOT, ICO)}`) + if (process.platform === 'win32') { + run('magick', [PNG, '-define', 'icon:auto-resize=256,128,64,48,32,16', ICO]) + log(`wrote ${path.relative(ROOT, ICO)}`) + } - if (process.platform !== 'darwin') return + if (process.platform !== 'darwin') return true const iconset = path.join(DIST, 'app-icon.iconset') rmSync(iconset, { recursive: true, force: true }) @@ -114,10 +125,11 @@ async function buildIcons(): Promise { } run('iconutil', ['-c', 'icns', iconset, '-o', ICNS]) log(`wrote ${path.relative(ROOT, ICNS)}`) + return true } -async function compileBinary(): Promise { - log('bundling chat.tsx into a standalone binary') +async function compileBinary(withIcon: boolean): Promise { + log(`bundling ${path.relative(ROOT, ENTRY)} into a standalone binary`) const compile: { outfile: string target?: string @@ -129,9 +141,7 @@ async function compileBinary(): Promise { version: string description: string } - } = { - outfile: BINARY, - } + } = { outfile: BINARY } if (COMPILE_TARGET) { compile.target = COMPILE_TARGET log(`target ${COMPILE_TARGET}`) @@ -142,18 +152,16 @@ async function compileBinary(): Promise { title: APP_NAME, publisher: 'GPUIX', version: '0.1.0', - description: 'Native GPUIX chat example', + description: `${APP_NAME}, a desktop app built with GPUIX`, } - if (process.platform === 'win32' && existsSync(ICO)) { + // Only set the key when the file exists. Bun rejects `icon: undefined` + // with "windows.icon must be a valid path to an ico file". + if (withIcon && process.platform === 'win32' && existsSync(ICO)) { compile.windows.icon = ICO } } - const result = await Bun.build({ - entrypoints: [path.join(ROOT, 'chat.tsx')], - compile, - minify: true, - }) + const result = await Bun.build({ entrypoints: [ENTRY], compile, minify: true }) if (!result.success) { for (const message of result.logs) console.error(message) throw new Error('bun build --compile failed') @@ -162,11 +170,10 @@ async function compileBinary(): Promise { log(`wrote ${path.relative(ROOT, output)}`) } -function wrapMacApp(): void { +function wrapMacApp(withIcon: boolean): void { if (process.env.COMPILE_SKIP_APP === '1') return if (process.platform !== 'darwin') return if (COMPILE_TARGET && !COMPILE_TARGET.includes('darwin')) return - log(`wrapping ${path.relative(ROOT, BINARY)} in ${path.basename(APP_BUNDLE)}`) rmSync(APP_BUNDLE, { recursive: true, force: true }) const macos = path.join(APP_BUNDLE, 'Contents', 'MacOS') @@ -174,16 +181,11 @@ function wrapMacApp(): void { mkdirSync(macos, { recursive: true }) mkdirSync(resources, { recursive: true }) - const executable = path.join(macos, 'chat') + const executable = path.join(macos, SLUG) run('cp', [BINARY, executable]) run('chmod', ['+x', executable]) - if (existsSync(ICNS)) { - run('cp', [ICNS, path.join(resources, 'AppIcon.icns')]) - } + if (withIcon) run('cp', [ICNS, path.join(resources, 'AppIcon.icns')]) - const iconEntries = existsSync(ICNS) - ? [' CFBundleIconFile', ' AppIcon'] - : [] const plist = [ '', '', @@ -194,10 +196,10 @@ function wrapMacApp(): void { ' CFBundleDisplayName', ` ${APP_NAME}`, ' CFBundleExecutable', - ' chat', - ...iconEntries, + ` ${SLUG}`, + ...(withIcon ? [' CFBundleIconFile', ' AppIcon'] : []), ' CFBundleIdentifier', - ' dev.gpuix.chat', + ` ${BUNDLE_ID}`, ' CFBundleInfoDictionaryVersion', ' 6.0', ' CFBundleName', @@ -217,17 +219,23 @@ function wrapMacApp(): void { '', ].join('\n') writeFileSync(path.join(APP_BUNDLE, 'Contents', 'Info.plist'), plist) + // The binary Bun writes carries only the linker's ad hoc signature, which + // does not cover the payload Bun appends. LaunchServices kills it with + // "Code Signature Invalid" when the app opens from Finder, while a terminal + // lets it run. Signing again covers the whole file. + run('codesign', ['--force', '--sign', '-', executable]) + run('codesign', ['--force', '--sign', '-', APP_BUNDLE]) run('touch', [APP_BUNDLE]) log(`wrote ${path.relative(ROOT, APP_BUNDLE)}`) } async function main(): Promise { - log(`output dir ${path.relative(ROOT, DIST) || '.'}`) + log(`output dir ${path.relative(ROOT, DIST)}`) rmSync(DIST, { recursive: true, force: true }) mkdirSync(DIST, { recursive: true }) - await buildIcons() - await compileBinary() - wrapMacApp() + const withIcon = await buildIcons() + await compileBinary(withIcon) + wrapMacApp(withIcon) log('done') if (process.platform === 'darwin' && existsSync(APP_BUNDLE)) { log(`run: open "${APP_BUNDLE}"`) diff --git a/examples/demo.test.tsx b/examples/demo.test.tsx new file mode 100644 index 00000000..7c925d65 --- /dev/null +++ b/examples/demo.test.tsx @@ -0,0 +1,427 @@ +/** + * The demo, driven through the native GPUI test renderer. + * + * Every panel mounts and paints on real Metal, and the cases that carry a + * number are asserted rather than looked at. Screenshots land in + * gpuix-demo/*.png under the OS temp directory for the ones that only + * a person can judge. + */ + +import fs from "fs" +import os from "os" +import path from "path" +import React from "react" +import { describe, expect, it } from "vitest" +import { createTestRoot, hasNativeTestRenderer } from "@gpuix/react/testing" +import type { TestRoot } from "@gpuix/react/testing" +import { App, BASE, PALETTES } from "./demo/app" +import { ClassNames } from "./demo/class-names" +import { Colors } from "./demo/colors" +import { Effects } from "./demo/effects" +import { Gradients } from "./demo/gradients" +import { Inheritance } from "./demo/inheritance" +import { Lengths } from "./demo/lengths" +import { motion } from "@gpuix/react" +import { Motion } from "./demo/motion-panel" +import { Navigation } from "./demo/navigation" +import { IntoView, Scrollbars } from "./demo/scrollbars" +import { Selectors } from "./demo/selectors" +import { Variables } from "./demo/variables" +import { resolveClassName } from "./demo/classes" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +/// `/tmp` does not exist on Windows, and native `save()` never creates the +/// parent directory itself. +const SHOTS_DIR = path.join(os.tmpdir(), "gpuix-demo") +fs.mkdirSync(SHOTS_DIR, { recursive: true }) +const shot = (name: string) => path.join(SHOTS_DIR, `${name}.png`) + +function root(): TestRoot { + return createTestRoot({ resolveClassName }) +} + +const PANELS = [ + ["colors", ], + ["gradients", ], + ["effects", ], + ["lengths", ], + ["variables", ], + ["inheritance", ], + ["classes", ], + ["selectors", ], + ["motion", ], + ["scrollbars", ], + ["navigation", ], +] as const + +describeNative("demo panels", () => { + for (const [name, panel] of PANELS) { + it(`${name} mounts and paints`, () => { + const test = root() + test.render( +
+ {panel} +
+ ) + test.renderer.captureScreenshot(shot(name)) + expect(fs.statSync(shot(name)).size).toBeGreaterThan(0) + expect(test.renderer.getPaintedText().length).toBeGreaterThan(0) + test.unmount() + }) + } +}) + +describeNative("a class and the style it stands for", () => { + it("paints the same pixels", () => { + const viaClass = root() + viaClass.render(
) + viaClass.renderer.captureScreenshot(shot("class")) + viaClass.unmount() + + const viaStyle = root() + viaStyle.render( +
+ ) + viaStyle.renderer.captureScreenshot(shot("style")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("class")).equals(fs.readFileSync(shot("style")))).toBe(true) + }) + + it("lets the style prop beat the class in every state", () => { + const test = root() + test.render( +
+ ) + test.renderer.captureScreenshot(shot("inline-wins")) + test.unmount() + + const expected = root() + expected.render(
) + expected.renderer.captureScreenshot(shot("inline-wins-expected")) + expected.unmount() + + expect( + fs.readFileSync(shot("inline-wins")).equals(fs.readFileSync(shot("inline-wins-expected"))) + ).toBe(true) + }) +}) + +describeNative("selector classes", () => { + const FRAME = { + ...BASE, + ...PALETTES.midnight, + width: "100%", + height: "100%", + backgroundColor: "var(--color-bg)", + } as const + + it("paints divide-y the same as borders written by hand", () => { + const viaClass = root() + viaClass.render( +
+
+
+
+
+
+
+ ) + viaClass.renderer.captureScreenshot(shot("divide")) + viaClass.unmount() + + const line = { borderBottomWidth: 1, borderColor: "var(--color-line)" } as const + const viaStyle = root() + viaStyle.render( +
+
+
+
+
+
+
+ ) + viaStyle.renderer.captureScreenshot(shot("divide-expected")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("divide")).equals(fs.readFileSync(shot("divide-expected")))).toBe( + true + ) + }) + + it("paints last: on the row that is last right now", () => { + const viaClass = root() + viaClass.render( +
+
+
+
+
+
+ ) + viaClass.renderer.captureScreenshot(shot("last")) + viaClass.unmount() + + const viaStyle = root() + viaStyle.render( +
+
+
+
+
+
+ ) + viaStyle.renderer.captureScreenshot(shot("last-expected")) + viaStyle.unmount() + + expect(fs.readFileSync(shot("last")).equals(fs.readFileSync(shot("last-expected")))).toBe(true) + }) +}) + +describeNative("height: auto", () => { + const WORDS = + "The measurement runs at the width the element really gets, so the same " + + "words wrap into a different number of lines in a different column." + + const column = (width: number) => ( +
+ + {WORDS} + +
+ ) + + /// The same words in two widths. The narrow column wraps into more lines, so + /// it has to settle taller. Neither number is written anywhere, and measuring + /// at max-content instead would give both of them one line. + it("settles at the height the content takes at each width", () => { + const settled = [220, 440].map((width) => { + const test = root() + test.renderer.clockPause() + test.render(column(width)) + const id = test.renderer.findByType("div")[1]!.id + test.renderer.clockFastForward(2000) + const height = test.renderer.getElementBounds(id)?.[3] ?? -1 + test.renderer.clockResume() + test.unmount() + return height + }) + expect(settled[0]).toBeGreaterThan(0) + expect(settled[1]).toBeGreaterThan(0) + expect(settled[0]).toBeGreaterThan(settled[1]!) + }) + + it("animates open and reaches the measured height", () => { + const test = root() + test.renderer.clockPause() + test.render(column(300)) + const id = test.renderer.findByType("div")[1]!.id + const at = () => test.renderer.getElementBounds(id)?.[3] ?? -1 + const start = at() + test.renderer.clockFastForward(250) + const middle = at() + test.renderer.clockFastForward(500) + const end = at() + test.renderer.clockResume() + test.unmount() + expect(start).toBe(0) + expect(middle).toBeGreaterThan(start) + expect(end).toBeGreaterThan(middle) + }) + + it("collapses from the height it reached and turns back without a jump", () => { + const test = root() + test.renderer.clockPause() + const tree = (open: boolean) => ( +
+ +
+ +
+ ) + test.render(tree(true)) + const id = test.renderer.findByType("div")[1]!.id + const at = () => test.renderer.getElementBounds(id)?.[3] ?? -1 + test.renderer.clockFastForward(1000) + expect(at()).toBe(100) + test.render(tree(false)) + expect(at()).toBe(100) + test.renderer.clockFastForward(500) + expect(at()).toBe(50) + test.renderer.clockFastForward(500) + expect(at()).toBe(0) + + // Turn back part way through, which starts from a frame that is part + // pixels and part content. + test.render(tree(true)) + test.renderer.clockFastForward(500) + expect(at()).toBe(50) + test.render(tree(false)) + expect(at()).toBe(50) + test.renderer.clockFastForward(500) + expect(at()).toBe(25) + test.renderer.clockResume() + test.unmount() + }) +}) + +describeNative("the scrollbars panel", () => { + it("scrollIntoView honours scroll-padding and scroll-margin", () => { + const test = root() + test.render( +
+ +
+ ) + const box = test.renderer.findByTestId("into-view-box")! + expect(test.renderer.getScrollOffset(box.id)![1]).toBe(0) + + const start = test.renderer.findByText("start")! + const [x, y] = test.renderer.getElementBounds(start.id)! + test.renderer.nativeSimulateClick(x + 4, y + 4) + + expect(test.renderer.getScrollOffset(box.id)![1]).toBeLessThan(0) + const [, boxY] = test.renderer.getElementBounds(box.id)! + const row = test.renderer.findByTestId("into-view-target")! + const [, rowY] = test.renderer.getElementBounds(row.id)! + // 12px of scroll-padding plus 16px of scroll-margin, inside the border. + expect(rowY - boxY).toBeGreaterThanOrEqual(28) + expect(rowY - boxY).toBeLessThanOrEqual(30) + test.unmount() + }) +}) + +describeNative("the navigation panel", () => { + it("pushes the General screen from the right and pops it back", () => { + const test = root() + test.render( +
+ +
+ ) + test.renderer.clockPause() + + const general = test.renderer.findByTestId("nav-row-General")! + const [gx, gy] = test.renderer.getElementBounds(general.id)! + test.renderer.nativeSimulateClick(gx + 4, gy + 4) + + // At the start of the push, the General screen sits one screen width to + // the right of where it will rest. The phone is 320 wide with a 1px + // border on each side, so the screen is 318. + const about = test.renderer.findByText("About")! + const startX = test.renderer.getElementBounds(about.id)![0] + test.renderer.clockFastForward(600) + const endX = test.renderer.getElementBounds(about.id)![0] + expect(startX - endX).toBeCloseTo(318, 0) + + const back = test.renderer.findByTestId("nav-back")! + const [bx, by] = test.renderer.getElementBounds(back.id)! + test.renderer.nativeSimulateClick(bx + 4, by + 4) + test.renderer.clockFastForward(600) + expect(test.renderer.findByTestId("nav-row-General")).toBeDefined() + expect(test.renderer.findByText("About")).toBeUndefined() + + test.renderer.clockResume() + test.unmount() + }) +}) + +describeNative("the whole application", () => { + /// Walk the sidebar and paint each section, so the whole application is + /// covered rather than the one it opens on. The test renderer has the frame + /// overlay, so the performance panel is in the walk too. + it("paints every section the sidebar reaches", () => { + const test = root() + test.render() + expect(test.renderer.getPaintedText()).toContain("GPUIX") + + for (const title of ["Lengths", "Variables", "Inheritance", "className", "Selectors", "Motion", "Scrollbars", "Navigation", "Performance", "Colours"]) { + const item = test.renderer.findByText(title) + expect(item, `no sidebar item named ${title}`).toBeDefined() + const bounds = test.renderer.getElementBounds(item!.id) + expect(bounds).not.toBeNull() + test.renderer.nativeSimulateClick(bounds![0]! + 4, bounds![1]! + 4) + test.renderer.flush() + test.renderer.captureScreenshot(shot(`app-${title.toLowerCase()}`)) + expect(test.renderer.getPaintedText().length).toBeGreaterThan(4) + } + test.unmount() + }) + + /// A frame that changes nothing must resolve nothing. GPUI rebuilds its + /// element tree every frame, so this is what stops the rebuild from + /// repeating the style work. + it("resolves nothing on a frame that changed nothing", () => { + const test = root() + test.render() + test.renderer.resetStyleResolutions() + for (let frame = 0; frame < 5; frame += 1) test.renderer.flush() + expect(test.renderer.styleResolutions()).toBe(0) + test.unmount() + }) + + /// The palette is one declaration at the root, and every class reads it + /// through `var()`. Changing it has to reach the whole tree. + it("repaints the tree when the palette changes", () => { + const test = root() + test.render() + test.renderer.captureScreenshot(shot("palette-before")) + const paper = test.renderer.findByText("paper") + expect(paper).toBeDefined() + const bounds = test.renderer.getElementBounds(paper!.id) + expect(bounds).not.toBeNull() + test.renderer.nativeSimulateClick(bounds![0] + 4, bounds![1] + 4) + test.renderer.flush() + test.renderer.captureScreenshot(shot("palette-after")) + // Metal on the macOS CI VM returns stale captures, so the two files + // come out byte-identical there no matter what painted. + if (!process.env.CI) { + const before = fs.readFileSync(shot("palette-before")) + const after = fs.readFileSync(shot("palette-after")) + expect(before.equals(after)).toBe(false) + } + test.unmount() + }) +}) diff --git a/examples/demo.tsx b/examples/demo.tsx new file mode 100644 index 00000000..898acb2f --- /dev/null +++ b/examples/demo.tsx @@ -0,0 +1,23 @@ +/** + * Every feature on the css-values-and-classname branch, in one window. + * + * Colour values, lengths and arithmetic, custom properties, inheritance, the + * `className` channel and the `height: auto` animation each get a panel. Pick + * one in the sidebar. + * + * Run with: cd examples && bun run demo + */ + +import React from "react" +import { render } from "@gpuix/react" +import { App } from "./demo/app.js" +import { countedResolveClassName } from "./demo/classes.js" + +render(, { + title: "GPUIX", + width: 1180, + height: 820, + minWidth: 720, + minHeight: 520, + resolveClassName: countedResolveClassName, +}) diff --git a/examples/demo/app.tsx b/examples/demo/app.tsx new file mode 100644 index 00000000..d5a9c874 --- /dev/null +++ b/examples/demo/app.tsx @@ -0,0 +1,157 @@ +/// The shell around the panels. +/// +/// The whole palette is custom properties on one element. Every class token +/// points at one of them, so switching the palette changes one declaration at +/// the root and the whole tree follows it on the next frame. No token is +/// resolved again, because the class channel never held a colour. + +import React, { useState } from "react" +import { useGpuixRequired } from "@gpuix/react" +import type { StyleDesc } from "@gpuix/react" +import { ClassNames } from "./class-names.js" +import { Colors } from "./colors.js" +import { Gradients } from "./gradients.js" +import { Corners } from "./corners.js" +import { Effects } from "./effects.js" +import { Inheritance } from "./inheritance.js" +import { Lengths } from "./lengths.js" +import { Motion } from "./motion-panel.js" +import { Navigation } from "./navigation.js" +import { frameOverlay, Perf } from "./perf.js" +import { Scrollbars } from "./scrollbars.js" +import { Selectors } from "./selectors.js" +import { Variables } from "./variables.js" + +/// The palette every panel reads. Exported so a test can mount one panel +/// on its own and still get the colours. +export const PALETTES: Record = { + midnight: { + "--color-bg": "#0b0b12", + "--color-panel": "#14141d", + "--color-raised": "#1c1c28", + "--color-track": "#23232f", + "--color-line": "#2b2b3a", + "--color-fg": "#e8e8f2", + "--color-muted": "#9a9ab4", + "--color-faint": "#6b6b85", + "--color-brand": "#7c6cff", + }, + forest: { + "--color-bg": "#07120d", + "--color-panel": "#0e1c15", + "--color-raised": "#16281f", + "--color-track": "#1d3227", + "--color-line": "#24402f", + "--color-fg": "#e4f2e9", + "--color-muted": "#8fb5a0", + "--color-faint": "#5f8570", + "--color-brand": "#22c55e", + }, + paper: { + "--color-bg": "#f4f4f7", + "--color-panel": "#ffffff", + "--color-raised": "#ececf2", + "--color-track": "#e2e2ea", + "--color-line": "#d5d5e0", + "--color-fg": "#15151f", + "--color-muted": "#54546a", + "--color-faint": "#8a8aa0", + "--color-brand": "#5b4bd6", + }, +} + +type PaletteName = keyof typeof PALETTES + +/// Declarations every palette shares. +export const BASE: StyleDesc = { + "--spacing": "4px", + "--font-mono": "Menlo", + "--color-brand-soft": "color-mix(in oklch, var(--color-brand) 22%, var(--color-panel))", +} + +const SECTIONS = [ + { id: "colors", title: "Colours", render: () => }, + { id: "gradients", title: "Gradients", render: () => }, + { id: "corners", title: "Corner shape", render: () => }, + { id: "effects", title: "Effects", render: () => }, + { id: "lengths", title: "Lengths", render: () => }, + { id: "variables", title: "Variables", render: () => }, + { id: "inheritance", title: "Inheritance", render: () => }, + { id: "classes", title: "className", render: () => }, + { id: "selectors", title: "Selectors", render: () => }, + { id: "motion", title: "Motion", render: () => }, + { id: "scrollbars", title: "Scrollbars", render: () => }, + { id: "navigation", title: "Navigation", render: () => }, +] as const + +type SectionId = (typeof SECTIONS)[number]["id"] | "perf" + +function SidebarItem({ title, active, onClick }: { + title: string + active: boolean + onClick: () => void +}) { + return ( +
+ {title} +
+ ) +} + +export function App() { + const [section, setSection] = useState("colors") + const [palette, setPalette] = useState("midnight") + const current = SECTIONS.find((entry) => entry.id === section) + // The performance panel reads the frame overlay, which a renderer may not + // have. It is in the sidebar only when this one does. + const overlay = frameOverlay(useGpuixRequired()) + + return ( +
+
+
+ GPUIX + CSS values, classes, motion +
+ {SECTIONS.map((entry) => ( + setSection(entry.id)} + /> + ))} + {overlay ? ( + setSection("perf")} /> + ) : null} + +
+ Palette + {(Object.keys(PALETTES) as PaletteName[]).map((name) => ( + setPalette(name)} + /> + ))} +
+ +
+ {section === "perf" && overlay ? : current?.render()} +
+
+ ) +} diff --git a/examples/demo/class-names.tsx b/examples/demo/class-names.tsx new file mode 100644 index 00000000..87fb470b --- /dev/null +++ b/examples/demo/class-names.tsx @@ -0,0 +1,146 @@ +/// The `className` channel. +/// +/// GPUIX ships no resolver. A root takes one through +/// `createRoot(renderer, { resolveClassName })`. This demo passes the small one +/// in `classes.ts`, which is shaped like the `@gpuix/tailwind` package this +/// repository plans to publish. +/// +/// The resolver reads one token, never a whole string. That is what makes the +/// cache work. `clsx("p-4", a && "bg-brand", b && "text-lg")` writes up to +/// eight strings out of three tokens, and five toggles write thirty-two. A +/// bounded cache over whole strings sits in front of the token cache, because +/// the same string usually repeats between two frames. +/// +/// CSS Style Attributes gives the `style` attribute "a specificity higher than +/// any selector", so a declaration in `style` beats one from a class in every +/// state. + +import React, { useState } from "react" +import { Button, Grid, Panel, Row, Sample } from "./ui.js" +import { resolverCalls } from "./classes.js" + +const CARD = "col gap-2 p-4 rounded bg-raised border w-full" + +function Toggles() { + const [padded, setPadded] = useState(true) + const [loud, setLoud] = useState(false) + const [big, setBig] = useState(false) + const [round, setRound] = useState(true) + const [asked, setAsked] = useState(resolverCalls()) + + const className = [ + "row items-center justify-center h-[80px] border", + padded ? "p-6" : "p-1", + loud ? "bg-brand" : "bg-raised", + big ? "text-2xl" : "text-sm", + round ? "rounded-xl" : "rounded-none", + ].join(" ") + + return ( + + +
+ ) +} diff --git a/examples/demo/effects.tsx b/examples/demo/effects.tsx new file mode 100644 index 00000000..701fd64b --- /dev/null +++ b/examples/demo/effects.tsx @@ -0,0 +1,112 @@ +/// Filters, masks and blend modes. +/// +/// Each of these makes the element paint itself and its children into a +/// texture of its own. The GPU then paints that texture over the frame with +/// the effect, so the content under it is untouched. `filter` functions +/// other than `blur()` and `drop-shadow()` fold into one colour matrix. + +import React from "react" +import { Grid, Panel, Sample } from "./ui.js" + +const FILTERS: Array<[string, string]> = [ + ["blur(6px)", "a Gaussian, sigma in pixels"], + ["grayscale(1)", ""], + ["sepia(1)", ""], + ["invert(1)", ""], + ["hue-rotate(150deg)", ""], + ["saturate(3)", ""], + ["brightness(0.5) contrast(2)", "two functions, in order"], + ["opacity(0.4)", "same as opacity, as a filter"], + ["drop-shadow(0 8px 6px rgb(0 0 0 / 0.7))", "the alpha of the box, blurred and moved"], + ["drop-shadow(6px 6px #ff2fa0)", "a sharp shadow"], + ["blur(2px) drop-shadow(0 6px 4px #5cc8ff)", "the shadow follows the blurred box"], +] + +const BLENDS = [ + "normal", "multiply", "screen", "overlay", "darken", "lighten", "color-dodge", "color-burn", + "hard-light", "soft-light", "difference", "exclusion", "hue", "saturation", "color", "luminosity", +] + +function Picture() { + return ( +
+ Aa +
+ ) +} + +function Filters() { + return ( + + + {FILTERS.map(([value, hint]) => ( + +
+ +
+
+ ))} +
+
+ ) +} + +function Blends() { + return ( + + + {BLENDS.map((mode) => ( + +
+
+
+ + ))} + + + ) +} + +function Masks() { + return ( + + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
+ + +
+ +
+
+ + +
+ + + + ) +} + +export function Effects() { + return ( +
+ + + +
+ ) +} diff --git a/examples/demo/gradients.tsx b/examples/demo/gradients.tsx new file mode 100644 index 00000000..97f13ae8 --- /dev/null +++ b/examples/demo/gradients.tsx @@ -0,0 +1,159 @@ +/// Gradient fills. +/// +/// `linear-gradient()` reaches lightningcss as written. The engine fixes the +/// stops up the way CSS Images 3 says and the quad shader paints them, so a +/// gradient costs the same as a flat colour: one quad, no texture. Stop +/// positions are percentages. Radial and conic gradients are not painted yet. + +import React from "react" +import { Grid, Panel, Sample, Swatch } from "./ui.js" + +/// A sticky header over scrolling rows, with a progressive blur under it. +/// +/// The header box has `backdropFilter: blur() saturate()`, which blurs the +/// rows under it the way the iOS 26 navigation bar does, and a `maskImage` +/// gradient that fades that blur out toward the bottom of the box. The +/// easing on the mask keeps the fall-off smooth. There is no scrim, only a +/// faint tint at the top so the large title stays readable over bright +/// rows. `overscrollBehavior: "contain"` keeps the wheel inside the list, +/// so the page does not move with it. +export function StickyHeader() { + const rows = Array.from({ length: 40 }, (_, i) => `Row ${i + 1}`) + return ( + +
+
+ {rows.map((row, i) => ( +
+ {row} +
+ ))} +
+
+
+ Inbox +
+
+ + ) +} + +const DIRECTIONS: Array<[string, string]> = [ + ["linear-gradient(#ff5c8a, #5cc8ff)", "top to bottom, the default"], + ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "a side keyword"], + ["linear-gradient(45deg, #ff5c8a, #5cc8ff)", "an angle, clockwise from top"], + ["linear-gradient(0.75turn, #ff5c8a, #5cc8ff)", "the same in turns"], + ["linear-gradient(to top right, #ff5c8a, #5cc8ff)", "a corner: the 50% line joins the other two corners"], + ["linear-gradient(to bottom left, #ff5c8a, #5cc8ff)", ""], +] + +const STOPS: Array<[string, string]> = [ + ["linear-gradient(to right, red, orange, yellow, green, blue, indigo, violet)", "seven stops, spread evenly"], + ["linear-gradient(to right, #ff5c8a 30%, #5cc8ff 70%)", "flat colour outside the stops"], + ["linear-gradient(to right, #ff5c8a 50%, #5cc8ff 50%)", "two stops in one place make a hard edge"], + ["linear-gradient(to right, #ff5c8a, 20%, #5cc8ff)", "a hint moves the half-way point"], + ["linear-gradient(to right, #ff5c8a 60%, #5cc8ff 20%)", "a stop never goes backwards"], + ["linear-gradient(to right, var(--color-brand), white)", "a stop over a variable"], +] + +const ALPHA: Array<[string, string]> = [ + ["linear-gradient(to right, rgb(255 92 138 / 0), #ff5c8a)", "fades in from clear"], + ["linear-gradient(to bottom, transparent, black)", "a scrim"], + ["linear-gradient(to right, currentColor, transparent)", "currentColor as a stop"], +] + +const EASING: Array<[string, string]> = [ + ["linear-gradient(to right, #ff5c8a, #5cc8ff)", "no easing: a straight mix"], + ["linear-gradient(to right, #ff5c8a, ease-in-out, #5cc8ff)", "ease-in-out holds both ends longer"], + ["linear-gradient(to right, #ff5c8a, ease-in, #5cc8ff)", "ease-in keeps the first colour"], + ["linear-gradient(to right, #ff5c8a, cubic-bezier(0.7, 0, 0.3, 1), #5cc8ff)", "any cubic-bezier()"], + ["linear-gradient(to top, black, transparent)", "a straight scrim: dense at the bottom, a hard edge at the top"], + ["linear-gradient(to top, black, ease-in-out, transparent)", "the same scrim eased"], +] + +function List({ title, note, entries }: { + title: string + note: string + entries: Array<[string, string]> +}) { + return ( + + + {entries.map(([value, hint]) => ( + + + + ))} + + + ) +} + +export function Gradients() { + return ( +
+ + + + + +
+ ) +} diff --git a/examples/demo/inheritance.tsx b/examples/demo/inheritance.tsx new file mode 100644 index 00000000..d1ea9497 --- /dev/null +++ b/examples/demo/inheritance.tsx @@ -0,0 +1,124 @@ +/// Inheritance. +/// +/// A text property declared on an ancestor reaches every `` below it, +/// the way CSS inherits it. GPUI does this itself: a `div` pushes its text +/// style onto a window stack, and a `` with no style of its own paints +/// with the whole stack composed. +/// +/// Two more things inherit and are not text. `userSelect: "none"` turns off +/// selection for a subtree, and `selectionColor` sets the wash for one. + +import React from "react" +import type { StyleDesc } from "@gpuix/react" +import { Grid, Panel, Sample } from "./ui.js" + +const SENTENCE = "The quick brown fox jumps over the lazy dog" + +const CASES: Array<[string, StyleDesc]> = [ + ["color", { color: "#ff5c8a" }], + ["fontSize", { fontSize: 22 }], + ["fontWeight", { fontWeight: "bold" }], + ["fontFamily", { fontFamily: "Courier New" }], + ["lineHeight", { lineHeight: "2.2" }], + ["textAlign", { textAlign: "right" }], +] + +const BOX = { + width: 200, + height: 96, + padding: 8, + borderRadius: 8, + backgroundColor: "#ffffff", + color: "#101018", +} as const + +export function Inheritance() { + return ( +
+ + {CASES.map(([name, declaration]) => ( +
+ {JSON.stringify(declaration)} + + +
+ {SENTENCE} +
+
+ +
+ {SENTENCE} +
+
+
+
+ ))} +
+ + + + +
+
+ {SENTENCE} +
+
+
+ +
+
+ 18px, inherited past the inner box +
+
+
+
+
+ + + + +
+
+
+ + +
+
+
+
+
+ + + + + + + +
+ {SENTENCE} +
+
+ +
+ {SENTENCE} +
+
+ +
+ {SENTENCE} +
+
+
+
+
+ ) +} diff --git a/examples/demo/lengths.tsx b/examples/demo/lengths.tsx new file mode 100644 index 00000000..5dc543c2 --- /dev/null +++ b/examples/demo/lengths.tsx @@ -0,0 +1,174 @@ +/// Lengths, arithmetic and the units that do not resolve yet. +/// +/// A bare number is pixels, which is what the `style` prop has always taken. +/// A string carries the unit, so `8`, `"8px"` and `"var(--pad)"` mean the same +/// padding. `line-height` and `opacity` keep the CSS meaning of a bare number, +/// which is a multiple rather than a length. +/// +/// lightningcss folds the arithmetic while it parses, so `calc(8px + 12px)` +/// arrives as `20px`. Two shapes cannot fold. An expression that mixes a +/// percentage with an absolute length needs layout first, and GPUI has no +/// length type that carries an unfolded expression. A unit that needs the font +/// size or the window, such as `em`, `ch`, `vw` or `vh`, has nothing to read +/// yet. Both drop the declaration rather than paint a wrong size. +/// +/// `width`, `height`, `minWidth`, `minHeight`, `maxWidth` and `maxHeight` read +/// the same lengths, and `auto` and a percentage on top. The last panel here is +/// about that. + +import React from "react" +import type { StyleDesc } from "@gpuix/react" +import { Bar, Grid, Panel, Sample } from "./ui.js" + +const SIMPLE: Array<[string, string | number, string]> = [ + ["100", 100, "a bare number is pixels"], + ['"100px"', "100px", "the same length, with its unit"], + ['"6rem"', "6rem", "96px at a 16px root"], + ['"1in"', "1in", "96px"], + ['"72pt"', "72pt", "96px"], + ['"2cm"', "2cm", "about 76px"], + ['"4pc"', "4pc", "64px"], + ['"var(--spacing)"', "var(--spacing)", "4px"], +] + +const FOLDED: Array<[string, string, string]> = [ + ['"calc(100px + 2rem)"', "calc(100px + 2rem)", "132px"], + ['"calc(2rem * 3)"', "calc(2rem * 3)", "96px"], + ['"min(180px, 12rem)"', "min(180px, 12rem)", "180px"], + ['"max(40px, 6rem)"', "max(40px, 6rem)", "96px"], + ['"clamp(60px, 8rem, 120px)"', "clamp(60px, 8rem, 120px)", "120px"], + ['"calc(var(--spacing) * 30)"', "calc(var(--spacing) * 30)", "120px, the Tailwind spacing shape"], +] + +const DROPPED: Array<[string, string, string]> = [ + ['"50%"', "50%", "a percentage padding resolves against layout"], + ['"calc(100% - 8px)"', "calc(100% - 8px)", "a percentage next to a length needs layout"], + ['"12vw"', "12vw", "the viewport units need the window"], + ['"3em"', "3em", "em needs the font size of the element"], + ['"4ch"', "4ch", "ch needs the font metrics"], + ['"banana"', "banana", "not a length at all"], +] + +function Bars({ title, note, entries }: { + title: string + note: string + entries: Array<[string, string | number, string]> +}) { + return ( + + + {entries.map(([label, length, hint]) => ( + + + + ))} + + + ) +} + +const SENTENCE = "one two three four five six seven eight nine ten eleven twelve" + +function Lines({ label, hint, declaration }: { + label: string + hint: string + declaration: StyleDesc +}) { + return ( + +
+ {SENTENCE} +
+
+ ) +} + +export function Lengths() { + return ( +
+ + + + + + + + + + + + + + + + + + +
+
+
+ + +
+ + +
+
+
+
+ + +
+ Aa +
+
+ + + + + + +
+ + +
+ + +
+ + +
+
+
+ + +
+ + + +
+ ) +} diff --git a/examples/demo/motion-panel.tsx b/examples/demo/motion-panel.tsx new file mode 100644 index 00000000..bfa51654 --- /dev/null +++ b/examples/demo/motion-panel.tsx @@ -0,0 +1,254 @@ +/// Native motion, and animating a `height` to `auto`. +/// +/// `auto` is the height the content takes, and only layout knows that number. +/// The element asks taffy for a measured box, taffy calls back with the width +/// the parent gives it, and the content is measured at that width. So text +/// wraps at the width it will really have, whether the width came from a +/// declared length, from `flex`, from a percentage or from a stretched cross +/// axis. +/// +/// Taffy computes one tree at a time, and the measure closure runs inside that +/// computation. The content is laid out in a second tree for the length of the +/// closure. `IsolatedLayout` in the pinned GPUI fork is what holds it. +/// +/// A height is pixels plus a share of the content, so `auto` and a length are +/// the same kind of value. That is what lets a collapse start from the height +/// `auto` had, and lets a reversal start from a frame part way between the two. + +import React, { useEffect, useState } from "react" +import { motion } from "@gpuix/react" +import type { MotionEase } from "@gpuix/react" +import { Button, Panel, Row } from "./ui.js" + +const PARAGRAPH = + "The measurement runs at the width the element really gets. Two columns of " + + "different widths hold the same words, so the text wraps into a different " + + "number of lines, and each box animates to its own height." + +function Column({ width, open, label }: { width?: number; open: boolean; label: string }) { + return ( +
+ {label} + +
+ {PARAGRAPH} +
+
+ +
+ ) +} + +function Accordion() { + const [open, setOpen] = useState(false) + return ( + + +