Skip to content

fix: text and icons read on what they are drawn on; reduced motion (qorus#646) — 0.78.13 - #723

Open
davidnich wants to merge 85 commits into
developfrom
feature/contrast-readable-text-and-icons
Open

davidnich wants to merge 85 commits into
developfrom
feature/contrast-readable-text-and-icons

Conversation

@davidnich

@davidnich davidnich commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Text and icons read on what they are drawn on: the qoretechnologies/qorus#646 contrast work from #717, as its own PR. The front-end team asked for it to be kept apart from #717's functional fixes, which are in #722.

Base: develop. #722 merged into develop as 9c340d98 (0.78.10), and #728 as 52d0155a (0.78.11, a rich text editor chip is the size it says). Both are published. Develop is merged in (e1b98b12, 3b9de603), so this diff is only the contrast work.

Version: 0.78.13 (8db79562), develop's next version. Develop is at 0.78.12, which is on npm.

What it does:

  • Every component picks its text and icon colours against what it actually draws (WCAG AA: 4.5:1 for text, 3:1 for icons and large text).
  • What a component paints is handed down to what it holds (ReqoreSurfaceProvider / useReqoreSurface), and faded text is faded only as far as it still reads.
  • Explicit colours and opacities stay the caller's.
  • Reduced motion (prefers-reduced-motion) is honoured inside Reqore.
  • The story run checks the contrast of every story's text and icons (axe-core, plus Reqore's own measure on gradients and for icons).
    • axe-core stays at 4.10.2, as approved on fix: text and icons read on what they are drawn on; reduced motion (qorus#646) #717. The latest, 4.14.0, measures 8 more stories as too close in 5 files: ButtonRail · Surfaces, Comment · Basic, EmptyState · With Background, With Background Flat and With Gradient Background, FeatureCard · Disabled, Tier · Default and Group. Moving to it means more colour changes, which are for a follow-up.
  • Foxhoundn's review of the colour helpers and components is included: dcb327ca, f3bde577, a186d8a6 and 249e84c6 (on fix: text and icons read on what they are drawn on; reduced motion (qorus#646) #717: b824566, 11fdbfa, 951549c and cbcd1ad).

How it was built:

Visual change: against the functional branch, 625 of the 1189 captures the two share change. The table David approved counted 626; the one frame apart is Paging · Infinite With Auto Load, whose scroll position is noise.

  • Every one is a colour or opacity change; none changes layout.
  • There are 7 new stories: Contrast Check ×2, Message · Muted Refusal On The Page, Panel · Mid Grey Theme, Accordion · Minimal On A Light Gradient, Nav Rail · Light Gradient, Input · Transitions Under Reduced Motion.
  • Each frame's cause was flagged on fix: text and icons read on what they are drawn on; reduced motion (qorus#646) #717's qlip build #402. On this PR's first qlip build, #415, every changed and new frame is flagged with its cause; none is accepted.

Gates:

🤖 Generated with Claude Code

https://claude.ai/code/session_01EVRGWEwQAi5btU1qdHBfmb

David Nichols and others added 30 commits October 20, 2026 23:03
…wn on (qorus#646)

David's review found black text on a dark gray background: the muted
intent (#f2f2f230) picked its text from the near-white it is made of, but
is drawn at 19% over a dark page. Text and icon colors are now picked
against what is actually drawn, to WCAG AA: 4.5:1 for text, 3:1 for icons
and large text.

- getReadableColorFrom / getReadableColor choose the candidate with the
  higher contrast (not a luminance threshold), draw a translucent
  background over what is under it (the theme it was made from), and give
  up the dimmed #f2f2f2 / #0d0d0d for pure white / black where the dimmed
  one is under 4.5:1 (#f2f2f2 on the info blue is 4.2:1).
- New: getDrawnColor, getContrastRatio (unrounded: polished rounds 4.495
  up to 4.5), getReadableColorOn (one color for every color of a
  gradient), getReadableAlpha / getReadableFadedColor (fade only as far
  as it reads), getReadableAccentColor(On) (keep the hue, move toward the
  side that can read on every background), getReadableIntentColor,
  getReadableThemeColor, getReadableIconLook, getReadableTextOn,
  getReadableGroupOpacity (how far a group drawn as a whole can fade and
  every mark in it still read), getThemeSurface(s), fadeColor (a
  translucent color at a panel's opacity stays translucent; rgba()
  replaced its alpha).
- A one-color gradient (buildGradientColorsFromString) keeps its text
  readable at both ends, and a step lighter as it is drawn hovered or
  pressed: info's light end was 3.25:1 with white.
- ReqoreSurfaceProvider / useReqoreSurface (exported, with
  TReqoreSurface): what a component paints - one color, or each color of
  its gradient - is handed to what it holds, which reads against it
  rather than the page. An app can tell Reqore about a background of its
  own the same way.

Tests that fail before: __tests__/helpers/readableColors.test.tsx (the
helpers missing, black picked for the muted intent, #f2f2f2 kept on the
info blue, info's gradient 3.86:1 a step lighter) and
__tests__/surfaceContext.test.tsx.
…orus#646)

Each component picks its text and icon colors against the background it
actually draws (a minimal wash, a translucent intent, a gradient, a
lightened header), hands that background to what it holds, and fades
secondary text and icons only as far as they stay readable (4.5:1 text,
3:1 icons and large text; WCAG 1.4.3 / 1.4.11).

- Button: text, description and icons against its drawn fill at rest,
  hovered and active; a minimal or transparent button keeps its color in
  a readable shade; badges read on the button, every color of it.
- Tag: solid, minimal, soft and text tags, against every color they are
  drawn on; the key is darkened as far as its text needs (over a light
  color 30% black left it in the mid-tones, where nothing reads); icons
  and actions; label and key effects read on the tag.
- Input / Textarea / RichTextEditor: text, placeholder, icons and the clear
  button against the field's own wash.
- Span / Paragraph / Heading / Icon: an intent, or a theme color named by
  name, in a shade of it that reads where it is drawn; text drawn in a
  gradient is drawn in colors that read, hovered and pressed too; a text
  glow's letters sit in a halo of the glow and are drawn in the color
  that reads on it (white letters in a white halo inside a red glow were
  3.85:1).
- An effect with several gradients is read as the layers they are drawn
  as, each over the ones under it.
- Panel (content, the title bar over the content's gradient, description,
  title icon, a title rendered as a link), Message, notifications,
  Callout, EntityRow, Statistic, Testimonial, FeatureCard, Accordion,
  Timeline, Table cells, NavRail, Breadcrumbs (the separator too),
  SegmentedControl, CollapsibleContent's reveal button, Rating, Tree,
  StackedBar, DescriptionList, Bubble timestamps: each reads against its
  own surface; one with a gradient hands every color of it to what it
  holds. A ControlGroup or a Menu drawn in a gradient does too, and a
  menu divider's label reads on the menu.
- Checkbox: the box against every color under it; a switch's word on the
  knob reads on the knob and the other on the track (one color for both
  was white on the light info knob, 2.56:1), a gradient track and a knob
  in the effect's colors are read as drawn, and an unset knob is drawn
  faint without fading its icon, which still stands out by 3:1.
- Notifications: the text, icon, close button and pending spinner read on
  the notification as drawn over every color under it; content of the
  caller's own (a tag) is no longer faded with the text under a title; an
  iconColor is drawn in a shade of it that stands out from its tile.
- NavRail: an idle rail rests at idleOpacity, or as little less faded as
  its icons need to stand out by 3:1 - the marks' and the active mark's on
  its fill, faded into the page with the rail (it rested at 0.34: 2.3:1).
- A translucent theme drawn at a panel's opacity stays translucent (the
  muted EmptyState was white on white).
- Breadcrumbs no longer force the bar's text color onto a crumb with its
  own intent (dark text on the warning crumb).
- StackedBar writes each segment in what reads on it (white on a light
  segment was 1.9:1).
- Disabled components that are not form controls say so (aria-disabled):
  Tag, Callout, Panel, Accordion item, EntityRow, Statistic,
  CollapsibleContent, the Checkbox row, Progress, SeverityRow, StackedBar,
  Testimonial, the Textarea wrapper, a disabled row's table cells. WCAG
  exempts disabled controls from contrast; assistive technology is told.

Tests that fail before: __tests__/componentSurfaces.test.tsx (a group or
a menu drawn in a gradient hands its colors on), __tests__/tagColors.test.tsx
(a tag's key darkened as far as it takes, a tag reads on every color it
is drawn on). The story run's contrast gate (the last commit) failed 524
of 1186 story tests before these and the story colors, and none after.
…us#646)

Some stories put text or icons on colors no text color reads on: a
cyan-to-pink badge gradient, a purple-to-teal item, purple-to-pink groups
and menus, a pink message, an orange badge under a darkened key, a light
green backdrop blob, a magenta sphere, dark icon colors on a dark panel, a
red spinner and a red bold leaf on dark gray, a muted wash on a mid-blue
picker. Each keeps the same kind of color in a shade the text reads on,
so the demos show readable choices. The notification stories' busy
backdrop tells the notifications on it what it is drawn in
(ReqoreSurfaceProvider), as an app would for a background of its own,
and its row labels are a shade less faint.
…orus#646)

After each story settles (its transitions and finite animations
finished), in its theme:
- text by axe-core's color-contrast rule (4.5:1, 3:1 when large);
- text on a gradient, which axe cannot decide, against the gradient's
  colors where the text is drawn on it, and text drawn in a gradient
  (background-clip: text) in each of its colors;
- icons (an svg in .reqore-icon) at 3:1 against what they are drawn on
  (WCAG 1.4.11), which axe does not check.
What a mark is drawn on is composed as the browser draws it: each
ancestor's paint over what is under it (a translucent color blended, a
gradient as its colors along the part the mark covers, a fill over a ring
counting alone), an element at an opacity drawn as a whole - its paint
and everything in it - then faded into what is under it, and a floating
layer (a popover, a modal) over the app layout's surface. Disabled
content is exempt (WCAG), as is anything not drawn and decoration the
page marks as such (data-decorative).

Fail-first: the first gated run failed 524 of 1186 story tests in 62 of
75 story files (Button 22/25, Tag 18/22, Table 49/71, Panel 32/59, ...);
with the previous commits every story passes. Unit tests:
__tests__/contrastGate.test.ts (the gradient position, faded group and
settling cases fail on the gate before them). axe-core 4.10.2 is a dev
dependency.

src/components/COMPONENTS.md has a section on how Reqore picks text and
icon colors now: against what is drawn, the surface handed down
(ReqoreSurfaceProvider), the floors on fades, what stays the caller's.
…city the button draws it at (qorus#646)

A button draws its icons at CONTROL_ICON_OPACITY and picks an icon's own
color (leftIconColor, iconColor) in the shade and at the opacity that
stand out by 3:1. A color given in the icon's own props instead -
leftIconProps={{ intent: 'warning' }}, as the Qorus IDE's readiness rows
give it - was spread over that look: the icon made it readable at full
opacity and the button drew it at 70%, a warning icon at 2.4:1 (814
findings in the IDE's contrast gate).

The button now takes the intent or color in leftIconProps /
rightIconProps as the icon's own color (they still win over leftIconColor,
as they did when spread last) and picks it as it does leftIconColor; the
rest of the props are passed on as before.

Test (fails before): an icon colored through its props is drawn exactly
like one colored through leftIconColor, left and right; that look stands
out by 3:1 at rest and hovered, on the dark theme and a light one; the
other icon props (its size) still reach it.
…ld holds (qorus#646)

The contrast work (85f6099..98c46c3) computes each faded description,
label and icon opacity in the render and hands it to a memo component
(ReqoreIcon, ReqoreP, ReqoreSpan, ReqoreButton) as a new object every
time, and DeepScan reported 21 REACT_INEFFICIENT_PURE_COMPONENT_PROP and one
INSUFFICIENT_NULL_CHECK as new on #708 (FAILURE at 98c46c3, 6e203e6 and
5960623).

Each such effect, style and handler is memoized on what it is made of:
Button, Input and Tag's icon effects; Checkbox's knob and box icons;
Callout, EntityRow and FeatureCard's descriptions (and EntityRow's
metadata, Callout's close handler); RichTextEditor's placeholder and
paragraph styles; SegmentedControl's item refs (one callback per item);
Statistic's label; Timeline's timestamp and content styles. Panel reads
the description effect's color and opacity null-safely, as it reads its
textSize below.
…ks for it (qorus#646)

A user who asks for reduced motion (WCAG 2.3.3) still got every spring
Reqore runs - an input's clear button fading in, a drawer sliding, a
reveal - and every CSS transition. A contrast check measured them on
their way: the clear button, which rests at 4.8:1, at 1.4-2.6:1 in the
IDE's gate (a react-spring fade, which no gate can wait on).

- ReqoreUIProvider turns react-spring's skipAnimation on while the user
  prefers reduced motion (set as it renders, so the first render's
  springs settle too, and again when the preference changes; it turns
  off only what it turned on): every spring settles at once, at its end.
- Its global style takes every CSS transition inside the layout and the
  portal to 0.01ms under the preference (not none: a transition of no
  time sends no transitionend, which some components wait for).
  Keyframe animations (a spinner, a pulse) are as they were.
- The story run's contrast gate asks for reduced motion while it
  measures (a browser command, emulateReducedMotion), so it measures
  what stays on screen, never a frame of a fade. A story tagged
  `reduced-motion` runs under it; the others run with the motion a user
  sees, as their checks of a marquee, a count-up or a sheet expect.

Tests (fail before): under reduced motion an input's clear button is at
its settled opacity on first paint and Reqore's springs skip; the story
Form/Input "Transitions Under Reduced Motion" finds the clear button's
transition at 0.01ms (0.1s without the preference).
…put, a tag or a panel draws it faded (qorus#646)

As with the button (5960623): an input's icons, a tag's and a panel's
title icon are drawn at CONTROL_ICON_OPACITY, and the component picks an
icon's own color and that opacity together so that it stands out by
3:1. A color given in the icon's props - leftIconProps={{ intent }},
iconProps={{ color }} - was spread over the pick: the icon made it
readable at full opacity, and drawn at 70% it fell short (a collection's
filter icon given leftIconProps={{ intent: 'muted' }} at 2.2:1, the IDE's
gate). Each now takes it as the icon's own color (it still wins over the
component's own, as it did when spread last) and passes the rest on. The
split is one helper, splitIconColor (helpers/iconProps), the button uses
it too.

Test (fails before): an input's left and right icon, a tag's and a
panel's title icon colored through their props are drawn exactly like
ones colored through the component's own prop.
…#646)

A link with an intent was written in the shade of it that reads on the
theme's page, wherever it was: on a table cell tinted with the warning
intent 3.5:1, over an entity row's gradient 2.8:1 (the IDE's gate). It
now reads the surface a component around it hands down, as a span does.

Test (fails before): an info link reads 4.5:1 on a warning-tinted cell
and on every color of a gradient, in a shade of blue.
…646)

An active button (a selected menu item, a sidebar section on the active
path) is filled with its color, but its description, its badges and its
icons were picked for the button at rest - its color's wash over the
page: a light blue description 2.0:1 and badge 2.7:1 on the info blue
of the IDE's account menu, a grey description 4.4:1 on a sidebar's
section toggle. The description is now picked on the active fill, the
button hands that fill to what it holds, and its icons are picked on it.
The nav rail reads its active mark as the button now draws it (its icon
on the fill, in the rail's activeEffect), so it still rests only as
faded as that icon stands out by 3:1.

Tests (fail before): an active button with a color writes its
description and draws its badge on its fill; one without a color writes
its description on its fill; at rest the description reads as before.
The NavRail idle stories fail with the old rail and this button.
…at they hold (qorus#646)

An entity row faded its description and metadata as a whole, so what
they hold with a color of its own was faded a second time: a minimal
warning tag in the metadata at 4.0:1, a link in the description at
2.8:1 (the IDE's gate). Their text is now written in the faded color
(the readable floor as before; an opacity a caller gives is still that
floor, a color a caller gives is the caller's), and a tag or a link in
them is drawn as it is.

Test (fails before): both slots are drawn whole in a faded color, a tag
and a link in them are not faded, and a caller's opacity is kept.
…through it (qorus#646)

A gradient effect paints its fill over a lighter ring of the same
gradient (border-box). With an opaque fill the ring is hidden; with a
translucent one - a glow of 'info:lighten:1:0.5' over the page - it
shows through, but the colors a component read its text against left
it out: a statistic's label faded for the blue over the page read 3.6:1
on what was painted (the IDE's support hero). The effect's own readable
colors and getEffectBackgrounds now draw the fill over the ring (one
fill layer stop by stop, as they share the gradient's geometry).

Test (fails before): a statistic's label stands out by 4.5:1 from the
corner of a translucent radial glow as it is painted, ring included.
…rt hex as CSS reads it, and results are cached (qorus#646)

Review of the contrast pass found these in the color helpers:

- A color the helpers cannot parse (a CSS variable, `currentColor`, a
  name, a typo, `#f00a` before this fix) threw. It is now returned as it
  is given: `getContrastRatio` is NaN for it, a surface it names is left
  out, and a wash of it is mixed by the browser (`getColorAtAlpha` writes
  `color-mix(...)` for it). `isReadableColor` tells whether the helpers
  can read a color.
- A short hex was repeated instead of doubled (`#fdc` read as `#fdcfdc`).
  polished writes colors short where it can (`#bbaaff` as `#baf`), so a
  minimal button's text came out green instead of the lavender it was
  picked as. Every helper now reads and writes the long form.
- `getContrastRatio` is unrounded (polished rounds luminance to three
  places: `#595959` on black was 3.0, it is 2.998).
- An explicit hex icon color is drawn as given, as a standalone
  ReqoreIcon draws it and as COMPONENTS.md says; only a theme color (an
  intent, `main`, a shade of one) is moved to a readable shade
  (`getReadableIconLook` -> `getReadableThemeColor`).
- A caller's own `ifLight` / `ifDark` pair stays theirs when neither
  reaches 4.5:1: the better of the two is returned.
- Each pure helper keeps its last 2000 results (`createColorCache`):
  every Button, Tag, Effect and Input re-render ran the color maths again.
- `ReqoreSurfaceProvider` keeps its value while its colors are the same,
  so an inline effect does not re-render everything below it.

Tests: helpers/colorParsing, helpers/explicitColors,
surfaceProviderIdentity.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, colors are worked out once per render, and reduced motion stays inside Reqore (qorus#646)

The rest of the review of the contrast pass:

- Crashes: `descriptionEffect.color: 'red'`, a CSS variable or an intent
  named where a hex was expected no longer throw (entity row, callout,
  feature card, panel, statistic).
- Explicit values are the caller's: a nav rail's `idleOpacity`, an
  explicit `descriptionEffect` / `labelEffect` / `metadataEffect`
  `opacity`, a hex icon color on a button, a tag or an input. The
  floors apply to the defaults only.
- Own fills and surfaces:
  - `effect.color` on a button or a tag is read on its own fill, at
    rest and hovered; a soft or text tag reads on every stop of a
    gradient it sits on;
  - a transparent or minimal element with its own `customTheme` reads
    on that color; a callout's own text, a transparent statistic or
    testimonial, and an entity row's actions read on their own surface;
  - a popover's content and a drawer read on their own fill, not on
    whatever opened them;
  - a nav rail's active group hands its gradient (or tint) to its marks:
    a resting section mark picked its text for the rail around the
    group, and only read on the Effects story because `#baf` was misread
    as a light green;
  - `theme.breadcrumbs.item.color` reaches the crumbs; description list,
    rich-text placeholder, stacked bar, timeline, message, tree and the
    segmented control's More button read on what they are drawn on.
- `aria-disabled`: a component that is not disabled keeps the one its
  caller gives.
- Render cost: Button and Tag work their colors out once per render
  (`getButtonPaint`, `getTagPaint`) instead of in each style rule, Tag
  is a `memo` component, Effect remembers its gradient pass, Span, P and
  Link memoise their color, Input and Textarea use `getInputLook`. A
  hovered button's text color declaration lost its semicolon and was
  dropped with button animations on.
- Reduced motion reaches Reqore's own springs and `reqore-` elements
  only; the app's own react-spring animations and transitions are left
  alone (no `Globals.assign`).

COMPONENTS.md describes each. Tests: ariaDisabledPassThrough,
breadcrumbsColors, colorTolerance, controlOwnColors,
descriptionColorTolerance, explicitOpacityAndAria, fieldColors,
messageColors, navRailActiveGroupSurface, notificationColors,
portalSurfaces, reducedMotionSprings, stackedBarColors, timelineColors,
unpaintedSurfaceText, and updates to DescriptionList, reducedMotion and
segmentedControl.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ere they are drawn (qorus#646)

A hex icon color and an explicit opacity are now drawn as given, so the
stories that passed values which only read after Reqore changed them
fail the contrast gate. Each keeps the same kind of value in one that
reads:

- Button: the minimal button's left and right icons in a light purple
  and blue (they were near-black on the dark washes); the described
  button's icon in `success:lighten:2`, a theme color, since one hex
  cannot read on both the white and the dark fills the template is drawn
  in.
- Tag and Input: a lighter pink and purple.
- EntityRow and Testimonial With Effects: metadata and role at 0.7
  opacity (0.6 read at 4.18:1 and 4.13:1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…w holds (qorus#646)

DeepScan's REACT_INEFFICIENT_PURE_COMPONENT_PROP, 13 new on 473ada3:

- ReqoreTag is a memo component now, so every new object it is handed
  is flagged: DataView's bold and type-label effects are module
  constants, and the dropdown's trail of levels takes its click handlers
  from a `useCallback` and a `useMemo`'d list instead of new arrows.
- The segmented control's More button had the same three issues before
  (its props, its menu and the menu items' click handlers); its tap and
  surface changes moved their lines, so DeepScan counted them as new.
  They are memoised now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Qorus IDE draws a refusal as a muted ReqoreMessage (default fill, an icon and a title). On reqore
0.78.5 its text was near-black on the translucent muted grey over a dark page: the text was picked
against the opaque muted color the fill is made from, which is near-white. This PR's contrast work
picks it against the fill as drawn, which already fixes it.

Story Other/Message "Muted Refusal On The Page" draws the IDE's exact message and asserts that every
text in it reads (axe color-contrast against the composited fill). It fails on 0.78.5 (#0d0d0d on
#575757, 2.68:1, title and text) and passes here.
…(qorus#646)

Frosted text fades toward its foot, and Reqore raises the default
opacity as far as the foot needs to stand out from what it is drawn on.
It raised an opacity the caller gave the same way, against COMPONENTS.md:
"what a caller sets is the caller's: an explicit hex color or
effect.opacity is drawn as given ... the floors apply to the defaults
only". It could even go past 1 (an opacity divided by the foot's 0.52).

The frost's opacity is now `getFrostOpacity`: the caller's as given, or
the default 0.72 raised as far as the foot reads. The foot's part is
the named `FROST_FOOT`, used in both places that drew it.

Tests: a frost's given opacity is drawn as given (fails before: raised);
by default its foot reads by 4.5:1.
…for all of them (qorus#646)

Five components paint a card - an entity row, a callout, a testimonial,
a statistic, a feature card - and each worked out on its own what it was
drawn on: the surface around it, its fill over that, its gradient, the
text that reads there. The copies had drifted (the /audit of #717):

- an entity row's and a statistic's own text was picked against their
  fill, never their gradient, and their root never got the gradient for
  an `effect.color` either (a testimonial's, a feature card's and a
  callout's root neither), so on a light gradient a dark page's colors
  were kept;
- a callout's content took its `effect.color` against the page, not the
  callout (it was handed its fill under a name the effect does not read);
- a feature card picked its text and its description's fade against its
  flat fill, and handed only its footer the card's surface: its label and
  description were read on what was around it;
- a statistic with an intent rings its gradient in the intent's color,
  and a ringed gradient is drawn without its own surface color, but it
  read its text on that surface color all the same.

`useReqoreDrawnSurface` now works it out for all five: the surface, the
fill over it, each color of the gradient the card paints (a statistic's
as it paints it), and the text that reads on all of them. Each card's
root draws that text and reads an `effect.color` on those colors; a
feature card hands its whole body the card's surface. The surface helpers
move from the Panel folder to `src/helpers/surface.ts`: six components
use them.

Tests (cardsReadOnWhatTheyPaint): on a light gradient over the dark page,
an entity row's own effect.color, a callout content's effect.color and a
feature card's text and handed-down surface read on the gradient (each
fails before); an entity row's, a statistic's and a testimonial's text,
and a statistic with an intent, likewise.
…d leaves decoration alone on both paths (qorus#646)

Two gaps in the story run's contrast check, from the /audit of #717:

- What axe-core could not decide was measured only when it sat on a
  gradient; everything else it left undecided was dropped without a word
  (a single character, which axe cannot tell from an icon font; text
  beside pseudo-content; a text shadow), though the check said only text
  on an image is left alone. It is now measured against what it is drawn
  on, as text on a gradient is. Left alone, by name: text on an image
  (IMAGE_KEYS) and text something else is drawn over or that is off
  screen, where what is under it is not what its ancestors paint
  (POSITION_KEYS).
- `data-decorative` was honoured only on the gradient path: decoration on
  a solid fill that axe found too faint still failed the story. Every
  path now leaves exempt text alone (`isExempt`, was `isDisabled`, a name
  that hid the decoration rule).

The helpers only the check itself uses are no longer exported.

Stories (Other/Contrast Check): Decoration Is Left Alone and Undecided
Text Is Measured both fail before.
… how it came to be (qorus#646)

Message › Muted Refusal On The Page now says what it renders and shows, in
the "Renders X…" form, without naming internals or history (the /audit of
#717).

The contrast part of 8e93b4e on #717; the Collection story's description
is on the functional branch with the story it belongs to.
… portal too (qorus#646)

An app can open Reqore's drawers, modals and popovers in a portal of its
own (`customPortalId`). Reduced motion stilled Reqore's elements in the
layout and in Reqore's own portal only, so there they kept their
transitions. The provider now names the app's portal in the rule as
well (`getReducedMotionSelectors(customPortalId)`).

Test: a drawer in the app's portal is stilled where the provider is told
the portal, and Reqore's own portal alone does not reach it.
…nds out (qorus#646)

A rating's `filledColor` or `emptyColor` given as a theme's color
(`'warning'`, `'info:lighten:2'`) was turned into its hex first, and a
hex is drawn as given, so the star kept the color however little it
showed on what the rating was drawn on, while an icon given the same
color is moved to a shade that stands out by 3:1. It is now that shade;
a hex is still drawn as given.

The surfaces it reads are worked out once per surface (they were built
on every render and keyed on a joined string), and the hook is no longer
called inside an argument.

Tests: a star in the theme's warning stands out by 3:1 on a brown close
to it (fails before: 1.6:1); a hex is drawn as given.
…(qorus#646)

Each of these was written out twice, the copies able to drift apart with
nothing to notice; each now has one owner, and nothing changes in what
is drawn:

- the search for the least readable alpha (colors.ts, in getReadableAlpha
  and getReadableGroupOpacity): `leastReadingAlpha`;
- the ring a gradient effect draws under its fill (Effect, in
  getEffectGradientBackgrounds and getEffectRingStops): the latter;
- a panel's fill and its bar's fill, in the styled rules that paint them
  and the memos that hand them down as surfaces: `getPanelFill`,
  `getPanelBarFill`;
- the color of a card's own mark (an entity row's and a callout's icon,
  a testimonial's avatar): `getCardMarkColor` in helpers/surface;
- a field's look (Input's and Textarea's `lookOf`): Input's
  `lookOfField`; the clear button's opacity (`getInputClearButtonOpacity`
  repeated `getInputLook`): defined by it;
- a notification's softer content color: `getReadableFadedColor`;
- whether a checkbox's knob paints nothing (twice in Checkbox).

Dead code removed: `getReadableOpacity` (colors.ts) and
`getInputPlaceholderColor` (Input), which nothing called. Effect's
`getEffectRingStops`, `drawEffectLayers` and `drawGradientLayers` are no
longer exported: only Effect uses them. Three comments sit with the code
they describe again (colors.ts, Input, NavRail). Checkbox no longer calls
a hook inside an argument and keys its colors on a joined string. The
reduced-motion story command is typed with vitest's own `BrowserCommand`.
COMPONENTS.md labels the two sections this PR adds 0.78.7, the version
it ships in.
… (qorus#646)

From the /audit of #717, each checked against the code first:

Stories
- Input › Transitions Under Reduced Motion passed whether or not reduced
  motion was asked for; it now checks it is, then that the transition
  takes no time. Its description no longer describes the test run.
- NavRail › Idle Reveal / Idle Resting, Icon › Basic, Notification ›
  what goes inside: described in the "Renders X…" form, not as test
  steps.
- DatePicker › With Effect is on the theme's info again (its darker
  shades), not hex. The notification backdrop is drawn from the colors
  its surfaces are read from, with its stripes as the plain wash they are
  read as (no blend mode the model did not follow).

Tests
- aria-disabled: the disabled branch of the five components is tested
  (disabled says so, whatever the caller says; neither says nothing).
- A crumb with an intent is checked to read on its own fill, not just to
  differ from the theme's item color; a menu's handed-down surface is
  checked against what it paints. A commit hash left a test name.
- Shared, instead of copied: the reduced-motion mock (and the two cases
  both files tested), the surface probe (five files), the tests'
  independent color maths, the icon look reader.

The contrast part of 5732f35 on #717; its functional part (the row-menu
and folded-trail stories, the control group tests' shared layout model) is
on the functional branch.
…r is drawn on (qorus#646)

The accordion read its header's text against one color: the header's fill
over what is around the accordion. When the accordion's effect paints a
gradient, a minimal or transparent header is drawn on that gradient, and
its text was picked for the page instead. What a header holds (its icons,
its title, its badge) was read on the page too, not on the header.

Now the accordion hands its items each color of its gradient as what they
are drawn on. A header's text reads on every color it is drawn on, and the
header hands those colors to what it holds. An icon of the item's own color
is shaded by the icon itself, against the header. The body's fill is one
helper, used both by its styled rule and by what it hands its content.

Tests: Accordion.test, two cases, both failing before:
- a minimal header's text reads on the gradient;
- what a header holds is drawn on the header.
…r as its intent's shade reads (qorus#646)

An uppercase label is drawn fainter than the content, as far as it still
reads. How far was worked out for the list's text color, even when the label
is written in its intent's shade (labelIntent). Danger's shade on the dark
theme stands out less than white, so faded as far as white can be, it no
longer read (2.4:1).

Now the fade is worked out for the color the label is written in. A label
in the content's case is not faded, and nothing is worked out for it. The
label is no longer a memo component: what it holds is new on every render
of the list.

Tests: DescriptionList.test.
- An uppercase danger label reads at 4.5:1 or better on what the list
  hands its rows. Fails before (2.4:1).
- A label in the content's case is drawn at full strength.
Test helper: colorMath.fromHex.
…(qorus#646)

The rail read what its marks are drawn on as its fill over what is under it,
even when its own effect paints a gradient. On the dark theme a light
gradient gave the marks light text on light (1.15:1).

Now the rail's surfaces are each color its effect paints: its fill when the
effect paints no gradient, as before. Its marks, the active group's tint
over it, the idle opacity's measure and what the rail hands its items are
all read on those colors.

The rail's fill color is one helper, used by its styled rule, by its
surfaces and by the fade at its scrolling edges, which each wrote it out
before.

Test: navRailActiveGroupSurface.test. A rail with a light gradient of its
own gives its resting marks text that reads at 4.5:1 on every color of the
gradient. Fails before (1.15:1).
…opacity (qorus#646)

At full opacity, a panel wrote its own text (what it holds that sets no
color of its own) for its theme's main. The panel does not paint that color:
it paints it a step darker. On a mid grey the step alone decides which text
reads. A panel in #808080 paints #737373, and dark text picked for #808080
read at only 4.1:1 there, where white reads.

Now its text is picked for what its content is drawn on, at any opacity: its
fill as painted, or each color of its content's gradient. Below full opacity
it already was.

Tests: secondaryMarksReadWhereDrawn.
- A panel's own text reads on the fill it paints. Fails before (4.1:1).
- A bubble's timestamp is faded only as far as it reads where the bubble is
  drawn (inside such a panel it failed with the panel).
- A timeline marker's icon in its item's intent stands out from the marker
  by 3:1.
…e props for memo children (qorus#646)

From the #717 audit's leads, each checked against the code first.

What a component paints is written once, and read from the same place:
- a fill its styled rule paints and its surface memo reads: the menu
  (getMenuFill), the segmented control's track, the switch's track, the
  input's and the text area's wash (getInputFill), the notification's fill
  (getNotificationFill);
- the newer notification's frosted surface, bloom and icon tile alphas are
  named constants, used by its rules and its palette alike;
- the placeholder's least fade is INPUT_PLACEHOLDER_ALPHA, shared by the
  input and the rich text editor;
- FeatureCard's large-text size is LARGE_BOLD_TEXT_PX (WCAG's 14pt bold),
  and its marker scale is a named constant.

Memo children are handed the same props for as long as they hold:
- a segmented control's items get one click handler and one ref callback
  each, which select through the latest onChange. An item that is gone
  takes them along: the map was never pruned. The selected item's theme
  is memoized;
- a testimonial's author row gets memoized label and description effects;
- a menu divider calls its surface hook at the top level and memoizes the
  surfaces.

Hygiene:
- the clear button no longer omits a prop it never forwards;
- the tree's and the heading's comments say only what holds.

Test: segmentedControl.test. A click answers to the onChange the control
has now, not the one it was first given. It fails if the handler keeps the
first select.
…them at any width (qorus#646)

Tier · Modern, Modern Light and Tier Group · Row checked one row of
equal-height cards, which holds only where the grid has room for every
card on one line. Opened at a phone's width, the grid puts one card
under the other, as it should, and the plays failed ("expected 54.5 to
be less than or equal to 1").

`expectModernRow` now reads the line count from the grid's own rule
(auto-fit columns no narrower than its `minColumnWidth`, with its gap,
on its content width): one row where they all fit, one card a line on a
phone, and the lines in between. It checks each line's heights and
buttons. The group's default minimum is exported as
TIER_GROUP_MIN_COLUMN_WIDTH, so the play and the group read the same
value. The frames do not change; the plays pass at 390, 800 and 1440 px
and in the Storybook UI.
Comment thread src/helpers/locale.ts Fixed
@davidnich davidnich changed the title fix: text and icons read on what they are drawn on; reduced motion (qorus#646) — 0.78.12 fix: text and icons read on what they are drawn on; reduced motion (qorus#646) — 0.78.13 Oct 24, 2026
@davidnich

Copy link
Copy Markdown
Member Author

Merged develop (#729). Version 0.78.13, develop+1. Added reqore fixes for qorus#646:

  • autosized textareas are as high as their text, padding and border, are refit when their width changes, and are measured with no scrollbar at their own width;
  • DatePicker reads a POSIX locale name (en-US@posix under LANG=C on Linux, a host's cs_CZ.UTF-8) as the tag it stands for instead of throwing.

Test infrastructure:

  • check-only story projects storybook-scrollbars and storybook-posix-locale run in yarn test:stories and make no captures;
  • the contrast gate now waits for motion to settle (react-spring idle and CSS transitions finished) instead of relying on qlip's capture;
  • the Tier and TierGroup stories hold at any width: phone stories in a phone-wide column, and the row checks follow their grid's width rule.

Docs: the features labelled 0.78.7, a version never published, now say 0.78.10.

No review comments or qlip denials were open.

…anguage itself (qorus#646)

On the CI runner (ubuntu-latest, amd64) all 27 stories of the
storybook-posix-locale project failed on its guard: the browser,
launched with LANG and LC_ALL set to C.UTF-8, reported `en-US`, so the
project would have checked nothing. Reproduced on ubuntu 24.04 amd64
with Node 22.19.0 and `playwright install --with-deps chromium`, as
the job installs it, and in the Playwright 1.60.0 image on amd64: the
amd64 Linux Chromium (headless shell and full) reports `en-US` whatever
LANG, LC_ALL, LANGUAGE or `--lang` says. Only the arm64 Linux build,
where the project was first checked, takes its language from the POSIX
locale and reports `en-US@posix`. Neither the runner's LANGUAGE nor the
provider was the cause: the provider passes no locale and no `--lang`.

The project now gives its browser context the language itself, with
Playwright's `locale: 'en-US@posix'` (POSIX_LANGUAGE). Chromium then
reports it as it is, Intl's default locale follows it, and
`new Intl.DateTimeFormat(navigator.language)` throws as it does for
such a user, on every platform and build, macOS included. The guard
checks for exactly that language, everywhere. On ubuntu 24.04 amd64 the
project fails 22 of 27 DatePicker stories on develop's DatePicker
(`Invalid language tag: en-US@posix`) and passes all 27 with the fix.
@davidnich

Copy link
Copy Markdown
Member Author

CI fix (11cb7ba): the POSIX-locale project now gives its browser the POSIX language through Playwright's locale (en-US@posix). The CI runner's amd64 Chromium ignores LANG/LC_ALL and reported en-US, which the project's guard caught in run 40094851984; the guard now requires en-US@posix on every platform.

@davidnich

Copy link
Copy Markdown
Member Author

@Foxhoundn qlip build 20261024-132450: 336 of the 549 changed frames are the same capture against the same baseline as build 20261022-115740, which passed them at 0%, so they now cross qlip's threshold rather than changing in this push (probably the comparison change after qlip-server#50); each has a one-sentence cause.

…aws (qorus#646)

ReqoreDataView declared and documented a `labels` prop (0.71.15) and
never read it: every section summary ("Object · 2 fields"), edit and add
affordance and type-picker name was the built-in English, so a consumer
translating the view, or naming a hash "2 fields", changed nothing. The
prop was not even destructured, and went on to the panel.

Every word the view draws now comes from the labels, the consumer's over
REQORE_DATA_VIEW_DEFAULT_LABELS (the words it drew before, so a view with
no labels reads exactly as before). Two labels are new: booleanTrue and
booleanFalse, the words of a yes/no value ("Yes", "No"), which keeps its
boolean colour.

Tests: a hash and a list named by the labels, the root's single value, a
yes/no in the labels' words, the edit affordances, and the defaults
unchanged (four fail without the fix). 0.78.13.
…orus#646)

The labels were spread over REQORE_DATA_VIEW_DEFAULT_LABELS, so a label
given as `undefined` replaced its default with nothing: a consumer that
builds its labels from its own translations, and passes those it has
none for as `undefined`, made the view call `undefined` for a summary,
and it threw (TypeError: ctx.labels.objectSection is not a function).
The view now takes only the labels that are given (omitBy isUndefined,
as SegmentedControl does), resolved once per labels object.

IReqoreDataViewLabels and REQORE_DATA_VIEW_DEFAULT_LABELS are exported
from the package, so a consumer can type its labels and build on the
built-in words.

Tests: undefined labels keep their defaults, the built-in words are
published, and a yes/no in the labels' words keeps the boolean colour
(two fail without the fix).
…phone (qorus#646)

Custom Labels renders an editable DataView whose labels are plain words:
"9 fields" and "3 entries" in its summaries, "Yes" and "No" for its
yes/no values, and "New field" / "New entry" on its add buttons. Custom
Labels On A Phone is the same view at 390px (touch). Both plays check
the summaries, that no built-in "Object ·" / "List ·" or "true" is
left, the yes/no words and the add buttons; the phone play also checks
the page is no wider than the screen. Both fail without the labels fix;
the 25 existing DataView frames are byte-identical.
…orus#646)

Every Vitest run, and so every unit and story run, warned:

  (!) Your Vite config uses features that are unsupported by
  `configLoader: 'native'` ...
    - ESM syntax in a file loaded as CommonJS (vitest.config.ts:1:1)
    - ESM syntax in a file loaded as CommonJS (src/stories/utils/posixLocale.ts:10:1)
    - ESM syntax in a file loaded as CommonJS (vite.config.ts:1:1)

The configs are written as ES modules, but a `.ts` file takes its module
format from the nearest package.json, and reqore's has no "type", so
Node, and Vite's native config loader (planned to become the default),
read them as CommonJS. Vite 8's bundling loader still loads them, as
CommonJS, and its compatibility check reports each file it loaded.
"type": "module" in package.json is not the fix: it would make the
CommonJS that `yarn build` publishes in dist/ load as ESM.

vite.config, vitest.config and the module the config shares with the
story setup (src/stories/utils/posixLocale) are now `.mts`, ES modules
whatever the package says, and the config imports them by their real
names, as Node needs. tsconfig.json includes src/**/*.mts, so
posixLocale stays type-checked. References to vitest.config.ts name
vitest.config.mts.

No behaviour change: the resolved config (every option, project and
plugin) is the same as before, loaded by the bundling loader and by the
native one, which now loads it too. Storybook finds vite.config.mts.
…the first render (qorus#646)

Picking a date in a picker with none warned, once for each field that
changed (WARN: A component changed from uncontrolled to controlled.):

- the calendar was given `focusedValue={focusedValue}`, which is `null`
  until a date is focused, and react-aria's useCalendarState reads a
  null focused value as none given. It was uncontrolled until the first
  date was focused, then controlled. It is now given a date always: the
  one focused, else the value's, else today's, the date it opened on
  before.
- the time field was given `value={time}`, `undefined` until the first
  date set a time. With none it is now `null`, which react-aria reads as
  a controlled empty field.

What the picker shows and does is unchanged: its 27 frames are the same.
The story run now fails a story that warns (the next commits), and
Without Default Value and Should Save Time When Date Value Is Null
failed on these warnings.
…ontrol (qorus#646)

Entity Row's Transparent With Icon Tile warned: "Addon controls: Control
of type color only supports string, received "boolean" instead". The
preview gives a color control to every arg whose name ends in
"background" or "color", and `iconHasBackground` (EntityRow,
Notification) is a yes or no. Storybook checks every arg's name against
the matcher and warns whatever control the story's argTypes give it
(a boolean control in Entity Row's meta still warned), so the matcher
is where it is fixed: a name that says whether something has a
background or a color is no longer read as a color. Every other
color-named prop still takes a color picker; the frames are unchanged.
…heck its warning (qorus#646)

Two Table stories give the table what its development warnings are
about, on purpose, and the warnings were printed in every story run:

- Wrapped Rows Fit Their Body gives a wrapping table a `rowHeight`, to
  show its body ignores it ("`rowHeight` has no effect on a table that
  renders every row");
- Long Content Wrapped And Virtualized wraps a virtualized table
  ("`wrap` / `cell.wrap` has no visual effect when `virtualized` is
  explicitly true").

Both warnings are right. The stories now check that the table gives
each one, once, and keep it off the console: `expectedWarning`
(src/stories/utils/expectedWarning.ts) is a story's `beforeEach` that
takes the console warnings matching it from before the story renders,
and passes any other on. What the stories draw is unchanged.
… it answers (qorus#646)

Storybook warned on every start:

- "Using CommonJS in your main configuration file is deprecated with
  Vite": .storybook/main.js was `module.exports`. It is now main.mts,
  an ES module whatever package.json says (it has no "type"), with
  `export default` of a typed `StorybookConfig`. Its settings are
  unchanged.
- "--host is set to 0.0.0.0 but no allowedHosts are defined. Allowing
  all hosts.": served on every interface, it answered a request for any
  host name. `core.allowedHosts` now names localhost and this machine's
  own name, in full and its first label (`host.example.com`, `host`);
  an IP address is always allowed. A page that names another
  host gets "Invalid host" (403) from Storybook and from Vite, so a site
  that rebinds its own name to this machine cannot read it.

Checked: Storybook starts with neither warning and serves the machine's
short and full names, localhost and 127.0.0.1, and 403 for any other
name; `storybook build` builds; the
story run finds the stories through main.mts as before.
…s each (qorus#646)

The table story run warned "Over 200 classes were generated for
component styled.div" (StyledTableCell): every column width, minimum
and maximum, pin offset, height cap and cell color was written into
the cell's styles, so each one made a class of its own - 200 in the
Table stories' page, 43 of them for widths alone - and a hovered or
selected row swapped every cell's class for a new one.

Those values are now CSS variables the cell sets on itself (`attrs`
style: --reqore-table-cell-width, -min-width, -max-width, -pin-offset,
-max-height, -background, -color, -border-color, -hover-background),
read by the same declarations in the same rules, so the cascade is as
it was. A value a cell has none for is not set and not read, as the
declaration was dropped before. The cell's classes now differ only in
what it is (pinned, wrapping, padded, interactive, its size).

The 74 Table frames are byte-identical, and the warning is gone.

Unit tests compute CSS variables as a browser does: jsdom gives a
property that reads one as written (`var(--reqore-table-cell-width)`),
so the folded-column tests read NaN for a cell's width.
__tests__/cssVariables.ts makes getComputedStyle substitute each
variable a property reads with its value where the element is (or its
fallback; one set nowhere stays as written) and give it as jsdom gives
a declared value; the unit setup installs it.
…class each (qorus#646)

The Button story run warned "Over 200 classes were generated for
component styled.span with the id of sc-aXZVg". That id is
StyledEffect's, but StyledEffect made 31 classes: styled-components
copies a component's statics onto every component that extends it,
`warnTooManyClasses` among them, so StyledButton, StyledTag and every
other styled(StyledEffect) count their classes together under
StyledEffect's name. In the Button stories' page StyledButton alone
made 150: each color a button is drawn in (22 fills, 17 edges, 13
description colors, 11 text colors, 9 rings) and each length (13
paddings, 9 heights, ...) made a class of its own.

The button's paint (rest, hover and active fills, text, edges, ring,
description colors) and its lengths (padding, font size, heights,
widths, radius, description padding) are now CSS variables the button
sets on itself (`attrs` style, --reqore-button-*), read by the same
declarations in the same rules, so the cascade - a Tabs item's
override of a button's colors included - is as it was. A color the
paint has none for is not set and not read, and a length of a size a
button lacks is written as before, so each declaration CSS dropped is
dropped still. The button's classes now differ only in what it is.

The 27 Button frames are byte-identical. The card hover-fill unit test
reads a button's :hover rule from the stylesheet, which now names a
variable: it computes it where the button is (computedValueAt).
…each (qorus#646)

The Tag story run warned "Over 200 classes were generated for
component styled.span" (StyledEffect's name, for every component that
extends it, StyledTag among them: see the button's commit). StyledTag
made 153 classes in the Tag stories' page: each fill and hover fill
(12 each), border, text color, `maxWidth`, `width` and radius made a
class of its own.

A tag's paint (a solid tag's fill and text, hovered too, its key's
shade and text, its edge and outline), a soft or text tag's colors,
its `maxWidth` and `width`, and its size's font size, height and
radius are now CSS variables the tag sets on itself (`attrs` style,
--reqore-tag-*), read by the same declarations in the same rules. A
value the tag has none for is not set and not read, as the declaration
was dropped before. The tag's classes now differ only in what it is.

The 21 Tag frames are byte-identical, and the Tag and Button story
pages no longer warn.
…akes React warn does (qorus#646)

The story run failed a story that made React warn, and printed any
other warning in the middle of the run and passed: a field that turned
from uncontrolled to controlled (DatePicker), a color control given a
boolean (Entity Row), styled-components making over 200 classes (the
table cell, and Button and Tag together), and the table's own warnings
about props it ignores. The previous commits fix each of them.

The warning gate (src/stories/utils/reactWarningGate.ts) now keeps
every other console.warn too, under its own rule ("a console warning":
its first line, with the frames that name reqore's source), and fails
the story it was given in, before it or after a file's last story, as
it does React's. An error on the console is not a warning: a story
that shows what an error does logs it. A story that does on purpose
what a component warns about checks the warning with `expectedWarning`
instead. The gate also no longer names its own frame as where a
warning was given: its file moved to src/stories/utils/, and the
filter still named the old path.

Fail-first: with the gate and none of the fixes, the full story run
failed 10 stories in 6 files on these warnings; with the fixes, all
1269 pass and the run prints no warning. Documented in
.claude/CLAUDE.md, with how unit tests read CSS variables.
… an error does and checks it (qorus#646)

The story run printed three errors for each Error Boundary story that
throws (Throws, Show Details, With Custom Message, With Custom Global
Message, Resets), and passed:

- "Error: Uncaught Error: This is a test error": React, in
  development, reports an error it hands to a boundary as uncaught too,
  and the story run logs that report;
- "The above error occurred in the <ThrowsError> component:" (React);
- "ReqoreErrorBoundary caught an error: ..." (the boundary, which logs
  what it catches in production too).

The warning gate (reactWarningGate.ts) now fails a story for any
console.error that is not React's warning, under its own rule ("a
console error"), as it does for a console warning. Fail-first: with it,
those five stories failed on exactly these errors.

They are what those stories are about, so the stories check them:
`expectedError(...patterns)`, next to `expectedWarning` in
src/stories/utils/expectedConsole.ts (renamed from expectedWarning.ts),
is a story's `beforeEach` that takes the console errors the patterns
match off the console, and `expectErrored()` checks each was logged
once. Any other error goes on to the gate. Both read a call as the gate
does (`consoleCallText`: its first line, an error as its name and
message).

The precheck and the full story run pass (2090 unit tests, 1269
stories), and the run logs no warning and no error.
…y whoever runs it (qorus#646)

Vite 8 forwards a browser's console and its unhandled errors to the
terminal by default only when an AI agent runs it (std-env's
`isAgent`), so a run in an agent's shell printed what CI's and a
person's never did. With every warning and error now judged by the
story run's gate, what remained was "[Unhandled error] Error: This is
a test error" once for each Error Boundary story that throws: React,
in development, reports an error a boundary caught as uncaught too, by
dispatching it to the window, and Vite forwarded that event. No story
can stop it without stopping React's own handler, which runs after
Vite's.

`server.forwardConsole: false` in vitest.config.mts makes every run
print as CI's does. Nothing a story logs is lost: the gate fails a
story for any warning or error it did not expect and names it (checked
by dropping the Throws story's expectedError: it failed on its three
errors).
Two things printed on every unit run, outside any test's output:

- "Error: boom-from-child" and its stack, four times: React, in
  development, reports an error a boundary caught as uncaught too, by
  dispatching it to the window, and jsdom prints any error event no
  listener cancels. The error boundary tests now take that report
  (cancelled, as a page that handles it does) and check it came.
- "Not implemented: HTMLCanvasElement's getContext() method": jsdom has
  no canvas. The unit setup gives a canvas no 2D context, which the
  code that measures text with one handles (it measures nothing); a
  test that measures text still gives the canvas a context of its own.

2090 unit tests pass and the run prints only their results.
… story run (qorus#646)

Opened in Storybook (David's review), every Error Boundary story that
throws ended "errored", before this branch and after b51b4a4:

- Storybook marks a story errored when its page reports any error to
  the window while it plays, and React, in development, reports an
  error a boundary caught to the window as uncaught too. The browser
  printed it as well.
- b51b4a4's expectation named the story run's report of that error
  on the console ("Error: Uncaught Error: ..."), which Vitest makes and
  a browser opened in Storybook does not.
- Show Details and Resets pressed "Details" and "Reset" by their
  words, which a narrow screen draws as icons named by those words, so
  at 390px their plays failed.
- Both spread Throws after their own parameters, so they showed its
  description, not theirs.

`expectedError({ uncaught, logged })` now takes the window's report of
the `uncaught` error and cancels it, as a page that handles it does (so
the browser does not print it, and React does not log it again), takes
the story run's console report of it where there is one, and takes the
`logged` console errors. `expectErrored()` checks the uncaught error
was reported once and no other was, and each logged error once; the
stories spread its `parameters`, which leave Storybook's own check to
it. The stories press the fallback's actions by name and keep their
own descriptions.

All six pass in Storybook at 1440 and 390px and print nothing there,
and pass in the story run, which logs nothing. The frames are the same,
but Show Details, whose stack trace names the story file's lines.
@davidnich

Copy link
Copy Markdown
Member Author

Added (11cb7ba..87b115a), stories approved:

  • DataView draws the labels a consumer gives (labels was declared since 0.71.15 but never read; "Object · N fields" was hardcoded): every DataView string comes from labels, with defaults identical to today's text; new booleanTrue/booleanFalse; a label given as undefined keeps its default; IReqoreDataViewLabels and REQORE_DATA_VIEW_DEFAULT_LABELS are exported.
  • No console output in test runs: the story gate now fails a story on any unexpected console.warn or console.error; deliberate ones are asserted with expectedWarning / expectedError. Fixed sources: DatePicker controlled from the first render; Button, Tag and Table per-instance values as CSS variables (styled-components "Over 200 classes"); the preview's color-control matcher; the Vite/Vitest configs and Storybook main config as ES modules (.mts), with explicit allowedHosts.
  • Error Boundary stories pass in the Storybook UI too (at 390 px the actions are pressed by accessible name).

Default frames are unchanged (Show Details' stack trace shows new line numbers). Precheck: 2093 unit tests; story suite 1269/1269.

…hen the app gives one (qorus#646)

The navigation rail spells out its labels once the pointer has rested on
it for showLabelsDelay (1.5 s), on a setTimeout. An app's stories could
not say when that is: in the Qorus IDE's story run a play that clicked
the rail's chevron and moved on was captured with the labels open when
the runner was slow and closed when it was fast, and the pointer parked
"on the page" afterwards landed inside the widened rail.

ReqoreUIProvider takes options.schedule, (ms, fire) => cancel: how
Reqore does something after a while. It is the machine's clock by
default (scheduleOnTimer, the setTimeout it was), so nothing changes
for an app that gives none; an app's stories hand their own clock and
the dwell passes when they say. The rail's label dwell waits on it.
Two tests: the labels wait for the app's schedule however long the
machine's clock runs, and leaving the rail cancels the wait; both fail
on the timer.
…orus#646)

A drawer or modal traps focus while it is open and means to give it back to
the element that had it when it closes. The give-back read the saved element
one frame after the hook had cleared it, so it never happened: focus fell to
the page when the dialog went away with the focused control in it, and stayed
on the opener only when the dialog closed before its first frame. Which of the
two a reader got depended on timing; in the IDE's story runner the same story
ended with its tab focused on one run and nothing focused on the next.

The element is now read before it is cleared, and given focus back only when
focus would otherwise be lost (on the page, or still inside the container that
no longer traps it): focus the app has moved somewhere else since is left
there. A trap that ends before its first frame no longer pulls focus into its
container afterwards.
…ced motion (qorus#646)

A table scrolling back to its top, a page of results scrolling into view and a
navigation rail's section scrolled to smoothly whatever the user's "reduce
motion" setting (WCAG 2.3.3). They now ask smoothScrollBehavior() (next to
usePrefersReducedMotion), which answers 'smooth', or 'auto' (at once) for a
user who asked for reduced motion, read as each scroll starts. A menu item,
which already honoured the setting, read it once when it rendered first; it now
follows a change of it. A test fails on a scroll that names smooth scrolling
itself.
…p gives one (qorus#646)

A table's scrollbar thumb shows while its body scrolls and fades 1.2 s after,
on a timer of its own. An app that captures its pages, the IDE's story runner,
caught the thumb on one run and not on the next, by when the capture came. The
fade now waits on the provider's schedule (options.schedule, as the navigation
rail's hover dwell does), and on a timer, as before, without one. Tests: the
thumb waits for the app however long the machine waits, another scroll
cancels the pending fade, and without a schedule it fades on its timer.
… a pattern that backtracks (qorus#646)

CodeQL flagged `toLanguageTag` on #723 (js/polynomial-redos, alert 5):
`/[.@].*$/` dropped the POSIX codeset or modifier with a pattern that does not
reach past a line break, since `.` stops at one and `$` is only the end. In a
name with a line break after the codeset, it left the codeset in place, and
for every `.` before the break it tried again, which is quadratic in the length
of what a browser or host hands over. The tag is now cut at the first `.` or
`@` by position. A test reads a codeset and a modifier that run on past a line
break as dropped (it fails with the old pattern), and a long run of dots before
a break as no tag.
@davidnich

Copy link
Copy Markdown
Member Author

Added (87b115a..a5b4adf), for qorus#646; the four story-related commits were approved by David with their story table (nothing changes how a Reqore story looks):

  • 2c22de63 What happens after a while waits on the app's clock when the app gives one: ReqoreUIProvider takes options.schedule ((ms, fire) => cancel), and the navigation rail's 1.5 s hover dwell asks it. Without one, nothing changes (a timer, as before).
  • b3936cf8 A dialog that closes gives focus back to what had it: the focus trap cleared the element it had saved and read it a frame later, so focus fell to the page.
  • 0b01e4b4 A scroll Reqore starts jumps for a user who asked for reduced motion (WCAG 2.3.3): the table back to its top, a page of results, the rail's sections.
  • c51f9eae The table scrollbar's fade waits on the app's clock when the app gives one (the same options.schedule seam), and on a timer, as before, without one.
  • a5b4adfd CodeQL alert 5 (js/polynomial-redos, src/helpers/locale.ts) fixed: a locale tag is cut at its POSIX codeset or modifier by position instead of with /[.@].*$/, which did not reach past a line break and tried again from every . before one. A new test fails on the old pattern; every locale name without a line break reads as before.

Each fix has a test that fails without it. Precheck at a5b4adf: lint, 2106 unit tests, both type builds. The touched story files (Table, NavRail, Paging, Menu, DatePicker) pass in all four story projects (193 of 193).

DeepScan reports "3 new issues" on this PR. I could not read them: the DeepScan dashboard needs a login, so they are not looked at yet.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants