Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Overriding dependent variables on a per-scope basis? #2435

Closed
clarabstract opened this issue Feb 5, 2015 · 11 comments
Closed

Overriding dependent variables on a per-scope basis? #2435

clarabstract opened this issue Feb 5, 2015 · 11 comments

Comments

@clarabstract
Copy link

I'd like to be able to do something like this:

// core library:
@primary: red;
@bg: #fff;
@fg: #000;
@color-bg: desaturate(mix(@primary, @bg, 50%), 50%); // paler white-ish red

.panel() {
  background: @bg;
  color: @fg;
  blockquote {
    background: @color-bg;
  }
  a {
   color: @primary;
  }
}

// these are the tricky use cases I'd like to support:

.my-theme {
  @primary: green;
  article {
    .panel(); // should have a pale green background
  }
  article.serious {
    @color-bg: #ccc;
    .panel(); // keeps the green links, but not colored backgrounds
  }
  .aside {
      @bg: #000;
      @fg: #fff; 
      .panel(); // an "inverted" block - color background is a dark green now
  }
}

I think it would be phenomenally useful because it allows individual components to keep a large set of configuration variables without having to worry about the context it's included in. As things stand, every component can only define its configuration in absolute terms with respect to the entire page. For example, we can have a form component, and a nav sidebar component, but they will have their own config vars like @nav-bg and @form-input-bg. If we want to include a form inside the nav, it either has to match all other forms on the page or require its own exceedingly specific config, like @nav-form-input-bg

With the above approach, every component only cares about having "a background" and "a foreground". The form component would only ever need to be written to use @bg and @fg and could be used in endless varieties without modification, just by changing the scope it's included in. Moreover, other components included in the same scope would also automatically share the same basic schemes without needing to configure each one repeatedly.

I get that this is basically what parameterization is for, and although it is a perfectly workable solution when you only have a handful of configs, passing around a complete set of theming configs for each invocation is not. Parameter defaults also can't depend on each other - only existing variables in the enclosing scope .

Even with the fairly trivial example above, there is quite a lot of repetition.:

.panel(
  @primary: red,
  @bg: #fff,
  @fg: #000,
  @color-bg: desaturate(mix(red, #fff, 50%), 50%);
  ) {
  background: @bg;
  color: @fg;
  blockquote {
    background: @color-bg;
  }
  a {
   color: @primary;
  }
}



.my-theme {
  @primary: green;
  @color-bg: desaturate(mix(@primary, #fff, 50%), 50%)
  article {
    .panel(
      @primary: @primary,
      @color-bg: @color-bg,
    ); 
  }
  article.serious {
    @color-bg: #ccc;
    .panel(
      @primary: @primary,
      @color-bg: @color-bg,
    );
  }
  .aside {
      @bg: #000;
      @fg: #fff; 
      .panel(
        @bg: @bg,
        @fg: @fg,
        @color-bg: @color-bg,
      );
  }
}

Imagine how quickly things would get out of hand with say, 30 or so config variables (which is a very modest amount of config, frankly, once you add typography, alignment direction, spacing etc etc) and more than just one all-encompassing mixin. Having to restate all dependent variables when trying to override one of the "bases" is also going to be time-consuming and error-prone.

I can sort of make it work with less as-is, by separating out dependent variables by their "derivation level" and including them adjacent to each-other in the same scope. It is hacky as all hell but, it works:

// core library:
#vars-d0() {
  @primary: red;
  @bg: #fff;
  @fg: #000;  
}

#vars-d1() {
  @color-bg: desaturate(mix(@primary, @bg, 50%), 50%); 
}

#vars-d2() {
  //and so forth
}


.panel() {
  background: @bg;
  color: @fg;
  blockquote {
    background: @color-bg;
  }
  a {
   color: @primary;
  }
}

#vars() {
  #vars-d0();
  #vars-d1();
  #vars-d2();
}
#vars(0) {
  #set-d0();
  #vars-d0();
  #vars-d1();
  #vars-d2();
}
#vars(1) {
  #set-d0();
  #set-d1();
  #vars-d0();
  #vars-d1();
  #vars-d2();
}
#vars(2) {
  #set-d0();
  #set-d1();
  #set-d2();
  #vars-d0();
  #vars-d1();
  #vars-d2();
}

