clone

one developer profile for links and private tcp services, reached peer to peer over iroh with no open ports

feat: support light and dark theme colors

Dami committed 2a6f381
Allow validated light-dark() color pairs, retain the flat color grammar for both arguments, document scheme behavior, and cover storage and safety boundaries.
commit
2a6f38107ecd20c8a23e4159adbe20bfd4d0f3ae
parent
69b16d6b17494cf1ebb71aa521c8924f079ec899
author
Dami <dami.etoile@gmail.com>
4 files changed, +195 -14
Mcrates/devsite-server/src/db.rs15++-
Mcrates/devsite-server/src/theme.rs162+++++++++++++++++++++++++++++-
Mdocs/profile-template.md29++++-
Mweb/app.js3+-
M crates/devsite-server/src/db.rs +12 -3
M crates/devsite-server/src/theme.rs +157 -5
@@ -22,8 +22,9 @@ use serde::Serialize;
2222 2323 /// Longest accepted source, before parsing. A theme is a few dozen short lines.2424 pub const MAX_INPUT: usize = 4096;25-/// Longest accepted value. `oklch(62.8% 0.258 29.23)` is 24 characters.26-pub const MAX_VALUE: usize = 64;25+/// Longest accepted value. This leaves room for two functional colours inside26+/// `light-dark()` without changing the overall [`MAX_INPUT`] bound.27+pub const MAX_VALUE: usize = 128;2728 2829 /// One validated assignment. `property` is borrowed from [`PROPERTIES`], so a2930 /// stored declaration cannot name anything outside the table.
@@ -36,7 +37,7 @@ pub struct Declaration {
3637 /// The grammar a property's value must satisfy.3738 enum Kind {3839 /// Hex, `rgb()`/`rgba()`/`hsl()`/`hsla()`/`oklch()`, a CSS colour name,39- /// `transparent` or `currentcolor`.40+ /// `transparent`, `currentcolor`, or exactly two of those in `light-dark()`.4041 Color,4142 /// A non-negative number with a unit, or bare `0`.4243 Length,
@@ -236,7 +237,7 @@ impl Kind {
236237 237238 fn describe(&self) -> String {238239 match self {239- Kind::Color => "a colour, e.g. `#7b3fe4`, `rgb(123 63 228)` or `rebeccapurple`".into(),240+ Kind::Color => "a colour, e.g. `#7b3fe4`, `rgb(123 63 228)`, `rebeccapurple` or `light-dark(#7b3fe4, #a982ff)`".into(),240241 Kind::Length => "a length, e.g. `0.5rem`, `12px` or `0`".into(),241242 Kind::Number => "a number, e.g. `1.5`".into(),242243 Kind::Keyword(allowed) => format!("one of {}", allowed.join(", ")),
@@ -249,6 +250,50 @@ impl Kind {
249250 fn is_color(value: &str) -> bool {250251 let value = value.to_ascii_lowercase();251252 253+ if let Some(args) = value254+ .strip_prefix("light-dark(")255+ .and_then(|rest| rest.strip_suffix(')'))256+ {257+ return split_color_pair(args)258+ .is_some_and(|(light, dark)| is_flat_color(light) && is_flat_color(dark));259+ }260+261+ is_flat_color(&value)262+}263+264+/// Split the arguments of `light-dark()` at its one top-level comma.265+///266+/// Commas inside an existing functional colour belong to that colour. Tracking267+/// their parentheses here permits `rgba(1, 2, 3, 0.5)` on either side while268+/// still rejecting unbalanced input and any third top-level argument.269+fn split_color_pair(args: &str) -> Option<(&str, &str)> {270+ let mut depth = 0_u8;271+ let mut separator = None;272+273+ for (index, character) in args.char_indices() {274+ match character {275+ '(' => depth = depth.checked_add(1)?,276+ ')' => depth = depth.checked_sub(1)?,277+ ',' if depth == 0 && separator.is_some() => return None,278+ ',' if depth == 0 => separator = Some(index),279+ _ => {}280+ }281+ }282+283+ if depth != 0 {284+ return None;285+ }286+287+ let separator = separator?;288+ let light = args[..separator].trim();289+ let dark = args[separator + 1..].trim();290+ (!light.is_empty() && !dark.is_empty()).then_some((light, dark))291+}292+293+/// The original, deliberately flat colour grammar. Keeping it separate makes294+/// `light-dark()` the only supported nesting layer: neither side can introduce295+/// another `light-dark()`, `var()`, `calc()`, `url()`, or arbitrary function.296+fn is_flat_color(value: &str) -> bool {252297 if value == "transparent" || value == "currentcolor" {253298 return true;254299 }
@@ -267,7 +312,7 @@ fn is_color(value: &str) -> bool {
267312 .all(|b| b.is_ascii_digit() || b" .,%/+-".contains(&b))268313 && args.split([',', ' ', '/']).filter(|a| !a.is_empty()).count() <= 4;269314 }270- NAMED_COLORS.binary_search(&value.as_str()).is_ok()315+ NAMED_COLORS.binary_search(&value).is_ok()271316 }272317 273318 fn is_length(value: &str) -> bool {
@@ -508,6 +553,102 @@ mod tests {
508553 }509554 }510555 556+ #[test]557+ fn accepts_hex_named_and_functional_color_pairs() {558+ for value in [559+ "light-dark(#7b3fe4, #a982ff)",560+ "light-dark(white, rebeccapurple)",561+ "light-dark(transparent, currentcolor)",562+ "light-dark(rgb(250 248 255), rgb(24 18 32))",563+ "light-dark(rgba(250, 248, 255, 0.9), hsl(267 100% 75%))",564+ "light-dark(oklch(98% 0.02 295), oklch(20% 0.04 295))",565+ ] {566+ assert!(567+ parse(&format!("--pico-primary: {value};")).is_ok(),568+ "{value} should be a light/dark colour pair"569+ );570+ }571+ }572+573+ #[test]574+ fn a_color_pair_has_canonical_whitespace_and_round_trips() {575+ let source = "\n --devsite-scheme: auto;\n --pico-primary:\n light-dark( #7b3fe4 , rgb( 169 130 255 ) );\n";576+ let canonical = css(source).unwrap();577+ assert_eq!(578+ canonical,579+ "--devsite-scheme: auto;\n--pico-primary: light-dark( #7b3fe4 , rgb( 169 130 255 ) );\n"580+ );581+ assert_eq!(css(&canonical).unwrap(), canonical);582+ }583+584+ #[test]585+ fn damis_palette_can_define_both_schemes() {586+ let source = "587+ --devsite-scheme: auto;588+ --pico-background-color: light-dark(#fefae0, #283618);589+ --pico-color: light-dark(#283618, #fefae0);590+ --pico-primary: light-dark(#bc6c25, #dda15e);591+ ";592+ assert_eq!(593+ css(source).unwrap(),594+ "--devsite-scheme: auto;\n\595+ --pico-background-color: light-dark(#fefae0, #283618);\n\596+ --pico-color: light-dark(#283618, #fefae0);\n\597+ --pico-primary: light-dark(#bc6c25, #dda15e);\n"598+ );599+ }600+601+ #[test]602+ fn color_pairs_are_accepted_with_forced_and_automatic_schemes() {603+ for scheme in ["light", "dark", "auto"] {604+ let theme = format!(605+ "--devsite-scheme: {scheme}; \606+ --pico-primary: light-dark(#7b3fe4, #a982ff);"607+ );608+ assert!(609+ parse(&theme).is_ok(),610+ "{scheme} should accept a colour pair"611+ );612+ }613+ }614+615+ #[test]616+ fn rejects_malformed_color_pairs() {617+ for value in [618+ "light-dark()",619+ "light-dark(red)",620+ "light-dark(, blue)",621+ "light-dark(red, )",622+ "light-dark(red, blue, green)",623+ "light-dark(red,, blue)",624+ "light-dark(rgb(1 2 3), blue",625+ "light-dark(rgb(1 2 3, blue)",626+ "light-dark(rgb(1 2 3)), blue)",627+ ] {628+ assert!(629+ parse(&format!("--pico-primary: {value};")).is_err(),630+ "{value} should be refused"631+ );632+ }633+ }634+635+ #[test]636+ fn rejects_unsupported_functions_inside_color_pairs() {637+ for value in [638+ "light-dark(var(--pico-color), blue)",639+ "light-dark(calc(1 + 1), blue)",640+ "light-dark(url(https://example.com/x.png), blue)",641+ "light-dark(linear-gradient(red, blue), green)",642+ "light-dark(light-dark(red, blue), green)",643+ "light-dark(rgb(calc(1 + 1) 0 0), blue)",644+ ] {645+ assert!(646+ parse(&format!("--pico-primary: {value};")).is_err(),647+ "{value} should be refused"648+ );649+ }650+ }651+511652 #[test]512653 fn rejects_values_that_only_look_like_colors() {513654 for value in [
@@ -536,11 +677,22 @@ mod tests {
536677 "--pico-primary: red !important;",537678 "</style><script>alert(1)</script>",538679 "--pico-primary: red; /* unterminated",680+ "--pico-primary: light-dark(red, blue); } body { display: none",681+ "--pico-primary: light-dark(red, </style><script>alert(1)</script>);",539682 ] {540683 assert!(parse(source).is_err(), "{source:?} should be refused");541684 }542685 }543686 687+ #[test]688+ fn reported_color_grammar_includes_color_pairs() {689+ let description = properties()690+ .find(|(name, _)| *name == "--pico-primary")691+ .unwrap()692+ .1;693+ assert!(description.contains("light-dark(#7b3fe4, #a982ff)"));694+ }695+544696 #[test]545697 fn rejects_properties_outside_the_table() {546698 // Real CSS properties are refused as firmly as invented ones: a theme
M docs/profile-template.md +24 -5
@@ -158,13 +158,15 @@ the failure mode the list exists to prevent.
158158 159159 `--devsite-scheme` is the one key that is not a Pico variable. It sets160160 `data-theme` on the page, which is how Pico chooses between its own light and161-dark palettes; the rest of the theme then adjusts that starting point.161+dark palettes; the rest of the theme then adjusts that starting point. `auto`162+leaves `data-theme` unset and follows the viewer's system preference. Explicit163+`light` or `dark` forces that scheme.162164 163165 ### Value grammars164166 165167 | Kind | Accepts |166168 | --- | --- |167-| colour | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`; `rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()` over plain numbers; a CSS colour name; `transparent`; `currentcolor` |169+| colour | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`; `rgb()`, `rgba()`, `hsl()`, `hsla()`, `oklch()` over plain numbers; a CSS colour name; `transparent`; `currentcolor`; or `light-dark(<light-color>, <dark-color>)` containing exactly two of these colours |168170 | length | a non-negative number with `px`, `rem`, `em`, `ch`, `ex`, `vw`, `vh`, `vmin`, `vmax` or `%`, or bare `0` |169171 | number | a non-negative unitless number |170172
@@ -173,6 +175,21 @@ drawn from the alphabet `[0-9a-z#%.,/()+- ]`, so it cannot carry `<`, `"` or `}`
173175 — the rendered rule is safe to inline in a `<style>` element as a property of the174176 grammar, not of an escaping step someone has to remember.175177 178+`light-dark()` uses its first colour in light mode and its second in dark mode.179+Either side may use any supported colour form, including a functional colour:180+181+```css182+--devsite-scheme: auto;183+--pico-primary: light-dark(#7b3fe4, #a982ff);184+--pico-background-color: light-dark(rgb(250 248 255), rgb(24 18 32));185+```186+187+With `auto`, the pair follows the viewer's system preference. Setting188+`--devsite-scheme: light` always selects the first colour; setting it to `dark`189+always selects the second. The parser understands only this one level of190+nesting: another `light-dark()`, `var()`, `calc()`, `url()`, or any other191+function inside the pair is refused.192+176193 ## What a theme cannot do177194 178195 Refused outright, with a message naming the reason:
@@ -203,9 +220,11 @@ devsite theme clear
203220 204221 ```css205222 /* my-theme.css */206---devsite-scheme: dark;207---pico-primary: #7b3fe4;208---pico-primary-hover: #8f5bea;223+--devsite-scheme: auto;224+--pico-background-color: light-dark(#fefae0, #283618);225+--pico-color: light-dark(#283618, #fefae0);226+--pico-primary: light-dark(#bc6c25, #dda15e);227+--pico-primary-hover: light-dark(#606c38, #fefae0);209228 --pico-border-radius: 0.5rem;210229 --pico-font-weight: 400;211230 ```
M web/app.js +2 -1