├── LICENSE ├── Makefile ├── README.md ├── lib ├── stub.ink └── torus.js.ink ├── src ├── app.js.ink └── examples.js.ink ├── static ├── css │ ├── codemirror.css │ ├── main.css │ └── maverick.css ├── img │ └── maverick.png ├── index.html ├── ink │ ├── bundle.js │ ├── common.js │ ├── lib.js │ ├── september.js │ └── vendor.js └── js │ ├── closebrackets.js │ ├── codemirror.js │ ├── ink.js │ └── torus.min.js ├── test └── main.ink └── vendor ├── percent.ink ├── quicksort.ink ├── std.ink ├── str.ink └── suite.ink /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Linus Lee 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: build-all 2 | 3 | # build dependencies 4 | build-libs: 5 | september translate \ 6 | lib/stub.ink \ 7 | vendor/std.ink \ 8 | vendor/str.ink \ 9 | vendor/quicksort.ink \ 10 | | tee /dev/stderr > static/ink/lib.js 11 | 12 | # build september 13 | build-september: 14 | september translate \ 15 | ../september/src/iota.ink \ 16 | ../september/src/tokenize.ink \ 17 | ../september/src/parse.ink \ 18 | ../september/src/analyze.ink \ 19 | ../september/src/gen.ink \ 20 | ../september/src/translate.ink \ 21 | | tee /dev/stderr > static/ink/september.js 22 | 23 | # build app client 24 | build: 25 | cat static/js/ink.js \ 26 | static/js/codemirror.js \ 27 | static/js/closebrackets.js \ 28 | static/js/torus.min.js \ 29 | > static/ink/vendor.js 30 | september translate \ 31 | lib/torus.js.ink \ 32 | src/examples.js.ink \ 33 | src/app.js.ink \ 34 | | tee /dev/stderr > static/ink/common.js 35 | cat \ 36 | static/ink/vendor.js \ 37 | static/ink/lib.js \ 38 | static/ink/september.js \ 39 | static/ink/common.js \ 40 | > static/ink/bundle.js 41 | b: build 42 | 43 | # run all builds from scratch 44 | build-all: build-libs build-september build 45 | 46 | # build whenever Ink sources change 47 | watch: 48 | ls lib/*.ink src/*.ink | entr make build 49 | w: watch 50 | 51 | # run all tests under test/ 52 | check: 53 | ink ./test/main.ink 54 | t: check 55 | 56 | fmt: 57 | inkfmt fix lib/*.ink src/*.ink test/*.ink 58 | f: fmt 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ink playground, _"Maverick"_ 🦅 2 | 3 | **Maverick** is a simple web IDE and REPL for the [Ink programming language](https://dotink.co/), written in pure Ink and built on a self-hosted [September](https://github.com/thesephist/september) compiler toolchain that runs entirely in the browser. It compiles Ink code to JavaScript to run it locally without requiring a backend or a native execution environment, which is possible because the September compiler is used to compile the compiler itself to JavaScript bundled in the web app. 4 | 5 | You can try the demo at **[play.dotink.co](https://play.dotink.co)** by running some included example programs. 6 | 7 | ![Screenshot of Ink playground running a prime sieve program](static/img/maverick.png) 8 | 9 | ## Architecture 10 | 11 | Maverick's primary goal is to enable anyone to write and experiment with Ink programs without installing anything on their system. I had developed a version of the playground before that was backed by a sever-side evaluation service, but I didn't feel very secure publishing a remote code execution service to the open Internet, for obvious reasons. 12 | 13 | Maverick gets around this problem by _compiling and running Ink programs entirely in the browser_. This is possible because September, the Ink-to-JavaScript compiler, is compiled to JavaScript using itself. September then runs in the browser, and can compile other Ink programs to JavaScript programs and run them immediately in the browser using JavaScript's `eval()`. This has the unfortunate side-effect of Ink programs being able to introspect and modify the surrounding web application, but this isn't too much of an issue, because if anything breaks, you can just reload the page! 14 | 15 | Outside of the compiler, Maverick is a simple single-page web application built using [Torus](https://github.com/thesephist/torus). 16 | 17 | ### REPL environment 18 | 19 | Currently, Ink's module system does not work within September-compiled JavaScript bundles. So instead, these standard libraries are globally available in the REPL environment: 20 | 21 | - `std` 22 | - `str` 23 | - `quicksort` 24 | 25 | 26 | For example, we can type in `sort!(map([1, 2, 3], n => n * n))` to the REPL and execute it without importing `std`. 27 | 28 | ## Development 29 | 30 | Because Maverick depends significantly on the September compiler, the repository is designed to be cloned side-by-side to the [`thesephist/september`](https://github.com/thesephist/september) repository. Maverick, during the build step, will reach inside the September repository next to it to compile it into JavaScript. 31 | 32 | Maverick uses a Makefile for development and build tasks. Simply running `make` will re-compile all dependencies and the application, including the September compiler itself. it produces a static JavaScript bundle into `static/ink/bundle.js`. Other commands are as follows. 33 | 34 | - `make watch` 35 | - `make fmt` or `make f` formats all non-vendored source code, including tests. 36 | - `make check` or `make t` runs all tests. 37 | - There are several different build tasks, depending on what portion of the app you want to compile: 38 | - `make build-libs` compiles the common dependencies between September and Maverick into `ink/lib.js` 39 | - `make build-september` compiles the September compiler source from the directory `../september` into `ink/september.js` 40 | - `make build` compiles the Maverick application itself into the final bundle, assuming that the dependencies and September itself have been compiled. 41 | - `make watch` or `make w` watches Maverick's source code for changes and recompiles them when they are changed. This is useful for iterating during development, and depends on [entr](http://eradman.com/entrproject/). 42 | 43 | Maverick is continuously deployed as a static site on Vercel, on every push to git branch `main`. 44 | 45 | -------------------------------------------------------------------------------- /lib/stub.ink: -------------------------------------------------------------------------------- 1 | ` Ink stub to paper over JS / Ink environment differences 2 | when loaded into a web environment. ` 3 | 4 | load := s => window 5 | -------------------------------------------------------------------------------- /lib/torus.js.ink: -------------------------------------------------------------------------------- 1 | ` Torus : Ink API adapter 2 | 3 | renderer.ink provides an Ink interface for writing web user interfaces 4 | against the DOM with idiomatic Ink. The renderer operates on a single 5 | global render tree that renders against a single root node on the page, and 6 | uses a light Torus-backed virtual DOM to efficiently dispatch DOM edits. 7 | 8 | Initialize a render tree with the Renderer constructor: 9 | 10 | rootEl := bind(document, 'querySelector')('#root') 11 | r := Renderer(rootEl) 12 | update := r.update 13 | 14 | then call update() every time the app needs to update with the new render 15 | tree. The renderer comes with a few helper functions, h(), ha(), and hae(), 16 | for making render trees ergonomic to construct with Ink. 17 | 18 | It is conventional to create a single object called App that instantiates a 19 | renderer and closes over the update function with its own Redux-style 20 | global state management logic that it exposes to its child elements through 21 | a more restricted API. ` 22 | 23 | ` text nodes passed to Torus.render can't be normal Ink strings, because typeof 24 | != 'string'. We wrap Ink strings in str() here when passing to 25 | Torus to render strings correctly. ` 26 | str := s => bind(s, 'valueOf')(s) 27 | 28 | ` To quickly convert object-like Ink maps to arrays ` 29 | arr := bind(Object, 'values') 30 | 31 | ` Torus jdom declaration helpers ` 32 | 33 | hae := (tag, classList, attrs, events, children) => { 34 | tag: str(tag) 35 | attrs: attrs.('class') := arr(map(classList, str)) 36 | events: events 37 | children: arr(map(children, child => type(child) :: { 38 | 'string' -> str(child) 39 | _ -> child 40 | })) 41 | } 42 | ha := (tag, classList, attrs, children) => hae(tag, classList, attrs, {}, children) 43 | h := (tag, classList, children) => hae(tag, classList, {}, {}, children) 44 | 45 | ` generic abstraction for a view that can be updated asynchronously ` 46 | 47 | Renderer := root => ( 48 | render := window.Torus.render 49 | 50 | InitialDom := h('div', [], []) 51 | 52 | node := render((), (), InitialDom) 53 | bind(root, 'appendChild')(node) 54 | 55 | self := { 56 | node: node 57 | prev: InitialDom 58 | update: jdom => ( 59 | self.node := render(self.node, self.prev, jdom) 60 | self.prev := jdom 61 | self.node 62 | ) 63 | } 64 | ) 65 | 66 | -------------------------------------------------------------------------------- /src/app.js.ink: -------------------------------------------------------------------------------- 1 | ` Main application UI ` 2 | 3 | Tab := char(9) 4 | Newline := char(10) 5 | 6 | Line := { 7 | Prog: 0 8 | Result: 1 9 | Log: 2 10 | Error: 3 11 | } 12 | 13 | Page := { 14 | Home: 'home' 15 | About: 'about' 16 | } 17 | 18 | Embedded? := ( 19 | searchParams := jsnew(URLSearchParams, [location.search]) 20 | bind(searchParams, 'get')('embed') :: { 21 | () -> false 22 | '' -> false 23 | _ -> true 24 | } 25 | ) 26 | 27 | ` utility fns ` 28 | 29 | getItem := bind(localStorage, 'getItem') 30 | setItem := bind(localStorage, 'setItem') 31 | removeItem := bind(localStorage, 'removeItem') 32 | 33 | ` a debounce with leading edge, 1 hard-coded argument ` 34 | delay := (fn, timeout) => ( 35 | S := { 36 | to: () 37 | } 38 | dateNow := bind(Date, 'now') 39 | 40 | arg => ( 41 | clearTimeout(S.to) 42 | S.to := setTimeout(() => fn(arg), timeout) 43 | ) 44 | ) 45 | 46 | ` September interface ` 47 | 48 | ` from translate.ink > main: the load() here has no runtime meaning, just 49 | documentational reference to the source file. ` 50 | translateInkToJS := load('september/ink/translate').main 51 | 52 | reportError := jsErr => ( 53 | State.replLines.len(State.replLines) := { 54 | type: Line.Error 55 | text: jsErr.message + Newline + jsErr.stack 56 | } 57 | ) 58 | 59 | ` takes a program and synchronously returns the evaluation result as a string ` 60 | evaluateInk := prog => ( 61 | compiled := translateInkToJS(prog) 62 | ` semi-reliable error handling based on September behavior ` 63 | index(compiled, 'parse err @') :: { 64 | ~1 -> ( 65 | out := s => ( 66 | ` if last line is also an output, append to it. Otherwise, add 67 | new entry to replLines. ` 68 | lastLine := State.replLines.(len(State.replLines) - 1) :: { 69 | {type: Line.Log, text: _} -> ( 70 | State.replLines.(len(State.replLines) - 1) := { 71 | type: Line.Log 72 | text: lastLine.text + s 73 | } 74 | ) 75 | _ -> ( 76 | State.replLines.len(State.replLines) := { 77 | type: Line.Log 78 | text: s 79 | } 80 | ) 81 | } 82 | render() 83 | () 84 | ) 85 | log := s => out(string(s) + Newline) 86 | 87 | ` TODO: explain this error "handling" black magic ` 88 | jsProgram := format('try { {{ 0 }} } catch (e) { reportError(e); null }', [ 89 | compiled 90 | ]) 91 | 92 | ` eval() only works with proper strings ` 93 | replOutput := string(eval(str(jsProgram))) 94 | State.replLines.len(State.replLines) := { 95 | type: Line.Result 96 | text: replOutput 97 | } 98 | replOutput 99 | ) 100 | _ -> State.replLines.len(State.replLines) := { 101 | type: Line.Error 102 | text: compiled 103 | } 104 | } 105 | ) 106 | 107 | ` components ` 108 | 109 | Link := (name, href) => ha('a', [], { 110 | href: href 111 | target: '_blank' 112 | }, name) 113 | 114 | RunButton := () => hae('button', ['runButton'], {title: 'Run code (Ctrl + Enter)'}, { 115 | click: runRepl 116 | }, ['Run']) 117 | 118 | Header := () => h('header', [], [ 119 | h('nav', ['left-nav'], [ 120 | hae( 121 | 'a', [], {href: '/', target: '_blank'} 122 | { 123 | click: evt => Embedded? :: { 124 | false -> ( 125 | bind(evt, 'preventDefault')() 126 | render(State.page := Page.Home) 127 | focusReplLine() 128 | ) 129 | } 130 | } 131 | [ 132 | h('strong', [], ['Ink', h('span', ['desktop'], [' playground'])]) 133 | ] 134 | ) 135 | hae( 136 | 'a', [], {href: 'https://github.com/thesephist/maverick'} 137 | { 138 | click: evt => ( 139 | bind(evt, 'preventDefault')() 140 | render(State.page := (State.page :: { 141 | Page.Home -> Page.About 142 | Page.About -> Page.Home 143 | })) 144 | ) 145 | } 146 | ['about'] 147 | ) 148 | ]) 149 | h('nav', ['right-nav'], [ 150 | Embedded? :: { 151 | false -> hae( 152 | 'select' 153 | ['exampleSelect'] 154 | {} 155 | { 156 | 'change': evt => evt.target.value :: { 157 | '' -> render(State.exampleName := evt.target.value) 158 | _ -> ( 159 | exName := evt.target.value 160 | State.file := Examples.(exName) 161 | State.exampleName := exName 162 | render() 163 | ) 164 | } 165 | } 166 | ( 167 | defaultOption := ha('option', [], { 168 | value: '' 169 | selected: State.exampleName = '' 170 | }, ['-- examples --']) 171 | options := map(sort!(keys(Examples)), k => ha('option', [], { 172 | value: k 173 | selected: State.exampleName = k 174 | }, [k])) 175 | append([defaultOption], options) 176 | ) 177 | ) 178 | } 179 | hae( 180 | 'select' 181 | ['colorSchemeSelect'] 182 | {} 183 | { 184 | 'change': evt => render(State.theme := evt.target.value) 185 | } 186 | map(['light', 'dark'], theme => ha('option', [], { 187 | value: theme 188 | selected: State.theme = theme 189 | }, [theme])) 190 | ) 191 | ]) 192 | ]) 193 | 194 | Editor := ( 195 | ` CodeMirror <-> Maverick UI interface ` 196 | 197 | editorContainer := bind(document, 'createElement')('div') 198 | editorContainer.className := 'editorContainer' 199 | 200 | cmEditor := CodeMirror(editorContainer, { 201 | indentUnit: 4 202 | tabSize: 4 203 | lineWrapping: false 204 | lineNumbers: true 205 | indentWithTabs: true 206 | ` provided by js/closebrackets.js addon ` 207 | autoCloseBrackets: true 208 | }) 209 | getValue := bind(cmEditor, 'getValue') 210 | setValue := bind(cmEditor, 'setValue') 211 | setOption := bind(cmEditor, 'setOption') 212 | 213 | setOption('extraKeys', { 214 | 'Cmd-Enter': () => runRepl() 215 | 'Ctrl-Enter': () => runRepl() 216 | }) 217 | setOption('theme', str('maverick')) 218 | 219 | bind(cmEditor, 'on')('change', (_, changeEvt) => ( 220 | State.file := getValue() 221 | persistFile() 222 | render() 223 | )) 224 | requestAnimationFrame(() => requestAnimationFrame(() => ( 225 | bind(cmEditor, 'refresh')() 226 | ))) 227 | 228 | () => ( 229 | State.file :: { 230 | getValue() -> () 231 | ` CodeMirror assumes string type arg ` 232 | _ -> setValue(str(State.file)) 233 | } 234 | 235 | h('div', ['editor'], [ 236 | editorContainer 237 | RunButton() 238 | ]) 239 | ) 240 | ) 241 | 242 | AddToEditorButton := prog => hae('button', ['addToEditorButton'], {}, { 243 | click: evt => ( 244 | bind(evt, 'stopPropagation')() 245 | render(State.file := State.file + Newline + prog + Newline) 246 | ) 247 | }, 'edit') 248 | 249 | Repl := () => hae( 250 | 'div' 251 | ['repl'] 252 | {} 253 | { 254 | click: () => focusReplLine() 255 | } 256 | [ 257 | h('div', ['replTerm'], map(State.replLines, line => ( 258 | h('div', ['replLine'], [ 259 | line.type :: { 260 | Line.Prog -> hae('code', ['prog-line'], {}, { 261 | click: evt => ( 262 | render(State.line := line.text) 263 | focusReplLine() 264 | ) 265 | }, ['> ', line.text, AddToEditorButton(line.text)]) 266 | Line.Result -> h('code', ['result-line'], [line.text]) 267 | Line.Log -> h('code', ['log-line'], [line.text]) 268 | Line.Error -> h('code', ['error-line'], [line.text]) 269 | } 270 | ]) 271 | ))) 272 | h('div', ['inputLine'], [ 273 | h('div', ['inputPrompt'], ['> ']) 274 | hae( 275 | 'textarea' 276 | ['replInputLine'] 277 | { 278 | value: State.line 279 | autofocus: ~Embedded? 280 | placeholder: 'Type an expression to run' 281 | } 282 | { 283 | input: evt => ( 284 | render(State.line := evt.target.value) 285 | inputEl := evt.target 286 | 287 | inputEl.style.height := 0 288 | normHeight := inputEl.scrollHeight :: { 289 | bind(inputEl, 'getBoundingClientRect')().height -> () 290 | _ -> inputEl.style.height := string(normHeight) + 'px' 291 | } 292 | ) 293 | keydown: evt => evt.key :: { 294 | 'Enter' -> ( 295 | bind(evt, 'preventDefault')() 296 | evt.ctrlKey | evt.metaKey :: { 297 | true -> runRepl() 298 | _ -> trim(State.line, ' ') :: { 299 | '' -> () 300 | _ -> ( 301 | State.replLines.len(State.replLines) := { 302 | type: Line.Prog 303 | text: State.line 304 | } 305 | evaluateInk(State.line) 306 | State.line := '' 307 | State.commandIndex := ~1 308 | render() 309 | scrollToReplEnd() 310 | ) 311 | } 312 | } 313 | ) 314 | 'l' -> evt.ctrlKey | evt.altKey :: { 315 | true -> render(State.replLines := []) 316 | } 317 | 'ArrowUp' -> ( 318 | bind(evt, 'preventDefault')() 319 | historicalCommands := map(reverse(filter( 320 | State.replLines 321 | line => line.type = Line.Prog 322 | )), line => line.text) 323 | selectedCmd := historicalCommands.(State.commandIndex + 1) :: { 324 | () -> () 325 | _ -> ( 326 | State.line := selectedCmd 327 | State.commandIndex := State.commandIndex + 1 328 | render() 329 | 330 | inputLine := bind(document, 'querySelector')('.replInputLine') 331 | log(selectedCmd) 332 | bind(inputLine, 'setSelectionRange')(len(selectedCmd), len(selectedCmd)) 333 | ) 334 | } 335 | ) 336 | 'ArrowDown' -> ( 337 | bind(evt, 'preventDefault')() 338 | historicalCommands := map(reverse(filter( 339 | State.replLines 340 | line => line.type = Line.Prog 341 | )), line => line.text) 342 | State.commandIndex :: { 343 | ~1 -> () 344 | _ -> ( 345 | selectedCmd := historicalCommands.(State.commandIndex - 1) 346 | 347 | State.line := selectedCmd 348 | State.commandIndex := State.commandIndex - 1 349 | render() 350 | 351 | selectedCmd :: { 352 | () -> () 353 | _ -> ( 354 | inputLine := bind(document, 'querySelector')('.replInputLine') 355 | bind(inputLine, 'setSelectionRange')(len(selectedCmd), len(selectedCmd)) 356 | ) 357 | } 358 | ) 359 | } 360 | ) 361 | } 362 | } 363 | [] 364 | ) 365 | ]) 366 | ] 367 | ) 368 | 369 | Nbsp := char(160) 370 | Credits := () => h('div', ['credits'], [ 371 | 'Ink playground is a project by ' 372 | Link('Linus', 'https://thesephist.com/') 373 | ' built with ' 374 | Link('Ink', 'https://dotink.co/') 375 | Nbsp + '&' + Nbsp 376 | Link('September', 'https://github.com/thesephist/september') 377 | ]) 378 | 379 | AboutPage := () => h('div', ['aboutPage'], [ 380 | h('div', ['aboutContent'], [ 381 | hae('button', ['aboutBackButton'], {}, { 382 | click: () => render(State.page := Page.Home) 383 | }, ['← back']) 384 | h('h1', [], ['About Ink playground']) 385 | h('p', [], [ 386 | 'The Ink playground is a web based IDE and REPL for the ' 387 | Link('Ink', 'https://dotink.co/') 388 | ' programming language. It lets you write and run Ink programs 389 | privately in the browser. Ink programs in the playground run 390 | completely within your browser, and are not sent to a centralized 391 | server.' 392 | ]) 393 | h('p', [], [ 394 | 'The playground uses ' 395 | Link('September', 'https://github.com/thesephist/september') 396 | ', a compiler that compiles Ink to JavaScript, to compile Ink 397 | programs to JavaScript code for your browser to execute. The 398 | September compiler is also compiled to JavaScript using itself to 399 | be included in this app, and runs in the browser when you hit Run.' 400 | ]) 401 | h('p', [], [ 402 | 'Once an Ink program is compiled to JavaScript, the playground 403 | currently uses JavaScript\'s ' 404 | h('code', [], ['eval()']) 405 | ' function to execute code in the REPL. This means that a single 406 | browser session is one long REPL session — global variables are 407 | not cleared on every program run. There are rare edge cases where 408 | the compiler will crash on an invalid Ink program, or the compiled 409 | Ink program will error in a way that\'s unrecoverable. But because 410 | the playground is a static site, if anything seems off, you can 411 | simply reload the page and start fresh. Your Ink program in the 412 | editor will auto-save every few seconds.' 413 | ]) 414 | h('h2', [], ['Standard library and builtins']) 415 | h('p', [], [ 416 | 'In the playground, the standard libraries ' 417 | h('code', [], ['std']) 418 | ', ' 419 | h('code', [], ['str']) 420 | ', and ' 421 | h('code', [], ['quicksort']) 422 | ' are available from the global scope. This means you can, for 423 | example, call ' 424 | h('code', [], ['sort!(map([1, 2, 3], n => n * n))']) 425 | ' without loading any libraries in your program. Many built-in 426 | functions like ' 427 | h('code', [], ['time']) 428 | ', ' 429 | h('code', [], ['rand']) 430 | ', ' 431 | h('code', [], ['wait']) 432 | ', and most math functions are also supported.' 433 | ]) 434 | h('h2', [], ['More about this project']) 435 | h('p', [], [ 436 | 'The Ink playground is built using Ink and standard libraries from 437 | Ink version v0.1.9. The app is written entirely in Ink, but also 438 | depends on ' 439 | Link('Torus', 'https://github.com/thesephist/torus') 440 | ' to render the user interface. The source code for this project is 441 | available on GitHub at ' 442 | Link('thesephist/maverick', 'https://github.com/thesephist/maverick') 443 | '.' 444 | ]) 445 | ]) 446 | ]) 447 | 448 | ` application setup ` 449 | 450 | root := bind(document, 'querySelector')('#root') 451 | r := Renderer(root) 452 | update := r.update 453 | 454 | State := { 455 | ` editor content ` 456 | file: ( 457 | ` set State.file from URL query /?code=_ if present ` 458 | params := jsnew(URLSearchParams, [location.search]) 459 | codeParam := bind(params, 'get')('code') :: { 460 | () -> restored := getItem('State.file') :: { 461 | () -> Examples.'Hello World' 462 | _ -> restored 463 | } 464 | _ -> ( 465 | bind(history, 'replaceState')((), (), '/') 466 | codeParam 467 | ) 468 | } 469 | ) 470 | ` currently editing line in repl ` 471 | line: '' 472 | ` other lines in the repl ` 473 | replLines: [] 474 | ` currently selected example name ` 475 | exampleName: '' 476 | ` used to navigate repl comomand history with arrow keys. The index is the 477 | index into the reverse-chronological list of commands entered in this 478 | session. ~1 indicates no history entry selected (default). ` 479 | commandIndex: ~1 480 | 481 | theme: 'light' 482 | page: Page.Home 483 | } 484 | 485 | ` state fns ` 486 | 487 | clearRepl := () => render(State.replLines := []) 488 | 489 | focusReplLine := () => replLine := bind(document, 'querySelector')('.replInputLine') :: { 490 | () -> () 491 | _ -> bind(replLine, 'focus')() 492 | } 493 | 494 | runRepl := () => ( 495 | State.replLines := [] 496 | State.commandIndex := ~1 497 | evaluateInk(State.file) 498 | render() 499 | scrollToReplEnd() 500 | ) 501 | 502 | scrollToReplEnd := () => repl := bind(document, 'querySelector')('.repl') :: { 503 | () -> () 504 | _ -> repl.scrollTop := repl.scrollHeight 505 | } 506 | 507 | persistFileImmediately := () => setItem('State.file', State.file) 508 | persistFile := delay(persistFileImmediately, 800) 509 | 510 | ` main render loop ` 511 | render := () => update(h( 512 | 'div' 513 | [ 514 | 'app' 515 | State.theme 516 | Embedded? :: { 517 | true -> 'embedded' 518 | _ -> '' 519 | } 520 | ] 521 | [ 522 | Header() 523 | State.page :: { 524 | Page.Home -> h('div', ['workspace'], [ 525 | Editor() 526 | Repl() 527 | ]) 528 | Page.About -> AboutPage() 529 | } 530 | Embedded? :: { 531 | false -> Credits() 532 | } 533 | ] 534 | )) 535 | 536 | render() 537 | 538 | -------------------------------------------------------------------------------- /src/examples.js.ink: -------------------------------------------------------------------------------- 1 | ` example Ink programs repository ` 2 | 3 | Examples := { 4 | 'Hello World': 'log(\'Hello, \' + \'World!\')' 5 | 'FizzBuzz': 'fizzbuzz := n => each( 6 | range(1, n + 1, 1) 7 | n => [n % 3, n % 5] :: { 8 | [0, 0] -> log(\'FizzBuzz\') 9 | [0, _] -> log(\'Fizz\') 10 | [_, 0] -> log(\'Buzz\') 11 | _ -> log(n) 12 | } 13 | ) 14 | 15 | fizzbuzz(20)' 16 | 'Fibonacci': '` naive implementation ` 17 | fib := n => n :: { 18 | 0 -> 0 19 | 1 -> 1 20 | _ -> fib(n - 1) + fib(n - 2) 21 | } 22 | 23 | ` memoized / dynamic programming implementation ` 24 | memo := [0, 1] 25 | fibMemo := n => ( 26 | memo.(n) :: { 27 | () -> memo.(n) := fibMemo(n - 1) + fibMemo(n - 2) 28 | } 29 | memo.(n) 30 | ) 31 | 32 | out(\'Naive solution: \'), log(fib(20)) 33 | out(\'Dynamic solution: \'), log(fibMemo(20))' 34 | 'Prime sieve': '` Ink prime sieve ` 35 | 36 | ` we compute primes up to this limit ` 37 | Max := 100 38 | 39 | ` is a single number prime? ` 40 | prime? := n => ( 41 | ` is n coprime with nums < p? ` 42 | max := floor(pow(n, 0.5)) + 1 43 | (ip := p => p :: { 44 | max -> true 45 | _ -> n % p :: { 46 | 0 -> false 47 | _ -> ip(p + 1) 48 | } 49 | })(2) 50 | ) 51 | 52 | ` primes under N are numbers 2 .. N, filtered by prime? ` 53 | getPrimesUnder := n => filter(range(2, n, 1), prime?) 54 | 55 | ` display results ` 56 | primes := getPrimesUnder(Max) 57 | log(f(\'Total number of primes under {{ 0 }}: {{ 1 }}\' 58 | [Max, len(primes)])) 59 | log(stringList(primes))' 60 | } 61 | -------------------------------------------------------------------------------- /static/css/codemirror.css: -------------------------------------------------------------------------------- 1 | /* BASICS */ 2 | 3 | .CodeMirror { 4 | /* Set height, width, borders, and global font properties here */ 5 | font-family: monospace; 6 | height: 300px; 7 | color: black; 8 | direction: ltr; 9 | } 10 | 11 | /* PADDING */ 12 | 13 | .CodeMirror-lines { 14 | padding: 4px 0; /* Vertical padding around content */ 15 | } 16 | .CodeMirror pre.CodeMirror-line, 17 | .CodeMirror pre.CodeMirror-line-like { 18 | padding: 0 4px; /* Horizontal padding of content */ 19 | } 20 | 21 | .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { 22 | background-color: white; /* The little square between H and V scrollbars */ 23 | } 24 | 25 | /* GUTTER */ 26 | 27 | .CodeMirror-gutters { 28 | border-right: 1px solid #ddd; 29 | background-color: #f7f7f7; 30 | white-space: nowrap; 31 | } 32 | .CodeMirror-linenumbers {} 33 | .CodeMirror-linenumber { 34 | padding: 0 3px 0 5px; 35 | min-width: 20px; 36 | text-align: right; 37 | color: #999; 38 | white-space: nowrap; 39 | } 40 | 41 | .CodeMirror-guttermarker { color: black; } 42 | .CodeMirror-guttermarker-subtle { color: #999; } 43 | 44 | /* CURSOR */ 45 | 46 | .CodeMirror-cursor { 47 | border-left: 1px solid black; 48 | border-right: none; 49 | width: 0; 50 | } 51 | /* Shown when moving in bi-directional text */ 52 | .CodeMirror div.CodeMirror-secondarycursor { 53 | border-left: 1px solid silver; 54 | } 55 | .cm-fat-cursor .CodeMirror-cursor { 56 | width: auto; 57 | border: 0 !important; 58 | background: #7e7; 59 | } 60 | .cm-fat-cursor div.CodeMirror-cursors { 61 | z-index: 1; 62 | } 63 | .cm-fat-cursor-mark { 64 | background-color: rgba(20, 255, 20, 0.5); 65 | -webkit-animation: blink 1.06s steps(1) infinite; 66 | -moz-animation: blink 1.06s steps(1) infinite; 67 | animation: blink 1.06s steps(1) infinite; 68 | } 69 | .cm-animate-fat-cursor { 70 | width: auto; 71 | border: 0; 72 | -webkit-animation: blink 1.06s steps(1) infinite; 73 | -moz-animation: blink 1.06s steps(1) infinite; 74 | animation: blink 1.06s steps(1) infinite; 75 | background-color: #7e7; 76 | } 77 | @-moz-keyframes blink { 78 | 0% {} 79 | 50% { background-color: transparent; } 80 | 100% {} 81 | } 82 | @-webkit-keyframes blink { 83 | 0% {} 84 | 50% { background-color: transparent; } 85 | 100% {} 86 | } 87 | @keyframes blink { 88 | 0% {} 89 | 50% { background-color: transparent; } 90 | 100% {} 91 | } 92 | 93 | /* Can style cursor different in overwrite (non-insert) mode */ 94 | .CodeMirror-overwrite .CodeMirror-cursor {} 95 | 96 | .cm-tab { display: inline-block; text-decoration: inherit; } 97 | 98 | .CodeMirror-rulers { 99 | position: absolute; 100 | left: 0; right: 0; top: -50px; bottom: 0; 101 | overflow: hidden; 102 | } 103 | .CodeMirror-ruler { 104 | border-left: 1px solid #ccc; 105 | top: 0; bottom: 0; 106 | position: absolute; 107 | } 108 | 109 | /* DEFAULT THEME */ 110 | 111 | .cm-s-default .cm-header {color: blue;} 112 | .cm-s-default .cm-quote {color: #090;} 113 | .cm-negative {color: #d44;} 114 | .cm-positive {color: #292;} 115 | .cm-header, .cm-strong {font-weight: bold;} 116 | .cm-em {font-style: italic;} 117 | .cm-link {text-decoration: underline;} 118 | .cm-strikethrough {text-decoration: line-through;} 119 | 120 | .cm-s-default .cm-keyword {color: #708;} 121 | .cm-s-default .cm-atom {color: #219;} 122 | .cm-s-default .cm-number {color: #164;} 123 | .cm-s-default .cm-def {color: #00f;} 124 | .cm-s-default .cm-variable, 125 | .cm-s-default .cm-punctuation, 126 | .cm-s-default .cm-property, 127 | .cm-s-default .cm-operator {} 128 | .cm-s-default .cm-variable-2 {color: #05a;} 129 | .cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} 130 | .cm-s-default .cm-comment {color: #a50;} 131 | .cm-s-default .cm-string {color: #a11;} 132 | .cm-s-default .cm-string-2 {color: #f50;} 133 | .cm-s-default .cm-meta {color: #555;} 134 | .cm-s-default .cm-qualifier {color: #555;} 135 | .cm-s-default .cm-builtin {color: #30a;} 136 | .cm-s-default .cm-bracket {color: #997;} 137 | .cm-s-default .cm-tag {color: #170;} 138 | .cm-s-default .cm-attribute {color: #00c;} 139 | .cm-s-default .cm-hr {color: #999;} 140 | .cm-s-default .cm-link {color: #00c;} 141 | 142 | .cm-s-default .cm-error {color: #f00;} 143 | .cm-invalidchar {color: #f00;} 144 | 145 | .CodeMirror-composing { border-bottom: 2px solid; } 146 | 147 | /* Default styles for common addons */ 148 | 149 | div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} 150 | div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} 151 | .CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } 152 | .CodeMirror-activeline-background {background: #e8f2ff;} 153 | 154 | /* STOP */ 155 | 156 | /* The rest of this file contains styles related to the mechanics of 157 | the editor. You probably shouldn't touch them. */ 158 | 159 | .CodeMirror { 160 | position: relative; 161 | overflow: hidden; 162 | background: white; 163 | } 164 | 165 | .CodeMirror-scroll { 166 | overflow: scroll !important; /* Things will break if this is overridden */ 167 | /* 50px is the magic margin used to hide the element's real scrollbars */ 168 | /* See overflow: hidden in .CodeMirror */ 169 | margin-bottom: -50px; margin-right: -50px; 170 | padding-bottom: 50px; 171 | height: 100%; 172 | outline: none; /* Prevent dragging from highlighting the element */ 173 | position: relative; 174 | } 175 | .CodeMirror-sizer { 176 | position: relative; 177 | border-right: 50px solid transparent; 178 | } 179 | 180 | /* The fake, visible scrollbars. Used to force redraw during scrolling 181 | before actual scrolling happens, thus preventing shaking and 182 | flickering artifacts. */ 183 | .CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { 184 | position: absolute; 185 | z-index: 6; 186 | display: none; 187 | outline: none; 188 | } 189 | .CodeMirror-vscrollbar { 190 | right: 0; top: 0; 191 | overflow-x: hidden; 192 | overflow-y: scroll; 193 | } 194 | .CodeMirror-hscrollbar { 195 | bottom: 0; left: 0; 196 | overflow-y: hidden; 197 | overflow-x: scroll; 198 | } 199 | .CodeMirror-scrollbar-filler { 200 | right: 0; bottom: 0; 201 | } 202 | .CodeMirror-gutter-filler { 203 | left: 0; bottom: 0; 204 | } 205 | 206 | .CodeMirror-gutters { 207 | position: absolute; left: 0; top: 0; 208 | min-height: 100%; 209 | z-index: 3; 210 | } 211 | .CodeMirror-gutter { 212 | white-space: normal; 213 | height: 100%; 214 | display: inline-block; 215 | vertical-align: top; 216 | margin-bottom: -50px; 217 | } 218 | .CodeMirror-gutter-wrapper { 219 | position: absolute; 220 | z-index: 4; 221 | background: none !important; 222 | border: none !important; 223 | } 224 | .CodeMirror-gutter-background { 225 | position: absolute; 226 | top: 0; bottom: 0; 227 | z-index: 4; 228 | } 229 | .CodeMirror-gutter-elt { 230 | position: absolute; 231 | cursor: default; 232 | z-index: 4; 233 | } 234 | .CodeMirror-gutter-wrapper ::selection { background-color: transparent } 235 | .CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } 236 | 237 | .CodeMirror-lines { 238 | cursor: text; 239 | min-height: 1px; /* prevents collapsing before first draw */ 240 | } 241 | .CodeMirror pre.CodeMirror-line, 242 | .CodeMirror pre.CodeMirror-line-like { 243 | /* Reset some styles that the rest of the page might have set */ 244 | -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; 245 | border-width: 0; 246 | background: transparent; 247 | font-family: inherit; 248 | font-size: inherit; 249 | margin: 0; 250 | white-space: pre; 251 | word-wrap: normal; 252 | line-height: inherit; 253 | color: inherit; 254 | z-index: 2; 255 | position: relative; 256 | overflow: visible; 257 | -webkit-tap-highlight-color: transparent; 258 | -webkit-font-variant-ligatures: contextual; 259 | font-variant-ligatures: contextual; 260 | } 261 | .CodeMirror-wrap pre.CodeMirror-line, 262 | .CodeMirror-wrap pre.CodeMirror-line-like { 263 | word-wrap: break-word; 264 | white-space: pre-wrap; 265 | word-break: normal; 266 | } 267 | 268 | .CodeMirror-linebackground { 269 | position: absolute; 270 | left: 0; right: 0; top: 0; bottom: 0; 271 | z-index: 0; 272 | } 273 | 274 | .CodeMirror-linewidget { 275 | position: relative; 276 | z-index: 2; 277 | padding: 0.1px; /* Force widget margins to stay inside of the container */ 278 | } 279 | 280 | .CodeMirror-widget {} 281 | 282 | .CodeMirror-rtl pre { direction: rtl; } 283 | 284 | .CodeMirror-code { 285 | outline: none; 286 | } 287 | 288 | /* Force content-box sizing for the elements where we expect it */ 289 | .CodeMirror-scroll, 290 | .CodeMirror-sizer, 291 | .CodeMirror-gutter, 292 | .CodeMirror-gutters, 293 | .CodeMirror-linenumber { 294 | -moz-box-sizing: content-box; 295 | box-sizing: content-box; 296 | } 297 | 298 | .CodeMirror-measure { 299 | position: absolute; 300 | width: 100%; 301 | height: 0; 302 | overflow: hidden; 303 | visibility: hidden; 304 | } 305 | 306 | .CodeMirror-cursor { 307 | position: absolute; 308 | pointer-events: none; 309 | } 310 | .CodeMirror-measure pre { position: static; } 311 | 312 | div.CodeMirror-cursors { 313 | visibility: hidden; 314 | position: relative; 315 | z-index: 3; 316 | } 317 | div.CodeMirror-dragcursors { 318 | visibility: visible; 319 | } 320 | 321 | .CodeMirror-focused div.CodeMirror-cursors { 322 | visibility: visible; 323 | } 324 | 325 | .CodeMirror-selected { background: #d9d9d9; } 326 | .CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } 327 | .CodeMirror-crosshair { cursor: crosshair; } 328 | .CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } 329 | .CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } 330 | 331 | .cm-searching { 332 | background-color: #ffa; 333 | background-color: rgba(255, 255, 0, .4); 334 | } 335 | 336 | /* Used to force a border model for a node */ 337 | .cm-force-border { padding-right: .1px; } 338 | 339 | @media print { 340 | /* Hide the cursor when printing */ 341 | .CodeMirror div.CodeMirror-cursors { 342 | visibility: hidden; 343 | } 344 | } 345 | 346 | /* See issue #2901 */ 347 | .cm-tab-wrap-hack:after { content: ''; } 348 | 349 | /* Help users use markselection to safely style text background */ 350 | span.CodeMirror-selectedtext { background: none; } 351 | -------------------------------------------------------------------------------- /static/css/main.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | } 5 | 6 | body { 7 | background: var(--primary-bg); 8 | font-family: var(--sans); 9 | 10 | --sans: 'IBM Plex Sans', sans-serif; 11 | --mono: 'IBM Plex Mono', monospace; 12 | 13 | /* color variables taken from Merlot */ 14 | --primary-bg: #f9fafb; 15 | --primary-text: #111111; 16 | --secondary-bg: #f3f4f6; 17 | --secondary-text: #9b9b9b; 18 | --hover-bg: #eaebec; 19 | --active-bg: #dcdfe4; 20 | --translucent: rgba(249, 250, 251, .8); 21 | --transparent: rgba(249, 250, 251, 0); 22 | 23 | /* repl text colors */ 24 | --error: #da1111; 25 | } 26 | 27 | .dark { 28 | --primary-bg: #2f3437; 29 | --primary-text: #ebebeb; 30 | --secondary-bg: #373c3f; 31 | --secondary-text: #a4a7a9; 32 | --hover-bg: #474c50; 33 | --active-bg: #626569; 34 | --translucent: rgba(47, 52, 55, .8); 35 | --transparent: rgba(47, 52, 55, 0); 36 | 37 | --error: #ff554c; 38 | } 39 | 40 | a { 41 | color: var(--primary-text); 42 | } 43 | 44 | button { 45 | font-size: 1em; 46 | } 47 | 48 | pre, 49 | code, 50 | input, 51 | select, 52 | option, 53 | textarea { 54 | color: var(--primary-text); 55 | font-size: 1em; 56 | font-family: var(--mono); 57 | } 58 | 59 | .app { 60 | display: flex; 61 | flex-direction: column; 62 | width: 100%; 63 | height: 100vh; 64 | overflow: hidden; 65 | color: var(--primary-text); 66 | background: var(--primary-bg); 67 | } 68 | 69 | header { 70 | height: 50px; 71 | flex-grow: 0; 72 | flex-shrink: 0; 73 | justify-content: space-between; 74 | box-sizing: border-box; 75 | padding: 0 10px; 76 | background: var(--secondary-bg); 77 | border-bottom: 2px solid var(--active-bg); 78 | } 79 | 80 | header, 81 | header nav { 82 | display: flex; 83 | flex-direction: row; 84 | align-items: center; 85 | } 86 | 87 | nav a { 88 | margin: 0 8px; 89 | } 90 | 91 | nav a:hover { 92 | text-decoration: underline; 93 | } 94 | 95 | .left-nav { 96 | justify-content: flex-start; 97 | } 98 | 99 | .right-nav { 100 | justify-content: flex-end; 101 | } 102 | 103 | nav a { 104 | text-decoration: none; 105 | } 106 | 107 | header select { 108 | font-family: var(--sans); 109 | margin: 0 8px; 110 | cursor: pointer; 111 | background: var(--primary-bg); 112 | border-radius: 4px; 113 | padding: 2px 6px; 114 | } 115 | 116 | .workspace { 117 | display: flex; 118 | flex-direction: row; 119 | flex-grow: 1; 120 | flex-shrink: 1; 121 | justify-content: space-between; 122 | height: 0; 123 | width: 100%; 124 | } 125 | 126 | .editor, 127 | .repl { 128 | position: relative; 129 | height: 100%; 130 | width: 0; 131 | flex-grow: 1; 132 | flex-shrink: 1; 133 | } 134 | 135 | .editor + .repl { 136 | border-left: 2px solid var(--active-bg); 137 | } 138 | 139 | .editorContainer { 140 | height: 100%; 141 | width: 100%; 142 | } 143 | 144 | .editorContainer .CodeMirror { 145 | height: 100%; 146 | font-family: var(--mono); 147 | } 148 | 149 | .editor .runButton { 150 | padding: 8px 12px; 151 | border-radius: 4px; 152 | position: absolute; 153 | top: 8px; 154 | right: 8px; 155 | cursor: pointer; 156 | color: var(--primary-bg); 157 | background: var(--primary-text); 158 | border: 0; 159 | transition: background-color .15s; 160 | z-index: 100; 161 | } 162 | 163 | .editor .runButton:hover { 164 | background: var(--secondary-text); 165 | } 166 | 167 | .repl { 168 | overflow-x: hidden; 169 | } 170 | 171 | .replTerm { 172 | line-height: 1.5em; 173 | } 174 | 175 | .replTerm code { 176 | white-space: pre-wrap; 177 | display: block; 178 | box-sizing: border-box; 179 | padding: 0 8px; 180 | word-break: break-all; 181 | } 182 | 183 | .repl .prog-line { 184 | font-weight: bold; 185 | cursor: pointer; 186 | } 187 | 188 | .repl .prog-line:hover { 189 | background: var(--hover-bg); 190 | } 191 | 192 | .repl .result-line { 193 | font-style: italic; 194 | color: var(--secondary-text); 195 | } 196 | 197 | .repl .error-line { 198 | color: var(--error); 199 | font-style: italic; 200 | } 201 | 202 | .inputLine { 203 | display: flex; 204 | flex-direction: row; 205 | font-family: var(--mono); 206 | background: var(--hover-bg); 207 | padding: 0 8px; 208 | /* to not overlap with credits, ergonomics */ 209 | margin-bottom: 3em; 210 | } 211 | 212 | .embedded .inputLine { 213 | /* vertical real estate is at a premium in embedded mode */ 214 | margin-bottom: 0; 215 | } 216 | 217 | .inputPrompt { 218 | white-space: pre; 219 | font-weight: bold; 220 | } 221 | 222 | .addToEditorButton { 223 | display: none; 224 | float: right; 225 | background: transparent; 226 | border: 0; 227 | color: var(--secondary-text); 228 | font-family: var(--mono); 229 | cursor: pointer; 230 | } 231 | 232 | .replLine:hover .addToEditorButton { 233 | display: inline; 234 | } 235 | 236 | .addToEditorButton:hover { 237 | text-decoration: underline; 238 | } 239 | 240 | textarea.replInputLine { 241 | font-size: 1em; 242 | height: 1.5em; /* match line-height */ 243 | padding: 0; 244 | margin: 0; 245 | border: 0; 246 | background: transparent; 247 | outline: none; 248 | flex-grow: 1; 249 | font-family: var(--mono); 250 | line-height: 1.5em; 251 | resize: none; 252 | } 253 | 254 | textarea.replInputLine::placeholder { 255 | font-style: italic; 256 | color: var(--secondary-text); 257 | } 258 | 259 | .credits { 260 | position: fixed; 261 | right: 6px; 262 | bottom: 6px; 263 | font-size: 12px; 264 | text-align: right; 265 | } 266 | 267 | .credits, 268 | .credits a { 269 | color: var(--secondary-text); 270 | } 271 | 272 | .aboutBackButton { 273 | background: transparent; 274 | border: 0; 275 | cursor: pointer; 276 | padding: 0; 277 | margin: 0; 278 | color: var(--secondary-text); 279 | margin-top: 1em; 280 | } 281 | 282 | .aboutBackButton:hover { 283 | text-decoration: underline; 284 | } 285 | 286 | .aboutPage { 287 | background: var(--primary-bg); 288 | overflow-y: auto; 289 | } 290 | 291 | .aboutContent { 292 | width: calc(100% - 36px); 293 | max-width: 68ch; 294 | margin: 0 18px; 295 | margin-bottom: 6em; 296 | } 297 | 298 | .aboutContent p, 299 | .aboutContent li { 300 | line-height: 1.5em; 301 | } 302 | 303 | .aboutContent code { 304 | background: var(--hover-bg); 305 | border-radius: 3px; 306 | padding: 1px 4px 2px 4px; 307 | } 308 | 309 | /* embedded mode, /?=embed=1 for