.default {
  #vars(); // just using the defaults...
  .panel();
}

.my-theme {
  #set-d0() {
    @primary: green;
  }
  #vars(0);
  article {
    .panel();
  }
  article.serious {
    #set-d1() {
      @color-bg: #ccc;  
    }
    #vars(1);
    .panel();
  }
  .aside {
    #set-d0() {
      @primary: green; // this still has to be repeated since we're shadowing #set-d0 :(
      @bg: #000;
      @fg: #fff; 
    }
    #vars(0);
    .panel();
  }
}

Hideous.

Are there better ways of doing this sort of thing with less as-is? Am I missing some key concept maybe or something? Or can it be covered in a not-quite-obvious way by some seemingly unrelated feature? I'm definitely open to re-thinking this.

That aside though - I wonder if this sort of use case could be supported just by adding support for something like extra lazy variables? I know variables are already lazy, but they could actually be quite a bit lazier. We only really need to resolve them when we're about to emit the actual, final CSS - i.e., when interpolated in a property value (or sometimes property name or selector). Simply deriving one variable from another doesn't, strictly speaking, need to resolve either of them.

So, here's what it would all look like using a fictional lazy variable declaration syntax (arrows, just as an example - I'm sure a more "less-y" syntax could be devised) which prevents the RHS from evaluating until they are about to be emitted to the compiled output:

.vars() {
  @primary: red;
  @bg: #fff;
  @fg: #000;
  // this does not evaluate when .vars() is defined or included - @color-bg
  // remains a pointer to its un-evaluated RHS expression
  @color-bg: -> desaturate(mix(@primary, @bg, 50%), 50%);   
}



.panel() {
  background: @bg; 
  color: @fg;
  blockquote {
    background: @color-bg;
  }
  a {
   color: @primary;
  }
  &:after {
    content: @regular-var-example; 
  }
}



.my-theme {
  .vars();
  @primary: green; 
  article {
    .panel(); // The background: @color-bg declaration within forces @color-bg
              // to finally evaluate but it does so in the scope it ended up in,
              // not the one it was defined in. So @primary resolves to green
              // by way of .panel()'s regular mixin scope resolution rules.
  }
  article.serious {
    @color-bg: #ccc; // this is now shadowing the lazy @color-bg from vars
    .panel();  // by the time this evaluates @color-bg is just a regular constant
  }
  .aside {
      @bg: #000;
      @fg: #fff; 
      .panel();  // @color-bg's RHS would get @primary:green and @bg:#000 
                 // as expected
  }
  .or-maybe {
    @color-bg: -> fadeout(@primary, 60%); // nothing stopping us form shadowing
                                          // using a new lazy expression instead
                                          // of a constant
    .panel();
  }
}

It would basically allow a declarative flavor of variables Ii guess lambdas sort of?) that aren't constants.

Actually, now that I think about it I guess even just straight-up lambdas that have to be explicitly evaluated would work fine - it just introduces the small pain of having to keep track of which config vars have to be evaluated and which don't. Or maybe there can be an eval operator that will silently allow constants to pass through it. Or I could just declare all config vars as lambdas even if they simply return a literal and call it a day. Or a combination of all of the above, introducing a lambda value that can be explicitly evaluated but is also implicitly evaluated when used as a property value, and a tolerant eval operator.

Thoughts?

@seven-phases-max
Copy link
Member

For the reference the following does work on its own:

.panel() {
    color: @primary;
}

.my-theme {
    @primary: green;
    article {
        .panel(); // -> color: green;
    }
}

However if you also have @primary in the global scope it has higher priority inside .panel than the variable defined in the caller. For more details see #1316 (comment) (and my last post in that issue and another comment linked from there for why it cannot be so simply changed to caller > global).

