v0.31.0

feder-cr/AIHawkv0.31.0Sep 9, 2026by github-actions[bot]

AI Summary

This release focuses on a comprehensive overhaul of the browser pane and visual design, significantly improving accessibility and performance. It introduces strict error handling for failed steps and deletes, ensuring the interface accurately reflects the agent's state. Several UI refinements, including dark mode adjustments and keyboard navigation fixes, are included to enhance usability.

Key Highlights

  • The browser pane now renders distinct visual states (pulse, failure reasons, empty state chips) and adapts the frame to the actual shape of the browser window.
  • Failed steps are now visually represented as failures, and the interface correctly handles server refusals, distinguishing between a session being mid-run and a truly deleted session.
  • Controls that appear dead now use the `inert` attribute to disable both mouse and keyboard interaction, and ARIA roles have been corrected to reflect actual functionality.
  • The visual palette has been optimized for dark-adapted eyes with a move to amber accents, and performance has been improved by moving frame age to a monotonic clock and throttling background activity.

New Features

  • Implementation of the `inert` attribute for disabled UI elements to prevent keyboard focus.
  • Skip link added to the document for improved keyboard navigation.
  • Visual change from empty picture frames to chips for stopped browsers.
  • Sessions band now functions as an overlay on smaller screens.
  • Frame age tracking moved to a monotonic clock to prevent stale updates.

Full Release Notes

* The browser pane is the picture, and it says what it cannot show (0.28.0)

Every state of this pane was rendered side by side on a bench before anything
was changed - ten of them, from an empty session to four browsers of four
different window shapes, with the real page and canned data. Most of what was
wrong was only visible that way.

The cell was a container, not a frame. A card took the whole cell and the
picture floated inside it: a 1280x688 window at one-up drew 40% of the card as
black, and at two-up two cards of equal size held pictures of unequal size, so
the narrower window read as a mistake rather than as a narrower window. The
frame now takes the picture's own shape - cropped of the browser chrome - and
the cell is only the space it is centred in. It is two custom properties set
once per shape: the crop as a share of the picture's WIDTH, because that is what
a percentage margin resolves against, and the shape that is left after cropping.
Nothing is measured, and nothing has to be recomputed on resize; the version
before this one read the box on every frame and cached two numbers to avoid the
reflow.

Four states drew one black rectangle. A pane waiting for its first frame, a
browser with no tab open, one that had stopped answering and a capture that
failed were pixel-identical - three of them ordinary, one a failure, and the
product said the same thing about all four. Each says its own now: a pulse while
it waits, the reason on the screen when the capture fails (it used to be in a
title attribute nobody hovers), a scrim and a number in seconds when the frames
stop, and the ask when there is no tab. The reason is drawn for every screen and
not only the one being followed - on a 2x2 the three you are not watching are
exactly the ones whose silence you would otherwise have to guess at.

Six stopped browsers drew six empty picture frames, 168 by 133 each, the words
"not up" in the middle: a gallery of things that are not there, taking a whole
band under the stage. A thing with no image is a name, so it is a chip now.

The name moved onto the picture. It was a 28px bar bolted under the card, which
put the label of a thing outside the thing and cost a row of height in every
cell of a 2x2.

The empty stage is a state and not a void with a sentence in it: what it is,
what to do, and the shape of the sentence that does it - because there is no
button that opens a browser, on purpose. And the controls above it stop
pretending: with nothing open, the address, Live/Frozen and the layout picker
are dimmed and the word IDLE - which was the brightest thing on a bar describing
an empty room - is gone.

Two alignments, both of them small and both of them the kind of thing that reads
as a product being loose. The address bar was 24 tall between two controls of 30,
so three controls on one line sat on three rhythms; they share one height token
now, and the address gets a field's corner instead of a pill, because using both
shapes everywhere is what made the bar look assembled rather than designed. And
the tab strip stood on the header's own surface with the selected tab cut out of
it in a darker colour, so the current page read as a hole punched in the header -
it stands on the page's ground now and the selected tab is the bar's surface,
which is what makes a tab look attached to what it opens.

Eight known-bad mutations, eight killed, against a gate that EXECUTES the page:
the shape arithmetic over four window sizes, the four states drawing four
different things, the age stamp moving a screen into stale, the composition of a
screen, and a stopped browser getting a chip rather than a frame.

* The ink is picked from the room the screen is in (0.29.0)

Dark was the right choice here and it was made for the wrong reason - the
category, because tools are dark. The reason that holds is the scene: this
interface is watched during long runs in a room with the lights off, where the
screen is the only source of light. Everything in the palette follows from that
sentence now.

