← All posts

Rux v0.7.0: the fork, and things that move

Rux stops being stock rhai: a function can read and write the signals around it, a missing property raises instead of evaluating to nothing, and `?.` exists. Things animate, with a transition property, enter and leave, and progress you can drive with a finger. Vector geometry is an element. Plus the cross fade that worked once and then never again.

Two things were true of every Rux release before this one. A function could not change what was on the screen, so every handler in every example was written inline. And nothing moved: a box appeared where it was going to be, and left by ceasing to be drawn. v0.7.0 is the release where the language stopped borrowing someone else's script engine, and where a screen acquired a middle state between one arrangement and the next.

What landed

A function sees the scope it was written in, and can write it. fn bump() { count++ } is a working handler, and @tap="bump()" moves the screen. This retires the single biggest trap in the language, the one /learn spent a callout on and docs/05-as-built.md called "the single biggest trap": before this, a fn could read a copy of a signal, write to the copy, and report no error while nothing happened.

The mechanism was already in rhai, as an opt-in call form that runs in the caller's scope. The fork makes that what a call means, and the change is two lines. That is worth saying plainly, because it is wildly out of proportion to the size of the language change, and it is the strongest argument that vendoring the whole engine was right: the change was cheap precisely because it could be made in the one place that already knew about scopes. One consequence stands: a method call does not capture the caller's scope, because method dispatch passes its receiver by reference and the scope cannot also be borrowed. A function that needs the surrounding state is written as a plain call.

A missing map property raises instead of evaluating to nothing. Silence was exactly the failure the dev overlay exists to kill: a typo in a property name produced (), which then flowed through arithmetic and interpolation and turned up as a blank on screen three steps away from its cause. Strict bindings make it an error where it happens. ?. and ?? ship in the same release rather than later, because they are not a separate nicety: they are the escape hatch that strictness creates the need for. Without them, every legitimately absent property, from data still loading to an optional field, turns a silently wrong result into a noisily wrong one with no way to say "absent is fine".

Alongside those, truthiness is JavaScript's rather than rhai's, ++ exists, and arrow functions are a spelling of a closure.

Things move. transition: opacity 200ms ease-out in a stylesheet animates a property when it changes, and r-transition animates an element arriving and leaving, with :enter-from and :leave-to naming the states at the ends. A transition's progress can be bound rather than run off the clock, so a panel follows a finger and commits or reverses on release. The router takes an r-transition too, so a navigation is a crossover rather than a cut, and a navigation can itself be driven by a drag: the pages move with the hand, and the release decides whether the history moves.

Vector geometry is an element. <path d="…"> takes the full SVG path grammar, arcs and smooth continuations and relative commands included. Paint is CSS rather than attributes, so fill and stroke and stroke-width cascade and inherit like everything else. :d binds an expression, which makes a chart a computed path, and transition: d morphs one shape into another when the two have the same sequence of commands. When they do not, the shape cuts, which is the documented rule rather than a defect.

Layout gained the rest of position. All five values are honored, and sticky in particular, which parsed for four releases and quietly fell through to relative the whole time. Inside a scroller a sticky box clamps to the scroller's content box rather than to the part on screen. A transform now establishes a containing block, which is what makes an absolutely positioned child of a moving element move with it.

A route can refuse. A guard runs before the history moves, so a refusal leaves no entry behind to go back to, and it covers Back as well as forwards. Returning false cancels, a string redirects, and anything else allows.

A handler can read the screen. query(".row") returns element handles by CSS selector, and a handle answers reads about the frame already on screen. Reads only, and only from a handler: there is no frame to measure before there has been one, and tree mutation is deliberately not part of this.

Gestures beyond @tap. @press, @release, @longpress, @swipe and @drag, each carrying its touch points. A @drag claims the finger, so the page underneath does not scroll while a card is being dragged.

Lifecycle. mounted and unmounted run as inline bodies, and unmounted never fires unless mounted fired.

The editor knows the language. The VS Code extension completes elements, attributes, directives, honored CSS properties and their values, the script globals, and the names the open document declares. What it offers comes from the same list the runtime consults when it warns about a property it does not honor, which is the point of the arrangement: if the editor offers it, it works. A completion list that suggested float and then left a warning in the dev overlay would be worse than no list. Hover answers what the popup answered a minute ago and then took away, go to definition follows a use path to the file, and the outline is the three sections and what is in them.

One behaviour change, and it is the only one in v0.7. A document's rules now style the components the document uses. That was chosen deliberately, and it means a component and its caller that share a class name now collide. <style scoped> opts out and means the same thing from either side. Worth knowing separately: variables cascade into components, rules do not.

The bug the tests were green through

The cross fade between pages worked the first time and never again.

It was reported that way, in those words, and every check made before that had navigated exactly once. So the defect sat outside the test, and outside every manual look at it too. Measuring four navigations by colour channel showed the first fading properly, from 255 down to 99 over the full 800ms, and the second, third and fourth collapsing to zero in about 100ms.

A finished track kept its from at the value it had set off from, and a track that is no longer active answers with from. So the next transition on that node was handed a stale start: a page that had faded in from 0 to 1 was asked to leave and dutifully ran from 0 to 0. The first navigation worked, and hid it, because that page's track came from a settled state where the start and the target agree.

Two things about how this was found are worth more than the fix. It took a sequence, which no single-action check could have produced. And it took being contradicted: an earlier claim that both pages animate was disputed, the first probe built to settle it was a bad test that controlled away the very thing under suspicion, and that was pointed out too. Measuring the heading band afterwards showed brightness collapsing within 150ms of the click and then climbing back over 1400ms, which is the arrival alone. The eye was right and the claim was wrong.

It was not the only one that needed eyes. A shape set off from a square towards a circle and froze partway, permanently, because the equality check that tells the animator's own last write apart from a fresh authored value had no arm for path geometry and answered "different" every frame. A chart's filled band drew a whole band's height below its line and across the buttons underneath. A departing page was invisible at zero opacity while its scrollbar drew at full strength over the page behind it. None of the 615 tests passing at the time had an opinion about any of them.

The suite is 675 tests now, and green. That number is worth reading as what it is: protection against these coming back, not evidence that the next one will be caught.

What this release is not

There is no packaging. rux build moved to v0.8, where it is the first item, and rux.toml moves with it. A .rux app still runs through the rux binary, and there is no bundle and no standalone executable.

Almost none of the motion has met a finger. Every animation run above was a mouse or a laptop touchpad, and a touchpad reaches the app as a mouse and reports one finger however many are on it. A drag driven swap is the case that most wants a real hand, and the axis claim has never met one. Multi-touch, pinch and rotate are unverified for the same reason: the vocabulary is there and the second finger is not.

Animation ships with no prefers-reduced-motion. A whole animation system landed in this release and Rux never asks the operating system whether the person using it has said they want less of it. For some people motion is a medical problem rather than a taste, so that is an accessibility gap, it is the urgent half of what is missing, and naming it here is not the same as fixing it. It is the first thing v0.8 does.

What is missing is the asking, not the mechanism. A document can turn its own motion off today, with the same trick examples/router.rux uses to run a navigation in slow motion: put a class on the root and override the durations under it.

<template>
  <screen :class="#{ still: reduce_motion }">
    <view class="card" :class="#{ lifted: up }" @tap="up = !up" />
    <text @tap="reduce_motion = !reduce_motion">Reduce motion: {{ reduce_motion }}</text>
  </screen>
</template>

<style>
  .card {
    width: 120px; height: 120px; background-color: #4c7dff;
    transition: transform 240ms ease-out, background-color 240ms ease-out;
  }
  .card.lifted { transform: translateY(-40px); background-color: #ffb454; }
  /* The opt-out: one class on the root turns the motion off under it. */
  .still .card { transition: none; }
</style>

<script>
  let reduce_motion = signal(false);
  let up = signal(false);
</script>

transition: none removes the declaration rather than shortening it, so nothing animates and nothing is left half-applied. An app that wants to be careful about this can offer that switch in its own settings in v0.7. What it cannot do is default it from the system, and that is on Rux rather than on the author.

unmounted is document level. A per instance unmount hook is waiting on instance pruning, so a component cannot yet run something of its own on the way out.

Still standing from before: two <text> siblings stack rather than flowing inline, which is the known ceiling rather than a v0.7 gap; <input type="select"> has no native picker; and a <path> has never been through a real icon export from a design tool, though the grammar is complete and tested.

Next

v0.8 is packaging: rux build, rux.toml, and getting a Rux app onto a phone without a week of Gradle. Which is also what will finally put every one of the unverified rows above in front of a real hand.

The reduced motion query goes out ahead of all of that. Packaging is the bulk of the milestone and will take several releases to land, and an accessibility gap does not get to wait behind it.