Skip to content

4227 shoelace to webawesome#4262

Merged
freakboy3742 merged 35 commits intobeeware:mainfrom
shalgrim:4227-shoelace-to-webawesome
Apr 28, 2026
Merged

4227 shoelace to webawesome#4262
freakboy3742 merged 35 commits intobeeware:mainfrom
shalgrim:4227-shoelace-to-webawesome

Conversation

@shalgrim
Copy link
Copy Markdown
Contributor

@shalgrim shalgrim commented Mar 21, 2026

Changes

Component Migration

Migrates the web backend from Shoelace (now in maintenance mode) to WebAwesome, its official successor maintained by the Font Awesome team.

There is no official migration guide from Shoelace to WebAwesome. Changes were verified against individual WebAwesome component docs:

Change Shoelace WebAwesome Source
Component prefix sl-* wa-* Rebranding — all WebAwesome components use wa-
Slider renamed sl-range wa-slider wa-slider docs
Menu removed sl-menu + sl-menu-item wa-dropdown-item directly wa-dropdown docs
Button variant variant="primary" variant="brand" wa-button docs
Button appearance Default (filled) appearance="outlined" WA defaults to a filled "accent" button; outlined matches Shoelace's default look
Events unprefixed sl-change, sl-input change, input wa-input, wa-select, wa-switch
Dialog API .show() / .hide() .open property wa-dialog docs
Divider direction vertical attribute orientation="vertical" wa-divider docs
CSS tokens --sl-font-sans, --sl-color-primary-800, etc. --wa-font-family-body, --wa-color-brand-40, etc. Ported all --sl-* tokens in toga.css to native --wa-* equivalents
CDN path dist/ dist-cdn/ WA install docsdist-cdn is required for direct browser loading without a bundler
Loading strategy Synchronous shoelace.js bundle Async webawesome.loader.js autoloader WA lazy-loads component definitions on demand instead of bundling everything upfront

Loads WebAwesome's shoelace.css compatibility theme. The theme's palette and overrides are scoped to .wa-palette-shoelace and .wa-theme-shoelace on the <html> element; a synchronous script in index.html~head sets both classes before CSS is evaluated. All CSS token references in toga.css have been migrated to native --wa-* variables.

Async element upgrade workarounds

WebAwesome registers custom elements asynchronously via a lazy loader. Unlike Shoelace's synchronous bundle, element properties (.value, .checked, .min, .max, etc.) may not exist when Python code runs during widget init. This causes AttributeError crashes on Switch, Selection, and Slider.

shoelace.js was a pretty sizeable file that loaded/defined every custom element in the shoelace library. WA doesn't do that. Instead it loads a very small autoloader that watches for mutations in the DOM and, if it sees a new custom element in the DOM that is in its library, then it downloads that code and defines the element and upgrades it to an element of the custom element class

However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. This causes AttributeErrors in both sets and gets.

The workaround is to wrap the native element in a proxy that:

  • For sets, maintains a dict (ordered by default in Python) that maintains property values in a dict before an element is upgraded.
  • For gets, refers to the dict before upgrade; if the element is not in the dict, delegates to the underlying element (which will itself raise AttributeError for custom-element-only properties pre-upgrade, letting the widget supply a default).
  • Registers a callback that, when components are defined, upgrades the element and "replays" the dict of attributes that got set.

Since we're wrapping the native element in a Python object, we provide an unwrap method that returns the native element for cases where it's necessary (i.e., when we call a JS method (e.g., appendChild, insertBefore)).

We can auto-unwrap any NativeProxys sent as arguments when the receiver is itself a NativeProxy by modifying our __getattr__ override to inspect any callables and arguments and unwrap where necessary. One .unwrap is still necessary when calling appendChild on Window's native since Window is not itself a NativeProxy.

Cleanup

  • Removed style_framework from example pyproject.toml files. This key was deprecated in Briefcase and is no longer consumed.
  • Changed Shoelace references to WebAwesome throughout documentation

Pre-existing bug fixes

These fix bugs that predate the Shoelace→WA conversion and are unrelated to the migration itself:

  • Widget registration (db21b903b, freakboy3742): ActivityIndicator and TimeInput were missing from the entry points in web/pyproject.toml and were not registered as web widgets.
  • Box child management: Box.remove_child was a DOM no-op (only cleared the Python container reference without touching the DOM), and Box.insert_child was ignoring the index and always appending. Added proper removeChild/insertBefore overrides in box.py.
  • Button text color (451ae0628): wa-button (like sl-button before it) sets text color via a shadow DOM CSS variable, so the host element's inherited color property was silently ignored. Fixed by forwarding the Pack color as --wa-color-on-quiet in the inline style. Also restores feature parity for wa-switch label color via --wa-form-control-value-color.

