{
  "version": "https://jsonfeed.org/version/1.1",
  "title": "RUNAWAYDEVIL",
  "description": "A textual system: shell, virtual filesystem, BBS reader and ANSI gallery, in a fixed 80x25 grid.",
  "home_page_url": "https://runawaydevil.com/",
  "feed_url": "https://runawaydevil.com/feed.json",
  "language": "en",
  "authors": [
    {
      "name": "Pablo Murad",
      "url": "https://runawaydevil.com/about"
    }
  ],
  "items": [
    {
      "id": "https://runawaydevil.com/posts/eighty-by-twenty-five",
      "url": "https://runawaydevil.com/posts/eighty-by-twenty-five",
      "title": "Eighty by Twenty-Five",
      "summary": "The grid on this site is a specification, not a theme. Here is what enforcing it actually cost.",
      "content_html": "<h1>Eighty by Twenty-Five</h1>\n<p>This system is 80 columns by 25 lines. Not \"about 80\". Not \"80 on desktop\".\nEighty, on a phone, on a 4K panel, in a window you have dragged to the size of\na business card. What changes with your screen is how large one cell is drawn,\nand nothing else.</p>\n<p>That sounds like a decoration. It is closer to a constitution: almost every\nawkward decision in this codebase comes from refusing to break it.</p>\n<h2>What the browser wants to do instead</h2>\n<p>Every terminal library for the web is built around the opposite idea. You give\nit a container, it measures the container, it reports back \"you have 142\ncolumns and 38 rows\", and the program on the other end reflows to match. That\nis correct for a terminal that hosts <em>someone else's</em> program.</p>\n<p>Here there is no other program. The system is the only thing writing to the\nscreen, so the size can be fixed and everything above it can be composed\nagainst a known width. That is why the terminal in this site is written here\nrather than pulled in — not because the libraries are bad, but because their\ncentral feature is the one thing that had to go.</p>\n<h2>Nothing measures text with .length</h2>\n<p>The first real casualty was string length. <code>\"日本語\".length</code> is 3, and it\noccupies 6 cells. <code>\"coração\"</code> measured after Unicode normalisation can be 8\ncode points and 7 cells. A line with colour in it —</p>\n<pre><code class=\"language-text\">ESC[91mRUNAWAY ESC[0m\n</code></pre>\n<p>— has fourteen characters of escape that occupy nothing at all.</p>\n<p>So there is exactly one module allowed to reason about horizontal space, and\nit counts <em>cells</em>: grapheme clusters, East Asian widths, combining marks, and\nan ANSI scanner that skips what is invisible. Everything else calls it. A test\nfails if any composed screen exceeds cell 80, which is how a typo gets caught\nbefore it becomes a crooked box.</p>\n<h2>Three defences, because one is not enough</h2>\n<ol>\n<li><strong>The model cannot exceed it.</strong> The screen allocates exactly 2000 cells.\nWriting past column 80 wraps. There is no code path that grows the grid.</li>\n<li><strong>Composition measures in cells.</strong> Every listing, every header, every piece\nof art is measured before it ships. The art validator refuses to build a\npiece 92 cells wide.</li>\n<li><strong>The renderer fits, never reflows.</strong> Playwright asserts <code>cols === 80</code> and\n<code>rows === 25</code> at 3440×1440, at 1280×720, on a tablet and on a phone.</li>\n</ol>\n<h2>The part that took three tries</h2>\n<p>Filling the screen is where the constraint bites. An 80×25 grid has an aspect\nratio near 1.6:1; a modern monitor is closer to 1.9:1. Scale the grid until it\nfills the height and the glyphs come out around 38 pixels tall — legible, and\nabsurd for reading prose. Cap the size and a wide screen shows a lot of black.</p>\n<p>There is no arrangement that satisfies both. So the system does the honest\nthing: it renders at the size an 80×25 terminal has always been — 640 by 400\npixels, cells of 8 by 16 — and lets the surround be black. The frame is a\nhairline, so the emptiness reads as a terminal in a room rather than a page\nthat failed to load.</p>\n<p>It also stopped scaling with a CSS transform, which was quietly resampling\nevery glyph. Setting the font size instead means the browser rasterises at the\nreal size. The text got noticeably sharper the day that changed.</p>\n<h2>Why bother</h2>\n<p>Because the limit does the editing. Seventy-six usable columns is roughly the\nwidth prose wants anyway; the constraint just makes you notice. A pager forces\nan argument to have parts. Sixteen colours force contrast to mean something.</p>\n<p>None of that is nostalgia. It is a set of limits that happen to produce better\ndecisions than no limits at all.</p>",
      "content_text": "Eighty by Twenty-Five\nThis system is 80 columns by 25 lines. Not \"about 80\". Not \"80 on desktop\".\nEighty, on a phone, on a 4K panel, in a window you have dragged to the size of\na business card. What changes with your screen is how large one cell is drawn,\nand nothing else.\nThat sounds like a decoration. It is closer to a constitution: almost every\nawkward decision in this codebase comes from refusing to break it.\nWhat the browser wants to do instead\nEvery terminal library for the web is built around the opposite idea. You give\nit a container, it measures the container, it reports back \"you have 142\ncolumns and 38 rows\", and the program on the other end reflows to match. That\nis correct for a terminal that hosts someone else's program.\nHere there is no other program. The system is the only thing writing to the\nscreen, so the size can be fixed and everything above it can be composed\nagainst a known width. That is why the terminal in this site is written here\nrather than pulled in — not because the libraries are bad, but because their\ncentral feature is the one thing that had to go.\nNothing measures text with .length\nThe first real casualty was string length. \"日本語\".length is 3, and it\noccupies 6 cells. \"coração\" measured after Unicode normalisation can be 8\ncode points and 7 cells. A line with colour in it —\nESC[91mRUNAWAY ESC[0m — has fourteen characters of escape that occupy nothing at all.\nSo there is exactly one module allowed to reason about horizontal space, and\nit counts cells : grapheme clusters, East Asian widths, combining marks, and\nan ANSI scanner that skips what is invisible. Everything else calls it. A test\nfails if any composed screen exceeds cell 80, which is how a typo gets caught\nbefore it becomes a crooked box.\nThree defences, because one is not enough\nThe model cannot exceed it. The screen allocates exactly 2000 cells.\nWriting past column 80 wraps. There is no code path that grows the grid.\n\nComposition measures in cells. Every listing, every header, every piece\nof art is measured before it ships. The art validator refuses to build a\npiece 92 cells wide.\n\nThe renderer fits, never reflows. Playwright asserts cols === 80 and\nrows === 25 at 3440×1440, at 1280×720, on a tablet and on a phone.\n\nThe part that took three tries\nFilling the screen is where the constraint bites. An 80×25 grid has an aspect\nratio near 1.6:1; a modern monitor is closer to 1.9:1. Scale the grid until it\nfills the height and the glyphs come out around 38 pixels tall — legible, and\nabsurd for reading prose. Cap the size and a wide screen shows a lot of black.\nThere is no arrangement that satisfies both. So the system does the honest\nthing: it renders at the size an 80×25 terminal has always been — 640 by 400\npixels, cells of 8 by 16 — and lets the surround be black. The frame is a\nhairline, so the emptiness reads as a terminal in a room rather than a page\nthat failed to load.\nIt also stopped scaling with a CSS transform, which was quietly resampling\nevery glyph. Setting the font size instead means the browser rasterises at the\nreal size. The text got noticeably sharper the day that changed.\nWhy bother\nBecause the limit does the editing. Seventy-six usable columns is roughly the\nwidth prose wants anyway; the constraint just makes you notice. A pager forces\nan argument to have parts. Sixteen colours force contrast to mean something.\nNone of that is nostalgia. It is a set of limits that happen to produce better\ndecisions than no limits at all.",
      "date_published": "2026-08-21T12:00:00Z",
      "tags": [
        "runawaydevil",
        "terminal",
        "design"
      ]
    },
    {
      "id": "https://runawaydevil.com/posts/a-shell-that-is-not-a-shell",
      "url": "https://runawaydevil.com/posts/a-shell-that-is-not-a-shell",
      "title": "A Shell That Is Not a Shell",
      "summary": "RunawayShell has a prompt, a filesystem and a history. It has no interpreter, and that is the whole design.",
      "content_html": "<h1>A Shell That Is Not a Shell</h1>\n<p>The prompt at the bottom of this system looks like <code>bash</code>. It completes paths\nwith Tab, walks history with the arrows, understands <code>Ctrl+W</code>, and prints\n<code>no such file or directory</code> in the right tone of voice.</p>\n<p>It cannot run anything. There is no interpreter behind it at all.</p>\n<h2>What actually happens when you press Enter</h2>\n<p>The line is split into words, honouring quotes. The first word is looked up in\na table. If it is there, that entry's function runs with the remaining words.\nIf it is not there, nothing happens and you get a boxed notice.</p>\n<p>That is the entire mechanism. No <code>eval</code>, no <code>Function</code> constructor, no\ndynamic dispatch on a string, no fallthrough to anything. Type</p>\n<pre><code class=\"language-text\">guest@runawaydevil:~ $ rm -rf /\n</code></pre>\n<p>and the parser produces four words, fails to find <code>rm</code>, and says so. <code>|</code>, <code>></code>,\n<code>&#x26;&#x26;</code> and <code>$(...)</code> are not operators here — they are characters that end up\ninside a word and get looked up like any other.</p>\n<h2>The part that is easy to get wrong</h2>\n<p>The temptation, when building something like this, is a small convenience:\none command that takes an expression, or a debug hatch, or a plugin hook that\ntakes a callback name. Each is harmless alone and each one turns a table into\nan interpreter.</p>\n<p>So the rule is written as a test:</p>\n<pre><code class=\"language-text\">for (const name of ['eval','exec','sh','bash','fetch','curl','wget','sudo'])\n  expect(findCommand(name)).toBeNull()\n</code></pre>\n<p><code>node</code> exists, but it prints node status — the BBS sense of the word. The test\nasserts that too, because a name that <em>looks</em> dangerous deserves to be pinned\ndown rather than argued about.</p>\n<h2>There is no filesystem either</h2>\n<p><code>~/journal/2026-08-21-eighty-by-twenty-five.md</code> is not a path. It is a key in\na tree of objects built when the site was compiled, from Markdown files in a\nrepository. <code>ls</code> walks it, <code>cat</code> prints its contents, <code>grep</code> searches them.\nNothing has a writer. <code>..</code> cannot climb past the root, because there is no\nroot to climb past — resolution is string arithmetic over an array.</p>\n<p>Which means the usual questions about a web shell do not apply. There is no\ncontainer to escape, no PTY to hijack, no socket to hold open, no host process\nthat exists. The whole system is static files and a program that runs in your\nbrowser, and the worst thing a visitor can do to it is press keys.</p>\n<h2>Why keep the shape then</h2>\n<p>Because the shape is good. A directory listing is a better index than a\nnavigation bar: it has dates, sizes, and an obvious way to look inside. <code>grep</code>\nis a better search box than a search box. And muscle memory is real — people\nwho have typed <code>ls</code> ten thousand times should get what they expect.</p>\n<p>The commands, the filesystem and the pager know nothing about the DOM. They\nconsume key events and produce ANSI. Point that stream at a socket instead of\na renderer and the same system answers a telnet call. That is not implemented\nand may never be, but the seam is real and it is marked.</p>",
      "content_text": "A Shell That Is Not a Shell\nThe prompt at the bottom of this system looks like bash . It completes paths\nwith Tab, walks history with the arrows, understands Ctrl+W , and prints\nno such file or directory in the right tone of voice.\nIt cannot run anything. There is no interpreter behind it at all.\nWhat actually happens when you press Enter\nThe line is split into words, honouring quotes. The first word is looked up in\na table. If it is there, that entry's function runs with the remaining words.\nIf it is not there, nothing happens and you get a boxed notice.\nThat is the entire mechanism. No eval , no Function constructor, no\ndynamic dispatch on a string, no fallthrough to anything. Type\nguest@runawaydevil:~ $ rm -rf / and the parser produces four words, fails to find rm , and says so. | , > ,\n&& and $(...) are not operators here — they are characters that end up\ninside a word and get looked up like any other.\nThe part that is easy to get wrong\nThe temptation, when building something like this, is a small convenience:\none command that takes an expression, or a debug hatch, or a plugin hook that\ntakes a callback name. Each is harmless alone and each one turns a table into\nan interpreter.\nSo the rule is written as a test:\nfor (const name of ['eval','exec','sh','bash','fetch','curl','wget','sudo'])\nexpect(findCommand(name)).toBeNull() node exists, but it prints node status — the BBS sense of the word. The test\nasserts that too, because a name that looks dangerous deserves to be pinned\ndown rather than argued about.\nThere is no filesystem either\n~/journal/2026-08-21-eighty-by-twenty-five.md is not a path. It is a key in\na tree of objects built when the site was compiled, from Markdown files in a\nrepository. ls walks it, cat prints its contents, grep searches them.\nNothing has a writer. .. cannot climb past the root, because there is no\nroot to climb past — resolution is string arithmetic over an array.\nWhich means the usual questions about a web shell do not apply. There is no\ncontainer to escape, no PTY to hijack, no socket to hold open, no host process\nthat exists. The whole system is static files and a program that runs in your\nbrowser, and the worst thing a visitor can do to it is press keys.\nWhy keep the shape then\nBecause the shape is good. A directory listing is a better index than a\nnavigation bar: it has dates, sizes, and an obvious way to look inside. grep\nis a better search box than a search box. And muscle memory is real — people\nwho have typed ls ten thousand times should get what they expect.\nThe commands, the filesystem and the pager know nothing about the DOM. They\nconsume key events and produce ANSI. Point that stream at a socket instead of\na renderer and the same system answers a telnet call. That is not implemented\nand may never be, but the seam is real and it is marked.",
      "date_published": "2026-08-18T12:00:00Z",
      "tags": [
        "runawaydevil",
        "security",
        "software"
      ]
    },
    {
      "id": "https://runawaydevil.com/posts/nothing-is-recorded",
      "url": "https://runawaydevil.com/posts/nothing-is-recorded",
      "title": "Nothing Is Recorded",
      "summary": "No analytics, no cookies, no backend, and a payload that stopped being shipped thirty-seven times.",
      "content_html": "<h1>Nothing Is Recorded</h1>\n<p>There is nothing on the other end of this site. No database, no session store,\nno analytics pipeline, no consent platform, no log of who read what. Not\nbecause those were disabled — because they were never built, and there is\nnowhere for them to run.</p>\n<p>That is easy to claim and cheap to check, so here is the actual accounting.</p>\n<h2>What the browser stores</h2>\n<p>Six keys, all prefixed <code>rd.</code>:</p>\n<pre><code class=\"language-text\">rd.lastVisit        the date you were last here\nrd.handle           a name, if you ever set one\nrd.theme            dos | amber | green | mono\nrd.bootSeen         whether to play the connect sequence\nrd.history          the commands you typed\nrd.readerPosition   the line you left each entry on\n</code></pre>\n<p>All of it is in your browser and none of it is sent anywhere. A test fails if\na seventh key appears, and another asserts the cookie jar is empty.</p>\n<h2>What the network does</h2>\n<p>One HTML page, one stylesheet, one script, one font, one JSON file. After\nthat, a test watches every request the page makes while a session runs\ncommands and searches — and asserts the list is empty. The terminal genuinely\ndoes not talk to anything once it has loaded.</p>\n<p>The JSON file is the interesting one, because it used to be much worse.</p>\n<h2>Thirty-seven copies of the same thing</h2>\n<p>The shell needs the whole collection in hand whatever route you arrived on:\n<code>ls</code> is not a per-page question, and neither is <code>search</code>. So the payload was\nembedded in the page — which meant every one of the site's 37 routes carried\nits own copy of the same 67KB.</p>\n<pre><code class=\"language-text\">before   dist 3166 KB   index.html 82 KB\nafter    dist  490 KB   index.html 5.4 KB\n</code></pre>\n<p>Two changes. The payload became one static file that every route fetches and\nthe browser caches once. And the plain-text field came out of it entirely —\nit was 30% of the bytes and it is derivable, since stripping the escapes off\nthe rendered lines gives the same words. It gives them <em>better</em>, actually:\n<code>grep</code> now matches exactly what is on the screen.</p>\n<p>The page is now smaller than most sites' cookie banners.</p>\n<h2>The feeds are the point</h2>\n<p>There are four, because different people read differently:</p>\n<pre><code class=\"language-text\">/feed.xml     RSS 2.0\n/feed.atom    Atom\n/feed.json    JSON Feed\n/sfeed.tsv    sfeed's TSV, for reading from a terminal\n</code></pre>\n<p>All four carry every entry in full. No account, no email, no \"subscribe to\ncontinue\". The Markdown source of any entry is at <code>/posts/&#x3C;slug>/index.md</code> if\nyou would rather have the file than the page.</p>\n<h2>And it works with the terminal off</h2>\n<p>Every route renders as ordinary HTML from the same Markdown. With JavaScript\ndisabled you get the text, the links, the credits under the art — a plain\ndocument. The terminal hides that fallback once it starts, and if it never\nstarts, the fallback is the site.</p>\n<p>The terminal is the interface. It is not an excuse to break the web.</p>",
      "content_text": "Nothing Is Recorded\nThere is nothing on the other end of this site. No database, no session store,\nno analytics pipeline, no consent platform, no log of who read what. Not\nbecause those were disabled — because they were never built, and there is\nnowhere for them to run.\nThat is easy to claim and cheap to check, so here is the actual accounting.\nWhat the browser stores\nSix keys, all prefixed rd. :\nrd.lastVisit the date you were last here\nrd.handle a name, if you ever set one\nrd.theme dos | amber | green | mono\nrd.bootSeen whether to play the connect sequence\nrd.history the commands you typed\nrd.readerPosition the line you left each entry on All of it is in your browser and none of it is sent anywhere. A test fails if\na seventh key appears, and another asserts the cookie jar is empty.\nWhat the network does\nOne HTML page, one stylesheet, one script, one font, one JSON file. After\nthat, a test watches every request the page makes while a session runs\ncommands and searches — and asserts the list is empty. The terminal genuinely\ndoes not talk to anything once it has loaded.\nThe JSON file is the interesting one, because it used to be much worse.\nThirty-seven copies of the same thing\nThe shell needs the whole collection in hand whatever route you arrived on:\nls is not a per-page question, and neither is search . So the payload was\nembedded in the page — which meant every one of the site's 37 routes carried\nits own copy of the same 67KB.\nbefore dist 3166 KB index.html 82 KB\nafter dist 490 KB index.html 5.4 KB Two changes. The payload became one static file that every route fetches and\nthe browser caches once. And the plain-text field came out of it entirely —\nit was 30% of the bytes and it is derivable, since stripping the escapes off\nthe rendered lines gives the same words. It gives them better , actually:\ngrep now matches exactly what is on the screen.\nThe page is now smaller than most sites' cookie banners.\nThe feeds are the point\nThere are four, because different people read differently:\n/feed.xml RSS 2.0\n/feed.atom Atom\n/feed.json JSON Feed\n/sfeed.tsv sfeed's TSV, for reading from a terminal All four carry every entry in full. No account, no email, no \"subscribe to\ncontinue\". The Markdown source of any entry is at /posts/<slug>/index.md if\nyou would rather have the file than the page.\nAnd it works with the terminal off\nEvery route renders as ordinary HTML from the same Markdown. With JavaScript\ndisabled you get the text, the links, the credits under the art — a plain\ndocument. The terminal hides that fallback once it starts, and if it never\nstarts, the fallback is the site.\nThe terminal is the interface. It is not an excuse to break the web.",
      "date_published": "2026-08-11T12:00:00Z",
      "tags": [
        "runawaydevil",
        "web",
        "privacy"
      ]
    },
    {
      "id": "https://runawaydevil.com/posts/carta-de-um-node-solitario",
      "url": "https://runawaydevil.com/posts/carta-de-um-node-solitario",
      "title": "Carta de um node solitário",
      "summary": "Sobre modems, madrugadas e a ideia de um lugar que só existe enquanto alguém está conectado.",
      "content_html": "<h1>Carta de um node solitário</h1>\n<p>Havia uma hora exata em que a linha ficava livre. Depois das onze, quando\nninguém mais ia usar o telefone, o modem podia gritar à vontade. Aquele ruído\n— o carrier, a negociação, o silêncio súbito quando os dois lados finalmente\nconcordavam — era a coisa mais próxima de uma porta se abrindo que a\ninformática já produziu.</p>\n<h2>Um lugar com capacidade um</h2>\n<p>O que mais me impressiona hoje não é a lentidão. É a <strong>exclusividade</strong>.</p>\n<p>Um BBS de um node só aceitava uma pessoa por vez. Enquanto você estava lá,\nninguém mais no mundo estava. Não havia timeline, não havia outras pessoas\nrolando a mesma tela em paralelo, não havia contagem de quem estava online.\nHavia você, o sysop dormindo, e um computador na sala dele.</p>\n<blockquote>\n<p>Um lugar que só existe enquanto alguém está conectado é uma ideia\nestranhamente honesta sobre o que é estar em qualquer lugar.</p>\n</blockquote>\n<h2>O que se fazia lá</h2>\n<ol>\n<li>Ler as mensagens novas desde a última visita.</li>\n<li>Responder duas ou três.</li>\n<li>Baixar um arquivo de 300 KB em quinze minutos.</li>\n<li>Olhar a arte ANSI da tela de entrada por tempo demais.</li>\n<li>Deslogar antes que a conta do telefone virasse assunto de família.</li>\n</ol>\n<p>Cinco coisas. Não havia uma sexta. A ausência de uma sexta coisa é\nexatamente o que fazia as outras cinco valerem a pena.</p>\n<h2>Coração, informação, acentuação</h2>\n<p>Uma nota técnica, já que este site insiste em ser um terminal: o português\nsempre foi um pequeno problema para telas de texto. Os acentos viviam na parte\nalta da tabela — <code>ç</code>, <code>ã</code>, <code>õ</code>, <code>é</code> —, e cada BBS decidia por conta própria se\nfalava CP437, CP850 ou nada disso. Você digitava <em>coração</em> e o outro lado lia\n<em>cora‡Æo</em>.</p>\n<p>Aqui isso está resolvido: por dentro é tudo UTF-8, e o CP437 fica onde deve\nficar, que é no desenho.</p>\n<h2>O que sobrou</h2>\n<p>Sobrou o formato. Uma tela por vez, um assunto por tela, e a certeza de que\nquando você desligar aquilo tudo simplesmente deixa de existir até a próxima\nmadrugada.</p>\n<p>Não é nostalgia. É que a coisa funcionava.</p>",
      "content_text": "Carta de um node solitário\nHavia uma hora exata em que a linha ficava livre. Depois das onze, quando\nninguém mais ia usar o telefone, o modem podia gritar à vontade. Aquele ruído\n— o carrier, a negociação, o silêncio súbito quando os dois lados finalmente\nconcordavam — era a coisa mais próxima de uma porta se abrindo que a\ninformática já produziu.\nUm lugar com capacidade um\nO que mais me impressiona hoje não é a lentidão. É a exclusividade .\nUm BBS de um node só aceitava uma pessoa por vez. Enquanto você estava lá,\nninguém mais no mundo estava. Não havia timeline, não havia outras pessoas\nrolando a mesma tela em paralelo, não havia contagem de quem estava online.\nHavia você, o sysop dormindo, e um computador na sala dele.\nUm lugar que só existe enquanto alguém está conectado é uma ideia\nestranhamente honesta sobre o que é estar em qualquer lugar.\n\nO que se fazia lá\nLer as mensagens novas desde a última visita.\n\nResponder duas ou três.\n\nBaixar um arquivo de 300 KB em quinze minutos.\n\nOlhar a arte ANSI da tela de entrada por tempo demais.\n\nDeslogar antes que a conta do telefone virasse assunto de família.\n\nCinco coisas. Não havia uma sexta. A ausência de uma sexta coisa é\nexatamente o que fazia as outras cinco valerem a pena.\nCoração, informação, acentuação\nUma nota técnica, já que este site insiste em ser um terminal: o português\nsempre foi um pequeno problema para telas de texto. Os acentos viviam na parte\nalta da tabela — ç , ã , õ , é —, e cada BBS decidia por conta própria se\nfalava CP437, CP850 ou nada disso. Você digitava coração e o outro lado lia\ncora‡Æo .\nAqui isso está resolvido: por dentro é tudo UTF-8, e o CP437 fica onde deve\nficar, que é no desenho.\nO que sobrou\nSobrou o formato. Uma tela por vez, um assunto por tela, e a certeza de que\nquando você desligar aquilo tudo simplesmente deixa de existir até a próxima\nmadrugada.\nNão é nostalgia. É que a coisa funcionava.",
      "date_published": "2026-07-29T12:00:00Z",
      "tags": [
        "bbs",
        "memoria",
        "portugues"
      ]
    },
    {
      "id": "https://runawaydevil.com/posts/sixteen-colours-and-a-registry",
      "url": "https://runawaydevil.com/posts/sixteen-colours-and-a-registry",
      "title": "Sixteen Colours and a Registry",
      "summary": "How the art in this system is made, measured, credited, and refused when the credit is missing.",
      "content_html": "<h1>Sixteen Colours and a Registry</h1>\n<p>The IBM PC text mode gave an artist sixteen foreground colours, eight\nbackgrounds, and 256 shapes. No pixels, no gradients, no partial transparency.\nA cell was one character in one colour on one background, and that was the\nwhole medium. People made astonishing things with it for twenty years.</p>\n<p>Everything drawn for this system works under the same rules. Here is what that\nmeans in practice, and what the build does about it.</p>\n<h2>The vocabulary is smaller than the palette</h2>\n<pre><code class=\"language-text\">█ full block     ▓ dark shade\n▒ medium shade   ░ light shade\n</code></pre>\n<p>Those four are not textures — they are <em>mixing</em>. Light shade of white on black\nis a grey that does not exist in the sixteen. Medium shade of bright blue on\nblue is a blue that is not in the palette either. The entire tonal range of the\nmedium comes out of four characters.</p>\n<p>The site's own pieces are held to the CP437 character set, and the validator\nsays so out loud when they wander:</p>\n<pre><code class=\"language-text\">WARN  ornaments   27x4   outside CP437: ▪ ▫ †\n</code></pre>\n<p>Which is how <code>▪▫▪</code> became <code>■·■</code> and a dagger became <code>♦</code>. Worth knowing: CP437\nhas no tilde vowels. It has <code>ç</code>, it has <code>á</code>, it has no <code>ã</code>. That is exactly why\na Brazilian on a 1994 board read <em>cora‡Æo</em> instead of <em>coração</em> — and why the\nwebfont here is the \"Plus\" variant rather than the pure 437 one.</p>\n<h2>The art is data, and that is deliberate</h2>\n<p>Original pieces live as typed data rather than as <code>.ANS</code> files on disk, so the\ncompiler and the tests can measure them with the same code that measures every\nother line in the system. A piece 92 cells wide fails the build instead of\narriving crooked.</p>\n<p>The format is not lost. <code>npm run art:export</code> writes each piece out as a real\n<code>.ANS</code>: CP437 bytes, CRLF, a SAUCE record built from the registry, a\nterminating SUB. They open in PabloDraw and pass through AnsiLove.</p>\n<p>Getting that right took two bug fixes worth mentioning, because both destroy\nart silently:</p>\n<ul>\n<li><code>encodeCp437(' ')</code> returned <code>0x00</code>, not <code>0x20</code>. Byte zero also decodes to a\nblank, and it won the reverse lookup.</li>\n<li>Escape, carriage return and line feed were being encoded as <code>←</code>, <code>♪</code> and <code>◙</code>.\nIn an art <em>buffer</em> the low bytes are glyphs; in a <em>stream</em> they are controls.\nConfusing the two turns every colour change in a file into a arrow character.</li>\n</ul>\n<h2>Nobody's work goes up without their name</h2>\n<p>Every piece has an entry in <code>src/art/art-registry.yml</code>: author, handle, group,\nyear, licence, original filename, notes. A piece with no entry does not build.\nWhen the terms are unclear the entry says <code>reference_only: true</code>, and the\nvalidator keeps it out of the build, out of the gallery and out of <code>dist/</code>.</p>\n<p>SAUCE records — the 128 bytes the scene stapled to the end of a file with the\nartist's handle, group and date — are read on import and shown by\n<code>art info &#x3C;slug></code>. They are the reason we still know who drew what, thirty\nyears later.</p>\n<p>There is one rule with no exception: an artist's signature is never removed.\nNot to fit a layout, not to fit the grid. If the signature does not fit in 80\ncolumns, the piece does not go in.</p>\n<h2>Why it still reads well</h2>\n<p>A constraint that severe forces an artist to think in shape and value rather\nthan detail. Same discipline as woodcut, as stencil, as any printing process\nwith a limited number of passes. Work made under those rules tends to survive\nchanges in display technology, because it never depended on the display being\ngood.</p>\n<p>An ANSI drawn in 1993 on a CRT still looks correct on a 5K panel in 2026. Very\nlittle else from 1993 can say that.</p>",
      "content_text": "Sixteen Colours and a Registry\nThe IBM PC text mode gave an artist sixteen foreground colours, eight\nbackgrounds, and 256 shapes. No pixels, no gradients, no partial transparency.\nA cell was one character in one colour on one background, and that was the\nwhole medium. People made astonishing things with it for twenty years.\nEverything drawn for this system works under the same rules. Here is what that\nmeans in practice, and what the build does about it.\nThe vocabulary is smaller than the palette\n█ full block ▓ dark shade\n▒ medium shade ░ light shade Those four are not textures — they are mixing . Light shade of white on black\nis a grey that does not exist in the sixteen. Medium shade of bright blue on\nblue is a blue that is not in the palette either. The entire tonal range of the\nmedium comes out of four characters.\nThe site's own pieces are held to the CP437 character set, and the validator\nsays so out loud when they wander:\nWARN ornaments 27x4 outside CP437: ▪ ▫ † Which is how ▪▫▪ became ■·■ and a dagger became ♦ . Worth knowing: CP437\nhas no tilde vowels. It has ç , it has á , it has no ã . That is exactly why\na Brazilian on a 1994 board read cora‡Æo instead of coração — and why the\nwebfont here is the \"Plus\" variant rather than the pure 437 one.\nThe art is data, and that is deliberate\nOriginal pieces live as typed data rather than as .ANS files on disk, so the\ncompiler and the tests can measure them with the same code that measures every\nother line in the system. A piece 92 cells wide fails the build instead of\narriving crooked.\nThe format is not lost. npm run art:export writes each piece out as a real\n.ANS : CP437 bytes, CRLF, a SAUCE record built from the registry, a\nterminating SUB. They open in PabloDraw and pass through AnsiLove.\nGetting that right took two bug fixes worth mentioning, because both destroy\nart silently:\nencodeCp437(' ') returned 0x00 , not 0x20 . Byte zero also decodes to a\nblank, and it won the reverse lookup.\n\nEscape, carriage return and line feed were being encoded as ← , ♪ and ◙ .\nIn an art buffer the low bytes are glyphs; in a stream they are controls.\nConfusing the two turns every colour change in a file into a arrow character.\n\nNobody's work goes up without their name\nEvery piece has an entry in src/art/art-registry.yml : author, handle, group,\nyear, licence, original filename, notes. A piece with no entry does not build.\nWhen the terms are unclear the entry says reference_only: true , and the\nvalidator keeps it out of the build, out of the gallery and out of dist/ .\nSAUCE records — the 128 bytes the scene stapled to the end of a file with the\nartist's handle, group and date — are read on import and shown by\nart info <slug> . They are the reason we still know who drew what, thirty\nyears later.\nThere is one rule with no exception: an artist's signature is never removed.\nNot to fit a layout, not to fit the grid. If the signature does not fit in 80\ncolumns, the piece does not go in.\nWhy it still reads well\nA constraint that severe forces an artist to think in shape and value rather\nthan detail. Same discipline as woodcut, as stencil, as any printing process\nwith a limited number of passes. Work made under those rules tends to survive\nchanges in display technology, because it never depended on the display being\ngood.\nAn ANSI drawn in 1993 on a CRT still looks correct on a 5K panel in 2026. Very\nlittle else from 1993 can say that.",
      "date_published": "2026-07-14T12:00:00Z",
      "tags": [
        "runawaydevil",
        "ansi",
        "art"
      ]
    }
  ]
}
