├── .browserslistrc ├── .editorconfig ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── Procfile ├── README.md ├── babel.config.js ├── build-helpers └── index.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── src ├── client │ ├── components │ │ ├── CustomProperty.js │ │ ├── Footer.js │ │ ├── Home.js │ │ ├── Paintlet.js │ │ └── PaintletList.js │ ├── routes │ │ └── home.js │ ├── styles │ │ ├── Base.less │ │ ├── CustomProperty.less │ │ ├── Footer.less │ │ ├── Home.less │ │ ├── Paintlet.less │ │ ├── PaintletList.less │ │ └── _variables.less │ └── worklets │ │ ├── blotto.js │ │ ├── bumpy.js │ │ ├── bytemare.js │ │ ├── chemistreak.js │ │ ├── circuits.js │ │ ├── flashy.js │ │ ├── parallelowow.js │ │ └── slapdash.js └── server │ ├── helpers │ └── html.js │ ├── index.js │ └── worklets.js └── webpack.config.babel.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 0.25% 2 | IE > 10 3 | Firefox ESR 4 | not dead 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | end_of_line = lf 9 | indent_size = 2 10 | charset = utf-8 11 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | node: true 6 | }, 7 | extends: [ 8 | "eslint:recommended", 9 | "plugin:react/recommended" 10 | ], 11 | parserOptions: { 12 | ecmaFeatures: { 13 | jsx: true 14 | }, 15 | ecmaVersion: 2018, 16 | sourceType: "module" 17 | }, 18 | plugins: [ 19 | "react" 20 | ], 21 | rules: { 22 | indent: [ 23 | "error", 24 | 2 25 | ], 26 | "no-unused-vars": [ 27 | "error", 28 | { 29 | varsIgnorePattern: "^(h|render|Fragment)$" 30 | } 31 | ], 32 | "linebreak-style": [ 33 | "error", 34 | "unix" 35 | ], 36 | quotes: [ 37 | "error", 38 | "double" 39 | ], 40 | semi: [ 41 | "error", 42 | "always" 43 | ], 44 | "react/react-in-jsx-scope": "off", 45 | "react/prop-types": "off" 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm-debug.log 4 | Thumbs.db 5 | dist 6 | compilation-stats.json 7 | .env 8 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v12.14.0 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: NODE_ENV=production node ./dist/server/index.js 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # paintlets! 2 | 3 | a gallery of tweakable and downloadable paint worklets: https://paintlets.herokuapp.com/ 4 | 5 | ## What the hell is this? 6 | 7 | This is a small Node app currently in development that showcases [paint worklets](https://developers.google.com/web/updates/2018/01/paintapi) as a gallery. 8 | 9 | ## What the hell are "paint worklets"? 10 | 11 | Paint worklets are a part of CSS Houdini. They're a really neat way of combining [programmatically generated artwork](https://www.youtube.com/watch?v=4Se0_w0ISYk) with CSS. The API used to draw the artwork is the same as the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API), only your code is wrapped in a class with a `paint` method that gets exposed to CSS, where it can then be used in properties like `background-image`: 12 | 13 | ```css 14 | .fancy { 15 | background-image: paint(my-paint-worklet); 16 | } 17 | ``` 18 | 19 | ## How the hell can I get my paint worklet(s) on this thing? 20 | 21 | _**tl;dr:** It's a lot of work, and I'm very particular. If you have a paint worklet you'd like me to add for you, I'd be happy to do so. Just file an issue with a link to your paint worklet, the screen name you'd like to be credited under, a link to your personal website, twitter, or whatever._ 22 | 23 | If you have a paint worklet you'd like to add to the gallery, the process is a bit involved. Don't fret! I'll walk you through it. 24 | 25 | ### 1. Fork the repo and install npm packages 26 | 27 | You'll submit your paint worklet through a pull request. To do that, fork the repo, clone it, and install the npm packages for the project. 28 | 29 | ### 2. Properly scope your paint worklet and its custom properties 30 | 31 | To ensure your paint worklet works properly in the app, it should be properly scoped. Let's say you have a worklet called `barf`, and you access it in CSS with `paint(barf)`. `barf` is your scope. The name of your paint worklet's JavaScript file should be the same as the scope you've chosen, meaning in this example, it should be called `barf.js`. 32 | 33 | If your paint worklet has custom properties that allow it to be controlled by CSS (and your paint worklet should have at least one), you'll need to scope those, too. The format this app uses to register custom properties is as follows: 34 | 35 | ``` 36 | --[SCOPE]-[PROPERTY_NAME] 37 | ``` 38 | 39 | Continuing with our `barf` example, let's say our paint worklet has a custom property for controlling the alpha transparency of the generated art. That property would look like this: 40 | 41 | ``` 42 | --barf-alpha 43 | ``` 44 | 45 | This scoping is done to ensure that paint worklets don't register properties that conflict with other ones in the app. It's also just good practice. 46 | 47 | If you find that your chosen scope is already taken, rescope your paint worklet accordingly: 48 | 49 | 1. Rename the JavaScript file. 50 | 2. Rename whatever handle you've given your paint worklet in the `registerPaint` method. 51 | 3. Rename your custom properties in your paint worklet's static `inputProperties` method. 52 | 53 | Now you're ready to add your cool paint worklet to the app itself! 54 | 55 | ### 3. Add your paint worklet to the `worklets` folder 56 | 57 | Once you've scoped your paint worklet, you'll need to add its JavaScript to this project. A paint worklet should consist of a single JavaScript file. Add that file to the [`src/client/worklets` folder](https://github.com/malchata/paintlets/tree/master/src/client/worklets). 58 | 59 | ### 4. Add your paint worklet to `worklets.js` 60 | 61 | This app discovers paint worklets through a file named [`worklets.js`](https://github.com/malchata/paintlets/blob/master/src/server/worklets.js), which is found in the [`src/server` folder](https://github.com/malchata/paintlets/tree/master/src/server). This file exports a collection of objects. Each one is for an individual paint worklet. The key for each object is the scope for that paint worklet. 62 | 63 | Each object in the collection has three child objects: 64 | 65 | 1. `author`, which identifies the creator of the paint worklet. 66 | 2. `backgroundColor`, which is the initial background color assigned to the paint worklet's container element after it loads. 67 | 2. `customProperties`, which contains the custom properties for the associated paint worklet. 68 | 69 | Here's what a definition for our `barf` paint worklet may look like: 70 | 71 | ```javascript 72 | export default { 73 | barf: { 74 | author: { 75 | // Screen names preferred. 76 | screenName: "pukebeast", 77 | // If you don't have a website, point to a social media URL. 78 | website: "https://muhwebsites.wtf/" 79 | }, 80 | backgroundColor: "#fffbfe", 81 | customProperties: { 82 | // Custom properties with multiple words should be in kebab case and quoted. 83 | "splatter-radius": { 84 | syntax: "", 85 | value: 32 86 | }, 87 | color: { 88 | syntax: "", 89 | value: "#f0f" 90 | }, 91 | } 92 | }, 93 | // Other worklets... 94 | }; 95 | ``` 96 | 97 | Each key in your `customProperties` collection should correspond to an individual custom property. The key is interpolated into the scoped custom property format described above. So in the above example, the `splatter-radius` key is interpolated into a custom property which is ultimately named `--barf-splatter-radius`. 98 | 99 | The two objects necessary for each custom property are `syntax` and `value`: 100 | 101 | 1. `syntax` should be a [supported syntax string](https://www.w3.org/TR/css-properties-values-api-1/#supported-syntax-strings). 102 | 2. `value` should be a default value. When your paint worklet renders, it will initially use this value for the associated custom property. Ensure this default is valid for the chosen syntax string. 103 | 104 | ### 5. Make sure everything works properly and submit a PR 105 | 106 | Once you've done everything, build the app with either `npm run build` or `npm run build:dev`. Then, test it locally by spinning up the node server with `npm run server` and go to http://localhost:8080. If there are no bugs and everything seems to work, [submit a PR](https://github.com/malchata/paintlets/pulls) for review. 107 | 108 | ## How the hell can I fix what's broken on this thing? 109 | 110 | Even though it functions, this project is still under development, so it's totally possible things could be busted that I didn't catch. Before you file an issue, though, make sure your browser actually supports paint worklets. Chrome and its derivatives (e.g., Opera and Edge) support them. Safari supports them, but the `registerProperty` method throws an error. Browser support bugs will be closed. 111 | 112 | If your issue is _not_ due to browser compatibility, then file an issue. An unsolicited PR will likely be declined unless it addresses a bug. Major architectural changes are not likely to be accepted, unless there's a compelling reason. Filing issues is a preferred default, as it spurs conversation rather than assumptions. 113 | 114 | ## Who the hell are you? 115 | 116 | I'm [Jeremy Wagner](https://jeremy.codes/). I'm an independent web performance consultant, [author](https://jeremy.codes/writing), and [speaker](https://speaking.jeremy.codes). 117 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | module.exports = { 4 | env: { 5 | production: { 6 | presets: [ 7 | [ 8 | "@babel/preset-env", { 9 | targets: { 10 | node: "current" 11 | } 12 | } 13 | ] 14 | ] 15 | }, 16 | development: { 17 | presets: [ 18 | [ 19 | "@babel/preset-env", { 20 | targets: { 21 | node: "current" 22 | } 23 | } 24 | ] 25 | ] 26 | }, 27 | clientLegacy: { 28 | presets: [ 29 | [ 30 | "@babel/preset-env", { 31 | modules: false, 32 | loose: true 33 | } 34 | ], 35 | [ 36 | "@babel/preset-react", { 37 | pragma: "h", 38 | pragmaFrag: "Fragment" 39 | } 40 | ] 41 | ], 42 | plugins: [ 43 | "@babel/plugin-transform-runtime" 44 | ] 45 | }, 46 | clientModern: { 47 | presets: [ 48 | [ 49 | "@babel/preset-env", { 50 | modules: false, 51 | loose: true, 52 | targets: { 53 | esmodules: true 54 | } 55 | } 56 | ], 57 | [ 58 | "@babel/preset-react", { 59 | pragma: "h", 60 | pragmaFrag: "Fragment" 61 | } 62 | ] 63 | ] 64 | }, 65 | server: { 66 | presets: [ 67 | [ 68 | "@babel/preset-env", { 69 | modules: false, 70 | targets: { 71 | node: "current" 72 | } 73 | } 74 | ], 75 | [ 76 | "@babel/preset-react", { 77 | pragma: "h", 78 | pragmaFrag: "Fragment" 79 | } 80 | ] 81 | ], 82 | plugins: [ 83 | "@babel/plugin-transform-runtime" 84 | ] 85 | } 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /build-helpers/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | // Built-ins 4 | import path from "path"; 5 | 6 | // webpack-specific 7 | import AssetsWebpackPlugin from "assets-webpack-plugin"; 8 | import MiniCssExtractPlugin from "mini-css-extract-plugin"; 9 | 10 | export const mode = process.env.NODE_ENV === "production" ? "production" : "development"; 11 | export const src = (...args) => path.resolve(process.cwd(), "src", ...args); 12 | export const dist = (...args) => path.resolve(process.cwd(), "dist", ...args); 13 | export const isProd = mode === "production"; 14 | export const assetsPluginInstance = new AssetsWebpackPlugin({ 15 | filename: "assets.json", 16 | path: dist("server"), 17 | update: true, 18 | fileTypes: ["mjs", "js", "jpg"] 19 | }); 20 | 21 | export const commonConfig = { 22 | mode, 23 | devtool: isProd ? "hidden-source-map" : "source-map", 24 | stats: { 25 | exclude: /\.map$/i, 26 | excludeAssets: /\.map$/i, 27 | excludeModules: /\.map$/i, 28 | builtAt: false, 29 | children: false, 30 | modules: false 31 | }, 32 | }; 33 | 34 | export const commonClientConfig = { 35 | entry: { 36 | home: src("client", "routes", "home.js") 37 | }, 38 | plugins: [ 39 | assetsPluginInstance, 40 | new MiniCssExtractPlugin({ 41 | filename: `css/${isProd ? "[name].[contenthash:8].css" : "[name].css"}`, 42 | chunkFilename: `css/${isProd ? "[name].[contenthash:8].css" : "[name].css"}` 43 | }) 44 | ], 45 | resolve: { 46 | alias: { 47 | "Components": src("client", "components"), 48 | "Styles": src("client", "styles") 49 | } 50 | }, 51 | ...commonConfig 52 | }; 53 | 54 | export const commonClientLoaders = [ 55 | { 56 | test: /\.(c|le)ss$/i, 57 | use: [ 58 | MiniCssExtractPlugin.loader, 59 | "css-loader", 60 | "postcss-loader", 61 | "less-loader" 62 | ] 63 | } 64 | ]; 65 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paintlets", 3 | "version": "1.0.0", 4 | "description": "A fun little gallery of paint worklets!", 5 | "main": "dist/server/index.js", 6 | "scripts": { 7 | "clean": "rm -rf ./dist", 8 | "copy:worklets": "cp -Rfv ./src/client/worklets ./dist/client/worklets", 9 | "build": "npm run clean && NODE_ENV=production npx webpack --progress && npm run build:worklets", 10 | "build:dev": "npm run clean && NODE_ENV=development npx webpack --progress && npm run copy:worklets", 11 | "build:stats": "NODE_ENV=production npx webpack --progress --profile --json > compilation-stats.json", 12 | "build:worklets": "npm run copy:worklets && find ./dist/client/worklets -type f -name '*.js' | xargs -P 16 -I {} npx terser -c -m -o {} --ecma 8 {}", 13 | "start": "npm run build && npm run server", 14 | "server": "node ./dist/server/index.js", 15 | "preview": "npm run build:dev && npm run server" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/malchata/paintlets.git" 20 | }, 21 | "keywords": [ 22 | "css", 23 | "houdini", 24 | "paint", 25 | "worklets" 26 | ], 27 | "author": "Jeremy L. Wagner ", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/malchata/paintlets/issues" 31 | }, 32 | "homepage": "https://github.com/malchata/paintlets#readme", 33 | "devDependencies": { 34 | "@babel/cli": "^7.12.8", 35 | "@babel/core": "^7.12.9", 36 | "@babel/node": "^7.12.6", 37 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 38 | "@babel/plugin-transform-runtime": "^7.12.1", 39 | "@babel/preset-env": "^7.12.7", 40 | "@babel/preset-modules": "^0.1.4", 41 | "@babel/preset-react": "^7.12.7", 42 | "@babel/register": "^7.12.1", 43 | "assets-webpack-plugin": "^6.1.2", 44 | "autoprefixer": "^10.1.0", 45 | "babel-loader": "^8.2.2", 46 | "css-loader": "^5.0.1", 47 | "cssnano": "^4.1.10", 48 | "eslint": "^7.15.0", 49 | "eslint-plugin-react": "^7.21.5", 50 | "less": "^3.12.2", 51 | "less-loader": "^7.1.0", 52 | "mini-css-extract-plugin": "^1.3.2", 53 | "null-loader": "^4.0.1", 54 | "postcss-loader": "^4.1.0", 55 | "webpack": "^5.10.0", 56 | "webpack-cli": "^4.2.0", 57 | "webpack-node-externals": "^2.5.2" 58 | }, 59 | "dependencies": { 60 | "@babel/runtime": "^7.12.5", 61 | "compression": "^1.7.4", 62 | "express": "^4.17.1", 63 | "express-sslify": "^1.2.0", 64 | "preact": "^10.5.7", 65 | "preact-render-to-string": "^5.1.12" 66 | }, 67 | "peerDependencies": { 68 | "postcss": "^8.2.0" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | const plugins = [ 4 | require("autoprefixer") 5 | ]; 6 | 7 | if (process.env.NODE_ENV === "production") { 8 | plugins.push(require("cssnano")); 9 | } 10 | 11 | module.exports = { 12 | plugins 13 | }; 14 | -------------------------------------------------------------------------------- /src/client/components/CustomProperty.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import { h, render, Component, Fragment } from "preact"; 3 | 4 | // App-specific 5 | import "Styles/CustomProperty.less"; 6 | 7 | class CustomProperty extends Component { 8 | constructor (props) { 9 | super(props); 10 | 11 | this.onCustomPropertyChange = this.onCustomPropertyChange.bind(this); 12 | } 13 | 14 | onCustomPropertyChange () { 15 | this.props.onCustomPropertyChange(this.props.name, this.customPropertyInput.value); 16 | } 17 | 18 | render () { 19 | const { id, name, value, disabled } = this.props; 20 | 21 | return ( 22 | <> 23 | 24 | this.customPropertyInput = customPropertyInput} 27 | name={name} 28 | type="text" 29 | id={id} 30 | value={value} 31 | disabled={disabled} 32 | /> 33 | 34 | ); 35 | } 36 | } 37 | 38 | export default CustomProperty; 39 | -------------------------------------------------------------------------------- /src/client/components/Footer.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import { h, render } from "preact"; 3 | 4 | // App-specific 5 | import "Styles/Footer.less"; 6 | 7 | const Footer = () => ( 8 | 14 | ); 15 | 16 | export default Footer; 17 | -------------------------------------------------------------------------------- /src/client/components/Home.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import { h, render, Fragment } from "preact"; 3 | 4 | // App-specific 5 | import "Styles/Base.less"; 6 | import "Styles/Home.less"; 7 | import PaintletList from "Components/PaintletList"; 8 | import Footer from "Components/Footer"; 9 | 10 | const Home = ({ worklets }) => ( 11 |
12 |
13 |

Paintlets!

14 |

A gallery of tweakable and downloadable paint worklets!

15 |
16 | 17 |
18 |
19 | ); 20 | 21 | export default Home; 22 | -------------------------------------------------------------------------------- /src/client/components/Paintlet.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import { h, render, Component } from "preact"; 3 | 4 | // App-specific 5 | import "Styles/Paintlet.less"; 6 | import CustomProperty from "Components/CustomProperty"; 7 | 8 | class Paintlet extends Component { 9 | constructor (props) { 10 | super(props); 11 | 12 | const { customProperties, backgroundColor } = props; 13 | 14 | this.state = { 15 | customProperties, 16 | paintAPISupported: true, 17 | loading: true, 18 | error: false, 19 | fullscreen: false, 20 | backgroundColor 21 | }; 22 | 23 | this.updateCustomProperty = this.updateCustomProperty.bind(this); 24 | this.updateBackgroundColor = this.updateBackgroundColor.bind(this); 25 | this.toggleFullscreen = this.toggleFullscreen.bind(this); 26 | 27 | if (typeof window !== "undefined" && props.lazy) { 28 | this.paintletObserver = new IntersectionObserver((entries, observer) => { 29 | entries.forEach(entry => { 30 | if (entry.isIntersecting || entry.intersectionRatio) { 31 | this.registerPaintlet(); 32 | observer.unobserve(entry.target); 33 | this.paintletObserver.disconnect(); 34 | } 35 | }); 36 | }); 37 | } 38 | } 39 | 40 | componentDidMount () { 41 | this.paintletPreview.style.backgroundColor = this.state.backgroundColor; 42 | 43 | if (this.props.lazy) { 44 | this.paintletObserver.observe(this.paintletRoot); 45 | } else { 46 | this.registerPaintlet(); 47 | } 48 | } 49 | 50 | registerPaintlet () { 51 | if (window.CSS.registerProperty) { 52 | Object.keys(this.props.customProperties).forEach(customPropertyName => { 53 | const { syntax, value } = this.props.customProperties[customPropertyName]; 54 | const name = `--${this.props.workletName}-${customPropertyName}`; 55 | 56 | CSS.registerProperty({ 57 | name, 58 | syntax, 59 | inherits: false, 60 | initialValue: value 61 | }); 62 | }); 63 | } 64 | 65 | if (window.CSS.paintWorklet) { 66 | CSS.paintWorklet.addModule(`/worklets/${this.props.workletName}.js`).then(() => { 67 | this.paintletPreview.style.backgroundImage = `paint(${this.props.workletName})`; 68 | 69 | this.setState({ 70 | loading: false 71 | }); 72 | }).catch(() => { 73 | this.setState({ 74 | loading: false, 75 | error: true 76 | }); 77 | }); 78 | } else { 79 | this.setState({ 80 | paintAPISupported: false, 81 | loading: false 82 | }); 83 | } 84 | } 85 | 86 | updateCustomProperty (customPropertyName, customPropertyValue) { 87 | let customProperties = { ...this.state.customProperties }; 88 | customProperties[customPropertyName].value = customPropertyValue; 89 | 90 | this.setState({ 91 | customProperties 92 | }, () => { 93 | Object.keys(this.state.customProperties).map(customPropertyName => { 94 | this.paintletPreview.style.setProperty(`--${this.props.workletName}-${customPropertyName}`, this.state.customProperties[customPropertyName].value); 95 | }); 96 | }); 97 | } 98 | 99 | updateBackgroundColor () { 100 | this.setState({ 101 | backgroundColor: this.backgroundColorInput.value 102 | }, () => { 103 | this.paintletPreview.style.backgroundColor = this.state.backgroundColor; 104 | }); 105 | } 106 | 107 | toggleFullscreen () { 108 | this.setState({ 109 | fullscreen: !this.state.fullscreen 110 | }, () => { 111 | document.body.style.overflow = this.state.fullscreen ? "hidden" : "auto"; 112 | }); 113 | } 114 | 115 | render () { 116 | const { author, workletName } = this.props; 117 | const { loading, error, paintAPISupported, backgroundColor, customProperties, fullscreen } = this.state; 118 | const backgroundColorFieldName = `paintlet-background-color-${workletName}`; 119 | const paintletClassNames = ["preview"]; 120 | 121 | if (!paintAPISupported) { 122 | paintletClassNames.push("state-no-support"); 123 | } 124 | 125 | if (loading) { 126 | paintletClassNames.push("state-loading"); 127 | } 128 | 129 | if (error) { 130 | paintletClassNames.push("state-error"); 131 | } 132 | 133 | if (fullscreen) { 134 | paintletClassNames.push("state-fullscreen"); 135 | } 136 | 137 | return ( 138 |
  • this.paintletRoot = paintletRoot}> 139 |

    140 | {workletName} by {author.screenName} 141 |

    142 |
    this.paintletPreview = paintletPreview}> 143 | 144 |

    😖 Paint API not supported

    145 |

    ⏳ Loading paintlet...

    146 |

    🐜 Arrrgh! There was a bug!

    147 |
    148 | 149 | this.backgroundColorInput = backgroundColorInput} 153 | value={backgroundColor} 154 | id={backgroundColorFieldName} 155 | name={backgroundColorFieldName} 156 | disabled={error || loading || !paintAPISupported} 157 | /> 158 |
    159 |
    160 |
    161 | {Object.keys(customProperties).map((customPropertyName, key) => { 162 | const { syntax, value } = customProperties[customPropertyName]; 163 | 164 | return ( 165 | 174 | ); 175 | })} 176 |
    177 |
  • 178 | ); 179 | } 180 | } 181 | 182 | export default Paintlet; 183 | -------------------------------------------------------------------------------- /src/client/components/PaintletList.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import { h, render } from "preact"; 3 | 4 | // App-specific 5 | import "Styles/PaintletList.less"; 6 | import Paintlet from "Components/Paintlet"; 7 | 8 | const PaintletList = ({ worklets }) => { 9 | let lazyKey = 1; 10 | 11 | if (typeof window !== "undefined") { 12 | if (window.innerWidth > 799 || window.innerHeight > 1279) { 13 | lazyKey = 2; 14 | } 15 | 16 | if (window.innerWidth > 1439 || window.innerHeight > 1279) { 17 | lazyKey = 3; 18 | } 19 | } 20 | 21 | return ( 22 |
      23 | {Object.keys(worklets).map((workletName, key) => = lazyKey} key={key} workletName={workletName} customProperties={worklets[workletName].customProperties} backgroundColor={worklets[workletName].backgroundColor} author={worklets[workletName].author} />)} 24 |
    25 | ); 26 | }; 27 | 28 | export default PaintletList; 29 | -------------------------------------------------------------------------------- /src/client/routes/home.js: -------------------------------------------------------------------------------- 1 | import { h, hydrate } from "preact"; 2 | import Home from "Components/Home"; 3 | import worklets from "../../server/worklets"; 4 | 5 | hydrate(, document.getElementById("app")); 6 | -------------------------------------------------------------------------------- /src/client/styles/Base.less: -------------------------------------------------------------------------------- 1 | @import "_variables.less"; 2 | 3 | * { 4 | box-sizing: border-box; 5 | } 6 | 7 | html, 8 | body, 9 | div, 10 | span, 11 | h1, 12 | h2, 13 | h3, 14 | h4, 15 | p, 16 | a, 17 | em, 18 | img, 19 | strong, 20 | u, 21 | ol, 22 | ul, 23 | li, 24 | fieldset, 25 | form, 26 | label, 27 | legend, 28 | footer, 29 | header, 30 | nav, 31 | section { 32 | margin: 0; 33 | padding: 0; 34 | border: 0; 35 | font-size: 100%; 36 | font: inherit; 37 | vertical-align: baseline; 38 | } 39 | 40 | footer, 41 | header, 42 | nav, 43 | section { 44 | display: block; 45 | } 46 | 47 | html { 48 | font-size: 13.8588133391px; 49 | } 50 | 51 | body { 52 | line-height: 1; 53 | background: var(--black); 54 | padding: var(--size-6); 55 | font-family: monospace; 56 | } 57 | 58 | input, 59 | button { 60 | appearance: none; 61 | padding: 0; 62 | margin: 0; 63 | border: 0; 64 | border-radius: 0; 65 | font-family: monospace; 66 | 67 | &[disabled] { 68 | opacity: 0.5; 69 | } 70 | } 71 | 72 | ol, 73 | ul { 74 | list-style: none; 75 | } 76 | 77 | a { 78 | color: var(--accent); 79 | font-weight: bold; 80 | } 81 | 82 | @media screen and (min-width: 50rem) { 83 | html { 84 | font-size: 16px; 85 | } 86 | } 87 | 88 | @media screen and (min-width: 90rem) { 89 | html { 90 | font-size: 18.472px; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/client/styles/CustomProperty.less: -------------------------------------------------------------------------------- 1 | .properties { 2 | padding: var(--size-6) 0 0; 3 | display: grid; 4 | grid-template-columns: max-content auto; 5 | grid-gap: var(--size-4); 6 | align-items: baseline; 7 | max-width: 35.5rem; 8 | width: 100%; 9 | margin: 0 auto; 10 | 11 | & > label, 12 | & > input { 13 | font-size: var(--size-7); 14 | line-height: 1.309; 15 | display: block; 16 | } 17 | 18 | & > label { 19 | color: var(--white); 20 | cursor: pointer; 21 | } 22 | 23 | & > input { 24 | background: transparent; 25 | border: none; 26 | padding: var(--size-2) var(--size-2) var(--size-0); 27 | border-bottom: var(--size-0) solid var(--light-grey); 28 | width: 100%; 29 | color: var(--light-grey); 30 | } 31 | 32 | & > input:focus { 33 | outline: var(--size-0) solid var(--accent); 34 | color: var(--accent); 35 | border-bottom-color: var(--accent); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/client/styles/Footer.less: -------------------------------------------------------------------------------- 1 | footer { 2 | padding: var(--size-6) 0 0; 3 | border-top: var(--size-0) dotted var(--accent); 4 | margin: var(--size-6) 0 0; 5 | 6 | p { 7 | font-size: var(--size-6); 8 | color: var(--white); 9 | text-align: center; 10 | line-height: 1.309; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/client/styles/Home.less: -------------------------------------------------------------------------------- 1 | .home { 2 | h1, 3 | h2 { 4 | line-height: 1.309; 5 | text-align: center; 6 | text-transform: lowercase; 7 | color: var(--white); 8 | } 9 | 10 | h1 { 11 | font-size: var(--size-10); 12 | padding: 0 0 var(--size-1); 13 | } 14 | 15 | h2 { 16 | font-size: var(--size-8); 17 | } 18 | 19 | hgroup { 20 | padding: 0 0 var(--size-6); 21 | border-bottom: var(--size-0) dotted var(--accent); 22 | margin: 0 0 var(--size-6); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/client/styles/Paintlet.less: -------------------------------------------------------------------------------- 1 | .paintlet { 2 | padding: 0 0 var(--size-6); 3 | 4 | & > h3 { 5 | border-radius: var(--size-3) var(--size-3) 0 0; 6 | padding: var(--size-4); 7 | background: var(--dark-grey); 8 | color: var(--white); 9 | font-weight: bold; 10 | font-size: var(--size-6); 11 | box-shadow: 0 0 var(--size-1) var(--dark-grey); 12 | text-align: center; 13 | 14 | & > a { 15 | &:hover { 16 | color: var(--accent); 17 | } 18 | } 19 | } 20 | } 21 | 22 | .preview { 23 | height: 256px; 24 | height: 42.2314872894vh; 25 | background-color: var(--white); 26 | border-radius: 0 0 var(--size-3) var(--size-3); 27 | display: flex; 28 | flex-flow: column nowrap; 29 | align-items: center; 30 | justify-content: space-between; 31 | contain: strict; 32 | border-color: var(--dark-grey); 33 | border-width: 0 var(--size-0) var(--size-0); 34 | border-style: solid; 35 | 36 | &.state-no-support { 37 | .message-no-support { 38 | display: block; 39 | } 40 | } 41 | 42 | &.state-loading { 43 | .message-loading { 44 | display: block; 45 | } 46 | } 47 | 48 | &.state-error { 49 | .message-error { 50 | display: block; 51 | } 52 | } 53 | 54 | &.state-no-support, 55 | &.state-loading, 56 | &.state-error { 57 | justify-content: center; 58 | 59 | .controls, 60 | .fullscreen-toggle { 61 | display: none; 62 | } 63 | } 64 | 65 | &.state-fullscreen { 66 | position: fixed; 67 | top: 0; 68 | left: 0; 69 | z-index: 10; 70 | width: 100%; 71 | height: 100%; 72 | border: 0; 73 | 74 | .controls { 75 | display: none; 76 | } 77 | } 78 | } 79 | 80 | .message-no-support, 81 | .message-loading, 82 | .message-error { 83 | display: none; 84 | font-size: var(--size-7); 85 | text-align: center; 86 | color: var(--black); 87 | font-weight: bold; 88 | } 89 | 90 | .controls { 91 | padding: var(--size-3); 92 | background: var(--dark-grey); 93 | border-radius: var(--size-3) var(--size-3) 0 0; 94 | 95 | & > label, 96 | & > input { 97 | line-height: 1.309; 98 | font-size: var(--size-6); 99 | color: var(--white); 100 | } 101 | 102 | & > input { 103 | background: transparent; 104 | border: none; 105 | padding: var(--size-2) var(--size-2) var(--size-0); 106 | border-bottom: var(--size-0) solid var(--light-grey); 107 | width: 100%; 108 | max-width: 10rem; 109 | color: var(--light-grey); 110 | margin: 0 0 0 var(--size-1); 111 | text-align: center; 112 | 113 | &:focus { 114 | color: var(--accent); 115 | border-bottom-color: var(--accent); 116 | outline: var(--size-0) solid var(--accent); 117 | } 118 | } 119 | } 120 | 121 | .fullscreen-toggle { 122 | font-weight: 700; 123 | padding: var(--size-5); 124 | cursor: pointer; 125 | background: var(--dark-grey); 126 | line-height: 1; 127 | font-size: var(--size-6); 128 | color: var(--white); 129 | border-radius: var(--size-3); 130 | margin: var(--size-5) 0 0; 131 | 132 | &:focus { 133 | outline: var(--size-0) solid var(--accent); 134 | } 135 | } 136 | 137 | .properties { 138 | & > input { 139 | & + label { 140 | margin-top: var(--size-5); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/client/styles/PaintletList.less: -------------------------------------------------------------------------------- 1 | @media screen and (min-width: 50rem) { 2 | .paintlet-list { 3 | list-style: none; 4 | display: grid; 5 | grid-gap: var(--size-6); 6 | grid-template-columns: repeat(2, 1fr); 7 | } 8 | } 9 | 10 | @media screen and (min-width: 90rem) { 11 | .paintlet-list { 12 | grid-template-columns: repeat(3, 1fr); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/client/styles/_variables.less: -------------------------------------------------------------------------------- 1 | :root { 2 | // Colors 3 | --black: #03120e; 4 | --accent: #ff92c2; 5 | --dark-grey: #333232; 6 | --light-grey: #a8a8a8; 7 | --white: #fffbfe; 8 | 9 | // Sizing/padding values 10 | --size-10: 3rem; 11 | --size-9: 2.2918258212rem; 12 | --size-8: 1.750821865rem; 13 | --size-7: 1.3375262528rem; 14 | --size-6: 1.0217924009rem; 15 | --size-5: 0.7805900695rem; 16 | --size-4: 0.5963254923rem; 17 | --size-3: 0.4555580537rem; 18 | --size-2: 0.3480199035rem; 19 | --size-1: 0.2658670004rem; 20 | --size-0: 0.2031069522rem; 21 | } 22 | -------------------------------------------------------------------------------- /src/client/worklets/blotto.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "blotto"; 4 | 5 | class Blotto { 6 | static get inputProperties () { 7 | return [ 8 | `--${paintName}-tile-size`, 9 | `--${paintName}-color`, 10 | `--${paintName}-amplitude`, 11 | `--${paintName}-max-opacity`, 12 | `--${paintName}-blend-mode` 13 | ]; 14 | } 15 | 16 | paint (ctx, geom, properties) { 17 | const tileSize = parseInt(properties.get(`--${paintName}-tile-size`)); 18 | const xTiles = geom.width / tileSize; 19 | const yTiles = geom.height / tileSize; 20 | const amplitude = parseFloat(properties.get(`--${paintName}-amplitude`)); 21 | const maxOpacity = parseFloat(properties.get(`--${paintName}-max-opacity`)); 22 | const fullCircle = Math.PI * 2; 23 | 24 | ctx.fillStyle = properties.get(`--${paintName}-color`).toString(); 25 | ctx.globalCompositeOperation = properties.get(`--${paintName}-blend-mode`).toString(); 26 | 27 | for (let y = 0; y < yTiles; y++) { 28 | const yOffset = y * tileSize; 29 | 30 | for (let x = 0; x < xTiles; x++) { 31 | const opacity = Math.random() % Math.random(); 32 | 33 | ctx.globalAlpha = opacity > maxOpacity ? maxOpacity : opacity; 34 | ctx.beginPath(); 35 | ctx.arc(x * tileSize, yOffset, tileSize * Math.random() * amplitude, 0, fullCircle); 36 | ctx.fill(); 37 | } 38 | } 39 | } 40 | } 41 | 42 | registerPaint(paintName, Blotto); 43 | -------------------------------------------------------------------------------- /src/client/worklets/bumpy.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "bumpy"; 4 | 5 | class Bumpy { 6 | static get inputProperties () { 7 | return [ 8 | `--${paintName}-tile-size`, 9 | `--${paintName}-thickness`, 10 | `--${paintName}-color`, 11 | `--${paintName}-probability` 12 | ]; 13 | } 14 | 15 | paint (ctx, geom, properties) { 16 | const tileSize = parseInt(properties.get(`--${paintName}-tile-size`)); 17 | const thickness = parseFloat(properties.get(`--${paintName}-thickness`)); 18 | const color = properties.get(`--${paintName}-color`).toString(); 19 | const probability = parseFloat(properties.get(`--${paintName}-probability`)); 20 | const geomTileHeight = (geom.height * 2) / tileSize; 21 | const geomTileWidth = geom.width / tileSize; 22 | 23 | ctx.strokeStyle = color; 24 | ctx.lineWidth = thickness; 25 | 26 | for (let y = 0; y < geomTileHeight; y++) { 27 | for (let x = 0; x < geomTileWidth; x++) { 28 | this.drawStroke(ctx, tileSize, (x * tileSize), (y * (tileSize / 2)), thickness, probability); 29 | } 30 | } 31 | } 32 | 33 | drawStroke (ctx, tileSize, xOffset, yOffset, thickness, probability) { 34 | const thirdTile = tileSize / 3; 35 | const quarterTile = tileSize / 4; 36 | const lineOffset = yOffset - (thickness / 2); 37 | 38 | if (Math.random() >= probability) { 39 | ctx.beginPath(); 40 | ctx.moveTo(xOffset, lineOffset); 41 | ctx.lineTo((xOffset + quarterTile), lineOffset); 42 | ctx.stroke(); 43 | 44 | ctx.beginPath(); 45 | ctx.moveTo(xOffset + quarterTile, yOffset); 46 | 47 | const cp1x = xOffset + quarterTile; 48 | const cp1y = (yOffset - thirdTile); 49 | const cp2x = ((xOffset + tileSize) - quarterTile); 50 | const cp2y = (yOffset - thirdTile); 51 | const xDest = ((xOffset + tileSize) - quarterTile); 52 | const yDest = yOffset; 53 | 54 | ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, xDest, yDest); 55 | ctx.stroke(); 56 | 57 | ctx.beginPath(); 58 | ctx.moveTo(((xOffset + tileSize) - quarterTile), lineOffset); 59 | ctx.lineTo((xOffset + tileSize), lineOffset); 60 | ctx.stroke(); 61 | 62 | return; 63 | } 64 | 65 | ctx.beginPath(); 66 | ctx.moveTo(xOffset, lineOffset); 67 | ctx.lineTo((xOffset + tileSize), lineOffset); 68 | ctx.stroke(); 69 | } 70 | } 71 | 72 | registerPaint(paintName, Bumpy); 73 | -------------------------------------------------------------------------------- /src/client/worklets/bytemare.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "bytemare"; 4 | 5 | class Bytemare { 6 | constructor () { 7 | this.radians = (Math.PI / 180) * 45; 8 | } 9 | 10 | static get inputProperties () { 11 | return [ 12 | `--${paintName}-tile-size`, 13 | `--${paintName}-gap`, 14 | `--${paintName}-color`, 15 | `--${paintName}-probability` 16 | ]; 17 | } 18 | 19 | paint (ctx, geom, properties) { 20 | const tileSize = parseInt(properties.get(`--${paintName}-tile-size`)); 21 | const gap = parseInt(properties.get(`--${paintName}-gap`)); 22 | const color = properties.get(`--${paintName}-color`).toString(); 23 | const darker = this.darkenColor(color, 20); 24 | const darkest = this.darkenColor(color, 40); 25 | const probability = parseFloat(properties.get(`--${paintName}-probability`)); 26 | const geomTileHeight = geom.height / tileSize; 27 | const geomTileWidth = geom.width / tileSize; 28 | const outerRadius = geom.width > geom.height ? geom.width * 1.5 : geom.height * 1.5; 29 | 30 | for (let y = -4; y < geomTileHeight; y++) { 31 | const yOffset = y * tileSize; 32 | 33 | for (let x = -4; x < geomTileWidth; x++) { 34 | const xOffset = x * tileSize; 35 | 36 | if (Math.random() > probability) { 37 | // 1. Draw shape on the right side of the tower cap 38 | ctx.fillStyle = darker; // Change fill to darker color 39 | ctx.beginPath(); // Start new path 40 | ctx.lineTo(xOffset + tileSize, yOffset + gap); // Move to upper right 41 | ctx.lineTo((Math.cos(this.radians) * outerRadius), (Math.sin(this.radians) * outerRadius)); // Draw line off canvas 42 | ctx.lineTo(xOffset + tileSize, yOffset + tileSize); // Draw to lower right 43 | ctx.lineTo(xOffset + gap, yOffset + tileSize); // Draw line to lower left 44 | ctx.fill(); // Fill shape 45 | 46 | // 2. Draw shape on the right side of the tower cap 47 | ctx.fillStyle = darkest; // Change fill to darkest color 48 | ctx.beginPath(); // Start new path 49 | ctx.moveTo(xOffset + tileSize, yOffset + tileSize); // Move to lower right 50 | ctx.lineTo(xOffset + gap, yOffset + tileSize); // Draw line to lower left 51 | ctx.lineTo((Math.cos(this.radians) * outerRadius), (Math.sin(this.radians) * outerRadius)); // Draw line off canvas toward the lower left 52 | ctx.lineTo(xOffset + tileSize, yOffset + tileSize); // Draw line back to lower right 53 | ctx.fill(); // Fill shape 54 | 55 | // 3. Draw the tower cap 56 | ctx.fillStyle = color; // Change fill to the base color 57 | ctx.beginPath(); // Start new path 58 | ctx.rect(xOffset + gap, yOffset + gap, tileSize - gap, tileSize - gap); // Draw a rectangle 59 | ctx.fill(); // Fill shape 60 | } 61 | } 62 | } 63 | } 64 | 65 | darkenColor (rgbString, amt) { 66 | rgbString = rgbString.replace(/rgb\(/g, "").replace(/\)/g, "").replace(/\s/g, ""); 67 | let rgbParts = rgbString.split(","); 68 | 69 | for (let i = 0; i < rgbParts.length; i++) { 70 | rgbParts[i] = rgbParts[i] - amt; 71 | 72 | if (rgbParts[i] < 0) { 73 | rgbParts[i] = 0; 74 | } 75 | } 76 | 77 | return `rgb(${rgbParts[0]}, ${rgbParts[1]}, ${rgbParts[2]})`; 78 | } 79 | } 80 | 81 | registerPaint(paintName, Bytemare); 82 | -------------------------------------------------------------------------------- /src/client/worklets/chemistreak.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "chemistreak"; 4 | 5 | class Chemistreak { 6 | static get inputProperties () { 7 | return [ 8 | `--${paintName}-tile-width`, 9 | `--${paintName}-stroke-weight`, 10 | `--${paintName}-stroke-color`, 11 | `--${paintName}-fill-color`, 12 | `--${paintName}-stroke-probability`, 13 | `--${paintName}-cap-probability`, 14 | `--${paintName}-color-step` 15 | ]; 16 | } 17 | 18 | paint (ctx, geom, properties) { 19 | const tileWidth = parseInt(properties.get(`--${paintName}-tile-width`)); 20 | const strokeProbability = parseFloat(properties.get(`--${paintName}-stroke-probability`)); 21 | const capProbability = parseFloat(properties.get(`--${paintName}-cap-probability`)); 22 | const tileHeight = tileWidth * (7 / 6); 23 | const halfTileWidth = tileWidth / 2; 24 | const heightIncrement = tileHeight * (1 / 7); 25 | const xTiles = geom.width / tileWidth; 26 | const yTiles = geom.height / tileHeight; 27 | const strokeWeight = parseFloat(properties.get(`--${paintName}-stroke-weight`)); 28 | const colorStep = parseInt(properties.get(`--${paintName}-color-step`)); 29 | 30 | // These need to adjust on every pass, so no const here 31 | let strokeColor = properties.get(`--${paintName}-stroke-color`).toString().trim(); 32 | let fillColor = properties.get(`--${paintName}-fill-color`).toString().trim(); 33 | 34 | ctx.lineWidth = strokeWeight; 35 | ctx.lineCap = "round"; 36 | 37 | for (let y = 0; y < yTiles; y++) { 38 | const yOffset = y * tileHeight; 39 | 40 | for (let x = 0; x < xTiles; x++) { 41 | const xOffset = x * tileWidth; 42 | const xMidTile = xOffset + halfTileWidth; 43 | const yMidTile = yOffset + (heightIncrement * 4); 44 | const xFullTile = xOffset + tileWidth; 45 | const midTop = yOffset + (heightIncrement * 2); 46 | const midBottom = yOffset + (heightIncrement * 5); 47 | const coords = [ 48 | [xMidTile , yOffset ], // Top middle 49 | [xFullTile, midTop ], // Right top 50 | [xFullTile, midBottom ], // Right bottom 51 | [xMidTile , yOffset + tileHeight], // Bottom middle 52 | [xOffset , midBottom ], // Left bottom 53 | [xOffset , midTop ] // Left top 54 | ]; 55 | const randoms = [ 56 | Math.random() >= strokeProbability, 57 | Math.random() >= strokeProbability, 58 | Math.random() >= strokeProbability, 59 | Math.random() >= strokeProbability, 60 | Math.random() >= strokeProbability 61 | ]; 62 | 63 | ctx.strokeStyle = strokeColor; 64 | 65 | for (let i = 0; i < coords.length; i++) { 66 | if (randoms[i]) { 67 | const coord = i < coords.length - 1 ? coords[i] : coords[4]; 68 | const nextCoord = i < coords.length - 1 ? coords[i + 1] : coords[5]; 69 | 70 | ctx.beginPath(); 71 | ctx.moveTo(coord[0], coord[1]); 72 | ctx.lineTo(nextCoord[0], nextCoord[1]); 73 | ctx.closePath(); 74 | ctx.stroke(); 75 | } 76 | } 77 | 78 | if (Math.random() >= capProbability) { 79 | ctx.strokeStyle = fillColor; 80 | ctx.fillStyle = fillColor; 81 | 82 | ctx.beginPath(); 83 | ctx.moveTo(coords[0][0], coords[0][1]); 84 | ctx.lineTo(coords[1][0], coords[1][1]); 85 | ctx.lineTo(xMidTile, yMidTile); 86 | ctx.lineTo(coords[5][0], coords[5][1]); 87 | ctx.lineTo(coords[0][0], coords[0][1]); 88 | ctx.closePath(); 89 | ctx.stroke(); 90 | ctx.fill(); 91 | } 92 | } 93 | 94 | strokeColor = this.adjustBrightness(strokeColor, colorStep); 95 | fillColor = this.adjustBrightness(fillColor, colorStep); 96 | } 97 | } 98 | 99 | adjustBrightness (rgbString, amt) { 100 | rgbString = rgbString.replace(/rgba?\(/g, "").replace(/\)/g, "").replace(/\s/g, ""); 101 | 102 | const rgbParts = rgbString.split(",").map((rgbPart, index) => { 103 | if (index > 2) { 104 | return; 105 | } 106 | 107 | rgbPart = parseInt(rgbPart) + amt; 108 | 109 | if (rgbPart < 0) { 110 | rgbPart = 0; 111 | } else if (rgbPart > 255) { 112 | rgbPart = 255; 113 | } 114 | 115 | return rgbPart; 116 | }); 117 | 118 | return rgbString.indexOf("rgba") !== -1 ? `rgba(${rgbParts.join(",")})` : `rgb(${rgbParts.join(",")})`; 119 | } 120 | } 121 | 122 | registerPaint(paintName, Chemistreak); 123 | -------------------------------------------------------------------------------- /src/client/worklets/circuits.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "circuits"; 4 | 5 | class Circuits { 6 | constructor () { 7 | this.fullArc = Math.PI * 2; 8 | } 9 | 10 | static get inputProperties () { 11 | return [ 12 | `--${paintName}-tile-size`, 13 | `--${paintName}-thickness`, 14 | `--${paintName}-color`, 15 | ]; 16 | } 17 | 18 | paint (ctx, geom, properties) { 19 | const tileSize = parseInt(properties.get(`--${paintName}-tile-size`)); 20 | const thickness = parseFloat(properties.get(`--${paintName}-thickness`)); 21 | const color = properties.get(`--${paintName}-color`).toString(); 22 | const xTiles = geom.width / tileSize; 23 | const yTiles = geom.height / tileSize; 24 | const leadSize = Math.sqrt(tileSize) / 1.5; 25 | 26 | ctx.strokeStyle = color; 27 | ctx.fillStyle = color; 28 | ctx.lineWidth = thickness; 29 | 30 | for (let y = 0; y < yTiles; y++) { 31 | const yOffset = y * tileSize; 32 | 33 | for (let x = 0; x < xTiles; x++) { 34 | const xOffset = x * tileSize; 35 | 36 | ctx.beginPath(); 37 | ctx.moveTo(xOffset, yOffset); 38 | 39 | if (Math.random() >= 0.5) { 40 | ctx.lineTo(xOffset + tileSize, yOffset + tileSize); 41 | } else { 42 | ctx.lineTo(xOffset, yOffset + tileSize); 43 | } 44 | 45 | ctx.stroke(); 46 | 47 | if (Math.random() >= 0.66) { 48 | ctx.save(); 49 | ctx.beginPath(); 50 | ctx.arc(xOffset, yOffset, leadSize, 0, this.fullArc, false); 51 | ctx.fill(); 52 | ctx.restore(); 53 | } 54 | 55 | ctx.fill(); 56 | } 57 | } 58 | } 59 | } 60 | 61 | registerPaint(paintName, Circuits); 62 | -------------------------------------------------------------------------------- /src/client/worklets/flashy.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "flashy"; 4 | 5 | class Flashy { 6 | constructor () { 7 | this.fullArc = Math.PI * 2; 8 | this.degToRad = Math.PI / 180; 9 | } 10 | 11 | static get inputProperties () { 12 | return [ 13 | `--${paintName}-radius`, 14 | `--${paintName}-ray-width`, 15 | `--${paintName}-threshold`, 16 | `--${paintName}-color`, 17 | `--${paintName}-top`, 18 | `--${paintName}-left`, 19 | `--${paintName}-blend-mode`, 20 | ]; 21 | } 22 | 23 | paint (ctx, geom, properties) { 24 | // These are custom properties 25 | const radius = parseInt(properties.get(`--${paintName}-radius`)); 26 | const rayWidth = parseFloat(properties.get(`--${paintName}-ray-width`)); 27 | const threshold = parseFloat(properties.get(`--${paintName}-threshold`)); 28 | const color = properties.get(`--${paintName}-color`).toString(); 29 | const top = parseFloat(properties.get(`--${paintName}-top`)); 30 | const left = parseFloat(properties.get(`--${paintName}-left`)); 31 | 32 | const outerRadius = geom.width > geom.height ? geom.width * 1.5 : geom.height * 1.5; 33 | 34 | ctx.fillStyle = color; 35 | 36 | const x = geom.width * left; 37 | const y = geom.height * top; 38 | 39 | ctx.save(); 40 | ctx.beginPath(); 41 | ctx.arc(x, y, radius, 0, this.fullArc, false); 42 | ctx.fill(); 43 | ctx.restore(); 44 | 45 | for (let i = 0; i <= 360; i++) { 46 | if (Math.random() >= threshold) { 47 | const radiansEdge1 = this.degToRad * (i - rayWidth); 48 | const radiansEdge2 = this.degToRad * (i + rayWidth); 49 | 50 | ctx.beginPath(); 51 | ctx.moveTo(x, y); 52 | ctx.lineTo((Math.cos(radiansEdge1) * outerRadius), (Math.sin(radiansEdge1) * outerRadius)); 53 | ctx.lineTo((Math.cos(radiansEdge2) * outerRadius), (Math.sin(radiansEdge2) * outerRadius)); 54 | ctx.lineTo(x, y); 55 | ctx.fill(); 56 | } 57 | } 58 | } 59 | } 60 | 61 | registerPaint(paintName, Flashy); 62 | -------------------------------------------------------------------------------- /src/client/worklets/parallelowow.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "parallelowow"; 4 | 5 | class Parallelowow { 6 | static get inputProperties () { 7 | return [ 8 | `--${paintName}-tile-width`, 9 | `--${paintName}-base-color`, 10 | `--${paintName}-color-step`, 11 | `--${paintName}-probability`, 12 | `--${paintName}-stroke-weight`, 13 | ]; 14 | } 15 | 16 | paint (ctx, geom, properties) { 17 | const radians = (Math.PI / 180) * 39.375; 18 | const tileWidth = parseInt(properties.get(`--${paintName}-tile-width`)); 19 | const tileHeight = tileWidth * (1 / 4); 20 | const yTiles = geom.height / tileHeight; 21 | const xTiles = geom.width / tileWidth; 22 | 23 | let colors = [ 24 | properties.get(`--${paintName}-base-color`).toString(), 25 | this.adjustBrightness(properties.get(`--${paintName}-base-color`).toString(), -10), 26 | this.adjustBrightness(properties.get(`--${paintName}-base-color`).toString(), -30) 27 | ]; 28 | 29 | const colorStep = parseInt(properties.get(`--${paintName}-color-step`)); 30 | const probability = parseFloat(properties.get(`--${paintName}-probability`)); 31 | const strokeWeight = parseFloat(properties.get(`--${paintName}-stroke-weight`)); 32 | const outerRadius = geom.width > geom.height ? geom.width * 2 : geom.height * 2; 33 | 34 | if (strokeWeight > 0) { 35 | ctx.lineWidth = strokeWeight; 36 | ctx.strokeStyle = this.adjustBrightness(colors[0], 25); 37 | ctx.lineCap = "butt"; 38 | } 39 | 40 | for (let y = -1; y < yTiles; y++) { 41 | const yOffset = y * tileHeight; 42 | 43 | for (let x = -1; x < (xTiles + y); x++) { 44 | if (Math.random() > probability) { 45 | const xOffset = (x * tileWidth) - (y * tileHeight); 46 | 47 | // Helpers! 48 | const upperLeftX = xOffset; 49 | const upperLeftY = yOffset; 50 | const upperRightX = xOffset + tileWidth; 51 | const upperRightY = yOffset; 52 | const lowerRightX = xOffset + (tileWidth - tileHeight); 53 | const lowerRightY = yOffset + tileHeight; 54 | const lowerLeftX = xOffset - tileHeight; 55 | const lowerLeftY = lowerRightY; 56 | 57 | // 1. Draw shape on the right side of the parallelogram 58 | ctx.fillStyle = colors[1]; 59 | ctx.beginPath(); 60 | ctx.moveTo(upperRightX, upperRightY); 61 | ctx.lineTo((Math.cos(radians) * outerRadius), (Math.sin(radians) * outerRadius)); 62 | ctx.lineTo(lowerRightX, lowerRightY); 63 | ctx.lineTo(upperRightX, upperRightY); 64 | ctx.fill(); 65 | 66 | if (strokeWeight > 0) { 67 | ctx.stroke(); 68 | } 69 | 70 | // 2. Draw shape on the lower left side of the parallelogram 71 | ctx.fillStyle = colors[2]; 72 | ctx.beginPath(); 73 | ctx.moveTo(lowerRightX, lowerRightY); 74 | ctx.lineTo((Math.cos(radians) * outerRadius), (Math.sin(radians) * outerRadius)); 75 | ctx.lineTo(lowerLeftX, lowerLeftY); 76 | ctx.moveTo(lowerLeftX, lowerLeftY); 77 | ctx.fill(); 78 | 79 | if (strokeWeight > 0) { 80 | ctx.stroke(); 81 | } 82 | 83 | // 3. Draw parallelogram cap 84 | ctx.fillStyle = colors[0]; 85 | ctx.beginPath(); 86 | ctx.moveTo(upperLeftX, upperLeftY); 87 | ctx.lineTo(upperRightX, upperRightY); 88 | ctx.lineTo(lowerRightX, lowerRightY); 89 | ctx.lineTo(lowerLeftX, lowerLeftY); 90 | ctx.lineTo(upperLeftX, upperLeftY); 91 | ctx.fill(); 92 | 93 | if (strokeWeight > 0) { 94 | ctx.stroke(); 95 | } 96 | } 97 | } 98 | 99 | // 4. Slightly darken colors for next run. 100 | colors = colors.map(colorKey => this.adjustBrightness(colorKey, colorStep)); 101 | } 102 | } 103 | 104 | adjustBrightness (rgbString, amt) { 105 | rgbString = rgbString.replace(/rgba?\(/g, "").replace(/\)/g, "").replace(/\s/g, ""); 106 | 107 | const rgbParts = rgbString.split(",").map((rgbPart, index) => { 108 | if (index > 2) { 109 | return; 110 | } 111 | 112 | rgbPart = parseInt(rgbPart) + amt; 113 | 114 | if (rgbPart < 0) { 115 | rgbPart = 0; 116 | } else if (rgbPart > 255) { 117 | rgbPart = 255; 118 | } 119 | 120 | return rgbPart; 121 | }); 122 | 123 | return rgbString.indexOf("rgba") !== -1 ? `rgba(${rgbParts.join(",")})` : `rgb(${rgbParts.join(",")})`; 124 | } 125 | } 126 | 127 | registerPaint(paintName, Parallelowow); 128 | -------------------------------------------------------------------------------- /src/client/worklets/slapdash.js: -------------------------------------------------------------------------------- 1 | /* global registerPaint */ 2 | 3 | const paintName = "slapdash"; 4 | 5 | class Slapdash { 6 | static get inputProperties() { 7 | return [ 8 | `--${paintName}-tile-size`, 9 | `--${paintName}-color`, 10 | `--${paintName}-weight`, 11 | `--${paintName}-probability`, 12 | `--${paintName}-direction`, 13 | `--${paintName}-crosshatch` 14 | ]; 15 | } 16 | 17 | paint (ctx, geom, properties) { 18 | const tileSize = parseInt(properties.get(`--${paintName}-tile-size`)); 19 | const crosshatchTileSize = tileSize / 4; 20 | const xTiles = geom.width / tileSize; 21 | const xCrosshatchTiles = geom.width / crosshatchTileSize; 22 | const yTiles = geom.height / tileSize; 23 | const yCrosshatchTiles = geom.height / crosshatchTileSize; 24 | const color = properties.get(`--${paintName}-color`).toString(); 25 | const weight = parseFloat(properties.get(`--${paintName}-weight`)); 26 | const probability = parseFloat(properties.get(`--${paintName}-probability`)); 27 | const crosshatchProbability = probability + (Math.abs(probability - 1) / 2); 28 | const direction = !!parseInt(properties.get(`--${paintName}-direction`)); 29 | const crosshatch = !!parseInt(properties.get(`--${paintName}-crosshatch`)); 30 | 31 | // Set styles 32 | ctx.lineWidth = weight; 33 | ctx.strokeStyle = color; 34 | ctx.lineCap = "butt"; 35 | 36 | for (let y = 0; y < yTiles; y++) { 37 | const yOffset = y * tileSize; 38 | 39 | for (let x = 0; x < xTiles; x++) { 40 | if (Math.random() >= probability) { 41 | const xOffset = x * tileSize; 42 | 43 | this.line(ctx, xOffset, yOffset, tileSize, direction); 44 | } 45 | } 46 | } 47 | 48 | if (crosshatch) { 49 | for (let y = 0; y < yCrosshatchTiles; y++) { 50 | const yOffset = y * crosshatchTileSize; 51 | 52 | for (let x = 0; x < xCrosshatchTiles; x++) { 53 | if (Math.random() >= crosshatchProbability) { 54 | const xOffset = x * crosshatchTileSize; 55 | 56 | this.line(ctx, xOffset, yOffset, crosshatchTileSize, !direction); 57 | } 58 | } 59 | } 60 | } 61 | } 62 | 63 | line (ctx, x, y, tileSize, direction) { 64 | ctx.beginPath(); 65 | 66 | if (direction === false) { 67 | ctx.moveTo(x, y); 68 | ctx.lineTo(x + tileSize, y + tileSize); 69 | } else { 70 | ctx.moveTo(x + tileSize, y); 71 | ctx.lineTo(x, y + tileSize); 72 | } 73 | 74 | ctx.stroke(); 75 | } 76 | } 77 | 78 | registerPaint(paintName, Slapdash); 79 | -------------------------------------------------------------------------------- /src/server/helpers/html.js: -------------------------------------------------------------------------------- 1 | // Vendors 2 | import render from "preact-render-to-string"; 3 | 4 | export default function (metadata, route, component, assets) { 5 | const scriptMarkup = Object.keys(assets).map(assetKey => ` 6 | 7 | 8 | `); 9 | 10 | return ` 11 | 12 | 13 | 14 | ${metadata.title !== "Home" ? `${metadata.title} — ` : ""}Paintlets! 15 | 16 | 17 | ${metadata.metaTags.map(metaTag => ` ` ${metaTagAttribute}="${metaTag[metaTagAttribute]}"`).join("")}>`).join("")} 18 | 19 | 20 | 21 | 22 |
    ${render(component)}
    23 | 24 | ${scriptMarkup} 25 | 26 | 27 | `; 28 | } 29 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | // Built-ins 2 | import { readFile } from "fs"; 3 | import { resolve } from "path"; 4 | 5 | // Vendors 6 | import express from "express"; 7 | import compression from "compression"; 8 | import enforceTLS from "express-sslify"; 9 | import { h } from "preact"; 10 | 11 | // App-specific 12 | import html from "Helpers/html"; 13 | import Home from "Components/Home"; 14 | import worklets from "./worklets"; 15 | 16 | // Init express app 17 | const app = express(); 18 | let renderCache = {}; 19 | 20 | // Specify caching routine 21 | const staticOptions = { 22 | setHeaders: res => { 23 | res.set("Cache-Control", "max-age=31557600, public"); 24 | } 25 | }; 26 | 27 | // Use compression. 28 | app.use(compression()); 29 | 30 | // Force TLS if in prod and minify HTML 31 | if (process.env.NODE_ENV === "production") { 32 | app.use(enforceTLS.HTTPS({ 33 | trustProtoHeader: true 34 | })); 35 | } 36 | 37 | // Static content paths 38 | app.use("/js", express.static(resolve(process.cwd(), "dist", "client", "js"), staticOptions)); 39 | app.use("/worklets", express.static(resolve(process.cwd(), "dist", "client", "worklets"), staticOptions)); 40 | app.use("/css", express.static(resolve(process.cwd(), "dist", "client", "css"), staticOptions)); 41 | 42 | // Spin up web server 43 | app.listen(process.env.PORT || 8080, () => { 44 | readFile(resolve(process.cwd(), "dist", "server", "assets.json"), (error, manifestData) => { 45 | if (error) { 46 | throw error; 47 | } 48 | 49 | app.get("/", (req, res) => { 50 | const metadata = { 51 | title: "Home", 52 | metaTags: [ 53 | { 54 | name: "description", 55 | content: "A gallery of tweakable and downloadable paint worklets!" 56 | } 57 | ] 58 | }; 59 | 60 | if ("index" in renderCache === false) { 61 | renderCache["index"] = html(metadata, "/", , JSON.parse(manifestData.toString())); 62 | } 63 | 64 | res.set("Content-Type", "text/html"); 65 | res.status(200); 66 | res.send(renderCache["index"]); 67 | }); 68 | }); 69 | }); 70 | -------------------------------------------------------------------------------- /src/server/worklets.js: -------------------------------------------------------------------------------- 1 | export default { 2 | chemistreak: { 3 | author: { 4 | screenName: "malchata", 5 | website: "https://jeremy.codes/" 6 | }, 7 | backgroundColor: "#15112c", 8 | customProperties: { 9 | "tile-width": { 10 | syntax: "", 11 | value: 64 12 | }, 13 | "stroke-weight": { 14 | syntax: "", 15 | value: 0.833 16 | }, 17 | "stroke-color": { 18 | syntax: "", 19 | value: "#20a4f3" 20 | }, 21 | "fill-color": { 22 | syntax: "", 23 | value: "#ce6c47" 24 | }, 25 | "stroke-probability": { 26 | syntax: "", 27 | value: 0.666 28 | }, 29 | "cap-probability": { 30 | syntax: "", 31 | value: 0.75 32 | }, 33 | "color-step": { 34 | syntax: "", 35 | value: -6 36 | }, 37 | } 38 | }, 39 | parallelowow: { 40 | author: { 41 | screenName: "malchata", 42 | website: "https://jeremy.codes/" 43 | }, 44 | backgroundColor: "#c9f", 45 | customProperties: { 46 | "tile-width": { 47 | syntax: "", 48 | value: 56 49 | }, 50 | "base-color": { 51 | syntax: "", 52 | value: "#c9f" 53 | }, 54 | "color-step": { 55 | syntax: "", 56 | value: -3 57 | }, 58 | probability: { 59 | syntax: "", 60 | value: 0.33 61 | }, 62 | "stroke-weight": { 63 | syntax: "", 64 | value: 0.5 65 | } 66 | } 67 | }, 68 | bytemare: { 69 | author: { 70 | screenName: "malchata", 71 | website: "https://jeremy.codes/" 72 | }, 73 | backgroundColor: "#735cdd", 74 | customProperties: { 75 | "tile-size": { 76 | syntax: "", 77 | value: 16 78 | }, 79 | gap: { 80 | syntax: "", 81 | value: 1 82 | }, 83 | color: { 84 | syntax: "", 85 | value: "#735cdd" 86 | }, 87 | probability: { 88 | syntax: "", 89 | value: 0.375 90 | } 91 | } 92 | }, 93 | flashy: { 94 | author: { 95 | screenName: "malchata", 96 | website: "https://jeremy.codes/" 97 | }, 98 | backgroundColor: "#e59eff", 99 | customProperties: { 100 | radius: { 101 | syntax: "", 102 | value: 48 103 | }, 104 | "ray-width": { 105 | syntax: "", 106 | value: 1.5 107 | }, 108 | threshold: { 109 | syntax: "", 110 | value: 0.75 111 | }, 112 | color: { 113 | syntax: "", 114 | value: "#fffbfe" 115 | }, 116 | top: { 117 | syntax: "", 118 | value: 0.25 119 | }, 120 | left: { 121 | syntax: "", 122 | value: 0.1875 123 | } 124 | } 125 | }, 126 | circuits: { 127 | author: { 128 | screenName: "malchata", 129 | website: "https://jeremy.codes/" 130 | }, 131 | backgroundColor: "#14131c", 132 | customProperties: { 133 | "tile-size": { 134 | syntax: "", 135 | value: 20 136 | }, 137 | color: { 138 | syntax: "", 139 | value: "#fbcaef" 140 | }, 141 | thickness: { 142 | syntax: "", 143 | value: 1.25 144 | } 145 | } 146 | }, 147 | blotto: { 148 | author: { 149 | screenName: "malchata", 150 | website: "https://jeremy.codes/" 151 | }, 152 | backgroundColor: "#fffbfe", 153 | customProperties: { 154 | "tile-size": { 155 | syntax: "", 156 | value: 8 157 | }, 158 | color: { 159 | syntax: "", 160 | value: "#6369d1" 161 | }, 162 | amplitude: { 163 | syntax: "", 164 | value: 2.25 165 | }, 166 | "max-opacity": { 167 | syntax: "", 168 | value: 1.0 169 | }, 170 | "blend-mode": { 171 | syntax: "", 172 | value: "multiply" 173 | } 174 | } 175 | }, 176 | bumpy: { 177 | author: { 178 | screenName: "malchata", 179 | website: "https://jeremy.codes/" 180 | }, 181 | backgroundColor: "#e59eff", 182 | customProperties: { 183 | "tile-size": { 184 | syntax: "", 185 | value: 32 186 | }, 187 | thickness: { 188 | syntax: "", 189 | value: 1.25 190 | }, 191 | color: { 192 | syntax: "", 193 | value: "#fffbfe" 194 | }, 195 | probability: { 196 | syntax: "", 197 | value: 0.33 198 | }, 199 | } 200 | }, 201 | slapdash: { 202 | author: { 203 | screenName: "malchata", 204 | website: "https://jeremy.codes/" 205 | }, 206 | backgroundColor: "#fffbfe", 207 | customProperties: { 208 | "tile-size": { 209 | syntax: "", 210 | value: 64 211 | }, 212 | color: { 213 | syntax: "", 214 | value: "#e59eff" 215 | }, 216 | weight: { 217 | syntax: "", 218 | value: 3.0 219 | }, 220 | probability: { 221 | syntax: "", 222 | value: 0.166 223 | }, 224 | direction: { 225 | syntax: "", 226 | value: 0 227 | }, 228 | crosshatch: { 229 | syntax: "", 230 | value: 1 231 | } 232 | } 233 | } 234 | }; 235 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | // webpack-specific 4 | import WebpackNodeExternals from "webpack-node-externals"; 5 | import { isProd, src, dist, commonConfig, commonClientConfig, commonClientLoaders } from "./build-helpers"; 6 | 7 | // webpack configs 8 | module.exports = [ 9 | // Client (legacy) 10 | { 11 | name: "client-legacy", 12 | output: { 13 | filename: isProd ? "js/[name].[chunkhash:8].js" : "js/[name].js", 14 | chunkFilename: isProd ? "js/[name].[chunkhash:8].js" : "js/[name].js", 15 | path: dist("client"), 16 | publicPath: "/" 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.m?js$/i, 22 | exclude: /node_modules/i, 23 | use: [ 24 | { 25 | loader: "babel-loader", 26 | options: { 27 | envName: "clientLegacy" 28 | } 29 | } 30 | ] 31 | }, 32 | ...commonClientLoaders 33 | ] 34 | }, 35 | ...commonClientConfig 36 | }, 37 | // Client (modern) 38 | { 39 | name: "client-modern", 40 | output: { 41 | filename: isProd ? "js/[name].[chunkhash:8].mjs" : "js/[name].mjs", 42 | chunkFilename: isProd ? "js/[name].[chunkhash:8].mjs" : "js/[name].mjs", 43 | path: dist("client"), 44 | publicPath: "/" 45 | }, 46 | module: { 47 | rules: [ 48 | { 49 | test: /\.m?js$/i, 50 | exclude: /node_modules/i, 51 | use: [ 52 | { 53 | loader: "babel-loader", 54 | options: { 55 | envName: "clientModern" 56 | } 57 | } 58 | ] 59 | }, 60 | ...commonClientLoaders 61 | ] 62 | }, 63 | ...commonClientConfig 64 | }, 65 | // Server 66 | { 67 | target: "node", 68 | name: "server", 69 | entry: { 70 | server: src("server", "index.js") 71 | }, 72 | output: { 73 | filename: "index.js", 74 | path: dist("server") 75 | }, 76 | module: { 77 | rules: [ 78 | { 79 | test: /\.m?js$/i, 80 | exclude: /node_modules/i, 81 | use: [ 82 | { 83 | loader: "babel-loader", 84 | options: { 85 | envName: "server" 86 | } 87 | } 88 | ] 89 | }, 90 | { 91 | test: /\.(c|le)ss$/i, 92 | use: "null-loader" 93 | } 94 | ] 95 | }, 96 | resolve: { 97 | alias: { 98 | "Components": src("client", "components"), 99 | "Styles": src("client", "styles"), 100 | "Helpers": src("server", "helpers") 101 | } 102 | }, 103 | externals: [ 104 | WebpackNodeExternals() 105 | ], 106 | ...commonConfig 107 | } 108 | ]; 109 | --------------------------------------------------------------------------------