Testing

Tested 19 web-compatible examples against both main (Shoelace) and this branch (WebAwesome) using manual interactive testing. All work the same on both branches.

What problem does this solve?

Shoelace is in maintenance mode and will not receive new features. WebAwesome is its actively developed successor.

Fixes #4227

PR Checklist:

  • All new features have been tested
  • All new features have been documented
  • I have read the CONTRIBUTING.md file
  • I will abide by the code of conduct

Trailers

Assisted-by: Claude Sonnet 4.6

Use the WebAwesome shoelace.css compatibility theme to preserve existing CSS variable names and minimize the scope of this migration.
- Rename sl-* tags to wa-* (e.g., sl-button -> wa-button)
- Rename sl-range to wa-slider (component was redesigned and renamed)
- Unprefix custom events: sl-change -> change, sl-input -> input
- Replace divider vertical attribute with orientation="vertical"
- Dialog: replace .show()/.hide() with .open property
  (https://www.webawesome.com/docs/components/dialog)
- Menu: remove sl-menu wrapper; wa-dropdown now takes
  wa-dropdown-item children directly
  (https://www.webawesome.com/docs/components/dropdown)
- Button variant: primary -> brand
  (https://www.webawesome.com/docs/components/button)
@freakboy3742
Copy link
Copy Markdown
Member

As you've noted, the style_framework key is no longer used by Briefcase. This was part of some work done last year in Briefcase; we retained the settings in the example apps as a transition mechanism - see #3822 for details on what needs to be done to clean up that transition mechanism.

#3891 already exists, ready to land, as a fix for the naming of the CSS file; it would make sense for this PR to clean up the rest of the style_framework handling.

Briefcase no longer uses the style_framework key; the web backend
now loads WebAwesome via insert files instead. Refs beeware#3822.
Comment thread examples/tutorial0/sandbox.html Outdated
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file is actually a stray - it shouldn't exist in the repo at all. It's evidently some cruft that got accidentally committed when we first added Shoelace support.

The dist/ build requires a bundler to resolve bare module specifiers;
dist-cdn/ bundles all dependencies for direct browser use.
WebAwesome registers custom elements asynchronously, so native element
properties (.value, .checked, .min, .max, etc.) may not exist when
Python code runs during widget init. Add _get_native_attr/_set_native_attr
helpers on the base Widget class that catch AttributeError and warn
instead of crashing. Apply to Switch, Selection, and Slider.
Comment thread web/src/toga_web/widgets/base.py Outdated
Comment thread web/src/toga_web/widgets/base.py Outdated
return getattr(self.native, attr)
except AttributeError:
tag = getattr(self.native, "tagName", "unknown")
warnings.warn(
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of this verbosity exists because I was trained by a past security team and now I have a reflexive need to not have blank except blocks without at least logging. It could probably just be pass

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.

Comment thread web/src/toga_web/static/toga.css
@shalgrim shalgrim marked this pull request as ready for review March 27, 2026 02:40
@shalgrim
Copy link
Copy Markdown
Contributor Author

Status

@freakboy3742 FYI I'm putting this PR down for two weeks. But it's probably ready for a preliminary review to see if things are on the right track

I've made an effort to write the description to be clear about what I've done as far as changes and testing. I've also commented on various lines that I felt would benefit the reviewer in understanding certain changes.

As mentioned, when testing the different examples, I now find the 19 examples mostly work the same on this branch as on main. In some cases, they crash on both branches. In other cases, they're slightly broken in the same way.

The main difference is the styling is just...different. I haven't yet figured out how to get them to match better, but I'm not sure how important it is. Here is an example of one of the most striking differences. This is font_size. I think it may actually work better now.

image

The main difference that exists just about everywhere is that black/gray vs. blue. That's the part I haven't figured out how to fix yet and can't judge the severity of that difference.

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like good progress!

I've flagged my concerns with the async attribute getter/setter tooling; your explanation makes sense, but as noted inline, I'd like to have a better understanding of the why - and in particular, if there's anything we can do to avoid the problem.

The change in color is an odd one. The differences are pretty stark - I don't think it matters too much if there are some cosmetic differences, but the broad look and feel should be retained to the extent possible.

Comment thread web/src/toga_web/widgets/base.py Outdated
Comment thread web/src/toga_web/widgets/base.py Outdated
return getattr(self.native, attr)
except AttributeError:
tag = getattr(self.native, "tagName", "unknown")
warnings.warn(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understandable (and good practice); however, as noted above, I'd like to have a better idea of why this will occur at all.

@freakboy3742
Copy link
Copy Markdown
Member

The test failures don't appear to be your problem - GitHub has clearly changed something in their Linux configuration over the last day or so.

@@ -1,5 +1,6 @@
from __future__ import annotations

import warnings
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like my linter got this though it's not related to the change of this PR. I'll change that back if it's not common practice.

@shalgrim
Copy link
Copy Markdown
Contributor Author

I'd like to have a better understanding of the why

So shoelace.js was a pretty sizeable file that loaded/defined every custom element in the shoelace library. WA doesn't do that. Instead it loads a very small autoloader that watches for mutations in the DOM and, if it sees a new custom element in its library, then it downloads that code and defines the element and upgrades it to an element of the custom element class

However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. Hence the AttributeErrors.

and in particular, if there's anything we can do to avoid the problem.

I think so. I'll start working on a pattern that:

  1. stores these special attributes in a dict
  2. waits on customElements.whenDefined to resolve when the tag has been registered by the autoloader
  3. forces the element to upgrade and "replays" that special attributes dict into actual attribute assignments

LMK if you have any thoughts on that approach

@freakboy3742
Copy link
Copy Markdown
Member

I'd like to have a better understanding of the why
...
However, in Toga's widget constructors, we first create the element, then start setting attributes (some of which don't exist on vanilla HTMLElement), then later it gets added to the DOM. Hence the AttributeErrors.

Ok - that makes sense (or, at the very least, explains the behavior we're seeing here :-) ) Thanks for that investigation.

and in particular, if there's anything we can do to avoid the problem.

I think so. I'll start working on a pattern that:

  1. stores these special attributes in a dict
  2. waits on customElements.whenDefined to resolve when the tag has been registered by the autoloader
  3. forces the element to upgrade and "replays" that special attributes dict into actual attribute assignments

LMK if you have any thoughts on that approach

I think the general approach is on the right track.

We do a broadly similar thing for WebView on a couple of platforms (Windows, for example) - we can't set the URL on a webview until the webview is fully initialized, so we have a @requires_initialization decorator that checks if the view is initialised; if it isn't, the invocation is deferred until initialization is complete.

That works well for setter methods; getters need a little more handling to ensure that appropriate defaults are returned (or the getter has a way to get the "current" value if the element isn't initialized).

The other approach that might work is a proxy wrapper for the native object - If we write a class that intercepts __getattr__ and __setattr__, we can set up the native object so that attribute access is deferred until the autoloader completion signal fires, then replays those attribute calls.

I don't have any strong opinions on which of those approaches we should use, though.

The last approach,  _get_native_attr / _set_native_attr, handled WebAwesome's async upgrade by silently dropping pre-upgrade writes, so widgets lost any state configured before upgrade.
Replace with a NativeProxy that buffers pre-upgrade sets and replays them from a customElements.whenDefined callback after force-upgrading the element. Widget code reverts to direct self.native.foo access with local AttributeError fallbacks where reads can happen before writes.
Callers that pass .native into appendChild/insertBefore now use .unwrap() to reach the underlying JsProxy, since Pyodide does not auto-unwrap Python wrappers.
Comment thread web/src/toga_web/widgets/box.py Outdated

def add_child(self, child):
self.native.appendChild(child.native)
self.native.appendChild(child.native.unwrap())
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't send the Python object in to these JS calls, so this unwrap method sends in the JsProxy

Comment thread web/src/toga_web/widgets/selection.py
Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've taken a quick review pass; a couple of notes about possible cleanups and edge cases of the "no default yet" handling, but otherwise, this definitely looks like it's on the right track.

Comment thread web/src/toga_web/widgets/base.py
Comment thread web/src/toga_web/widgets/base.py
Comment thread web/src/toga_web/widgets/selection.py
Comment thread web/src/toga_web/widgets/slider.py
toga.css referenced --sl-* tokens that no longer exist in WA's
shoelace theme, leaving the header unstyled. Replace with --wa-*
equivalents. wa-button defaults to appearance="accent" which
rendered as a dark filled pill; switch to "outlined" to match the
previous Shoelace look and let per-widget background colors show
through.
Instead of needing to call .unwrap when we’re calling into a JS method, this adds to our __getattr__ override to, if the method we’re calling is a JS method, automatically unwrap any NativeProxy arguments
@shalgrim
Copy link
Copy Markdown
Contributor Author

I've added the auto-unwrap and re-tested the examples. Ready for re-review when you have time @freakboy3742

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My review of the code all looks great; I still need to do a functional review to make sure everything still works. I should get a chance to do that early next week.

@shalgrim
Copy link
Copy Markdown
Contributor Author

I still need to do a functional review

As a heads up, button and font_size were the two examples where I saw different behavior but afaict things worked closer to how they were intended with the new code

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've had a chance to take the examples for a spin - I only spotted a handful of issues, and the answer for most of them might be "lets treat that as a future problem" unless you can find a quick solution:

  1. ActivityIndicator and TimeInput aren't registered as web widgets.
  2. Titlebar color is rgb(0, 63, 156), rather than rgb(7, 89, 133).
  3. ActvityIndicator - Starts in a "visible and active" state.
  4. Box - Disabling the "yellow" button doesn't disable or hide the yellow button.
  5. Slider - Release message doesn't work unless you're hovering over the handle when you release the mouse button (if you press, and move horizontally and vertically, you don't get the release message).
  6. I think I'm seeing a FOUC (Flash of Unstyled Content), especially when there's a multiple widgets on the page.

(1) was a pre-existing problem; I've pushed an update to correct it as part of this PR.

(2) may just be a theming issue. It seems weird that the Shoelace compatibility theme doesn't actually maintain complete compatibility; but if that's what WA gives us, then I guess that's what we've got. It's not worth spending any time on.

(3)-(5) are at least partially pre-existing problems. They are either in the shoelace version of the demos, or there's an analogous problem. Since we're in the area and updating things, it's worth having a quick poke around to see if we can fix those problems, but don't burn too much time on it.

(6) is a bigger problem. I'm guessing this is related to the delayed-load handling. If you've got any ideas on how to minimize the FOUC (possibly by deferring the window.show() until such time as all the content has been loaded?), then it might be worth tackling now - but I can also accept this as a longer term problem to be resolved.

@shalgrim
Copy link
Copy Markdown
Contributor Author

Thanks. Just wanted to acknowledge that I got this feedback and plan to work through it. I don't know when that will be, but wanted to put this here so it wasn't like I was ghosting.

insert_child was ignoring the index and always appending; remove_child
was only clearing the Python container reference without touching the DOM.

In the Box example, removing the yellow button does not reset the background
color — the Switch controls button availability, not color state.
The shoelace.css compatibility theme was loading but silently doing
nothing: its palette and theme rules are scoped to .wa-palette-shoelace
and .wa-theme-shoelace respectively, and neither class was present on
<html>. Added a synchronous script to index.html~head that sets both
classes before CSS is evaluated.

With the shoelace palette now active, --wa-color-brand-40
(rgb(0, 93, 147)) is closer to the original Shoelace
--sl-color-primary-800 (rgb(7, 89, 133)) than --wa-color-brand-30 was.
@shalgrim
Copy link
Copy Markdown
Contributor Author

shalgrim commented Apr 26, 2026

Thanks for running through the examples. Here's where I've landed on each item:

  1. DONE - Resolved by your commit.
  2. DONE BUT FEEDBACK REQUESTED - On investigation, the shoelace compatibility theme was loading but silently doing nothing. Its palette and theme rules are scoped to .wa-palette-shoelace and .wa-theme-shoelace on the <html> element, and neither class was ever being set. Fixed by adding a one-line script to index.html~head that adds both classes before CSS is evaluated. With the shoelace palette now properly active, switched the header from --wa-color-brand-30 to --wa-color-brand-40 (rgb(0, 93, 147)), which is the closest WA token to Shoelace's --sl-color-primary-800 (rgb(7, 89, 133)). Not a pixel-perfect match but very close IMO. The script is a workaround. The cleaner fix would be adding those classes directly to the <html> element in Briefcase's web template. Happy to open a PR on Briefcase for that, or just file an issue if you'd rather track it separately.
  3. DONE - Fixed in this commit CLARIFICATION NEEDED - I just see a blank page on both branches. It appeared to be a pre-existing bug so I just ignored, but happy to open a new issue to track it. That said, I'm not following you on "starts visible and active," which seems like the opposite of a blank page. Am I misunderstanding your comment, or did you see something different?
  4. DONE - Another pre-existing bug, but I was able to fix this. Box.remove_child was a DOM no-op (only cleared the Python container reference), and Box.insert_child was ignoring the index and always appending. Added proper removeChild/insertBefore overrides in box.py.
  5. DONE - Fixed in this commit CLARIFICATION NEEDED - I can't reproduce this and/or I'm misunderstanding. I tried dragging the slider, moving diagonally with the button down, and the values kept updating as I moved. It seemed to behave the same on both branches.
  6. CAN'T REPRODUCE - I can't reproduce this either. This rule seems to hide individual WA elements until the browser has registered them, which should prevent unstyled flashes, but I think the window container itself is visible from creation. Maybe a fix is to defer window.show() until all expected wa-* components are defined. That seems like a bigger fix than this PR, but happy to track as a separate issue

_reapply_style replays style.cssText on element upgrade, which erases
any inline visibility set by stop(). Use a CSS class instead so the
two mechanisms don't interfere.
@shalgrim
Copy link
Copy Markdown
Contributor Author

shalgrim commented Apr 27, 2026

I implied this above, but in the interest of transparency, for the purposes of this PR if I ran across something that seemed odd to me but it was the same in both main and in this branch, I ignored it.

Some of them you mentioned above. Another example is that in progress bar, Running determinate and Stopped determinate are both stopped.

If you'd like, I can spend some time cataloging these and, after making a brief attempt to solve, log them as an issue. For some, I wasn't able to determine what they were supposed to do. scrollcontainer is an example like this. Spending more time with the code I probably could, but, like I said, my MO for this PR was that if it worked the same across branches it was good. Happy to adjust.

UPDATE: After writing that I kept going with tests and found the text color difference in the switch example was fixable. It also seems to have fixed button text color that we'd been ignoring (couldn't find an existing Issue for it, but one of the buttons in the example has blue text not visible in main).

wa-slider is built without pointer capture (sl-range wrapped a native
<input type=range> which the browser captures implicitly). Without
capture, pointerup fires on whatever element is under the pointer at
release time, so on_release could miss entirely or fire on a different
slider. Move the listener to document with a _dragging guard.
Both components set text color via CSS variables inside their shadow DOM
(--wa-form-control-value-color for wa-switch, --wa-color-on-quiet for
wa-button), so the host element's inherited 'color' property never
reached the label text. Override _reapply_style in each to forward the
Pack color as the appropriate CSS variable in the inline style string.

Switch: restores feature parity with sl-switch, which let the host
color cascade through naturally.
Button: fixes a pre-existing bug -- sl-button also blocked inherited
color the same way, so button text color was silently ignored on both
branches.
@freakboy3742
Copy link
Copy Markdown
Member

I implied this above, but in the interest of transparency, for the purposes of this PR if I ran across something that seemed odd to me but it was the same in both main and in this branch, I ignored it.

If you'd like, I can spend some time cataloging these and, after making a brief attempt to solve, log them as an issue. For some, I wasn't able to determine what they were supposed to do. scrollcontainer is an example like this. Spending more time with the code I probably could, but, like I said, my MO for this PR was that if it worked the same across branches it was good. Happy to adjust.

Completely agreed that constraining scope to "reproduce the errors that are already there" makes sense. I only flagged the ones above because there was an off chance they were simple oversights; if a fix didn't fall out immediately, then it's not worth it.

If you've got a list of known wrinkles, then it might be worth turning them into a bug report; but it's not worth going through a serious bug hunt looking for them.

The problem we have is that verification is difficult without a web testbed (see #2662). When we are eventually able to add that testing, having a clear catalog of know bugs will be useful - because each of them will be a test failure that can be resolved. At that point, it will be worth opening bugs, because we will have clear success criteria (and a basis to prevent future regression).

Copy link
Copy Markdown
Member

@freakboy3742 freakboy3742 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for those fixes. The FOUC issue is likely exacerbated by my geography - being in Perth is a really good way to reveal when there's a download delay :-)

However, we don't need to resolve that now. What is here works at least as well as what was there previously, and gets us away from the deprecated Shoelace. Nice work - thanks for the contribution!

@freakboy3742 freakboy3742 merged commit fc2e4d6 into beeware:main Apr 28, 2026
62 checks passed
@shalgrim
Copy link
Copy Markdown
Contributor Author

If you've got a list of known wrinkles

Nah, I wasn't organized enough for that...

@shalgrim shalgrim deleted the 4227-shoelace-to-webawesome branch April 28, 2026 14:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shoelace Project is now in Maintenance Mode

3 participants