P.S. So considering it's a scope priority problem (and not really a lazy-loading problem)
thoughts on the proposed feature (@var: ->):
Aside from looking quite arthifical ("here's special symbol that makes things to work opposite to what they normally do", imho) I'm afraid it also suffers from the same problem pointed in comments I mentioned above. If you allow a caller variables to override variables defined in mixin's parents (incl. global scope) you also allow to unintentionally override variables possibly used in any other (most likely unrelated) mixin. E.g. imagine you also use some library "Foo" and a few mixins from it:

@import "foo";

.my-theme article {
    #foo.some-button(); // using some nice mixin from the Foo library

    // set some parameter for our own .panel mixin and call it:
    @primary: -> green;
    .panel();
}

Now can you guess what can be wrong with this code before I reveal the sources of the "foo.less"?

@clarabstract
Copy link
Author

Yep - I'm aware - that's what my hacky solution relies on to work at all. Variable interdependence is what makes a big mess of it.

The global precedence gets in the way a bit but is easily dealt with by putting vars in their own scope first before inclusion - the variable eval, not so much.

@seven-phases-max
Copy link
Member

Btw., just in case, I thought of it a bit more and I guess I forgot to mention that the goal is also possible to achieve by putting your mixins like .panel into "parametric namespace":

@primary:   red;
@secondary: tint(@primary, 50%);

.whatever() {
    .panel() {
        color:      @primary;
        background: @secondary;
    }
}

.my-theme article {
    @primary: green;
    .whatever.panel();
}

(Though "parametric namespaces" are a bit more undocumented/unspecified feature to feel too safe).

@clarabstract
Copy link
Author

Ohkaaay, so why did that work at all? I get that it's "undocumented" but, err, what is that a consequence of exactly? Everything I've read/seen so far would suggest @secondary should immediately have evaluated to a red-tint when it was defined.

I'm also a bit confused about the edit in your 1st reply - it is a lazy-loading problem from where I'm standing - unless the "scope priority problem" you speak of has to do with the undocumented "parametric namespace" thing you mention above?

As to @var: -> - I actually agree with you regarding "here's special symbol that makes things to work opposite to what they normally do". On the other hand, I think introducing a distinct lambda value type does not have the same problem. E.g.

@primary:   red;
@secondary: ->(@amount:50%) {tint(@primary, @amount)};

    .panel() {
        color:      @primary;
        background: @secondary(); // note explicit "call" brackets
    }
// so to clarify - it's not a variable-specific construct, this should also work:
.some-mixin(->() { @primary}); // i.e. passing a it directly