Nothing on the ground is pure white any more: a full white on a near-black
ground at night is a lamp pointed at the reader. The ink ladder reads 14.5, 8.4
and 6.3 to one against the ground, so the quiet rung gains a step and a half of
headroom over the 4.5 the floor asks rather than sitting on it, and the ink that
is allowed to colour nothing but a dot or a chevron moves from 2.4 to 3.2 -
three to one, because a chevron and an idle dot carry meaning and a graphic that
carries meaning has a floor of its own. The accent moves off coral onto amber,
which is the warm end that disturbs dark-adapted eyes least.

Four smaller things, each of them a rule this file had broken once.

An icon is drawn. The chip that says a message is queued carried `✎` - a
pencil borrowed from the text encoding, landing in whatever font the reader has,
at whatever weight that font decided, beside icons drawn at a chosen one. And
the icons that were drawn used three different stroke weights: 2.5, 1.6 and 1.3,
which reads as three icon sets taken from three products. One weight now.

No callout wears a coloured bar down its left edge. A 2px stripe on an alert is
the house style of every framework and belongs to none of them; a tint plus a
hairline in the same hue says the same thing without borrowing anybody's accent.
The blockquote keeps its rule - a quotation is not a callout, and that rule is
older than the web.

The two heading sizes in an answer sat 17 and 15 against a body of 14, so the
hierarchy was carried by weight alone with the size along for the ride. 18 and
16 now: a step somebody can see.

The sessions band is deliberately untouched. The vertical word on the edge, the
line that stays when it is closed, the panel that floats over the conversation
instead of pushing it - that is the one control this room has, and it works.

Seven known-bad mutations against a new gate that reads the shipped file: a
glyph standing in for an icon, an icon at a different weight, the coloured bar
back on an alert, a heading floating equidistant between two paragraphs, and the
three surfaces the browser would otherwise pick for us - the text selection, the
focus ring, the scrollbar. All seven killed. That last group is the cheapest
signal that a page was built rather than assembled, and the first thing to rot.

This also carries the browser pane from 0.28.0, which was never published.

* A failed step is drawn as a failure, and eleven other things the audit found (0.30.0)

Two independent assessments of the live interface - one design review, one
technical scan, run in isolation so neither anchored the other - plus the
detector from the design skill this pass follows. The detector found one thing
and it was a false positive: it matched the string `<img>` inside the comment
that explains why this page never creates one. Everything below came from
reading the running product.

The worst of it was not visual.

A tool that FAILS answers with a result, not an exception: MCP puts `isError` on
the answer and only a broken transport raises. The loop read the text and threw
the flag away, so every failure reached the page as a success - the step row
kept the past tense that asserts the thing happened, `Navigated https://...`, in
the colour of an ordinary answer, with `NS_ERROR_UNKNOWN_HOST` printed after it.
Measured on a live transcript: not one row in the whole session had ever reached
the error state this page has always known how to draw. For an agent acting on
real websites that is worse than a gap in the log - it is a lie, and it costs
the reader the ability to trust any other row.

Two more that lied about what happened. The composer emptied itself and then
fired a request nobody read, so a server restart ate the instruction with
nothing said; the sentence now goes back in the box with the reason. And a
delete refused by the server - it will not forget a session whose agent is
mid-run, and says so with `forgotten:false` in a 200 - navigated the person away
as if it had worked, leaving the session and its browsers exactly where they
were.

The rest, in the order the design skill puts them.

Nothing the agent does was announced. Every step, every answer and every error
arrives by appending a node, and the only live region on the page carried the
word `idle`; the other channel is a screenshot with an empty alt on purpose. The
transcript is a log now. The state word goes off-screen instead of into
`display:none`, because a live region mutated inside a hidden subtree announces
nothing - which silenced exactly the transition that matters, live to error.

Five controls that looked dead answered the keyboard anyway. `pointer-events`
stops the mouse and nothing else: they kept their place in the tab order, kept
the focus ring, and Enter still fired. `inert` takes them out of both.

The pair beside the picture claimed to be tabs. `role="tablist"` promises panels
this page does not have and a keyboard pattern it does not implement, so a
screen reader announced "tab, 1 of 2" for a switch whose arrow keys do nothing.
It is a group of two toggles, which is what its neighbour the layout picker
already was.

The composer was the 112th thing a keyboard reached - 129 focusable elements,
108 of them transcript rows, and the count grows with every step of every run.
One skip link, first in the document.

Below 900px the sessions disappeared entirely: the panel and its only opener
were dropped by the same media query, and the new-session button lives inside
the panel. A window snapped to half a 1366-wide laptop lost every session
control at once, with hand-editing `?s=` in the address bar as the only route
left. The panel is an overlay; folding it costs nothing at any width.

