Avatar

jxnblk

Bicycle riding cat from the future
14 public vals
Joined April 28, 2024

Simple CSS Vars library

Create valimport { caviar } from "https://esm.town/v/jxnblk/caviar"; const vars = caviar({ dark: { text: "#000", bg: "#fff", }, light: { text: "#fff", bg: "#000", }, });
Create val// JSX example <div style={vars.style.light}> <h1 style={{ color: vars.text, backgroundColor: vars.bg, }}> Hello </h1> </div>

Can also be used for non-dynamic CSS vars

Create valimport { caviar } from "https://esm.town/v/jxnblk/caviar"; const vars = caviar({ blue: "#0cf", }); // <div style={vars.style} />

Tests: https://www.val.town/v/jxnblk/caviar_tests

TODO

  • Fallback for missing mode keys
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
export function caviar(obj: any) {
const result: any = {
style: {},
};
for (const [key, val] of Object.entries(obj)) {
if (key === "style") {
throw new Error(`Cannot use reserved key: '${key}'`);
}
if (typeof val === "object") {
createMode(result, key, val);
} else {
const varKey = `--${key}`;
const varVal = `var(${varKey})`;
result[key] = varVal;
result.style[varKey] = val;
}
}
return result;
}
function createMode(result: any, mode: string, obj: any) {
result.style[mode] = {};
for (const [key, val] of Object.entries(obj)) {
const varKey = `--${key}`;
const varVal = `var(${varKey})`;
result[key] = varVal;
result.style[mode][varKey] = val;
}
}

Test color contrast values with a URL that unfurls to share with others

Usage

Add hex color values to the end of the URL. Don't include the # symbol. You can use 3 or 6 digit codes.

https://jxnblk-color_contrast.web.val.run/f8f/313