I don't actually see the problem with the foo.less mixin using either explicit or implicit "lambda eval". Won't color: (#fff - @primary); attempt to resolve in its own scope first? How does it ever see our @primary: -> green; override? Is this the mysterious namespace behaviour again?

That said, requiring explicit eval via @secondary() should prevent any great surprises either way.

I'll also mention that, strictly speaking, I don't think the lambdas values terribly need to be able to take parameters (e.g. allowing @secondary(25%) for a different tint @amount) - it would just be strange to have a () notation that doesn't allow any value inside it. Again, I'm sure a more "less-y" syntax could be devised.

@seven-phases-max
Copy link
Member

Everything I've read/seen so far would suggest @secondary should immediately have evaluated to a red-tint when it was defined.

No, actually nothing is evaluated immediately. It may only look so because the first @primary near is often evaluated before other @primiries (if any) no matter where @secondary is used, but it's never immediately, here's example (adopted from Lazy Loading) to demonstrate this:

@primary:   red;
@secondary: tint(@primary, 50%);

.lazy-eval {
    color:    @secondary;
    @primary: green;
}

The result is greenish.

Ohkaaay, so why did that work at all?

You did not read #1316 I linked above, did you? :) (yes, it's a language dark corner, we agreed there it's an inconsistency but no idea how it should be changed or can it be changed at all came out yet (there're yet more detailed related discussion about such "parametric namespace" evaluation stuff at #2212)). So I actually use this trick myself in my projects for similar use-cases (even if it's abuse - well, the world is not perfect anyway :)

it is a lazy-loading problem from where I'm standing

Nope. See my first example in the first comment, every ancestor scope variable is perfectly lazy-loaded and the problem only is that in many cases (and for many use-cases) you (and me btw.) would prefer
local > callee > caller > parent > global scope precedence while in Less it's historically
local > callee > parent > global > caller (actually the global is the parent of any other parent so it can't be something mixed up in between like parent > caller > global or so). Or to say more precisely, there're two orthogonal scope hierarchies for mixins:

  • one is of the mixin definition (and parent incl. global are the ancestor of the definition)
  • another is of the mixin expansion (i.e. "call") - and a caller is the ancestor in this one (also having its parents including global one but here they come later).

So here's the key: the mixin definition scope hierarchy has higher precedence than mixin expansion one, thus finally resulting in parent > global > caller priority.

So this is the problem of different scope priority expectations (as everything is lazy-loaded as it should just not in the order we would sometimes want it to).
(Though this is more like point of view of course, it's fine to say "it's a lazy-loading problem because lazy-loading works not the way I want", not a big deal really).


@secondary: ->(@amount:50%) {tint(@primary, @amount)};
background: @secondary();

This way it's not lambda this is a classical pure function defined with awkward syntax (and it's missing return statement :) (which is another big story: #538).

And yet again the problem is that you expect your function to evaluate a caller scope variables before a parent scope variables while nothing in its definition (I mean the feature definition not "lambda" definition) dictates this. And you've proposed the whole new feature with the whole new syntax (it's does not really matter if it's lambda or function or whatever) only to force caller > parent instead of parent > caller behind the scenes (while yet again nothing in the feature itself shows why it should do so (not counting "because we'll make it to do so" of course)).

@clarabstract
Copy link
Author

Alright took me a few tries but I think I get it now. Variable evaluation really is "fully lazy" and you are of course absolutely right that it's just a scope precedence problem. There is indeed no need to introduce "lambdas" or anything like that.

For future reference to anyone coming in from Google, the initial example can be made to work as follows:

// core library:

@primary: red;
@bg: #fff;
@fg: #000;
@color-bg: desaturate(mix(@primary, @bg, 50%), 50%); // paler white-ish red

#library() {
    // fun fact: variables declared here are completely invisible to anything inside .panel()
    .panel() {
        background: @bg;
        color: @fg;
        blockquote {
        background: @color-bg;
    }
    a {
        color: @primary;
    }
}


}
// these are the tricky use cases I'd like to support:

.my-theme {
  @primary: green;
  article {
    #library.panel(); // should have a pale green background
  }
  article.serious {
    @color-bg: #ccc;
    #library.panel(); // keeps the green links, but not colored backgrounds
  }
  .inverted {
      @bg: #000;
      @fg: #fff; 
      #library.panel(); // an "inverted" block - color background is a dark green now
  }
}

And it works very specifically because of this:

non-parametric parent:
local > callee > parent > global > caller

parametric parent:
local > callee > caller > global > parent

Is this a consequence of implementation by the way? It's really hard to think about this intuitively (without consulting that call order explicitly every time), and it may well be easier to follow what the elevator actually does instead.

I'm a bit concerned about how I can properly document the behavior of our less libraries in a way that will minimize unpleasant surprises. It will either require an unreasonably long explanation to start with or a "here be dragons" type warning with no background given.

@seven-phases-max
Copy link
Member

I won't comment everything (this would be too much, and too primary-opinion based anyway...) Just a few thoughts:

Thinking of it more, this whole approach in your recent example starts to look sort of flawed... it's like a "I make some theme then make a mixin hardcoded to use that theme and then searching for ways to patch individual properties of those things on a per-selector basis (or in other words: "providing highest possible abstraction while wishing to retain the lowest possible granularity to control it", this never takes off) ... Well, wishing for a least verbose code is understandable, but propbably the ven more important problem with all this is that nothing in the code of .my-theme I'd read (as a library user) indicates what and how those variables can affect .panel() or if they do this at all...
So if this comes to writing a public library I'd say it's quite questionable approach (a syntactic sugar stuff requiring too much documentation/understanding of its underline behaviour, not counting that eventually you will have to end with theme-bla-bla-bg-like variable names instead of just @bg to avoid potential conflicts and the whole thing won't look so slick anymore anyway).

Regardless of above, notice that your last example in the first post can do all that with just a minor modification:

.theme(@base: red) {
    @primary: @base;
    @bg: #fff;
    @fg: #000;
    @color-bg: desaturate(mix(@primary, @bg, 50%), 50%);
}

.panel() {
    background: @bg;
    color:      @fg;
    blockquote {
        background: @color-bg;
    }
    a {
        color: @primary;
    }
}

.my-green-domain {
    .theme(blue);

    article {
        .panel();
    }
    article.serious {
        @color-bg: #ccc;
        .panel();
    }
    .aside {
        @bg: #000;
        @fg: #fff; 
        .panel();
    }
    .or-maybe {
        @color-bg: fadeout(@primary, 60%);
        .panel();
    }
}

This obviously requires some extra duplication in .theme() definition but at the usage part it basically does what you probably had in mind initially.


Getting back to "providing highest possible abstraction while wishing to retain the lowest possible granularity to control it" stuff, here're just a few pretty random but related comments/examples/questions inspired by code above. Those can be summarized as something like "OK, it all makes sense but are there any use-case that's really worth getting to deep trying to improve it?", i.e. the code above already looks more like some kind of a DSL-language and while there's always room for improvement in that context (more abstraction, more syntactic sugar, more more) it's probably time to remember that such abstraction may also hurt by cluttering the final goal/result of all that code... So examples:

[1]:

.my-green-domain {
    article {
        .panel();
    }
    article.serious {
        @color-bg: #ccc;
        .panel();
    }
}

Now (assuming some real project) I go to browser's style-sheet debugger and... Oh, I guess I'll better rewrite the same as:

.my-green-domain {
    article {
        .panel();
    }
    article.serious blockquote {
        background: #ccc;
    }
}

Both variants have the same problem of requiring to know internals of the .panel but it's article.serious blockquote that I see in the debugger (I simply can't or do not want to remember if it's @color-bg and .panel() or whatever, so even if it was like that initially it's fair to predict I'll end up with the latter variant after a few editing iterations... At least this way I can see what's going on there (even if I have to hardcode blockquote selector there). Not counting the final CSS is also getting more compact :)

[2]:

.aside {
    @bg: #000;
    @fg: #fff;
    .panel();
}

It looks like it's a perfect case for .panel-inverted mixin instead...
Indeed, individual color manipulation starts to look quite artificial for the example as a whole (. Finally why do I need that .panel thing at all if I need to set all those individual properties anyway? (Sure, "you don't have to, but if you want you have a slick way to do that" is an answer for that Q but all that cluttering as above does not look like a reasonable price for such occasional stuff anymore).

Etc. (actually I have a suspition that for almost any individual sub-selector of this example adopted for some more real-world use-case - a better solution would be in more adoptable .panel itself (in various way: maybe more modular/split, more variations/specializations, and/or finally not using the mixin at all) rather than in trying to control it via individual variables).

In summary (just repeating some thoughts from above in other words): while all these scoping problems are there (and there's always room for some improvement), the particular use-case example (of course assuming it's simplified and real use-cases may vary in details quite wide) does not look like something that makes those "scope improvements" to get some "not lowest" priority.
(yet again, "making a solid color theme with some predefined connections between certain colors and in the same time providing the least verbose way to hook into every aspect of the theme on a per-selector basis and in the same time retaining inheritance of the modified colors via nesting" already sounds self-destroying).

@clarabstract
Copy link
Author

Thanks for all the detailed answers just by the by, it's all been very helpful.

So, you are quite right that there is a real danger here of defeating our own abstractions by allowing for too much granularity, especially when plain CSS already has a perfectly workable mechanism for such "spot" overrides. The sample use cases however are intentionally stripped down so they are easier to follow.

The actual problem I am trying to solve is that for our projects everything needs to be themable through and through. We have a number of types of sites (think "store", "blog", "forums" - etc.) and separate instances of each are deployed for a number of separate brands. Brands all have their own branding style guides that have to be consistent throughout. Furthermore, the sites include a number of shared modular components that have their own non-trivial styling needs (video players, share widgets, that sort of thing.).

Something I very specifically want to avoid is a config-var granularity explosion for each site-type and module combination. To make things worse, these aren't "functional" productivity apps where all components can and should be nice and uniform - it's all rich and interactive and the designers love to alternate color schemes on the same page and even within the same area just for visual effect. It's impractical to come up with alternate and inverted variations for every component and hook them in each site-brand combination explicitly.

So, the approach I am going to try is to have components only ever be aware of a single "local" set of config vars - @fg always refers to whatever the foreground color is for this particular component - could be dark-on-bright or bright-on-dark, the component itself should never have to know. @primary is whatever the primary color element is for that component - it isn't necessarily the primary color of the brand's palette because maybe the designer wanted to use the secondary brand color in that area, or maybe it's gray-scale, and so on and so on. This lets us express variations in those terms - so a color background (which might be dark, might be bright) is simply @primary-bg and @primary-muted-bg for a more subtle, color-situation-independent variation. We can also maintain some amount of layout flexibility by having something like an @align config that dictates right-to-left vs left-to-right flow (and mixins that automatically invert padding-left with padding-right and so forth).

What I don't want to have is @video-player-dark-bg and @video-player-light-bg, or god forbid @video-player-dark-bg-play-btn-fg, and then having to manually specify that in each branding theme.
There may be some more granular subsets of colors within a module but they should still be expressed as not-module-specific configs, e.g. @ctrl-icon-fg for the play button. There shouldn't really be a scenario where you very specifically need to modify only the video player control buttons that can't be dealt with by configuring the class the video module's mixin is included in.

So the desired end result is that there are a limited number of configurable variables which implementers do need to be familiar with (hence why there need to be a limited amount of them). The level of granularity available for configuring a given component is always restricted to those variables (exceptions can be made if absolutely needed and dealt with on a case by case basis - could be regular CSS overrides, could be exposing an explicit mixin parm, but ideally we can just be smart about our abstraction levels). It's a rough equivalent to OOP where just how re-usable a library is often comes down to how well thought-out and flexible its public interfaces are. You can absolutely bury yourself trying to over-do it with re-usability and abstraction so that's definitely something I'm being very mindful of at the moment.

On that note, it would certainly be more explicit and easy to understand to just pass that entire "interface" of config vars as actual parameters to the modular mixins every time you use them, especially if that interface isn't too large. The main thing you lose is being able to define inter-dependent defaults in the params, which may or may not actually be that important in actual use. I may revisit that approach, though my intuition is that the functionality is needed. For example, most of the time @primary-muted-bg can just be computed using desaturate() and mix() but ever so often the brand will dictate a very specific shade which is slightly "off-hue" for certain background variations.

Anyway, sorry about the wall of text, no small part of this was writing it all out for my own sake to see if actually looks like an obviously bad idea at a glance.

@lukeapage
Copy link
Member

I would have to spend an hour at least to fully understand this I think - so @seven-phases-max would you be able to tag or close as appropriate now you've reached a kind-of conclusion?

@clarabstract
Copy link
Author

It's just #1316 basically, and me not understanding the special scoping rules therein.

I'm reasonably sure it's safe to close this as a dupe of #1316 and friends.

@seven-phases-max
Copy link
Member

Yes, in summary this is discussion around #1316. I kept it initially open since the first post has a feature-request for a special syntax/functionality as a workaround. My bad - I forgot to add tags, sorry.
And yes, I agree it's OK to close this (the feature proposed was too artificial anyway).

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

No branches or pull requests

3 participants