Clicking a stopped browser's chip started it. Asking for a browser's tabs
resolves the id and the registry wakes the engine - 800 MB and seven seconds
nobody asked for, then repeated every two seconds because the pin never cleared.
Four places in the file already knew that rule.

Every frame is an object URL and a blob is not collected with its element. The
two rebuild paths threw their pictures away without revoking, and the stage
rebuilds whenever the agent moves to another browser, which it does on its own.

The arrow inside the only primary control measured 1.71:1 against the button it
sits on, where a graphic that is a button's whole label wants 3:1. `--on-accent`
had been declared for exactly this and used nowhere; it reads 8.6:1. The stop
square was pure white on red in a palette whose own rule forbids pure white.
Neither Send nor Stop nor the Live/Frozen pair had a hover state at all.

The name and the staleness plates sit on a live capture of somebody else's
website, so a translucent backdrop made their contrast a function of that page's
stylesheet: over a white page they measured 3.9:1 and 3.4:1, and most of the web
is white. The plates carry their own ground now.

`--fg-4` was licensed at 3.2:1 against the ground and then used in three places
that all sit one rung up, where it measures 2.96 - the same defect this ladder
already fixed for text, repeated for graphics. And `--t-small` was referenced
once and declared nowhere, so a session name rendered at whatever it inherited.

Two things about what the log says. A password typed into a page was printed
into the transcript and saved to disk - `Typed #passcode-input <- 434262` was on
the screen - and is masked by field name now. And `browser_open` had no case at
all, so it printed the name of an argument at the reader; a step names its
browser when the CALL named one, which is what turns 25 identical `Read body`
rows back into 25 different facts.

Twenty-one known-bad mutations across three gates, all killed.

Left open, with evidence, because they are design decisions rather than defects:
the 120-character threshold that decides whether a step row keeps its
disclosure, measured against a box that holds 48; `Clear` being the only
unguarded destructive control while deleting a session confirms; the parser's
unbounded recursion on a hostile answer; and the four polling loops that keep
running in a background tab.

This also carries the ink of 0.29.0 and the browser pane of 0.28.0, neither of
which was published.

* Nothing fails in silence, and nothing runs while nobody is looking (0.31.0)

The rest of what the audit found, closed in the order its own guidance puts
them: what lies or loses work first, then the missing states, then structure,
then the drift.

Six requests could fail with nothing said, and the sharpest of them is the one
that stops the agent - this file says elsewhere that it is the only thing that
ends a run which will not converge, and a press that never reached the server
looked exactly like a press that did. They go through one door now, which says
what did not happen on the page. Two keep their own recovery because it is more
than a message: the send puts the sentence back in the box, the delete reads
whether the server refused. Two fast clicks on New made two sessions and
orphaned the first. And a stream that had died looked exactly like an agent with
nothing to say.

The log was 40% unreadable. The threshold that decides whether a row keeps its
whole answer on one line was a hundred and twenty characters, into a track that
shows about forty-eight: 416px, 7.15px a character in mono at 13px, minus the
verb in front. Counted on a live transcript, 43 rows of 108 were cut off AND had
their disclosure removed, so the one thing that could have shown the rest was
gone - and `user-select:none` meant a truncated address could not even be
copied. The threshold is the box's now, anything longer keeps a body, and the
row says the whole of it on hover.

A hostile answer took the turn with it. The parser recursed once per `>` and
once per indent level with no floor, so 20,000 of either threw RangeError - and
the throw landed in the event handler, where it stranded the step clock, skipped
the redraw and ate the instruction somebody had queued. Measured after: the
three inputs that used to throw now parse in 1 to 12 ms.

The address bar was the brightest thing on a bar describing an empty room: the
script sets `dim` on the element and the only rule was a descendant selector, so
`no page yet` rendered in the inherited ink. And the guard that disarms the bar
asked whether there were browsers rather than whether there was anything to see,
so a browser with no tab open left the whole bar armed over a stage whose own
words were `no tab open`.

Clear was the only unguarded destructive control, and it sat in the permanent
header while deleting a whole session - rarer, behind a closed panel - asked
first and named what went with it. The guard was on the wrong control.

Three keyboard paths that did not exist: the cross that deletes a conversation
was reachable going forward and never going back, because `visibility:hidden`
takes an element out of the focus order; renaming was double-click only, with
nothing on screen saying so; and the page had no heading and no landmarks, so
every answer's own headings hung under nothing.

Four loops ran flat out in a background tab - the frame pump at up to forty
requests a second - and that budget was measured against what the pipe can carry
while the AGENT is using it. The agent keeps working when the tab is hidden,
which is exactly when the page was still spending its share on pictures nobody
could see. Coming back catches up immediately rather than waiting for the next
tick.