Share the link on social media or in chat to see a preview of the colors along with the level of contrast

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// usage:
// https://jxnblk-color_contrast.web.val.run/f0f/303
// card image: https://jxnblk-png_card.web.val.run/?text=hello&bg=000&color=f00
import { Color } from "https://deno.land/x/color/mod.ts";
import valTownBadge from "https://esm.town/v/jxnblk/valTownBadge";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const HASHRE = /^#[A-Fa-f0-9]{3,6}$/;
const LARGE = 3;
const AA = 4.5;
const AAA = 7;
const toHex = str => HEXRE.test(str) ? "#" + str : str;
const stripHash = str => HASHRE.test(str) ? str.slice(1) : str;
export default async function(req: Request): Promise<Response> {
const path = new URL(req.url).pathname;
const [
color = "#fff",
bg = "#000",
] = path.split("/").filter(Boolean);
const data = {
color: Color.string(toHex(color)),
bg: Color.string(toHex(bg)),
};
const contrast = data.color.contrast(data.bg).toFixed(2);
const level = getLevel(contrast);
const title = `${contrast} ${level}`;
const description = `${data.color.hex()} on ${data.bg.hex()}`;
const image = `https://jxnblk-png_card.web.val.run/?color=${stripHash(data.color.hex())}&bg=${
stripHash(data.bg.hex())
}&text=${title}&fontSize=128`;
const badge = valTownBadge(import.meta.url);
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<title>${title}</title>
<meta property="og:image" content="${image}">
<meta property="og:title" content="${title}">
<meta property="og:description" content="${description}">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${image}">
<meta name="twitter:creator" content="@jxnblk">
<meta name="twitter:site" content="@jxnblk">
<style>${getCSS(data.color.hex(), data.bg.hex())}</style>
</head>
<body>
<div>
<h1>
${contrast}
<div>
${level}
</div>
</h1>
<div>${description}</div>
</div>
<div class="badge">${badge}</div>
</body>
</html>
`;
return new Response(html, {
headers: {
"content-type": "text/html",
},
});
return Response.json(params);
}
const getCSS = (color, bg) => `
* { box-sizing: border-box }
body {
margin: 0;
font-family: system-ui, sans-serif;
background-color: ${bg};
color: ${color};
padding: 32px;
text-align: center;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
h1 {
font-size: 64px;
line-height: 1;
margin: 0;
margin-bottom: 16px;
}
h1 div {
font-size: 48px;
}
.badge {

🐟 Simple CSS library for Val Town

Create valimport { css } from "https://esm.town/v/jxnblk/tuna"; const styles = css({ body: { // special keyword for <body> element fontFamily: "system-ui, sans-serif", margin: 0, backgroundColor: "#f5f5f5", }, container: { padding: 32, // numbers are converted to pixel units margin: "0 auto", maxWidth: 1024, }, input: { fontFamily: "inherit", fontSize: "inherit", lineHeight: "1.5", padding: "2px 8px", border: "1px solid #ccc", borderRadius: "4px", }, });
Create val// JSX example const html = render( <div className={styles.container}> <style {...styles.tag} /> <h1>Tuna Example</h1> <input type="text" defaultValue="hi" className={styles.input} /> </div> );
Create val// get raw CSS string styles.css

Nested selectors and pseudoselectors

Create valconst styles = css({ button: { background-color: "tomato", "&:hover": { background-color: "magenta", }, "& > svg": { fill: "currentColor", }, }, });

Media queries

Create valconst styles = css({ box: { padding: 16, "@media screen and (min-width: 768px)": { padding: 32, "&:hover": { color: "tomato", }, }, } });

Limitations

  • Does not support HTML element selectors (other than body)

Tests: https://www.val.town/v/jxnblk/tuna_tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { kebabCase } from "https://esm.sh/change-case@5.4.4";
const reserved = ["css", "tag"];
const cache = new Map();
const px = n => typeof n === "number" ? `${n}px` : n;
const createDeclaration = (key, val) => `${kebabCase(key, {})}:${px(val)}`;
const NESTED_RE = /&/;
const MEDIA_RE = /^@media/;
function createRule(obj, parentKey) {
const rules = [];
const classNames = [];
for (const [key, val] of Object.entries(obj)) {
const nested = NESTED_RE.test(key);
const media = MEDIA_RE.test(key);
// consider: typeof val === "object";
if (nested) {
// e.g. `&:hover`
const { _rules, _classNames } = createNestedRule(key, val);
rules.push(..._rules);
classNames.push(..._classNames);
continue;
}
if (media) {
const { _rules, _classNames } = createMediaRule(key, val);
rules.push(..._rules);
classNames.push(..._classNames);
continue;
}
const dec = createDeclaration(key, val);
if (cache.has(dec)) {
const cn = cache.get(dec);
classNames.push(cn);
} else {
const cn = "x" + cache.size.toString(36);
const rule = `.${cn}{${dec}}`;
rules.push(rule);
classNames.push(cn);
cache.set(dec, cn);
}
}
const css = rules.join("");
const className = classNames.join(" ");
return {
css,
className,
};
}
// e.g. box { "&:hover": { color: "red" } }
// "&:hover", { color: "red" }
function createNestedRule(selector, obj) {
const _rules = [];
const _classNames = [];
for (const [key, val] of Object.entries(obj)) {
if (typeof val === "object") {
throw new Error("Nested selectors are only supported one level deep");
}
const cn = "x" + cache.size.toString(36);
const className = selector.replace("&", cn);
const rule = `.${className}{${createDeclaration(key, val)}}`;
_rules.push(rule);
_classNames.push(cn);
cache.set(rule, className); // only used to increment cache size
}
return {
_rules,
_classNames,
};
}
function createMediaRule(media, obj) {
const _rules = [];
const _classNames = [];
for (const [key, val] of Object.entries(obj)) {
const nested = NESTED_RE.test(key);
if (nested) {
const n = createNestedRule(key, val);
const rule = `${media}{${n._rules.join("")}}`;
_rules.push(rule);
_classNames.push(...n._classNames);
continue;
}
const cn = "x" + cache.size.toString(36);
const dec = createDeclaration(key, val);
const rule = `${media}{.${cn}{${dec}}}`;
_rules.push(rule);
_classNames.push(cn);
cache.set(rule, cn); // increment cache size
}
return {
_rules,

SVG favicon service, with support for custom text/letters and colors

<img src="https://jxnblk-svg_favicon.web.val.run?text=Hi&color=000&bg=f0f" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/** @jsxImportSource https://esm.sh/preact */
import { render } from "npm:preact-render-to-string";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const toHex = (str) => HEXRE.test(str) ? "#" + str : str;
function SVG({
text = "Aa",
bg = "000",
color = "fff",
fontSize = 16,
}: {
text: string;
bg: string;
color: string;
fontSize: number;
}) {
// note: dominantBaseline does not work with resvg_wasm
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 32 32"
fill={toHex(color)}
style={{
fontFamily: "sans-serif",
fontWeight: "bold",
fontSize,
}}
>
<rect
x="0"
y="0"
width="32"
height="32"
fill={toHex(bg)}
/>
<text
textAnchor="middle"
x="16"
y={16}
dominantBaseline="middle"
>
{text}
</text>
</svg>
);
}
export default function favicon(req: Request): Response {
const params = Object.fromEntries(new URL(req.url).searchParams);
const svg = render(<SVG {...params} />);
return new Response(svg, {
headers: {
"Content-Type": "image/svg+xml",
},
});
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import { Color } from "https://deno.land/x/color/mod.ts";
const HEXRE = /^[A-Fa-f0-9]{3,6}$/;
const HASHRE = /^#[A-Fa-f0-9]{3,6}$/;
const LARGE = 3;
const AA = 4.5;
const AAA = 7;
const toHex = str => HEXRE.test(str) ? "#" + str : str;
const stripHash = str => HASHRE.test(str) ? str.slice(1) : str;
export function colorContrast (color, bg) : any {
const data = {
color,
bg,
};
const contrast = data.color.contrast(data.bg).toFixed(2);
const level = getLevel(contrast);
const title = `${contrast} ${level}`;
const image = encodeURI(
`https://jxnblk-png_card.web.val.run/?color=${stripHash(data.color.hex())}&bg=${
stripHash(data.bg.hex())
}&text=${title}&fontSize=128`,
);
return {
...data,
hex: {
color: data.color.hex(),
bg: data.bg.hex(),
},
params: {
color,
bg,
},
contrast,
level,
image,
};
}
export default async function(req: Request): Promise<Response> {
const path = new URL(req.url).pathname;
const [
color = "fff",
bg = "000",
] = path.split("/").filter(Boolean);
const data = colorContrast(Color.string(toHex(color)), Color.string(toHex(bg)));
return Response.json(data);
}
const getLevel = contrast => {
if (contrast >= AAA) {
return "AAA";
} else if (contrast >= AA) {
return "AA";
} else if (contrast >= LARGE) {
return "AA LARGE";
} else {
return "FAIL";
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
import { assert, assertEquals, assertMatch } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { css } from "https://esm.town/v/jxnblk/tuna";
const matches = (a, b) => assertMatch(a, new RegExp(b));
const bodyStyles = css({
body: {
color: "red",
fontFamily: "sans-serif",
},
});
assertEquals(bodyStyles.css, "body{color:red;font-family:sans-serif}");
assertEquals(bodyStyles.tag.dangerouslySetInnerHTML.__html, bodyStyles.css);
const styles = css({
button: {
color: "blue",
padding: "8px",
},
box: {
color: "blue",
padding: "32px",
},
});
matches(styles.css, "x0");
matches(styles.css, "x1");
matches(styles.css, "x2");
matches(styles.css, ".x0{color:blue}");
matches(styles.css, ".x1{padding:8px}");
matches(styles.css, ".x2{padding:32px}");
assertEquals(styles.button, "x0 x1");
assertEquals(styles.box, "x0 x2");
// pseudoselectors
const pseudos = css({
box: {
backgroundColor: "tomato",
"&:hover": {
backgroundColor: "blue",
color: "black",
},
},
// "box:hover": { }, // not supported
});
matches(pseudos.css, ".x3{background-color:tomato}");
matches(pseudos.css, ".x4:hover{background-color:blue}");
matches(pseudos.css, ".x5:hover{color:black}");
// child selectors
const children = css({
title: {
fontSize: "32px",
"& > span": {
fontSize: "16px",
},
},
});
matches(children.css, ".x6{font-size:32px}");
matches(children.css, ".x7 > span{font-size:16px}");
const attrs = css({
input: {
"&[type=checkbox]": {
backgroundColor: "tomato",
},
},
});
assertEquals(attrs.css, ".x8[type=checkbox]{background-color:tomato}");
const numbers = css({
box: {
fontSize: 12,
padding: 7,
},
});
matches(numbers.css, ".x9{font-size:12px}");
matches(numbers.css, ".xa{padding:7px}");
const media = css({
/* possible api??
"@media screen and (min-w-width: 768px)": {
box: {
fontSize: 13,
padding: 11,
},
},
*/
box: {
"@media screen and (min-width: 768px)": {
fontSize: 13,
padding: 11,
"&:hover": {
color: "red",
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts";
import { caviar } from "https://esm.town/v/jxnblk/caviar";
const simple = caviar({
blue: "#0cf",
});
assertEquals(simple.blue, "var(--blue)");
assertEquals(simple.style, { "--blue": "#0cf" });
const modes = caviar({
light: {
text: "#000",
bg: "#fff",
},
dark: {
text: "#fff",
bg: "#000",
},
});
assertEquals(modes.text, "var(--text)");
assertEquals(modes.style.light, { "--text": "#000", "--bg": "#fff" });
assertEquals(modes.style.dark, { "--text": "#fff", "--bg": "#000" });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// Example of using tuna and caviar with React (resrv) on Val Town
// what does the save button do?
/** @jsxImportSource https://esm.sh/react */
import { caviar } from "https://esm.town/v/jxnblk/caviar";
import resrv, { React } from "https://esm.town/v/jxnblk/resrv";
import { css } from "https://esm.town/v/jxnblk/tuna";
import ValBadge from "https://esm.town/v/jxnblk/val_badge_react";
// Helper for using CSS vars
const vars = caviar({
light: {
text: "#013",
bg: "#fff",
highlight: "#0cf",
},
dark: {
text: "#fff",
bg: "#013",
highlight: "#0cf",
},
});
const styles = css({
body: { // special keyword
fontFamily: "Lexend, system-ui, sans-serif",
margin: 0,
color: vars.text,
backgroundColor: vars.bg,
},
container: {
padding: "32px",
margin: "0 auto",
maxWidth: "1024px",
},
title: {
margin: 0,
fontSize: 48,
lineHeight: "1.0",
"@media screen and (min-width: 768px)": {
fontSize: 64,
},
},
input: {
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "1.5",
padding: "2px 8px",
borderWidth: "1px",
borderStyle: "solid",
borderColor: vars.text,
borderRadius: "4px",
"&:focus": {
borderColor: vars.highlight,
},
},
colorModeButton: {
position: "fixed",
top: 0,
right: 0,
margin: "16px",
},
});
const modes = ["light", "dark"];
function App({ script }) {
const [mode, setMode] = React.useState("light");
const toggleMode = e => {
e.preventDefault();
const i = (modes.indexOf(mode) + 1) % modes.length;
setMode(modes[i]);
};
return (
<>
<head>
<title>🐟 Tuna</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link href="https://fonts.googleapis.com/css2?family=Lexend:wght@400..600&display=swap" rel="stylesheet" />
</head>
<body style={vars.style[mode]}>
<div className={styles.container}>
<style {...styles.tag} />
<h1 className={styles.title}>
🐟 Tuna Example
</h1>
<p>Minimal CSS Library for Val Town</p>
<input
type="text"
defaultValue="hi"
className={styles.input}
/>
<button onClick={toggleMode} className={styles.colorModeButton}>
{mode}
</button>
</div>
<ValBadge path="jxnblk/tuna" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/** @jsxImportSource https://esm.sh/react */
import resrv, { React } from "https://esm.town/v/jxnblk/resrv";
function App() {
const [count, setCount] = React.useState(0);
return (
<div>
<h1>Resrv</h1>
<p>React SSR with client-side hydration in Val Town</p>
<pre>{count}</pre>
<button onClick={() => setCount(count - 1)}>-</button>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
export default resrv(App, import.meta.url);

React SSR and client-side hydration for Val Town

Usage

Create val/** @jsxImportSource https://esm.sh/react */ import resrv, { React } from "https://esm.town/v/jxnblk/resrv"; function App() { const [count, setCount] = React.useState(0); return ( <div> <h1>Resrv</h1> <p>React SSR with client-side hydration in Val Town</p> <pre>{count}</pre> <button onClick={() => setCount(count - 1)}>-</button> <button onClick={() => setCount(count + 1)}>+</button> </div> ); } export default resrv(App, import.meta.url);

Live example

React requires matching versions for SSR and hydration. Import React from https://esm.town/v/jxnblk/resrv to ensure your component uses the same version as this library (currently react@18.3.1).

HTML Root Hydration

To render a component that includes a <head> and <body> tag, pass root: true to the third options argument:

Create valfunction App ({ script }) { return ( <body> <h1>Hello</h1> {script} </body> ); } export default resrv(App, import.meta.url, { root: true });

Inspired by https://www.val.town/v/stevekrouse/react_http

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/** @jsxImportSource https://esm.sh/react */
import { renderToString } from "https://esm.sh/react-dom@18.3.1/server";
import * as React from "https://esm.sh/react@18.3.1";
// API:
// import resrv, { React } from "https://esm.town/v/jxnblk/resrv";
// export default resrv(Component, import.meta.url);
export { React };
const createScript = (module: string) => `
import App from "${module}";
import { hydrateRoot } from "https://esm.sh/react-dom@18.3.1/client";
import { jsx } from "https://esm.sh/react@18.3.1/jsx-runtime";
const root = document.getElementById("root");
hydrateRoot(root, jsx(App, {}));
`;
const App = ({ Component, module, simple }: {
Component: any;
module: string;
simple?: boolean;
}) => {
const script = (
<script
type="module"
dangerouslySetInnerHTML={{ __html: createScript(module) }}
/>
);
if (simple) {
return (
<>
<head></head>
<body id="root">
<Component />
{script}
</body>
</>
);
}
return <Component script={script} />;
};
interface Options {
root?: boolean;
}
export default function resrv(Component, module: string, opts: Options = {}) {
return function<T extends object>(args: Request | T): Response | any {
if (args instanceof Request) {
const body = renderToString(<App Component={Component} module={module} simple={!opts.root} />);
const html = `<!DOCTYPE html>
<html lang="en" ${opts.root ? "id='root'" : ""}>
${body}
</html>`;
return new Response(html, {
headers: {
"Content-Type": "text/html",
},
});
}
return <App Component={Component} module={module} />;
};
}