kobweb
kobweb copied to clipboard
Theme/Token API
Hi! I've been using Kobweb on a project of mine, and have run into some annoyances while creating styles that I think could be solved by a token API similar to the one that exists in Panda CSS (and many other CSS-in-JS libraries). First I'll outline a draft for what this kind of API could look like, then what the problems it solves are.
Draft API
The first (and could be only in a minimal implementation) step would be to include the current breakpoint inside a ComponentStyle block much like the current colorMode. With that, a theme object could be constructed in each style, and the tokens could be used in the styles:
val DemoStyle by CustomStyle {
val theme = MyTheme(colorMode, breakpoint)
base {
Modifier
.background(theme.palette.background)
.padding(theme.sizes.sm)
.borderRadius(theme.radii.sm)
}
}
class MyTheme(val colorMode: ColorMode, val breakpoint: Breakpoint) {
val palette = when(colorMode) {
ColorMode.Light -> Palette(background = rgb(0xFFFFFF), content = rgb(0x000000), /*...*/)
ColorMode.Dark -> Palette(/*...*/)
}
val sizes = when(breakpoint) {
Breakpoint.Zero -> Sizes(sm = 8.px, md = 12.px, /*...*/),
else -> Sizes(/*...*/)
}
val radii = Radii(/*...*/),
/*...*/
}
(This wouldn't replace the traditional method of using breakpoints in styles, but would be an alternative.)
The next step would be to create a wrapper around CustomStyle so that the user doesn't have to create the theme object each time. This could be done by default for a SilkTheme, which the user could then copy if they wanted to make their own version:
class SilkThemeStyle(init: SilkThemeStyleModifier.() -> Unit) {
// haven't fully figured out how this will work,
// but would essentially be a property delegate equivalent to
// by ComponentStyle {
// val theme = SilkTheme(colorMode, breakpoints)
// init.invoke(SilkThemeStyleModifier(theme))
// }
}
val DemoStyle by SilkThemeStyle.base { // this: SilkThemeStyleModifier { theme: SilkTheme }
Modifier
.background(theme.palette.background)
.padding(theme.sizes.sm)
}
The base SilkTheme could include sensible defaults for spacing, font sizes, etc. (and those should be customizable in @InitSilk just like the palette), but it should be clear that a custom theme object could include colors, spacing, radii, sizes, font sizes, shadows, and anything else the user wants.
What it solves
Currently, if the user wants to have a defined set of breakpoint-reliant sizes (or font sizes, radii, etc.), they have two options. One is to use rememberBreakpoint() and only ever include their sizes in inline styles, and the other is to manually deal with the breakpoints on every style:
@Composable
fun Demo() {
Column(Modifier.size(Sizes.SM)) {
/*...*/
}
}
object Sizes {
val SM @Composable get() = when(rememberBreakpoint()) {
Breakpoint.ZERO -> 8.px
Breakpoint.SM -> 12.px
else -> 16.px
}
val MD @Composable get() = /*...*/
}
val DemoStyle by ComponentStyle {
base {
Modifier.size(Sizes.SM.BreakpointZero)
}
Breakpoint.SM {
Modifier.size(Sizes.SM.BreakpointSm)
}
Breakpoint.MD {
Modifier.size(Sizes.SM.BreakpointMd)
}
}
Neither of these are super convenient, and they create an entirely separate method for thinking about theming since the methods for using the SilkPalette are quite different. So, putting all of that data into one central theme definition would help streamline the entire theming process. Finally, this is a first draft of the API, so I'd love to hear any thoughts/changes people would like to see.
Hey Andy, thanks for using Kobweb and taking the time to think about and draft this!
I'll send back a quick response now but just to let you know I'll keep thinking about this. Apologies if I miss some nuance in my fast reply.
By the way, your timing is great -- we are actively thinking about the "inline styles required to support sizes" problem. One thing we are thinking about is integrating KSP into Kobweb's compile time pipeline (this wasn't available when I started Kobweb, see also this old ass bug). This could give us more power that will enable us to do more clever things that the current Gradle plugins approach alone cannot.
Here are a few of my high level thoughts (given the current state of Kobweb):
- For every new parameter we add to component style, we are potentially creating an exponential number of styles. If you support breakpoint (which has 5 values) and color mode (which has 2), you can unintentionally create 10 style entries with a lot of redundancy between them.
- In most cases, you probably want your color mode changes and your breakpoint changes to be orthogonal.
- When Kobweb began, it didn't have StyleVariables solved yet. Now it does, and it's possible that if we had that working from the beginning, we might have done things differently.
- For example, you can define a ComponentStyle purely with StyleVariables, and then you can create additional styles that set those variables which you apply separately.
- This approach makes it easier to get that orthogonal separation of color mode and breakpoint features I mentioned above, but it's a bit more manual work.
- The current Kobweb Gradle plugin uses simple string matching to find interesting references for which it need to know how to generate code for (e.g. registering your component styles for you). Due to the fairly brute force approach, we can't really support arbitrary subclasses at this point (potentially an issue for the
by SilkThemeStyle.baseidea you are proposing here) - Note that widget sizes are not always tied to breakpoint sizes. For example, for switches, you might want a small switch even on a desktop screen, or a large switch even on a mobilescreen. It might be worth discussing more concrete cases, but we may not want to unintentionally encourage people to tie sizes to breakpoints. (Probably I need more concrete examples to chew on here).
All that said, I can see if you're just working on your own site that having access to breakpoints and color modes from within the same component style can be great for quick prototyping.
Supporting custom tokens is, as far as I can tell, not possible yet. I think the first pass would be to figure out and integrate KSP, which is something that I'm actively thinking about right now. Maybe it will enable something like this, but even if not, it should still help the project compile faster, in theory, while giving us the power to resolve types at compile time.
P.S. I'll also read through the Panda CSS docs to get a better feel of the full feature-set that they support. P.P.S. If you're not in the Discord, feel free to join if you want to hash ideas out in there as well.
(Apologies for the close / reopen, that was a misclick)
To add to what @bitspittle said about StyleVariables, there is currently a 3rd option for a defined set of breakpoint-reliant sizes:
val SizeVar by StyleVariable<CSSLengthValue>()
val ThemeStyle by ComponentStyle {
base {
Modifier.setVariable(SizeVar, 8.px)
}
Breakpoint.SM {
Modifier.setVariable(SizeVar, 10.px)
}
Breakpoint.MD {
Modifier.setVariable(SizeVar, 12.px)
}
}
val DemoStyle by ComponentStyle.base {
Modifier.size(SizeVar.value())
}
// in @App (or any other composable)
Surface(
ThemeStyle.toModifier()//...
)
That said, this approach still has its limits:
- It doesn't support using logic with these values like you can with colorMode:
val DemoStyle by ComponentStyle.base {
Modifier.color(if (colorMode.isDark) Colors.Black else Colors.White)
}
- It requires declaring individual variables for every value, instead of using a more streamlined approach such like
Sizes(sm = 8.px, md = 12.px, /*...*/). - It isn't centralized and could potentially be difficult to manage and track.
Speaking more broadly however, I do like the idea of some sort of token system, for the reasons you mentioned as well as potentially easier customization of Silk-provided components/styles/sizes. As mentioned, this would likely require some internal changes (KSP), but I think something similar to what CSS-in-JS libraries offer is possible and worth pursuing.
Thank you for the responses! I'm going to try to bullet point my thoughts in approximately the order they appear:
- I haven't really dug into how the styles are generated (so this could be completely off-base), but I was under the impression that a new style was generated for each
Modifier, so that if a token based off ofcolorModewas used in oneModifier, and a token based offbreakpointwas used in anotherModifierthat they'd be generated as separate styles.- And then the only styles that would go exponential would be ones where the user (for some reason, maybe shadows?) wanted to use both in a single style, which would be the intended behavior.
- Though I'm realizing as I write this that if the entire token object was generated off of both, then that could cause all tokens in the object to be counted as based off both (even if each token was only based off one parameter). And then I definitely see where the problem comes into play. Maybe having two objects--something like
paletteandtokenscorresponding tocolorModeandbreakpoint--would force that orthogonality? Though then that would break the shadows example where the user really does want both parameters in one token.
- I hadn't really considered the
StyleVariableoption, which seems interesting, though as y'all explained there are definitely some negative considerations also.- On the one hand, if having the theme object be made up of StyleVariables would solve the color mode/breakpoint orthogonality problem then that's very appealing. I especially wonder if in a KSP world there'd be a more elegant way to define a full object's worth of tokens as StyleVariables than fully top-level. (Also down the road, the fact that variables can be changed inline could allow for a CompositionLocalProvider pattern that could be really interesting.)
- On the other hand, having to declare a single variable for each is definitely unwieldy and breaks the pattern of having a theme object. I think it's also a question how many CSS variables are the right amount; there's something to be said for having hardcoded generated CSS rather than potentially having every single token as a variable.
- I'm definitely going to try StyleVariables out for breakpoints and hopefully will have more concrete thoughts on it soon.
- I totally agree that some widgets should have different size variants that aren't based on breakpoints--I think maybe choosing "size" as a demonstrative token wasn't a great call since that's ambiguous.
- In the switch example, I could imagine that the small variant might have a
Modifier.padding(theme.padding.sm), and the large variant might have aModifier.padding(theme.padding.md). Then both would be able for use on both platforms, with responsive styles. On the other hand, if a user wanted two static switch sizes (and to generally use the large variant on desktop and the small one on mobile but not always), that would be a task forrememberBreakpoint()+ hardcoded variants (without tokens).
- In the switch example, I could imagine that the small variant might have a
- In terms of "custom tokens", I'm really only imagining a mapping of the breakpoint and color mode information into an object with hardcoded values, which I think (though again don't super understand the implementation) would already be supported for just
colorMode.
I'm going to keep playing around with these options to see if I come up with any new ideas, and will be interested to see how the KSP exploration turns out. Thank you guys for your thoughts on this!
As a quick aside, it may be useful to look at the generated code to demystify what Kobweb is doing.
In your project, where I'm assuming your Kobweb stuff lives under a site folder:
../gradlew :site:kobwebGen- Open
build/generated/kobweb/src/jsMain/kotlin/main.kt - Search for "registerComponentStyle"
- Have an epiphany, or maybe flinch in horror :stuck_out_tongue:
In order to generate that code, Kobweb needs to scan your codebase and find the right lines to put in there, which it currently does by searching your code using fairly limited parsed information.
With KSP, we should have a lot more power to identify the appropriate lines of code to collect and register in your final generated main file.
Really glad to have you thinking about this. We'll definitely be in touch about the KSP stuff. I'm pretty sure it's something we'll be tackling in the near future.
We just got a huge KSP refactoring in, so we plan to revisit this issue. We set it to 1.0 because if we get it working before then, we'll also be able to apply it to our own widget code in a bunch of places. But this might be a soft 1.0, that could potentially slip if it had to.