Three pieces of work that cost and bought nothing: the staleness label wrote
three properties per screen per pass whether or not anything had changed, about
480 DOM writes a second at four screens for a label that is empty almost always;
every keystroke forced two layouts over a document holding the whole transcript,
where one number answers both questions; and the splitter wrote a width and a
localStorage entry on every pointermove, which at a 120 Hz pointer is 120 forced
layouts and 120 synchronous disk writes a second for a value nobody reads until
the drag ends. Frame age moves to the monotonic clock, like the two timers
beside it: a wall clock corrected by NTP marks every screen stale at once.

And the palette says what it means again. Seven surfaces had `--err` and `--well`
re-expanded as rgba by hand, there was a second orange one shade off the accent
in the text selection, a sixth grey in the scrollbar, two fallbacks that could
never render, and white ink in a palette whose own rule forbids pure white. A
token typed out is not that token: it is a colour that resembles it until
somebody moves the token.

Fourteen more known-bad mutations, all killed, across two gates. One of them was
accused by a comment on its first run - it found `role="tablist"` inside the
comment explaining why that role was removed - so the scan now strips both
comment syntaxes, which is the third time this project has recorded that shape.

Deliberately left: the transcript's 108 tab stops keep their natural order,
because the skip link solves the reach and a roving group would break the
find-in-page behaviour people rely on; and there is still no length cap on an
instruction, because the model's context is the real limit and a cap would
truncate a legitimate paste.

* The comment beside the clock is English like the rest of the repo

* The jump button follows the composer instead of guessing its height

It sat at a hard 110px from the bottom, which is a guess at the height of a
box that grows with what is typed into it and again with the reader font size:
at 200% with a queued message it landed on top of the thing it floats above.
Inside the composer now, anchored to its top edge, so the number is the
composer own height whatever that turns out to be.

* A grab area a hand can hit, and an allowlist that admits only what is used

The separator was nine pixels wide where WCAG 2.2 asks twenty-four, and none
of its exceptions cover one: the arrow keys are a path, not a control. The grab
area reaches eight pixels into each pane, which is where a hand aims anyway,
and no layout moves.

And the emoji gate allowed three characters through: the marker this project
writes its rules with, a curly quote and an em dash. Neither of the last two is
anywhere in the page, and the em dash is separately banned across the whole
product - so the gate was contradicting a rule while guarding a hole nobody had
walked through yet.

* A delete stays deleted, and a subtree that cannot be used stops looking usable

Deleting a conversation answered `forgotten:true`, closed its browsers and
erased the transcript, and the row came straight back in the column, empty and
unnamed, for as long as one tab stayed open on it. Measured against the running
server: every route resolved the id with the call that BUILDS what it does not
find, so a single `/live/browsers` from a page left open on the deleted session
declared it again. That poll runs every three seconds, and the frame pump runs
up to twenty-five times a second. Every visible signal said the delete had
failed.

The fix is one question asked before the id becomes a conversation, in the one
door all eight routes already share: a request is a way to look at a
conversation, never a way to start one, and `/sessions/new` is. It asks about
both halves of a session, because a conversation and its browsers are one
session and either half can be the only one on disk: an agent client that opens
a browser in `work` and never touches this interface writes the browsers and no
transcript, and refusing what has no transcript would have turned somebody away
from a session that plainly exists. The rename carries its id in the body rather
than the query string, so it goes through the same door instead of a copy of it.

The page half is the same shape. Six fetches carried the session id and each
asked on its own; they go through one function now, which reads the one answer
that will never stop being true and stops. It says what happened, closes the
stream, and goes inert everywhere except the way out, which is the column the
sentence tells you to use.

And the second defect was already there before this one found it. `inert` has no
look. This page spells "cannot be used" as `opacity:.3` on a disabled button,
then said the same thing about whole subtrees with `inert` and drew them exactly
as before: the Live and Frozen pair and the layout picker go inert whenever
there is nothing to see, and stayed fully lit the whole time. One rule, so the
look follows the mechanism rather than whoever remembers it.

Eight known-bad mutations, all killed, across five new gates. Three tests named
a session id nothing had declared, as a shortcut; they open it first now, which
is what the product does.

* A refusal to delete means one thing, and it is that the agent is still running

`forgotten:false` carried two opposite pieces of news: the server refused
because that conversation is mid-run, and there was nothing there to erase. The
page has exactly one sentence for it, so deleting a session somebody else had
already deleted answered "that session is still working, so it was not deleted"
- wrong in both halves, and it left the row on the screen.

It is reachable rather than theoretical: the column does not poll, so a panel
left open in another tab keeps showing a row that no longer exists, and its
cross is the thing a person clicks. The caller asked for it to be gone; if it is
gone, the answer is yes, and `false` is left saying the one thing the sentence
below it describes.

One more known-bad mutation, killed.