varabyte / kobweb

A modern framework for full stack web apps in Kotlin, built upon Compose HTML
https://kobweb.varabyte.com
Apache License 2.0
1.53k stars 68 forks source link

Theme/Token API #297

Open andyburris opened 1 year ago

andyburris commented 1 year ago

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.

bitspittle commented 1 year ago

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):

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.

bitspittle commented 1 year ago

(Apologies for the close / reopen, that was a misclick)

DennisTsar commented 1 year ago

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:

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.

andyburris commented 1 year ago

Thank you for the responses! I'm going to try to bullet point my thoughts in approximately the order they appear:

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!

bitspittle commented 1 year ago

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:

  1. ../gradlew :site:kobwebGen
  2. Open build/generated/kobweb/src/jsMain/kotlin/main.kt
  3. Search for "registerComponentStyle"
  4. 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.

bitspittle commented 1 year ago

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.