From 10e76cfda44cf3c24cce75b1446c844bb35a71e3 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Mon, 29 Jun 2026 22:41:01 -0500 Subject: [PATCH 01/12] docs: remove oxford comma Signed-off-by: Akshay Mestry --- docs/source/academia/thoughts-on-teaching.rst | 24 +- docs/source/explained/docker/index.rst | 39 +- .../explained/docker/month-into-docker.rst | 645 ------------------ docs/source/explained/eli5/index.rst | 4 +- docs/source/index.rst | 14 +- docs/source/miscellany/faq.rst | 10 +- docs/source/miscellany/guiding-stars.rst | 26 +- docs/source/miscellany/youtube-videos.rst | 24 +- docs/source/projects/slowtorch.rst | 42 +- docs/source/projects/xsnumpy.rst | 46 +- kaamiki/__init__.py | 6 +- kaamiki/extensions/author.py | 12 +- kaamiki/extensions/button.py | 6 +- kaamiki/extensions/repository.py | 6 +- kaamiki/extensions/roles.py | 18 +- kaamiki/extensions/thumbnail.py | 4 +- kaamiki/extensions/utils.py | 20 +- kaamiki/extensions/video.py | 6 +- kaamiki/extensions/youtube.py | 4 +- 19 files changed, 154 insertions(+), 802 deletions(-) delete mode 100644 docs/source/explained/docker/month-into-docker.rst diff --git a/docs/source/academia/thoughts-on-teaching.rst b/docs/source/academia/thoughts-on-teaching.rst index fd386ae..c10d7bf 100644 --- a/docs/source/academia/thoughts-on-teaching.rst +++ b/docs/source/academia/thoughts-on-teaching.rst @@ -1,6 +1,6 @@ .. Author: Akshay Mestry .. Created on: 07 April, 2026 -.. Last updated on: 27 June, 2026 +.. Last updated on: 28 June, 2026 :og:title: Thoughts on teaching :og:description: Teaching the same courses, but differently every single time! @@ -59,8 +59,8 @@ The content (more of less) stayed the same. I mean, how often do you expect a programming language curriculum to change? But the classes were yet different. Every quarter, the pacing was different, the questions were different, the amount of time spent on some topics was different, the assignments were -different, and the examples used and shared were different. Some days, the -lectures were conducted exactly as planned, and sometimes they were simply +different and the examples used and shared were different. Some days, the +lectures were conducted exactly as planned and sometimes they were simply "strange"?! And somewhere in that strange unpredictability, I found the part I enjoyed the @@ -76,7 +76,7 @@ Students who made it memorable. ------------------------------------------------------------------------------- .. rubric:: They brought their **humour**, their **hobbies**, their **side - comments**, and some of their perfectly timed jokes that derailed the whole + comments** and some of their perfectly timed jokes that derailed the whole class in the best way possible. :class: subtitle-text @@ -86,7 +86,7 @@ Questions ranged from unrealistic assignment extensions or the topics covered to sometimes unexpectedly personal. Like I said, every quarter was different, yet every single time, someone would ask what I would do in their place, as if I had everything figured out. I never knew how to respond to some of those -"what-if" scenarios, and I still don't! +"what-if" scenarios and I still don't! The questions weren't super hypothetical or anything. They were simple and grounded, which I sometimes felt like I should've also asked myself when I was @@ -100,12 +100,12 @@ Learning beyond the syllabus. ------------------------------------------------------------------------------- I started entering the classrooms with an intention of teaching something, be -it today's topic or sharing my past anecdotes and experiences, and I was +it today's topic or sharing my past anecdotes and experiences and I was leaving with answers to some of my personal questions and side-quests. It really felt like I was teaching and preparing myself through the means of my -students, and that's what made them all very special! I started seeing a -younger, more cocky version of me, and my friends in them. Over time, I started +students and that's what made them all very special! I started seeing a +younger, more cocky version of me and my friends in them. Over time, I started picking up new things and new perspectives. Things like new games I've never heard of, music I wouldn't have found on my own, hobbies like baking that reminded me of how much there is to learn outside my comfort zone. @@ -114,7 +114,7 @@ The learning and teaching happened in both directions. Every time I learned something new, I felt like I was able to teach and explain things more effectively from both my successes and failures. -.. rubric:: :fab:`seedling far` Growth, gratitude, and moments +.. rubric:: :fab:`seedling far` Growth, gratitude and moments :class: pre-title-text .. _a-year-shaped-by-people: @@ -127,13 +127,13 @@ Again, looking back, I'm seeing my lectures and classrooms in a much different way than I expected last year at this very moment. I'm seeing my whole year being shaped by so many people like my students, other faculty, staff members, and some friends I made along the way. It's shaped by those small moments, -shared laughter, curiosities, growth, stories, gossips, and loads of new +shared laughter, curiosities, growth, stories, gossips and loads of new memories! I'm super-duper grateful to all my students for making me a much better teacher than I started last year. I'm especially grateful to :ref:`Charlotte `, who first saw something in me I hadn't -fully seen in myself, and planted the very idea that I could even teach at all. +fully seen in myself and planted the very idea that I could even teach at all. This year-rounding reflection wouldn't be complete without mentioning my actual mentors, :ref:`Dr Tovar ` and Dr Robyn. Both, Dr Tovar and @@ -148,7 +148,7 @@ kind and grateful to all the people who made you who you are. .. epigraph:: - Always remember these words: Work hard, study well, and eat and sleep + Always remember these words: Work hard, study well and eat and sleep plenty! That is the Turtle Hermit way! -- Master Roshi, *Dragon Ball Super, 2017* diff --git a/docs/source/explained/docker/index.rst b/docs/source/explained/docker/index.rst index 92828ab..69eb724 100644 --- a/docs/source/explained/docker/index.rst +++ b/docs/source/explained/docker/index.rst @@ -1,6 +1,6 @@ .. Author: Akshay Mestry .. Created on: 30 August, 2025 -.. Last updated on: 28 June, 2026 +.. Last updated on: 29 June, 2026 :og:title: A week into Docker :og:description: Begineer's guide to Docker and containerisation @@ -35,14 +35,14 @@ This is my story from September 2020, roughly five years ago, as of writing this article. It was in the middle of the pandemic. I was switching jobs. I knew I would get a new machine from my next employer, but I had no idea when. -The pandemic was still at its peak, and I had to make the most of what I had. +The pandemic was still at its peak and I had to make the most of what I had. With how things were going, I knew I wouldn't get a new machine anytime soon, so I had to improvise and make my work machine last me a while longer. -As some of you know, I love writing code. It's what I do for the most part, and +As some of you know, I love writing code. It's what I do for the most part and during lockdown, I wrote a lot. I tried to keep myself busy with work and other -side projects, and while doing that, I got my machine messy really quick. I had -multiple versions of Python, Rust, various dependencies, configs, tools, and +side projects and while doing that, I got my machine messy really quick. I had +multiple versions of Python, Rust, various dependencies, configs, tools and frameworks that I won't be using after my "experimentation" phase. I knew `Docker`_ was a thing, but I never really got around to using it. I @@ -58,7 +58,7 @@ So, I decided to give it a try. In this first chapter, I want to walk you through my initial experience with this whole containerisation business and explain the basic ideas that finally made sense to me. In future articles, I'll dive into the practical side of -things, like how to write a Dockerfile, manage containers, and use it in your +things, like how to write a Dockerfile, manage containers and use it in your own projects. .. _docker-enters-the-chat: @@ -68,17 +68,17 @@ Docker enters the chat. ------------------------------------------------------------------------------- Learning and exploring Docker wasn't initially on my bingo list, but I'm glad I -did. Since then, it has made my "dodgy" projects super-duper manageable, and my +did. Since then, it has made my "dodgy" projects super-duper manageable and my local machine still thanks me for it. I don't want to get too technical or nerdy right now, but I want to make are we're on the same page. -Docker is a **platform** that allows you to develop, ship, and run code inside +Docker is a **platform** that allows you to develop, ship and run code inside :ref:`containers `. .. note:: Docker is not the only containerisation platform, but it's perhaps the most - popular one, and that's why people synonymously use the term Docker to + popular one and that's why people synonymously use the term Docker to refer to containers in general. But in reality, they are not the same thing. @@ -99,7 +99,7 @@ server in the cloud. :figclass: zoom grayscale In the spirit of keeping things simple, I'd say there are two main ideas around -Docker: `Docker Engine`_ and `Docker Hub`_, and let's just stick with these two +Docker: `Docker Engine`_ and `Docker Hub`_ and let's just stick with these two for now. 1. **Docker Engine.** This is the core part of Docker that runs on your @@ -120,7 +120,7 @@ for now. When I first started using Docker, I'll be honest, I really didn't understand what was going on. Almost every tutorial I watched started with using the -:console:`$ docker run` command. I knew that I could type it out, and +:console:`$ docker run` command. I knew that I could type it out and auto-magically, I'm dropped in an isolated environment where I can run my code or do whatever I want without messing up my local machine. @@ -136,7 +136,7 @@ or do whatever I want without messing up my local machine. Status: Downloaded newer image for python:3.8 Hello from inside a container! -But as I started using it more, I realised that there's a lot more to it, and +But as I started using it more, I realised that there's a lot more to it and Docker Engine is doing some really fancy stuff behind the scenes. Remember how I mentioned my machine was getting messy with all the different versions of Python and other dependencies? @@ -213,15 +213,15 @@ top of your existing OS using something called a `Hypervisor`_. .. note:: This is **not** dual-booting, where you have two OSs installed on your - machine, and you choose which one to boot into. + machine and you choose which one to boot into. -In a VM, you have your actual OS (host) running, and inside it, you have +In a VM, you have your actual OS (host) running and inside it, you have another OS (guest) running as a separate entity. VMs run like a regular application on your local machine. It's like running Windows on your Mac using `Parallels`_ or running Linux on your Windows using `VirtualBox`_. Since a VM runs just like a regular application, it needs its resources like -CPU, memory, storage, and processing power. It's thorough, but it's also heavy. +CPU, memory, storage and processing power. It's thorough, but it's also heavy. Very heavy... I mean, you're running multiple bloody OSs at the same time! @@ -314,7 +314,7 @@ no idea from where. But what's an image now? With a bit of research, I found out that a Docker image is a lightweight executable package that includes it needs to run the -code, including the runtime, libraries, environment variables, and +code, including the runtime, libraries, environment variables and configuration files. Wait, that sounds a lot like a container to me... But not quite. @@ -335,7 +335,7 @@ images from Docker Hub to run containers on your local machine, or you can push your own images to Docker Hub to share them with others. Docker Hub has loads of pre-built images for various applications and services, -like databases, web servers, programming languages, and more. This makes it +like databases, web servers, programming languages and more. This makes it easy to get started with Docker and quickly set up your development environment. @@ -376,8 +376,3 @@ and how it changed the way I started working on my local machine. .. _VirtualBox: https://www.virtualbox.org/ .. _LXC: https://linuxcontainers.org/ .. _OpenVZ: https://openvz.org/ - -.. toctree:: - :hidden: - - month-into-docker diff --git a/docs/source/explained/docker/month-into-docker.rst b/docs/source/explained/docker/month-into-docker.rst deleted file mode 100644 index 8f8c483..0000000 --- a/docs/source/explained/docker/month-into-docker.rst +++ /dev/null @@ -1,645 +0,0 @@ -.. Author: Akshay Mestry -.. Created on: 08 March, 2026 -.. Last updated on: 11 June, 2026 - -:og:title: A month into Docker -:og:description: An exhaustive guide to moving from "making it work" to - understanding the plumbing of containerisation -:og:type: article -:fb:title: This was me, but I'm curious about your story -:fb:description: Have you ever accidentally filled your entire hard drive with - Docker debris? How did you fix it? -:fb:button: Share your story - -.. _explained-a-month-into-docker: - -=============================================================================== -:fas:`box-open far` A month into Docker -=============================================================================== - -.. rst-class:: lead - - The month where I moved from "I can make this work" to "I actually - understand why this works". - -.. author:: @xames3 - :avatar: https://avatars.githubusercontent.com/u/90549089?v=4 - :target: https://github.com/xames3 - -.. role:: console(code) - :language: console - -If you're here, I'm assuming you've already read the previous -:doc:`chapter `. We're still in the middle of a pandemic, but it's -October 2020 now. I had spent the last four weeks treating `Docker`_ like any -other :abbr:`CLI (Command Line Interface)` utility. I was shamelessly -copy-pasting code from `StackOverflow`_, running commands without -understanding anything, fixing them as they failed and always hoping they'd -work the next time. - -During that whole phase of ignorance, I thought Docker was some kind of magical -black box that somehow kept my machine clean, or so I believed. - -In reality, I had simply traded my messy host system for a bloated but still -invisible pile of `containers`_ and `images`_. I was still writing my "hacky" -python scripts to automate various tasks, as that's what I do for the most -part. However, I was starting to notice that my laptop was getting sluggish, -not because of my scripts, but something else entirely. I knew I had to stop -just "typing the commands" and start understanding what the Docker CLI was -actually doing behind the scenes. - -.. rubric:: :fab:`skull far` Enter the deadzone - :class: pre-title-text - -.. _invisible-pile-of-dead-containers: - --------------------------------------------------------------------------------- -Invisible pile of dead containers. --------------------------------------------------------------------------------- - -I started to take things seriously when I tried to save a file and got a "Disk -Full" warning. I was super-duper confused. I mean, I thought I was using Docker -specifically to avoid this problem by not installing multiple versions of -Python and Rust on my machine. - -So, what was this warning all about? I had no idea, but I knew I had to fix it -somehow. - -With some googling, I learned about the :console:`$ docker ps` command and, -more importantly, running this command with the optional ``-a`` or ``--all`` -flag. Running :console:`$ docker ps -a` was an absolute game-changer for me. I -saw the output of the terminal, and my heart dropped. By this point, I -literally had hundreds of dead and unused containers just sitting there, taking -up precious disk space. - -This is when I realised that every time I ran a command like -:console:`$ docker run python3:8 python script.py`, I was creating a new, -isolated environment. What I didn't realise was that once my script finished -running, the container didn't just vanish. It stayed there, taking up space in -an **"exited"** state. - -I felt absolutely stupid. I was in awe, but still stupid, nonetheless. - -As I mentioned :ref:`here `, a container is a -lightweight, standalone package, and it's similar to an instance of a class, if -we're speaking in programming terms. However, its lifecycle doesn't end just -because the process inside it finishes. - -You've to actively manage its death just as much as its birth. - -.. rubric:: :fas:`trash-can far` Out with the dead - :class: pre-title-text - -.. _killing-the-dead: - --------------------------------------------------------------------------------- -Killing the dead. --------------------------------------------------------------------------------- - -.. rubric:: Or, learning that containers don't disappear just because you want - them to. - :class: subtitle-text - -Now that I knew the problem, I had to actually fix it. I Googled "how to remove -Docker containers" and found :console:`$ docker rm`. - -.. code-block:: console - - $ docker rm - -Simple enough. I tried it on a container ID I'd copied from the -:console:`$ docker ps -a` output. It worked. One down. About three hundred to -go. - -My first instinct was to write a quick script to loop through all the IDs. But -before I'd even opened my editor, I found something much better: -:console:`$ docker container prune`. - -.. code-block:: console - - $ docker container prune - WARNING! This will remove all stopped containers. - Are you sure you want to continue? [y/N] y - ... - Total reclaimed space: 2.3GB - -2.3 gigabytes. I had accidentally hoarded 2.3 gigabytes worth of dead -containers. I felt a strange mix of embarrassment and relief at the same time. -My machine breathed again. - -But then a thought hit me — could I have avoided this entirely? As it turns -out, yes. There's a flag called ``--rm`` that makes Docker automatically remove -a container the moment it stops running. - -.. code-block:: console - - $ docker run --rm python:3.8 python -c "print('hello')" - -The container runs, prints, and vanishes. No mess, no cleanup needed. I -remember staring at this flag in the documentation and thinking... -*where were you four weeks ago?!* - -.. admonition:: :fas:`badge-check green` One rule to carry forward - :class: unusual-one note - - Always use ``--rm`` when running throwaway containers. It's the simplest - habit to build and it will save you from exactly my situation. - -.. rubric:: :fas:`terminal far` Foreground vs. background - :class: pre-title-text - -.. _stuck-on-the-terminal: - --------------------------------------------------------------------------------- -Stuck on the terminal. --------------------------------------------------------------------------------- - -.. rubric:: The first time I ran a server inside Docker and lost my entire - terminal. - :class: subtitle-text - -With the machine cleaned up, I started experimenting more ambitiously. I wanted -to run an actual server inside Docker — a simple Flask app I had been tinkering -with. So I ran something like: - -.. code-block:: console - - $ docker run python:3.8 python server.py - -And my terminal just... froze. The server was running, but I couldn't type -anything. It was completely taken hostage. - -I know now this is completely normal and expected behaviour. When you run -:console:`$ docker run` without any extra flags, Docker attaches your terminal -directly to the container's process. If that process is a server that runs -forever, so does your terminal session. - -The fix is the ``-d`` flag — detached mode. - -.. code-block:: console - - $ docker run -d python:3.8 python server.py - -Run with ``-d``, and Docker starts the container in the background and -immediately hands your terminal back, along with a container ID. Your terminal -is free. The server is running. It's brilliant. - -But then came the obvious next problem: how do I know if the server is actually -doing anything? I couldn't see any output. That's where -:console:`$ docker logs` comes in. - -.. code-block:: console - - $ docker logs - -It dumps whatever the container has printed to stdout. If something went wrong, -the error messages will be there too. Think of it as checking the receipts on a -transaction you never saw happen. - -.. rubric:: :fas:`plug far` Opening the door - :class: pre-title-text - -.. _the-server-no-one-could-reach: - --------------------------------------------------------------------------------- -The server no one could reach. --------------------------------------------------------------------------------- - -.. rubric:: Running a web server inside Docker and not being able to open it - in a browser. - :class: subtitle-text - -Alright. Server running in the background. I opened my browser and typed -``localhost:5000``. Nothing. - -I checked whether the container was running — :console:`$ docker ps`, yes, it -was. I checked the logs — no errors. The server was healthy. So why couldn't I -reach it? - -This one stumped me for longer than I'd like to admit. - -Here's the thing: containers are isolated. That's the entire point. By default, -the port your application is listening on *inside* the container has no -connection to any port on your actual machine. They're separate worlds. The -container's port 5000 is just floating in there, unreachable, unless you -explicitly create a bridge between the two. - -That bridge is called port mapping, and the flag is ``-p``. - -.. code-block:: console - - $ docker run -d -p 5000:5000 python:3.8 python server.py - -The format is ``-p :``. So ``-p 5000:5000`` means: -take the container's port 5000 and make it accessible at port 5000 on my -machine. - -Now ``localhost:5000`` worked. I felt like I had just connected two entirely -separate train tracks together. - -You can also map to a different host port if you need to. Say I had something -already running on my machine's port 5000: - -.. code-block:: console - - $ docker run -d -p 8080:5000 python:3.8 python server.py - -The container's internal port 5000 is now accessible at ``localhost:8080``. The -container genuinely doesn't care what number you use on the outside — it just -listens on 5000 regardless. - -.. rubric:: :fas:`tag far` Know your containers - :class: pre-title-text - -.. _a-container-by-any-other-name: - --------------------------------------------------------------------------------- -A container by any other name. --------------------------------------------------------------------------------- - -.. rubric:: Why Docker's random name generator is charming but not very useful. - :class: subtitle-text - -By this point, I was running multiple containers, stopping them, restarting -them, and I kept having to copy-paste long container IDs from -:console:`$ docker ps` output. Strings like ``a3f8d91c204b``. Every. Single. -Time. - -Then I noticed that Docker also gives containers human-readable names. Things -like ``tender_wozniak``, ``sleepy_darwin``, ``naughty_einstein``. Apparently -Docker generates these by combining a random adjective with a famous scientist's -name. They're genuinely charming at first, but deeply impractical when you have -five containers running and you're trying to figure out which one is which. - -The ``--name`` flag solves this immediately. - -.. code-block:: console - - $ docker run -d --name my-flask-app -p 5000:5000 python:3.8 python server.py - -Now instead of: - -.. code-block:: console - - $ docker stop a3f8d91c204b - -I can do: - -.. code-block:: console - - $ docker stop my-flask-app - -It's such a small thing. But it's the difference between feeling in control and -feeling like you're herding cats. Name your containers. Always. - -.. rubric:: :fas:`hand far` Graceful vs. forceful - :class: pre-title-text - -.. _stopping-vs-killing: - --------------------------------------------------------------------------------- -Stopping vs. killing. --------------------------------------------------------------------------------- - -.. rubric:: There's a "please stop" and then there's a "stop. now." - :class: subtitle-text - -Since we're talking about :console:`$ docker stop` — I want to clear up -something that confused me for a while. There are two ways to stop a running -container: :console:`$ docker stop` and :console:`$ docker kill`. - -:console:`$ docker stop` sends a ``SIGTERM`` signal to the container's main -process, giving it a chance to shut down gracefully. It waits 10 seconds by -default, and if the container still hasn't stopped by then, it sends ``SIGKILL`` -to force it. - -:console:`$ docker kill` skips the grace period entirely and sends ``SIGKILL`` -straight away. The process inside has no chance to clean up. - -For most situations, :console:`$ docker stop` is the right call. It's the -polite option. :console:`$ docker kill` is for when a container is completely -unresponsive or you're in a hurry and genuinely don't care about graceful -shutdown. - -Think of it this way: :console:`$ docker stop` is asking someone to leave. -:console:`$ docker kill` is escorting them out. - -.. rubric:: :fas:`key far` Configuring containers - :class: pre-title-text - -.. _passing-secrets-into-the-box: - --------------------------------------------------------------------------------- -Passing secrets into the box. --------------------------------------------------------------------------------- - -.. rubric:: Environment variables, and the moment I tried to debug a Rust - binary. - :class: subtitle-text - -One of the reasons I started using Docker in the first place was to avoid -installing `Rust`_ directly on my machine. So when I came across a Rust-based -:abbr:`CLI (Command Line Interface)` tool on `Docker Hub`_ that I wanted to -test, running it in a container felt like the natural thing to do. - -I pulled the image, ran it, and it immediately panicked. The terminal output -was spectacularly unhelpful: - -.. code-block:: console - - thread 'main' panicked at 'something went wrong', src/main.rs:42:9 - note: run with `RUST_BACKTRACE=1` environment variable to display a - backtrace - -Right, ``RUST_BACKTRACE=1``. I'd done this a hundred times on my local machine -by just exporting it in my shell. But now the program was running *inside* a -container. My exported shell variables didn't follow it in there. - -The answer is the ``-e`` flag. - -.. code-block:: console - - $ docker run --rm -e RUST_BACKTRACE=1 some-rust-tool - -The ``-e`` flag sets an environment variable inside the container at runtime. -You can pass as many ``-e`` flags as you need, and they're available to the -process just like any other environment variable. The program has no idea -whether they were set on your local machine or passed in through Docker. - -.. code-block:: console - - $ docker run --rm \ - -e RUST_BACKTRACE=1 \ - -e RUST_LOG=debug \ - some-rust-tool - -The full stack trace finally appeared, and I was able to understand the -problem. But more importantly, I realised that ``-e`` is how you pass *any* -kind of runtime configuration into a container without baking it into the image -itself. API keys, connection strings, feature flags — all of it, passed in at -runtime and kept out of the image entirely. - -.. rubric:: :fas:`door-open far` Peek inside - :class: pre-title-text - -.. _getting-inside-the-box: - --------------------------------------------------------------------------------- -Getting inside the box. --------------------------------------------------------------------------------- - -.. rubric:: The first time I opened a shell inside a running container. - :class: subtitle-text - -By mid-October, I had a container running but something was behaving oddly. The -logs weren't giving me enough detail. I wanted to actually get inside — open a -terminal within the running container and have a proper look around. - -This is what :console:`$ docker exec` is for. - -.. code-block:: console - - $ docker exec -it my-flask-app bash - -And just like that, I had a shell inside the running container. I could -navigate the filesystem, check if my files were where I expected them to be, -run commands manually, poke around. - -The ``-it`` flags are doing the same thing they do in :console:`$ docker run`: - -- ``-i`` keeps stdin open so you can type -- ``-t`` allocates a terminal so it looks and behaves like a real one - -Together, ``-it`` gives you an interactive terminal session. Without them, -:console:`$ docker exec` just runs the command silently and exits. - -One thing to note: not every container has ``bash``. Smaller, minimal images, -especially anything based on ``alpine``, might only have ``sh``. In those -cases: - -.. code-block:: console - - $ docker exec -it my-container sh - -Knowing you can get a shell inside a running container changes how you debug. -Instead of guessing from the outside, you can just go in and see for yourself. - -.. rubric:: :fas:`layer-group far` The other mess - :class: pre-title-text - -.. _the-image-graveyard: - --------------------------------------------------------------------------------- -The image graveyard. --------------------------------------------------------------------------------- - -.. rubric:: I thought I was pulling one Python image. I was pulling several. - :class: subtitle-text - -With containers under control, I started noticing that my disk was still slowly -filling up. This time the culprit wasn't containers — it was images. - -Every time I ran :console:`$ docker run python:3.8`, Docker would check if the -image was already on my machine. If not, it would pull it. What I hadn't -noticed was that I'd been pulling variations of the same image with different -tags. ``python:3.8``, ``python:3.9``, ``python:3.8-slim``, ``python:latest``. -Each one a different download. Each one sitting on my disk. - -.. code-block:: console - - $ docker images - -The output was not pretty: - -.. code-block:: console - - REPOSITORY TAG IMAGE ID CREATED SIZE - python 3.9 a1b2c3d4e5f6 2 weeks ago 912MB - python 3.8 b2c3d4e5f6a1 3 weeks ago 884MB - python 3.8-slim c3d4e5f6a1b2 3 weeks ago 128MB - python latest d4e5f6a1b2c3 1 week ago 912MB - -Nearly 3GB of Python images alone. And I wasn't actively using half of them. - -To remove a specific image: - -.. code-block:: console - - $ docker rmi python:3.9 - -To remove all "dangling" images — ones with no tag and not referenced by any -container: - -.. code-block:: console - - $ docker image prune - -To be more aggressive and remove all unused images, tagged or not: - -.. code-block:: console - - $ docker image prune -a - -Be careful with the ``-a`` flag though. It'll remove any image not currently -used by a running container, including ones you might want to keep around for -later. - -You can also explicitly pull an image without running it, which is useful when -you want to download something in advance or just make sure you have the latest -version: - -.. code-block:: console - - $ docker pull python:3.8-slim - -.. rubric:: :fas:`code-compare far` Two dialects - :class: pre-title-text - -.. _old-commands-new-commands: - --------------------------------------------------------------------------------- -Old commands, new commands. --------------------------------------------------------------------------------- - -.. rubric:: The moment I realised Docker's CLI had two entirely different ways - to say the same thing. - :class: subtitle-text - -Around the same time, I noticed something that confused me quite a bit. I kept -seeing Docker commands written in two different ways across different tutorials. -Some people wrote :console:`$ docker ps`, others wrote -:console:`$ docker container ls`. Both did the same thing. Was one wrong? -Deprecated? Why did both exist? - -Turns out Docker's :abbr:`CLI (Command Line Interface)` has a bit of history. -The original shorter commands (:console:`$ docker ps`, :console:`$ docker rm`, -:console:`$ docker images`, and so on) were what everyone used from the -beginning. At some point, Docker reorganised the CLI into what are now called -management commands — a more structured, verb-noun format grouped by resource -type. - -.. list-table:: - :header-rows: 1 - - * - Legacy - - Management command - * - ``docker ps`` - - ``docker container ls`` - * - ``docker ps -a`` - - ``docker container ls -a`` - * - ``docker rm`` - - ``docker container rm`` - * - ``docker images`` - - ``docker image ls`` - * - ``docker rmi`` - - ``docker image rm`` - * - ``docker pull`` - - ``docker image pull`` - -This immediately reminded me of `Git`_. Git has what the community calls -"porcelain" commands — the friendly, high-level ones like :bash:`git add`, -:bash:`git commit`, :bash:`git push` — and "plumbing" commands — the -lower-level ones that power everything underneath. You mostly use the porcelain; -the plumbing is there if you need to go deeper. - -Docker's management commands are the porcelain equivalent. More structured, -more legible, more consistent. The legacy commands are shorthand that stuck -around because old habits die hard and backwards compatibility matters. - -Neither is wrong. Both work. I started preferring the management commands -because they make intent obvious, especially when you're still learning. -:console:`$ docker container rm` tells you immediately what kind of thing -you're deleting. :console:`$ docker rm` just... doesn't. - -.. rubric:: :fas:`broom far` Clean slate - :class: pre-title-text - -.. _nuclear-cleanup: - --------------------------------------------------------------------------------- -Nuclear cleanup. --------------------------------------------------------------------------------- - -.. rubric:: When you want to start completely from scratch. - :class: subtitle-text - -By the end of October, I had developed a proper rhythm with Docker. But every -so often, I'd do a big experimental session — trying different images, testing -random configurations, running things just to see what happened — and I'd want -to wipe everything and start clean. - -:console:`$ docker system prune` is the sledgehammer for exactly this. - -.. code-block:: console - - $ docker system prune - WARNING! This will remove: - - all stopped containers - - all networks not used by at least one container - - all dangling images - - all dangling build cache - - Are you sure you want to continue? [y/N] - -It removes stopped containers, unused networks, dangling images, and build -cache in one go. By default it won't touch images that are tagged or currently -in use by a running container, so it's safer than it sounds. - -If you want to go even further and remove all unused images as well: - -.. code-block:: console - - $ docker system prune -a - -And if you want to skip the confirmation prompt, which is handy in scripts: - -.. code-block:: console - - $ docker system prune -f - -I started running :console:`$ docker system prune` at the end of every -experimental session, like clearing a whiteboard before moving on. - -.. rubric:: :fas:`leaf far` A month in the rearview - :class: pre-title-text - -.. _what-i-actually-learned: - --------------------------------------------------------------------------------- -What I actually learned. --------------------------------------------------------------------------------- - -Looking back at that month, what I actually learned wasn't really about Docker. -It was about not using a :abbr:`CLI (Command Line Interface)` blindly. - -My first week was pure copy-paste. :console:`$ docker run`, see something -happen, move on. No understanding of what was being created, what was left -behind, or why. The second, third, and fourth week were the corrective phase — -understanding the lifecycle of a container, managing what I was creating, -learning which flags existed and *why* they existed. - -The pattern I kept noticing was this: most Docker problems that beginners run -into — disk space filling up, servers not being reachable, containers that -can't be told apart — are solved by flags that simply weren't mentioned in -whatever quick-start guide got them started. ``--rm``, ``-d``, ``-p``, -``--name``, ``-e``. None of these are advanced features. They're practically -required for normal use. They just don't show up in *"Getting started with -Docker in 5 minutes"*. - -None of this is complicated, either. It just takes a month of making the -mistakes to understand why each one matters. Every dead container I cleaned up, -every port mapping I finally got right, every random name I replaced with -something sensible — each of those small frustrations was a lesson I couldn't -have read my way to. - -I suppose that's the thing about :abbr:`CLI (Command Line Interface)` tools in -general. The documentation tells you what the flags do. Only the mistakes teach -you why you need them. - -.. _Docker: https://www.docker.com/ -.. _containers: https://en.wikipedia.org/wiki/Container_(virtualization)/ -.. _images: https://www.techtarget.com/searchitoperations/definition/Docker-image/ -.. _StackOverflow: https://stackoverflow.com/ -.. _Rust: https://rust-lang.org -.. _Docker Hub: https://hub.docker.com/ -.. _Git: https://git-scm.com/ diff --git a/docs/source/explained/eli5/index.rst b/docs/source/explained/eli5/index.rst index 9ac9abf..a3db166 100644 --- a/docs/source/explained/eli5/index.rst +++ b/docs/source/explained/eli5/index.rst @@ -33,7 +33,7 @@ five-year old**. I've said it before, I live by this old quote: -- Dr Richard Feynman -But for me, that teaching needs to be practical, relatable, and, honestly, to +But for me, that teaching needs to be practical, relatable and, honestly, to have a bit of a laugh sometimes. And this corner is where I'll be sharing my take on some of those concepts that can sometimes seem a bit daunting. @@ -74,7 +74,7 @@ clicks! The big idea. ------------------------------------------------------------------------------- -Each page here will focus on a single concept, and I'll explain it simply, +Each page here will focus on a single concept and I'll explain it simply, using practical examples that we encounter every day. For me, the best examples are always the ones you can see and feel. diff --git a/docs/source/index.rst b/docs/source/index.rst index 2cec116..70b1ed5 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -21,7 +21,7 @@ Akshay Mestry .. rst-class:: lead - Educator, software engineer, and storyteller. + Educator, software engineer and storyteller. .. author:: @xames3 :avatar: https://avatars.githubusercontent.com/u/90549089?v=4 @@ -32,9 +32,9 @@ Akshay Mestry Hello hello! -I'm Akshay, an educator, engineer, and currently an Assistant Professor at +I'm Akshay, an educator, engineer and currently an Assistant Professor at `National Louis University`_. Before getting into academia, I worked at -consulting firms, corporate product companies, and startups. This small corner +consulting firms, corporate product companies and startups. This small corner on the internet is my space to explore and share my experiences in software engineering through storytelling. @@ -81,8 +81,8 @@ Love for open source. I :fas:`heart mrl-0 red` Open source! -I accidentally got started on it, and I don't know why; I bloody loved it. -Doing open source was, and still is, a personal reminder to share my struggles +I accidentally got started on it and I don't know why; I bloody loved it. +Doing open source was and still is, a personal reminder to share my struggles and achievements/learnings with everyone and invite collaboration across various disciplines. @@ -113,7 +113,7 @@ reflect on both my technical depth and my knowledge gaps. An experimental re-implementation of a few NumPy features in Python. Beyond these, I've had the opportunity to speak at a few tech meetups and -conferences, sharing insights on my projects, my thoughts on AI, and the inner +conferences, sharing insights on my projects, my thoughts on AI and the inner workings of some frameworks. It wouldn't be possible if I hadn't explored this; in doing so, I realised I @@ -143,7 +143,7 @@ Early in my software engineering career, I came across a quote: And I absolutely fell in love with this idea. Soon, I realised you really can't call yourself an :abbr:`SME (Subject-matter expert)` until you can teach -a concept to those who barely know anything about it (or very little), and are +a concept to those who barely know anything about it (or very little) and are willing to entertain questions at that initial frame of reference. Thus began my journey into teaching. By the way... diff --git a/docs/source/miscellany/faq.rst b/docs/source/miscellany/faq.rst index a4a20fd..6bf0a9c 100644 --- a/docs/source/miscellany/faq.rst +++ b/docs/source/miscellany/faq.rst @@ -27,7 +27,7 @@ here it'll probably be added in the next few weeks. One of the best parts of sharing and writing these articles is the chats that -follow. I've had loads of interesting chats in person and a few over email, and +follow. I've had loads of interesting chats in person and a few over email and I think I've noticed a pattern. A lot of the same questions pop up; some are esoteric about a topic, while others are simple *"why?"* questions. Ever since I transitioned into teaching, some of you've also questioned that, too. @@ -52,7 +52,7 @@ to speak... Frequently asked questions ------------------------------------------------------------------------------- -.. dropdown:: What the f*ck is XAMES3, and why you use it everywhere? |pop| +.. dropdown:: What the f*ck is XAMES3 and why you use it everywhere? |pop| :class-container: site-faq .. rubric:: Asked on November 02, 2025 @@ -118,7 +118,7 @@ Frequently asked questions - understanding user or customer requirements - working with tools or accesses available at my disposal - reading and experimenting with documentations - - debugging logs, and spending time on terminal + - debugging logs and spending time on terminal - collaborating with other teams and fellow developers - building APIs - deployments (not on Fridays, those are f*cking horrible!) @@ -132,7 +132,7 @@ Frequently asked questions .. rubric:: Asked on December 02, 2025 :class: date-text - I'm a huge `Christopher Nolan`_ fan, and I :fas:`heart mrl-0 red` his + I'm a huge `Christopher Nolan`_ fan and I :fas:`heart mrl-0 red` his films. I tend to watch a lot of films, so it's difficult to say, what's my favourite. But, my go-to movie is `Interstellar`_. I absolutely love the music compositions by `Hans Zimmer`_. It's truly a masterpiece! @@ -167,7 +167,7 @@ Frequently asked questions My custom extensions are as follows: - **author.** This renders author details like name, :strike:`about`, - :strike:`email`, GitHub, :strike:`LinkedIn, and a timestamp of article` + :strike:`email`, GitHub, :strike:`LinkedIn and a timestamp of article` - **picture.** Show colour-scheme aware images on the website - **repository.** Renders a small widget to show information about the GitHub repository. The details include stars and fork counts diff --git a/docs/source/miscellany/guiding-stars.rst b/docs/source/miscellany/guiding-stars.rst index 1b8d497..55bf8fb 100644 --- a/docs/source/miscellany/guiding-stars.rst +++ b/docs/source/miscellany/guiding-stars.rst @@ -4,7 +4,7 @@ :og:title: Guiding stars :og:description: Expressing heartfelt gratitude to the heros who shaped my - path in life, learning, and beyond... + path in life, learning and beyond... :og:type: article :fb:title: What are the guiding stars in your life? :fb:description: We all have mentors and friends who guide us. Reading this, @@ -33,7 +33,7 @@ not only reflect on your achievements and/or mistakes, but also the people who've played a role in those moments in time. This article is that moment, where I'm looking back and paying tribute to those -people or my "guiding stars", whose wisdom, encouragement, belief, and +people or my "guiding stars", whose wisdom, encouragement, belief and friendship have been a solid foundation of my growth as a teacher/mentor and a lifelong learner. Each person mentioned here :abbr:`DOA (dead or alive)` has left a significant mark on me. @@ -80,11 +80,11 @@ pretty strong in my life. I still remember you encouraging me to question things deeply and to pursue understanding beyond the surface. Now, it doesn't make any sense to say it all, but your belief in my ability to -teach and explain things planted the seed of this very platform, and +teach and explain things planted the seed of this very platform and surprisingly... I still feel like we're chatting. -I know it's weird, and I totally get it. But if you were to be here today, -you'd be very proud of me. I know you always wanted me to teach, and now look +I know it's weird and I totally get it. But if you were to be here today, +you'd be very proud of me. I know you always wanted me to teach and now look where I am. Every time I get into my *"teaching"* mode, I see your spirit reminding me of our last chat... @@ -103,7 +103,7 @@ reminding me of our last chat... :class: subtitle-text And so, my life or this journey has always been shaped and tweaked by an -extraordinary cast of mentors, friends, coworkers, supporters, and even +extraordinary cast of mentors, friends, coworkers, supporters and even strangers, each playing an essential role in the stories I share. Everything you read here, whether it be project ideas or teaching inspiration, @@ -132,7 +132,7 @@ Antonio Tovar `Dr Tovar`_, I first met you at `National Louis University `_ in 2024, during the `NASA Open Science`_ workshop. You were co-leading that cohort and generously offered me the opportunity to intern under your guidance -at NASA. You've always been kind, supportive, a father figure, and a teacher +at NASA. You've always been kind, supportive, a father figure and a teacher ready with valuable advice and encouragement. .. figure:: https://i.imgur.com/H4LzSiU.jpeg @@ -141,7 +141,7 @@ ready with valuable advice and encouragement. :figclass: zoom grayscale Dr Tovar (left) with my students, `Huzaifa Quaid`_ (left-centre), - `Ishrak Rahman`_ (centre), `Orkhan Guliyev`_ (right-centre), and + `Ishrak Rahman`_ (centre), `Orkhan Guliyev`_ (right-centre) and `Sezim Mamatova`_ (right) at NASA Open Science 2025 after presentations. There were a lot of people who helped me on my journey to becoming a teacher, @@ -207,7 +207,7 @@ tech-networking event and perhaps the largest one of all in Chicago. I first met you at one of these events (June 2024) during a particularly difficult time in my life. -I was looking for a distraction of sorts, and it turned out to be exactly what +I was looking for a distraction of sorts and it turned out to be exactly what I needed: a supportive community that you and your team had fostered with care. As time passed, the community has grown significantly. I became a volunteer, and more people have found opportunities through your work. @@ -226,7 +226,7 @@ will get no strikes. Judy Cazares ------------------------------------------------------------------------------- -I first met `Judy`_ at `ChiPy`_ in 2024, and you were among the first to +I first met `Judy`_ at `ChiPy`_ in 2024 and you were among the first to welcome me. Since then, we've been great friends. Your kindness and support have always been appreciated. I've always valued your willingness to listen and offer thoughtful, grounded advice. @@ -272,7 +272,7 @@ genuine admiration for your steady commitment to hard work. Sameer and I, June 2025 at our graduation ceremony -What I don't usually say, and perhaps I should more often, is how much I admire +What I don't usually say and perhaps I should more often, is how much I admire your steady, **no-bullshit** approach to hard work and study. While I often pride myself on being a mentor or a bit of a teacher now and then, there have been moments where I've felt more like a student, learning a lesson or two from @@ -326,7 +326,7 @@ Young Song `Young`_, our friendship began in my second quarter at the uni, where we both took a class on Discrete Structures. From the outset, your curiosity and -support stood out, and I'm truly grateful for the friendship we've built since +support stood out and I'm truly grateful for the friendship we've built since then. .. figure:: https://i.imgur.com/weM4qDW.jpeg @@ -336,7 +336,7 @@ then. Young attending and celebrating our graduation ceremony, June 2025 One memory that remains vivid is my graduation day. My mum wasn't able to -attend, nor anyone else, and it could've been a lonely milestone. Yet you were +attend, nor anyone else and it could've been a lonely milestone. Yet you were there for :ref:`Sameer ` and me, arriving early (as usual) and staying through the entire ceremony, from the first moments at seven in the morning until the celebrations ended late in the afternoon. You brought us diff --git a/docs/source/miscellany/youtube-videos.rst b/docs/source/miscellany/youtube-videos.rst index 920ba27..9f935e1 100644 --- a/docs/source/miscellany/youtube-videos.rst +++ b/docs/source/miscellany/youtube-videos.rst @@ -4,7 +4,7 @@ :og:title: The best YouTube has to Offer :og:description: A curated list of my favourite YouTube videos that I've found - over the years: good, bad, and weird! + over the years: good, bad and weird! :og:type: article :og:image: https://i.imgur.com/RLsdjyG.jpeg :fb:title: Got a cracking video recommendation? @@ -23,10 +23,10 @@ :avatar: https://avatars.githubusercontent.com/u/90549089?v=4 :target: https://github.com/xames3 -Recently, out of nowhere, I was looking at my phone's screen time stats, and I +Recently, out of nowhere, I was looking at my phone's screen time stats and I realised that I spend shitload of my time on YouTube. I got into tech in 2017. Since then, it has been my go-to platform for learning new things, staying -updated with tech trends, and unwinding with some light-hearted or weird +updated with tech trends and unwinding with some light-hearted or weird content. Over the years, I curated a personal playlist of videos that have either taught @@ -55,11 +55,11 @@ Technology. .. rubric:: This section features some of my go-to videos and channels related to - **programming**, **science**, and **technology**. + **programming**, **science** and **technology**. :class: subtitle-text Here, the videos are related to programming tutorials, tips and tricks, tech -talks, education, concepts of computer science, and more. +talks, education, concepts of computer science and more. .. dropdown:: These are some videos that I often watch whenever I want to learn something new or just want to refresh my knowledge on a particular @@ -600,7 +600,7 @@ talks, education, concepts of computer science, and more. .. tab-item:: :fas:`flask far` Science & Education Here, the videos are related to general science concepts, physics, - mathematics, space, and more. I've always been fascinated by + mathematics, space and more. I've always been fascinated by science and love learning how some things just work. These videos have been a great way for me to explore and understand various scientific phenomena or sometimes just marvel how amazing @@ -795,7 +795,7 @@ Comic Relief. After hours of staring at code, sometimes all you need is a good, hearty laugh. I'm not saying these are the funniest videos out there, but they sure do the -trick for me. Some are downright silly, some are clever, and some are just +trick for me. Some are downright silly, some are clever and some are just plain weird and cringe. .. dropdown:: Some of my favourite funny videos that I watch and have come @@ -807,7 +807,7 @@ plain weird and cringe. An ever-growing collection of some of my favourite light-hearted comedy videos that I've found over the years. These videos are a - mix of stand-up comedy, sketches, parodies, and of course, Family + mix of stand-up comedy, sketches, parodies and of course, Family Guy. They never fail to make me laugh and cringe. .. grid:: 3 @@ -939,7 +939,7 @@ plain weird and cringe. you tilt your head and think, "how did I even get here?". This is a collection of those "gems". - These videos are a mix of surreal comedy, absurdist sketches, and + These videos are a mix of surreal comedy, absurdist sketches and just plain weird stuff. They never fail to cease to amuse. .. grid:: 3 @@ -1094,12 +1094,12 @@ magical. .. rubric:: We've all been there. It's late, you should be sleeping, but you've - stumbled upon a video that leads to another, and another. + stumbled upon a video that leads to another and another. :class: subtitle-text This section is a tribute to those late-night YouTube journeys. It's a random, -eclectic mix of fascinating documentaries, unsolved mysteries, and -thought-provoking explorations, and `VSauce`_ that are perfect for when your +eclectic mix of fascinating documentaries, unsolved mysteries and +thought-provoking explorations and `VSauce`_ that are perfect for when your curiosity gets the better of your sleep schedule. .. dropdown:: Some of my favourite late-night bored out of your mind videos diff --git a/docs/source/projects/slowtorch.rst b/docs/source/projects/slowtorch.rst index 0514c49..299dc9b 100644 --- a/docs/source/projects/slowtorch.rst +++ b/docs/source/projects/slowtorch.rst @@ -30,7 +30,7 @@ You know, you get this kick after finishing a project? That was exactly me in January of 2025, reflecting on `xsNumPy`_. I had spent weeks understanding and -learning arrays, memory buffers, and `broadcasting`_. As mentioned in +learning arrays, memory buffers and `broadcasting`_. As mentioned in :doc:`that story <./xsnumpy>`, it was indeed a trip for me. .. card:: Why write xsNumPy? @@ -78,7 +78,7 @@ complexity of the system. I had the same three rules. - Every line of code and every solution had to come from my own understanding and experimentation. - Pure Python only. No external dependencies, just the standard library. - - It should be clean, maintainable, statically typed, and well-documented + - It should be clean, maintainable, statically typed and well-documented code that mirrors PyTorch's public APIs, aiming to be a drop-in replacement where sensible. @@ -104,7 +104,7 @@ with xsNumPy, as :ref:`discussed here `. But this time, I had to add a few more things to make my tensor work like PyTorch's. I needed to implement a way to save the node and operation history for autodiff, which was a new concept for me. I also had to learn how to track -operations, gradients, and compute them efficiently. +operations, gradients and compute them efficiently. .. rubric:: Read the f*cking docs! .. rubric:: @@ -125,7 +125,7 @@ But my devices were just strings, like "cpu" or "gpu", with no actual hardware acceleration. The :meth:`__repr__ ` method was pretty similar to what I had in xsNumPy, but I had to add a few more details to reflect the tensor's properties like :py:attr:`shape `, -:py:attr:`device `, :py:class:`dtype `, and +:py:attr:`device `, :py:class:`dtype ` and whether it :py:attr:`requires gradients ` or not. .. seealso:: @@ -154,28 +154,28 @@ does it truly mean for a tensor to change based on its history? How does it know the appropriate path when asked to reverse its operations? To be super duper honest, my initial attempts were a complete mess. I attempted -to meticulously track every operation, parent, and child tensor, resulting in a +to meticulously track every operation, parent and child tensor, resulting in a code resembling a family tree. But Andrej's video made me realise that I was -overcomplicating things, and I reworked my implementation slowly. +overcomplicating things and I reworked my implementation slowly. .. admonition:: :fas:`sparkles` Inspiration :class: unusual-one danger `Andrej Karpathy`_ had explained this concept in much detail in his video, where he builds `micrograd`_, a simple autograd engine, from scratch. This - video is perhaps the best introduction and explanation, and the only thing - you need to know about how autograd works, and it helped me a ton in + video is perhaps the best introduction and explanation and the only thing + you need to know about how autograd works and it helped me a ton in understanding the core concepts. .. youtube:: https://www.youtube.com/watch?v=VMj-3S1tku0 By rewatching the video multiple times, I realised that each operation could be -represented as a node, and each node could carry a cheeky function which would +represented as a node and each node could carry a cheeky function which would know how to compute its own gradient. The "real" breakthrough happened when I stopped thinking of the graph as a static structure and started seeing it as a living, breathing thing, growing with every operation. -So, I created a ``Node`` class that represented each operation, and each tensor +So, I created a ``Node`` class that represented each operation and each tensor would have a reference to its parent nodes. This way, I could traverse the graph and compute gradients in a more structured way. @@ -211,7 +211,7 @@ graph and compute gradients in a more structured way. Every tensor (node) carried a ``grad_fn`` node in the computation graph. When you call ``backward``, the tensor does not just look at itself; it traces its -lineage, visiting every ancestor, and calls their gradient functions in reverse +lineage, visiting every ancestor and calls their gradient functions in reverse order. It is a wee bit like walking back through your own footsteps after a long hike, pausing at each fork to remember which way you came. @@ -239,7 +239,7 @@ Building the building blocks. ------------------------------------------------------------------------------- Once my tensor with autodiff support was in place, I started on the neural -networks. PyTorch's :py:mod:`torch.nn` module is a marvel of abstractions, and +networks. PyTorch's :py:mod:`torch.nn` module is a marvel of abstractions and I wanted to recreate it from scratch. I began by defining `Module`_, a base class that could hold parameters and submodules. @@ -248,7 +248,7 @@ saving and loading weights, switching between training and evaluation modes, and handling parameter updates. I was pacing through my development. Things were much clearer now. As more time -passed, I implemented many things. The layers, activations, losses, and +passed, I implemented many things. The layers, activations, losses and transforms were all implemented in their functional forms initially and later wrapped around classes much like PyTorch. @@ -382,7 +382,7 @@ wrapped around classes much like PyTorch. .. tab-item:: :fas:`chart-line-down far` Losses `Loss functions`_ were implemented as functions that took two tensors, - ``input`` and ``target``, and returned a new tensor representing the + ``input`` and ``target`` and returned a new tensor representing the calculated loss (forward pass). Each loss function also had a backward pass that computed the gradient with respect to the input and target tensors. @@ -554,7 +554,7 @@ wrapped around classes much like PyTorch. Recreating neural networks from first principles reminded me of learning to ride a bicycle without training wheels. I fell off a ton. But each time I got back on, I understood a little more. I was, in a way, backpropagating my -mistakes, learning from them, and adjusting my gradients. +mistakes, learning from them and adjusting my gradients. .. rubric:: Joy of manual optimisation. .. rubric:: @@ -567,7 +567,7 @@ optimiser, which presented another challenge. I implemented a simple optimiser, manually updating its parameters step by step. Once I was happy with my optimiser, I wrote a basic |storch.optim.Optimiser| -class that took a list of parameters and a learning rate, and it had a +class that took a list of parameters and a learning rate and it had a :python:`.step()` method that updated the parameters based on their gradients. .. code-block:: python @@ -586,9 +586,9 @@ class that took a list of parameters and a learning rate, and it had a continue param -= self.lr * param.grad -It was slow and clunky, but I could see every calculation, update, and mistake. +It was slow and clunky, but I could see every calculation, update and mistake. I had to understand how each parameter was updated, how the learning rate -(:math:`\mu`) affected the updates, and how momentum (:math:`\mu`) could help +(:math:`\mu`) affected the updates and how momentum (:math:`\mu`) could help smooth out the learning process. With time, I learnt techniques that improved the training process. Finally, I @@ -609,11 +609,11 @@ Embracing slowness as a virtue. As more time passed while building SlowTorch, I realised the hardest part wasn't the code or maths, but the mindset. I knew I couldn't compete with -PyTorch's raw speed, so I had to let go of my desire for speed, elegance, and +PyTorch's raw speed, so I had to let go of my desire for speed, elegance and perfection, which I had always strived for as a Software Engineer. -Instead, I embraced the slowness, curiosity, and experimentation of a child. -Every bug I encountered was a lesson, and every unexpected result was an +Instead, I embraced the slowness, curiosity and experimentation of a child. +Every bug I encountered was a lesson and every unexpected result was an opportunity to recuperate and learn. I quite often found myself talking to my code, asking it questions, coaxing it to reveal its secrets. diff --git a/docs/source/projects/xsnumpy.rst b/docs/source/projects/xsnumpy.rst index a874b61..62cac77 100644 --- a/docs/source/projects/xsnumpy.rst +++ b/docs/source/projects/xsnumpy.rst @@ -1,6 +1,6 @@ .. Author: Akshay Mestry .. Created on: 01 March, 2025 -.. Last updated on: 23 June, 2026 +.. Last updated on: 28 June, 2026 :og:title: Why write xsNumPy? :og:description: Journey of building a lightweight, pure-python implementation @@ -32,7 +32,7 @@ mid-November of 2024. I was still at the uni and in my second-to-last quarter. I was working on an assignment that required me to use `NumPy`_. -I multiplied some matrices; NumPy did its thing, as it always does, and made +I multiplied some matrices; NumPy did its thing, as it always does and made all the computations look super easy. But, under this "simplicity", a few questions began to gnaw at me. @@ -84,7 +84,7 @@ this properly, I needed discipline and some rules to follow. - Every line of code and every solution had to come from my own understanding and experimentation. - Pure Python only. No external dependencies, just the standard library. - - It should be clean, maintainable, statically typed, and well-documented + - It should be clean, maintainable, statically typed and well-documented code that mirrors NumPy's public APIs, aiming to be a drop-in replacement where sensible. @@ -108,7 +108,7 @@ implementing my own |xp.ndarray|_ data structure. :class: unusual-one seealso If you're new to arrays, think of them as egg cartons, each slot holds an - egg, and the shape of the carton tells you how many eggs you've got. + egg and the shape of the carton tells you how many eggs you've got. Where your hand moves from one slot to the next are the strides; the type of eggs is the dtype; the carton itself is the buffer or the actual @@ -116,7 +116,7 @@ implementing my own |xp.ndarray|_ data structure. Now, by this point in time, I had a basic understanding of arrays and how they worked. But, as I looked deeper and deeper, I discovered heaps of concepts, -including `memory allocation`_, `shape`_ calculations, `strides`_, and various +including `memory allocation`_, `shape`_ calculations, `strides`_ and various optimisation techniques for data storage. It felt like opening Pandora's box; I wasn't ready. After a few days of @@ -221,7 +221,7 @@ dynamically fetch whatever data type I fancied. Right, on to the ``buffer``. If no ``buffer`` was provided, the array was initialised without an external memory buffer. In this case, the ``offset`` -must be zero, and the ``strides`` must be :py:obj:`None`. The constructor would +must be zero and the ``strides`` must be :py:obj:`None`. The constructor would then calculate the `strides`_, which, put simply, are just the number of bytes between consecutive elements in memory. @@ -238,7 +238,7 @@ between consecutive elements in memory. But what if a buffer was provided? -Well, then it got trickier. It used the base buffer, and the strides were +Well, then it got trickier. It used the base buffer and the strides were either given directly or calculated. .. code-block:: python @@ -256,7 +256,7 @@ either given directly or calculated. self._strides = tuple(strides) Finally, calculating the total ``buffer`` size. This was worked out using the -strides, shape, and the size. The ``buffer`` itself was a type derived from +strides, shape and the size. The ``buffer`` itself was a type derived from the data type and its size. Depending on whether a buffer was passed or not, the constructor handled it accordingly, either creating a new buffer or using the existing one. @@ -282,7 +282,7 @@ telling myself Little did I know, I was shooting myself in the foot. At its core, a :meth:`__repr__ ` is an object's internal data representation. -I started with something simple, and it worked for scalars and 1D arrays. +I started with something simple and it worked for scalars and 1D arrays. .. code-block:: python :linenos: @@ -292,7 +292,7 @@ I started with something simple, and it worked for scalars and 1D arrays. Feeling quite pleased and a bit cocky, I tried a 2D array, but it unexpectedly printed everything as a flat list. I realised I hadn't accounted for the rows -and columns. No problem, I updated the code, and it worked. +and columns. No problem, I updated the code and it worked. .. code-block:: python :linenos: @@ -328,10 +328,10 @@ And so, I realised, printing a NumPy array from scratch was a rabbit hole. After wrestling with the "simple" things, I naively believed the hardest part was behind me. I was excited for the fun stuff: element-wise arithmetic, -`broadcasting`_, transposing, and other random functions. However, I didn't +`broadcasting`_, transposing and other random functions. However, I didn't realise my journey was about to get even more challenging. -Basic arithmetic operations like addition, subtraction, and scalar +Basic arithmetic operations like addition, subtraction and scalar multiplication seemed straightforward. I figured I could just iterate through my flattened data and perform operations element-wise. @@ -362,7 +362,7 @@ failed almost immediately for higher-dimensional vectors. What if I added a scalar to a matrix, or a ``(3,)`` array to a ``(3, 3)`` matrix? Could I add a :py:class:`float` to an :py:class:`int`? Each of those -experiments brought new challenges, and I was absolutely frustrated! +experiments brought new challenges and I was absolutely frustrated! That's when I realised I wasn't just adding or multiplying numbers, but learning and simultaneously recreating NumPy's broadcasting rules. @@ -410,13 +410,13 @@ intuitive and weird edge cases. With each iteration, every commit I made, I explored even more ways to optimise it, reducing redundant calculations. Every bug, every unexpected result, every failure, every new piece of answer -and advice that I received on Stack Overflow, and every small achievement +and advice that I received on Stack Overflow and every small achievement taught me something new about NumPy. As time passed, xsNumPy evolved into more than just a project and a scrappy experiment. It became a weird, obsessive mentality. A belief that the best way to learn is by rolling up your sleeves, taking things apart, trying to understand the -problem, and putting it back together, piece by piece. +problem and putting it back together, piece by piece. .. _so-what-can-xsnumpy-do: @@ -436,7 +436,7 @@ does quite well. .. tab-item:: :fas:`empty-set far` Creations xsNumPy provides familiar ways to create arrays. These creation - routines are consistent, predictable, and designed to slot neatly into + routines are consistent, predictable and designed to slot neatly into later operations. - ``array()`` @@ -458,10 +458,10 @@ does quite well. >>> xp.array([1, 0, 2], dtype=xp.bool) array([True, False, True]) - - ``zeros()``, ``ones()``, and ``full()`` + - ``zeros()``, ``ones()`` and ``full()`` - xsNumPy support |xp.zeros|_, |xp.ones|_, and |xp.full|_ functions for - repeatable initialisation of arrays filled with, zeros, ones, and any + xsNumPy support |xp.zeros|_, |xp.ones|_ and |xp.full|_ functions for + repeatable initialisation of arrays filled with, zeros, ones and any ``fill_value`` respectively. .. code-block:: python @@ -513,7 +513,7 @@ does quite well. - **Broadcasting arithmetic** - xsNumPy matches shapes, stretches smaller arrays, and makes sure the + xsNumPy matches shapes, stretches smaller arrays and makes sure the output shape followed NumPy's exact logic. Just like NumPy, these operations are broadcasted. @@ -713,12 +713,12 @@ does quite well. .. rubric:: Sharing notes with the community. .. rubric:: I gave a talk at `ChiPy`_ titled **"xsNumPy: Curiosity to Code"**, walking - through the decisions, the missteps, and the insights that stayed with me. + through the decisions, the missteps and the insights that stayed with me. :class: subtitle-text .. youtube:: https://www.youtube.com/watch?v=QIhyix3oEns :caption: The presentation covered the technical challenges, mathematical - discoveries, and most importantly, the mindset shift from viewing + discoveries and most importantly, the mindset shift from viewing libraries as opaque entities to understanding them as collections of elegant algorithms waiting to be explored. @@ -738,7 +738,7 @@ the best teaching and learning experience. I intend to work on this project in small, respectful steps whenever I get time. However, the larger work is already done. I re-learnt the essentials by -making them, and that learning will travel with me far beyond this code. +making them and that learning will travel with me far beyond this code. .. _NumPy: https://numpy.org .. _Generalised Matrix Multiplication (GEMM): https://docs.nvidia.com/ diff --git a/kaamiki/__init__.py b/kaamiki/__init__.py index 09eb4c5..eb4db52 100644 --- a/kaamiki/__init__.py +++ b/kaamiki/__init__.py @@ -4,18 +4,18 @@ Author: Akshay Mestry Created on: 21 February, 2025 -Last updated on: 26 April, 2026 +Last updated on: 28 June, 2026 This module serves as the primary entry point for the Kaamiki Sphinx Theme. It is responsible for initialising the theme, configuring its -extensions, and integrating with Sphinx's build process. +extensions and integrating with Sphinx's build process. This module connects the theme's internal utilities and configurations with the Sphinx application lifecycle, ensuring seamless interaction between theme components and the final HTML output. This theme is registered through the `setup()` function, which -configures the theme, maps user-configurable options, and binds event +configures the theme, maps user-configurable options and binds event hooks for post-processing and dynamic content handling. .. versionadded:: 21.2.2025 diff --git a/kaamiki/extensions/author.py b/kaamiki/extensions/author.py index b9b5f20..5b3c4f9 100755 --- a/kaamiki/extensions/author.py +++ b/kaamiki/extensions/author.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 27 June, 2026 +Last updated on: 28 June, 2026 This module defines a custom `author` directive for the Kaamiki Sphinx Theme. The directive allows embedding details directly within the @@ -28,7 +28,7 @@ .. versionchanged:: 19.10.2025 - The options `author`, `email`, and `github` are now optional and can + The options `author`, `email` and `github` are now optional and can default to project's details specified in `conf.py`. .. deprecated:: 19.10.2025 @@ -37,7 +37,7 @@ .. deprecated:: 15.01.2026 - Removed usage of Email, Bio, and LinkedIn metadata. + Removed usage of Email, Bio and LinkedIn metadata. .. versionchanged:: 24.04.2026 @@ -84,7 +84,7 @@ class directive(rst.Directive): """Custom `author` directive for reStructuredText. This class defines the behaviour of the `author` directive, - including how it processes options and content, and how it generates + including how it processes options and content and how it generates nodes to be inserted into the document tree. The directive supports the following options:: @@ -94,7 +94,7 @@ class directive(rst.Directive): .. versionchanged:: 19.10.2025 - The options `author`, `email`, and `github` are now optional + The options `author`, `email` and `github` are now optional and can default to project's details specified in `conf.py`. .. deprecated:: 17.03.2026 @@ -124,7 +124,7 @@ def run(self) -> list[nodes.Node]: """Parse directive options and create an `author` node. This method gathers all options provided by the user (if any) in - the `author` directive, constructs a new `node` instance, and + the `author` directive, constructs a new `node` instance and returns it wrapped in a list. The returned node is then placed into the document tree at the diff --git a/kaamiki/extensions/button.py b/kaamiki/extensions/button.py index fa13d56..119e7f5 100644 --- a/kaamiki/extensions/button.py +++ b/kaamiki/extensions/button.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 29 April, 2026 -Last updated on: 12 June, 2026 +Last updated on: 28 June, 2026 This module defines a custom `button` directive for the Kaamiki Sphinx Theme. The directive allows adding a button directly within the @@ -69,7 +69,7 @@ class directive(rst.Directive): """Custom `button` directive for reStructuredText. This class defines the behaviour of the `button` directive, including - how it processes options and content, and how it generates nodes to + how it processes options and content and how it generates nodes to be inserted into the document tree. The directive supports the following options:: @@ -90,7 +90,7 @@ def run(self) -> list[nodes.Node]: """Parse directive options and create an `button` node. This method gathers all options provided by the user (if any) in - the `button` directive, constructs a new `node` instance, and + the `button` directive, constructs a new `node` instance and returns it wrapped in a list. The returned node is then placed into the document tree at the diff --git a/kaamiki/extensions/repository.py b/kaamiki/extensions/repository.py index 4480788..6909810 100755 --- a/kaamiki/extensions/repository.py +++ b/kaamiki/extensions/repository.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 29 October, 2025 -Last updated on: 29 April, 2026 +Last updated on: 28 June, 2026 This module defines a custom `repository` directive for the Kaamiki Sphinx Theme. The directive allows embedding GitHub repository details @@ -60,7 +60,7 @@ class directive(rst.Directive): """Custom `repository` directive for reStructuredText. This class defines the behaviour of the `repository` directive, - including how it processes options and content, and how it + including how it processes options and content and how it generates nodes to be inserted into the document tree. The directive supports the following options:: @@ -83,7 +83,7 @@ def run(self) -> list[nodes.Node]: This method gathers all options provided by the user (if any) in the `repository` directive, constructs a new `node` - instance, and returns it wrapped in a list. + instance and returns it wrapped in a list. The returned node is then placed into the document tree at the directive's location. Further processing will convert the node diff --git a/kaamiki/extensions/roles.py b/kaamiki/extensions/roles.py index 95db930..c8654a4 100755 --- a/kaamiki/extensions/roles.py +++ b/kaamiki/extensions/roles.py @@ -89,7 +89,7 @@ def stylise( :raises: None, but will report an error message if the input format is invalid. """ - # NOTE(xames3): The parameters `role`, `options`, and `content` are + # NOTE(xames3): The parameters `role`, `options` and `content` are # currently unused but are included to match the expected signature # for a Sphinx role function. role = role or "" @@ -150,7 +150,7 @@ def email( :raises: None, but will report an error message if the input format is invalid. """ - # NOTE(xames3): The parameters `role`, `options`, and `content` are + # NOTE(xames3): The parameters `role`, `options` and `content` are # currently unused but are included to match the expected signature # for a Sphinx role function. role = role or "" @@ -208,9 +208,10 @@ def mark( representing the highlighted text and a list of system messages generated during processing (typically empty if no errors). """ - # NOTE(xames3): The parameters `role`, `rawtext`, `options`, `lineno`, - # `inliner`, and `content` are currently unused but are included to match - # the expected signature for a Sphinx role function. + # NOTE(xames3): The parameters `role`, `rawtext`, `options`, + # `lineno`, `inliner` and `content` are currently unused but are + # included to match the expected signature for a Sphinx role + # function. role = rawtext or role or "" lineno = lineno or inliner options = options or {} @@ -265,9 +266,10 @@ def underline( representing the underlined text and a list of system messages generated during processing (typically empty if no errors). """ - # NOTE(xames3): The parameters `role`, `rawtext`, `options`, `lineno`, - # `inliner`, and `content` are currently unused but are included to match - # the expected signature for a Sphinx role function. + # NOTE(xames3): The parameters `role`, `rawtext`, `options`, + # `lineno`, `inliner` and `content` are currently unused but are + # included to match the expected signature for a Sphinx role + # function. role = rawtext or role or "" lineno = lineno or inliner options = options or {} diff --git a/kaamiki/extensions/thumbnail.py b/kaamiki/extensions/thumbnail.py index 8d4e257..47e1e92 100644 --- a/kaamiki/extensions/thumbnail.py +++ b/kaamiki/extensions/thumbnail.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 06 September, 2025 -Last updated on: 29 April, 2026 +Last updated on: 28 June, 2026 This module defines a custom `thumbnail` directive for the Kaamiki Sphinx Theme. The directive allows embedding a YouTube video thumbnail @@ -58,7 +58,7 @@ class directive(rst.Directive): """Custom `thumbnail` directive for reStructuredText. This class defines the behaviour of the `thumbnail` directive, - including how it processes options and content, and how it + including how it processes options and content and how it generates nodes to be inserted into the document tree. """ diff --git a/kaamiki/extensions/utils.py b/kaamiki/extensions/utils.py index 08f0bbd..9438441 100644 --- a/kaamiki/extensions/utils.py +++ b/kaamiki/extensions/utils.py @@ -4,20 +4,20 @@ Author: Akshay Mestry Created on: 21 February, 2025 -Last updated on: 14 January, 2026 +Last updated on: 28 June, 2026 This module defines a collection of utility functions used for customising this sphinx theme. These utilities focus on enhancing the post-processing of the generated HTML output, as well as providing -additional support for interactive elements, theme options, and other +additional support for interactive elements, theme options and other dynamic behaviours. The functionality provided includes handling collapsible table of -contents (ToC), removal of unnecessary elements, and custom event +contents (ToC), removal of unnecessary elements and custom event handling for theme-specific features. The goal of this module is to ensure that this theme produces clean, -efficient, and interactive HTML documentation by leveraging Sphinx's +efficient and interactive HTML documentation by leveraging Sphinx's internal APIs and dynamic JavaScript bindings. .. deprecated:: 19.10.2025 @@ -120,7 +120,7 @@ def remove_empty_toctree_divs(tree: bs4.BeautifulSoup) -> None: and redundant elements in the final HTML output. This function scans the HTML tree, identifies empty toctree divs - (those containing only whitespace or line breaks), and removes them + (those containing only whitespace or line breaks) and removes them to maintain a clean and optimised document structure. :param tree: Parsed HTML tree representing the document structure. @@ -185,11 +185,11 @@ def postprocess(html: str, app: Sphinx) -> None: This function reads an HTML file, parses it into a BeautifulSoup tree, applies various transformations such as adding collapsible - navigation, cleaning up empty elements, and removing comments, and + navigation, cleaning up empty elements and removing comments and finally writes the modified content back to the file. Post-processing ensures that the generated HTML is not only - functional but also clean, optimised, and dynamic according to the + functional but also clean, optimised and dynamic according to the user's configuration options. :param html: Path to the HTML file to be post-processed. @@ -214,7 +214,7 @@ def env_before_read_docs( """Track the list of documents modified during the Sphinx build. This function captures the list of document names that have been - added, updated, or deleted, and stores them in the Sphinx + added, updated, or deleted and stores them in the Sphinx environment for later use. This ensures that post-processing only affects pages that have actually changed, optimising the build process by avoiding unnecessary rework. @@ -304,10 +304,10 @@ def build_finished(app: Sphinx, exc: Exception | None) -> None: final modifications to the output files. This function is triggered after the build process is completed. It - checks if there are any errors, and if the builder is set to produce + checks if there are any errors and if the builder is set to produce `HTML` or `dirhtml` output. It then applies final transformations to the list of modified documents stored in the environment, such as - collapsible navigation, and comment removal. + collapsible navigation and comment removal. :param app: Sphinx application object. :param exc: Any exception raised during the build process, or None diff --git a/kaamiki/extensions/video.py b/kaamiki/extensions/video.py index 6db58d0..fd61709 100644 --- a/kaamiki/extensions/video.py +++ b/kaamiki/extensions/video.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 29 April, 2026 +Last updated on: 28 June, 2026 This module defines a custom `video` directive for the Kaamiki Sphinx Theme. The directive allows embedding a video directly within the @@ -60,7 +60,7 @@ class directive(rst.Directive): """Custom `video` directive for reStructuredText. This class defines the behaviour of the `video` directive, including - how it processes options and content, and how it generates nodes to + how it processes options and content and how it generates nodes to be inserted into the document tree. The directive supports the following options:: @@ -82,7 +82,7 @@ def run(self) -> list[nodes.Node]: """Parse directive options and create an `video` node. This method gathers all options provided by the user (if any) - in the `video` directive, constructs a new `node` instance, and + in the `video` directive, constructs a new `node` instance and returns it wrapped in a list. The returned node is then placed into the document tree at the diff --git a/kaamiki/extensions/youtube.py b/kaamiki/extensions/youtube.py index be7859b..53cd74e 100644 --- a/kaamiki/extensions/youtube.py +++ b/kaamiki/extensions/youtube.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 29 April, 2026 +Last updated on: 28 June, 2026 This module defines a custom `youtube` directive for the Kaamiki Sphinx Theme. The directive allows embedding a YouTube video directly within @@ -61,7 +61,7 @@ class directive(rst.Directive): """Custom `youtube` directive for reStructuredText. This class defines the behaviour of the `youtube` directive, - including how it processes options and content, and how it + including how it processes options and content and how it generates nodes to be inserted into the document tree. The directive supports the following options:: From 165a23948fed0470ebfbffceb8f76d4b18601074 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Tue, 4 Aug 2026 13:12:29 -0500 Subject: [PATCH 02/12] test: upstream slopcode Signed-off-by: Akshay Mestry --- docs/source/conf.py | 9 + docs/source/index.rst | 1 + docs/source/miscellany/slopcode.rst | 26 + kaamiki/extensions/__init__.py | 2 + kaamiki/extensions/slopcode.py | 1126 +++++++++++++++++++++++++++ 5 files changed, 1164 insertions(+) create mode 100644 docs/source/miscellany/slopcode.rst create mode 100644 kaamiki/extensions/slopcode.py diff --git a/docs/source/conf.py b/docs/source/conf.py index 77027a3..219b949 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -150,6 +150,15 @@ "show_searchbox": True, "show_sphinx": False, "show_toctree": True, + # Base URL of the deployed slopcode Azure Functions backend, read + # by the `slopcode` directive (kaamiki/extensions/slopcode.py). + # Update this after running azure/slopcode/provision.sh -- it + # prints the real Function App URL at the end, and again any time + # azure/slopcode/teardown.sh + provision.sh are re-run, since a + # fresh run generates a new random Function App name. + "slopcode_api_base_url": ( + "https://func-slopcode-294281165.azurewebsites.net/api" + ), "sidebar_buttons": { "Check my availability": { "link": "#", diff --git a/docs/source/index.rst b/docs/source/index.rst index 70b1ed5..2487c74 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -166,6 +166,7 @@ and my position, when asked for any help. :titlesonly: academia/thoughts-on-teaching + miscellany/slopcode .. toctree:: :caption: Explained diff --git a/docs/source/miscellany/slopcode.rst b/docs/source/miscellany/slopcode.rst new file mode 100644 index 0000000..f564960 --- /dev/null +++ b/docs/source/miscellany/slopcode.rst @@ -0,0 +1,26 @@ +.. Author: Akshay Mestry +.. Created on: 03 August, 2026 +.. Last updated on: 03 August, 2026 + +:og:title: Slopcode +:og:description: Can you tell human writing from AI writing? A live, + multiplayer game to find out. +:og:type: article + +.. _miscellany-slopcode: + +=============================================================================== +:fas:`robot far` Slopcode +=============================================================================== + +.. rst-class:: lead + + Can you tell human writing from AI writing? Let's find out, live. + +This is a small live experiment: gather a few people, everyone pastes in a +chunk of text, and then everyone votes on whether each piece was written by a +human or by an AI -- with a reason. Host controls the pace; everyone sees the +results at the end. + +.. slopcode:: + :expected-headcount-max: 20 diff --git a/kaamiki/extensions/__init__.py b/kaamiki/extensions/__init__.py index 1622093..b7669cf 100644 --- a/kaamiki/extensions/__init__.py +++ b/kaamiki/extensions/__init__.py @@ -22,6 +22,7 @@ from . import button from . import picture from . import repository +from . import slopcode from . import thumbnail from . import video from . import youtube @@ -34,6 +35,7 @@ button, picture, repository, + slopcode, thumbnail, video, youtube, diff --git a/kaamiki/extensions/slopcode.py b/kaamiki/extensions/slopcode.py new file mode 100644 index 0000000..e3e2eec --- /dev/null +++ b/kaamiki/extensions/slopcode.py @@ -0,0 +1,1126 @@ +"""\ +Slopcode Directive +================== + +Author: Akshay Mestry +Created on: 03 August, 2026 + +This module defines the `slopcode` directive for the Kaamiki Sphinx +Theme -- a live, multiplayer "spot the AI-generated text" game. + +Unlike every other directive in `kaamiki/extensions/`, this one is +deliberately monolithic: the directive class, the full HTML shell, the +component's CSS, and its client-side JS all live in this single file +as plain strings, rather than the theme's usual split across a +`.html.jinja` template plus entries appended to `theme.css`/`theme.js`. +That's an intentional, explicit deviation from the theme's convention +(every other directive keeps template/static assets in separate +files) -- everything this widget needs to look and behave like part of +the site lives in one place, so there is nothing to keep in sync +across files when it changes. + +The `slopcode` directive can be used in reStructuredText documents as +follows:: + + .. code-block:: rst + + .. slopcode:: + :expected-headcount-max: 20 + +GitHub Pages only serves static files -- there's no server-side code +execution here. So while this file renders the entire game's UI, it +cannot itself host a "room" that multiple visitors share live; every +visitor's browser independently runs the JS this directive emits, and +that JS talks over HTTPS/SignalR to a small Azure Functions backend +(see `azure/slopcode/function_app.py`) which is the actual source of +truth for who's in a room, what's been submitted, and how people +voted. This directive is the client, not the server. + +The deployed backend's base URL is read from `conf.py`'s +`html_context` as `slopcode_api_base_url` (matching how `author.py` +already pulls project-wide defaults from `html_context` rather than +hardcoding them), with a `:function-base-url:` directive option +available as a manual override on a specific invocation. +""" + +from __future__ import annotations + +import typing as t + +import docutils.nodes as nodes +import docutils.parsers.rst as rst + +if t.TYPE_CHECKING: + from sphinx.writers.html import HTMLTranslator + +name: t.Final[str] = "slopcode" + +# Matches SignalR Free_F1's hard 20-concurrent-connection ceiling -- +# the UI's own headcount cap can't promise a room size the backend is +# physically unable to support. +DEFAULT_HEADCOUNT_MAX: t.Final[int] = 20 + +SIGNALR_CLIENT_CDN_URL: t.Final[str] = ( + "https://cdn.jsdelivr.net/npm/@microsoft/signalr@8.0.7/" + "dist/browser/signalr.min.js" +) + + +class node(nodes.Element): + """Class to represent a custom node in the document tree. + + This class extends the `nodes.Element` from `docutils`, serving as + the container for the parsed information. The node will ultimately + be transformed into HTML or other output formats by the relevant + Sphinx translators. + """ + + +class directive(rst.Directive): + """Custom `slopcode` directive for reStructuredText. + + This class defines the behaviour of the `slopcode` directive. It + takes no required arguments -- its only job is to drop the game's + widget shell at a location in the page. + + The directive supports the following options:: + + - `function-base-url`: Override for the deployed Azure + Functions base URL. Normally this comes from `conf.py`'s + `html_context["slopcode_api_base_url"]`; this option exists + only as a per-invocation escape hatch. + - `expected-headcount-max`: Upper bound the host's + room-creation form won't let them exceed. Defaults to 20, + matching SignalR Free_F1's connection cap. + """ + + required_arguments = 0 + option_spec = { # noqa: RUF012 + "function-base-url": rst.directives.uri, + "expected-headcount-max": rst.directives.positive_int, + } + + def run(self) -> list[nodes.Node]: + """Parse directive options and create a `slopcode` node. + + Reads `conf.py`'s `html_context` for the deployed backend's + base URL, letting an explicit `:function-base-url:` option on + this specific invocation take precedence if one is given. + + :return: A list containing a single raw HTML node. + """ + ctx = self.state.document.settings.env.config.html_context + api_base_url = self.options.get( + "function-base-url", ctx.get("slopcode_api_base_url", "") + ) + headcount_max = self.options.get( + "expected-headcount-max", DEFAULT_HEADCOUNT_MAX + ) + if not api_base_url: + raise self.error( + "slopcode directive requires either a " + "':function-base-url:' option or " + "'slopcode_api_base_url' set in conf.py's html_context." + ) + + uid = f"km-slopcode-{id(self)}" + html = _render(uid=uid, api_base_url=api_base_url, headcount_max=headcount_max) + return [nodes.raw(text=html, format="html")] + + +def visit(self: HTMLTranslator, node: node) -> None: + """Handle the entry processing of the `slopcode` node during HTML + generation. + + The `slopcode` node carries no attributes -- `run()` already + rendered the final HTML into a `nodes.raw` node, so there's + nothing left to do here. Present only to match the + `app.add_node(..., html=(visit, depart))` signature every + directive in this theme registers. + + :param self: The HTML translator instance. + :param node: The `slopcode` node being processed. + """ + + +def depart(self: HTMLTranslator, node: node) -> None: + """Handle the exit processing of the `slopcode` node during HTML + generation. + + :param self: The HTML translator instance. + :param node: The `slopcode` node being processed. + """ + + +def _render(*, uid: str, api_base_url: str, headcount_max: int) -> str: + """Render the widget's full HTML+CSS+JS as a single string. + + Every phase of the game (login, lobby, submission, voting, + results) is present in the DOM from first load; only one is ever + un-hidden at a time via the shared `.site-slopcode--hidden` class, + toggled by the JS below. This keeps DOM structure stable across + phase transitions rather than swapping innerHTML wholesale. + """ + css = _CSS_TEMPLATE + html_shell = _HTML_TEMPLATE.format(uid=uid) + script = _JS_TEMPLATE.format( + uid=uid, api_base_url=api_base_url, headcount_max=headcount_max + ) + return ( + f'
\n' + f"\n" + f"{html_shell}\n" + f'\n' + f"\n" + f"
\n" + ) + + +# ---------------------------------------------------------------------- +# CSS +# ---------------------------------------------------------------------- +# +# New `.site-slopcode*` BEM blocks, matching the theme's existing +# naming convention (`.site-youtube-card`, `.site-feedback-shell--split`, +# etc.) and reusing the site's existing design tokens (`--km-color-*`, +# `--km-radius-normal`, `--km-ease-smooth`, ...) rather than inventing +# a new visual language. Scoped entirely under `.site-slopcode` so it +# can't leak into or collide with the rest of the theme's CSS. + +_CSS_TEMPLATE: t.Final[str] = """ +.site-slopcode { + max-width: var(--km-layout-container, 861px); + margin: 2rem auto; + font-family: var(--km-font-sans, inherit); + color: hsl(var(--km-color-fg)); +} + +.site-slopcode--hidden { + display: none !important; +} + +.site-slopcode__card { + background-color: hsl(var(--km-color-surface)); + border: 1px solid hsl(var(--km-color-border)); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 1.75rem; + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-normal, 200ms); +} + +.site-slopcode__title { + font-size: 1.15rem; + font-weight: 600; + margin: 0 0 1rem 0; + color: hsl(var(--km-color-fg-strong)); +} + +.site-slopcode__subtitle { + font-size: 0.9rem; + color: hsl(var(--km-color-fg) / 0.7); + margin: -0.5rem 0 1rem 0; +} + +.site-slopcode__field { + display: flex; + flex-direction: column; + gap: 0.35rem; + margin-bottom: 1rem; +} + +.site-slopcode__label { + font-size: 0.85rem; + font-weight: 500; +} + +.site-slopcode__input, +.site-slopcode__textarea { + background-color: hsl(var(--km-color-input, var(--km-color-bg))); + border: 1px solid hsl(var(--km-color-border)); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 0.6rem 0.75rem; + color: hsl(var(--km-color-fg)); + font-family: inherit; + font-size: 0.95rem; +} + +.site-slopcode__textarea { + min-height: 140px; + resize: vertical; +} + +.site-slopcode__button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + background-color: hsl(var(--km-color-primary)); + color: hsl(var(--km-color-bg)); + border: none; + border-radius: var(--km-radius-round, 500px); + padding: 0.55rem 1.25rem; + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition-property: opacity, transform; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-fast, 120ms); +} + +.site-slopcode__button:hover { + opacity: 0.9; +} + +.site-slopcode__button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.site-slopcode__button--secondary { + background-color: transparent; + color: hsl(var(--km-color-fg)); + border: 1px solid hsl(var(--km-color-border)); +} + +.site-slopcode__error { + color: hsl(var(--km-color-red, 0 70% 55%)); + font-size: 0.85rem; + margin-top: 0.5rem; + min-height: 1.1rem; +} + +.site-slopcode__lobby-share { + display: flex; + align-items: center; + gap: 0.6rem; + background-color: hsl(var(--km-color-accent-bg)); + color: hsl(var(--km-color-accent-fg)); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 0.75rem 1rem; + margin-bottom: 1.25rem; + font-family: var(--km-font-mono, monospace); + font-size: 1.1rem; + letter-spacing: 0.08em; +} + +.site-slopcode__lobby-count { + font-size: 0.85rem; + color: hsl(var(--km-color-fg) / 0.7); + margin-bottom: 0.75rem; +} + +.site-slopcode__participant-list { + list-style: none; + margin: 0 0 1.25rem 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.site-slopcode__participant { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-radius: var(--km-radius-normal, 0.75rem); + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); + font-size: 0.9rem; +} + +.site-slopcode__participant--host::after { + content: "Host"; + margin-left: auto; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--km-color-primary)); +} + +.site-slopcode__participant--you { + border: 1px solid hsl(var(--km-color-primary)); +} + +.site-slopcode__participant--you::before { + content: "You:"; + font-weight: 600; + color: hsl(var(--km-color-primary)); +} + +.site-slopcode__host-panel { + border-top: 1px dashed hsl(var(--km-color-border)); + margin-top: 1.25rem; + padding-top: 1.25rem; +} + +.site-slopcode__host-panel-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: hsl(var(--km-color-fg) / 0.6); + margin-bottom: 0.5rem; +} + +.site-slopcode__voting-content { + background-color: hsl(var(--km-color-code-bg, var(--km-color-muted-bg))); + border: 1px solid hsl(var(--km-color-code-border, var(--km-color-border))); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 1.25rem; + margin-bottom: 1.25rem; + white-space: pre-wrap; + font-size: 0.95rem; + line-height: 1.6; + max-height: 400px; + overflow-y: auto; +} + +.site-slopcode__voting-progress { + font-size: 0.8rem; + color: hsl(var(--km-color-fg) / 0.6); + margin-bottom: 1rem; +} + +.site-slopcode__voting-actions { + display: flex; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.site-slopcode__voting-verdict-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: var(--km-radius-normal, 0.75rem); + border: 2px solid hsl(var(--km-color-border)); + background-color: transparent; + color: hsl(var(--km-color-fg)); + padding: 0.75rem; + font-weight: 600; + cursor: pointer; + transition-property: border-color, background-color; + transition-timing-function: var(--km-ease-smooth, ease); + transition-duration: var(--km-duration-fast, 120ms); +} + +.site-slopcode__voting-verdict-btn--ai.site-slopcode__voting-verdict-btn--selected { + border-color: hsl(var(--km-color-red, 0 70% 55%)); + background-color: hsl(var(--km-color-red, 0 70% 55%) / 0.12); +} + +.site-slopcode__voting-verdict-btn--human.site-slopcode__voting-verdict-btn--selected { + border-color: hsl(var(--km-color-green, 140 55% 45%)); + background-color: hsl(var(--km-color-green, 140 55% 45%) / 0.12); +} + +.site-slopcode__voting-tally { + font-size: 0.85rem; + color: hsl(var(--km-color-fg) / 0.7); + margin: 1rem 0; +} + +.site-slopcode__host-vote-feed { + list-style: none; + margin: 0.5rem 0 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; + max-height: 220px; + overflow-y: auto; +} + +.site-slopcode__host-vote-feed-item { + font-size: 0.85rem; + padding: 0.5rem 0.75rem; + border-radius: var(--km-radius-normal, 0.75rem); + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); +} + +.site-slopcode__results-item { + border: 1px solid hsl(var(--km-color-border)); + border-radius: var(--km-radius-normal, 0.75rem); + padding: 1.25rem; + margin-bottom: 1.25rem; +} + +.site-slopcode__results-item-content { + white-space: pre-wrap; + font-size: 0.9rem; + line-height: 1.6; + margin-bottom: 0.75rem; + max-height: 250px; + overflow-y: auto; +} + +.site-slopcode__results-item-tally { + display: flex; + gap: 1.25rem; + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.75rem; +} + +.site-slopcode__results-item-votes { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.site-slopcode__results-item-vote { + font-size: 0.8rem; + color: hsl(var(--km-color-fg) / 0.8); + padding: 0.4rem 0.6rem; + border-radius: var(--km-radius-normal, 0.75rem); + background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); +} + +.site-slopcode__radio-group { + display: flex; + gap: 1rem; + margin-bottom: 1rem; +} + +.site-slopcode__radio-label { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; + cursor: pointer; +} +""" + + +# ---------------------------------------------------------------------- +# HTML shell +# ---------------------------------------------------------------------- +# +# Five phase containers, all present from first load, toggled by JS +# via `.site-slopcode--hidden`. `{uid}` scopes every element id so two +# invocations of this directive on one page (not the current plan, but +# free to support given the existing theme's per-instance-uid +# precedent in the archived interpreter extension) wouldn't collide. + +_HTML_TEMPLATE: t.Final[ + str +] = """ +
+

Slopcode

+

Got a room code? Join below -- no sign-in needed.

+
+ + +
+
+ + +
+ +

+ +
+

Hosting? Sign in to create a room

+
+ + +
+
+ + +
+ +

+ +
+ + + +
+
+
+ +
+

Waiting room

+
Room code:
+

You are:

+

+
    +
    +

    Host controls

    + +
    +
    + +
    +

    Paste your text

    +

    Nobody else can see this until voting starts.

    +
    + +
    + +

    +
    +

    Host controls

    +

    + +
    +
    + +
    +

    Vote: AI or Human?

    +
    +

    +
    +
    + + +
    +
    + + +
    + +

    +
    +
    +

    You're done voting. Waiting for everyone else to finish...

    +
    +

    +
    +

    Host view -- votes as they arrive

    +
      +
      +
      + +
      +

      Results

      +

      +
      +
      + +
        +
        + + +
        +
        +

        Host controls

        + +
        +
        + +

        + +""" + + +# ---------------------------------------------------------------------- +# Client-side JS +# ---------------------------------------------------------------------- +# +# Plain vanilla JS, no bundler/build step (this repo has none, and +# Alpine.js' availability elsewhere in the theme isn't reliable enough +# to depend on here) -- state lives in a handful of top-level `let` +# bindings, phases are toggled via one `showPhase()` function, and the +# only "polling" that ever happens is a single one-shot resync call +# right after a dropped SignalR connection reconnects -- never a +# periodic timer, since a forgotten open tab polling for days is +# exactly the kind of abuse the backend's cost guardrails are meant to +# rule out. + +_JS_TEMPLATE: t.Final[str] = """ +(function() {{ + const UID = "{uid}"; + const API_BASE = "{api_base_url}"; + const HEADCOUNT_MAX = {headcount_max}; + const STORAGE_KEY = "slopcode-session-" + UID; + + const $ = (id) => document.getElementById(UID + "-" + id); + + let hostToken = null; + let roomCode = null; + let participantId = null; + let sessionToken = null; + let isHost = false; + let myDisplayName = null; + let hasSubmitted = false; + let selectedVerdict = null; + let connection = null; + let resultsItems = []; + let resultsIndex = 0; + let resultsShowingComments = false; + + function showPhase(phase) {{ + ["choice", "lobby", "submission", "voting", "results"].forEach((p) => {{ + const el = document.getElementById(UID + "-phase-" + p); + if (el) el.classList.toggle("site-slopcode--hidden", p !== phase); + }}); + }} + + function setStatus(message) {{ + const el = $("connection-status"); + if (el) el.textContent = message || ""; + }} + + function saveSession() {{ + sessionStorage.setItem(STORAGE_KEY, JSON.stringify({{ + roomCode, participantId, sessionToken, isHost, myDisplayName, + }})); + }} + + function clearSession() {{ + sessionStorage.removeItem(STORAGE_KEY); + roomCode = participantId = sessionToken = myDisplayName = null; + isHost = false; + }} + + async function api(path, options) {{ + options = options || {{}}; + const headers = Object.assign({{ "Content-Type": "application/json" }}, options.headers || {{}}); + const response = await fetch(API_BASE + path, Object.assign({{}}, options, {{ headers }})); + let body = null; + try {{ body = await response.json(); }} catch (e) {{ /* empty body is fine */ }} + if (!response.ok) {{ + const message = (body && body.error) || ("Request failed (" + response.status + ")"); + throw new Error(message); + }} + return body; + }} + + // ---- Login ---- + + $("login-submit").addEventListener("click", async () => {{ + const username = $("login-username").value.trim(); + const password = $("login-password").value; + $("login-error").textContent = ""; + try {{ + const result = await api("/login", {{ + method: "POST", + body: JSON.stringify({{ username, password }}), + }}); + hostToken = result.hostToken; + $("host-create-fields").classList.remove("site-slopcode--hidden"); + $("login-submit").disabled = true; + $("login-username").disabled = true; + $("login-password").disabled = true; + }} catch (err) {{ + $("login-error").textContent = err.message; + }} + }}); + + // ---- Create / join room ---- + + $("choice-create").addEventListener("click", async () => {{ + $("choice-error").textContent = ""; + const headcount = parseInt($("choice-headcount").value, 10); + if (!headcount || headcount < 1 || headcount > HEADCOUNT_MAX) {{ + $("choice-error").textContent = "Enter a headcount from 1 to " + HEADCOUNT_MAX + "."; + return; + }} + try {{ + const result = await api("/rooms", {{ + method: "POST", + body: JSON.stringify({{ hostToken, expectedHeadcount: headcount }}), + }}); + roomCode = result.roomCode; + participantId = result.participantId; + sessionToken = result.sessionToken; + isHost = true; + myDisplayName = "Host"; + saveSession(); + await enterLobby(); + }} catch (err) {{ + $("choice-error").textContent = err.message; + }} + }}); + + $("choice-join").addEventListener("click", async () => {{ + $("choice-error").textContent = ""; + const code = $("choice-room-code").value.trim().toUpperCase(); + const displayName = $("choice-display-name").value.trim(); + if (!code) {{ + $("choice-error").textContent = "Enter a room code."; + return; + }} + try {{ + const result = await api("/rooms/" + code + "/join", {{ + method: "POST", + body: JSON.stringify({{ displayName: displayName || null }}), + }}); + roomCode = code; + participantId = result.participantId; + sessionToken = result.sessionToken; + isHost = false; + myDisplayName = result.displayName; + saveSession(); + await enterLobby(); + }} catch (err) {{ + $("choice-error").textContent = err.message; + }} + }}); + + // ---- Lobby ---- + + async function enterLobby() {{ + await connectRealtime(); + // resyncState() sets the correct phase itself (lobby, submission, + // or voting -- e.g. if this join was the last one needed and the + // room already auto-started by the time this client catches up). + // Don't force "lobby" afterward; that would silently overwrite it. + await resyncState(); + }} + + function renderParticipants(participants, expectedHeadcount) {{ + const list = $("lobby-participants"); + list.innerHTML = ""; + participants.forEach((p) => {{ + const li = document.createElement("li"); + let className = "site-slopcode__participant"; + if (p.isHost) className += " site-slopcode__participant--host"; + if (p.participantId === participantId) className += " site-slopcode__participant--you"; + li.className = className; + li.textContent = p.displayName; + list.appendChild(li); + }}); + $("lobby-count").textContent = participants.length + " of " + expectedHeadcount + " joined"; + $("lobby-room-code").textContent = roomCode; + $("my-display-name").textContent = myDisplayName || ""; + $("lobby-host-panel").classList.toggle("site-slopcode--hidden", !isHost); + }} + + $("lobby-start-submission").addEventListener("click", async () => {{ + try {{ + await api("/rooms/" + roomCode + "/force-start-submission", {{ + method: "POST", + body: JSON.stringify({{ sessionToken }}), + }}); + // Don't wait on the PhaseChanged broadcast to update our own + // screen -- the action already succeeded server-side, so pull + // fresh state directly rather than depend on a round-trip + // message that may be delayed, dropped, or racing a fresh + // SignalR connection that hasn't finished joining its group yet. + await resyncState(); + }} catch (err) {{ + setStatus(err.message); + }} + }}); + + // ---- Submission ---- + + $("submission-submit").addEventListener("click", async () => {{ + $("submission-error").textContent = ""; + const content = $("submission-textarea").value.trim(); + if (!content) {{ + $("submission-error").textContent = "Paste something first."; + return; + }} + try {{ + await api("/rooms/" + roomCode + "/submit", {{ + method: "POST", + body: JSON.stringify({{ sessionToken, content }}), + }}); + hasSubmitted = true; + $("submission-submit").disabled = true; + $("submission-textarea").disabled = true; + }} catch (err) {{ + $("submission-error").textContent = err.message; + }} + }}); + + $("submission-start-voting").addEventListener("click", async () => {{ + try {{ + await api("/rooms/" + roomCode + "/force-start-voting", {{ + method: "POST", + body: JSON.stringify({{ sessionToken }}), + }}); + await resyncState(); + }} catch (err) {{ + setStatus(err.message); + }} + }}); + + function enterSubmissionPhase() {{ + showPhase("submission"); + // Mirrors the equivalent toggle in renderVotingItem() for the + // voting phase's host panel -- without this, the host-only live + // submission count and "Start voting phase" button stay + // permanently hidden, since the section starts hidden in the + // markup and nothing else ever un-hides it. + $("submission-host-panel").classList.toggle("site-slopcode--hidden", !isHost); + }} + + // ---- Voting ---- + + function selectVerdict(verdict) {{ + selectedVerdict = verdict; + $("voting-vote-ai").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "ai"); + $("voting-vote-human").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "human"); + $("voting-submit").disabled = false; + }} + + $("voting-vote-ai").addEventListener("click", () => selectVerdict("ai")); + $("voting-vote-human").addEventListener("click", () => selectVerdict("human")); + + $("voting-submit").addEventListener("click", async () => {{ + $("voting-error").textContent = ""; + if (!selectedVerdict) return; + const reason = $("voting-reason").value.trim(); + $("voting-submit").disabled = true; + $("voting-vote-ai").disabled = true; + $("voting-vote-human").disabled = true; + $("voting-reason").disabled = true; + try {{ + const next = await api("/rooms/" + roomCode + "/vote", {{ + method: "POST", + body: JSON.stringify({{ sessionToken, verdict: selectedVerdict, reason }}), + }}); + renderVotingItem(next); + }} catch (err) {{ + $("voting-error").textContent = err.message; + $("voting-submit").disabled = false; + $("voting-vote-ai").disabled = false; + $("voting-vote-human").disabled = false; + $("voting-reason").disabled = false; + }} + }}); + + function renderVotingItem(data) {{ + $("voting-host-panel").classList.toggle("site-slopcode--hidden", !isHost); + + if (data.done) {{ + $("voting-active").classList.add("site-slopcode--hidden"); + $("voting-waiting").classList.remove("site-slopcode--hidden"); + return; + }} + $("voting-active").classList.remove("site-slopcode--hidden"); + $("voting-waiting").classList.add("site-slopcode--hidden"); + + $("voting-progress").textContent = "Item " + (data.itemIndex + 1) + " of " + data.totalItems; + $("voting-content").textContent = data.content; + selectedVerdict = null; + $("voting-vote-ai").classList.remove("site-slopcode__voting-verdict-btn--selected"); + $("voting-vote-human").classList.remove("site-slopcode__voting-verdict-btn--selected"); + $("voting-vote-ai").disabled = false; + $("voting-vote-human").disabled = false; + $("voting-reason").disabled = false; + $("voting-reason").value = ""; + $("voting-submit").disabled = true; + $("voting-error").textContent = ""; + }} + + // ---- Results ---- + + async function loadResults() {{ + $("results-host-panel").classList.toggle("site-slopcode--hidden", !isHost); + try {{ + const result = await api("/rooms/" + roomCode + "/results", {{ + headers: {{ "x-session-token": sessionToken }}, + }}); + resultsItems = result.items; + resultsIndex = 0; + resultsShowingComments = false; + renderResultsItem(); + }} catch (err) {{ + setStatus(err.message); + }} + }} + + function renderResultsItem() {{ + if (!resultsItems.length) {{ + $("results-progress").textContent = "No submissions."; + $("results-content").textContent = ""; + $("results-tally").textContent = ""; + $("results-votes").innerHTML = ""; + $("results-prev").disabled = true; + $("results-next").disabled = true; + return; + }} + const item = resultsItems[resultsIndex]; + $("results-progress").textContent = "Item " + (resultsIndex + 1) + " of " + resultsItems.length; + $("results-content").textContent = item.content; + $("results-tally").textContent = "AI: " + item.aiVotes + " Human: " + item.humanVotes; + + const votesList = $("results-votes"); + votesList.innerHTML = ""; + item.votes.forEach((v) => {{ + const li = document.createElement("li"); + li.className = "site-slopcode__results-item-vote"; + li.textContent = v.voterDisplayName + " voted " + v.verdict + " -- \\"" + v.reason + "\\""; + votesList.appendChild(li); + }}); + votesList.classList.toggle("site-slopcode--hidden", !resultsShowingComments); + $("results-toggle-comments").textContent = resultsShowingComments ? "Hide comments" : "Read comments"; + + $("results-prev").disabled = resultsIndex === 0; + $("results-next").disabled = resultsIndex === resultsItems.length - 1; + }} + + $("results-toggle-comments").addEventListener("click", () => {{ + resultsShowingComments = !resultsShowingComments; + renderResultsItem(); + }}); + + $("results-prev").addEventListener("click", () => {{ + if (resultsIndex === 0) return; + resultsIndex -= 1; + resultsShowingComments = false; + renderResultsItem(); + }}); + + $("results-next").addEventListener("click", () => {{ + if (resultsIndex >= resultsItems.length - 1) return; + resultsIndex += 1; + resultsShowingComments = false; + renderResultsItem(); + }}); + + // ---- Realtime (SignalR) ---- + + async function connectRealtime() {{ + if (connection) return; + const negotiateInfo = await api("/negotiate", {{ + method: "POST", + headers: {{ "x-participant-id": participantId }}, + body: JSON.stringify({{ roomCode, participantId }}), + }}).catch(() => null); + if (!negotiateInfo) {{ + setStatus("Couldn't connect to realtime updates. Refresh to retry."); + return; + }} + connection = new signalR.HubConnectionBuilder() + .withUrl(negotiateInfo.url, {{ accessTokenFactory: () => negotiateInfo.accessToken }}) + .withAutomaticReconnect() + .build(); + + connection.on("PresenceUpdated", (data) => renderParticipants(data.participants, data.expectedHeadcount)); + connection.on("PhaseChanged", async (data) => {{ + if (data.phase === "submission") enterSubmissionPhase(); + if (data.phase === "voting") {{ + showPhase("voting"); + const item = await api("/rooms/" + roomCode + "/voting-item", {{ + headers: {{ "x-session-token": sessionToken }}, + }}); + renderVotingItem(item); + }} + if (data.phase === "results") {{ showPhase("results"); loadResults(); }} + }}); + connection.on("SubmissionArrived", (data) => {{ + if (!isHost) return; + $("submission-host-count").textContent = data.submittedCount + " of " + data.totalParticipants + " submitted"; + }}); + connection.on("VoteTallyUpdated", (data) => {{ + $("voting-tally").textContent = data.votedCount + " of " + data.totalParticipants + " voted on that item"; + }}); + connection.on("VoteCastHostView", (data) => {{ + if (!isHost) return; + const li = document.createElement("li"); + li.className = "site-slopcode__host-vote-feed-item"; + li.textContent = data.voterDisplayName + ": " + data.verdict + " -- \\"" + data.reason + "\\""; + $("voting-host-feed").appendChild(li); + }}); + connection.on("ResultsReady", () => loadResults()); + connection.on("RoomRestarted", () => {{ + clearSession(); + window.location.reload(); + }}); + + async function joinSignalRGroups() {{ + await api("/rooms/" + roomCode + "/join-groups", {{ + method: "POST", + body: JSON.stringify({{ sessionToken, participantId }}), + }}); + }} + + connection.onreconnecting(() => setStatus("Reconnecting...")); + connection.onreconnected(async () => {{ + // A reconnect gets a brand-new underlying connection id -- group + // membership doesn't carry over, so without rejoining here this + // client would silently stop receiving any broadcast (phase + // changes, results, everything) until the page is manually + // reloaded, even though resyncState() below makes it LOOK caught + // up at this one instant. + await joinSignalRGroups(); + setStatus(""); + await resyncState(); + }}); + connection.onclose(() => setStatus("Disconnected. Refresh the page to rejoin.")); + + await connection.start(); + await joinSignalRGroups(); + }} + + async function resyncState() {{ + try {{ + const state = await api("/rooms/" + roomCode + "/state", {{ + headers: {{ "x-session-token": sessionToken }}, + }}); + isHost = state.isHost; + const me = state.participants.find((p) => p.participantId === participantId); + if (me) {{ + myDisplayName = me.displayName; + saveSession(); + }} + renderParticipants(state.participants, state.expectedHeadcount); + if (state.phase === "lobby") showPhase("lobby"); + if (state.phase === "submission") enterSubmissionPhase(); + if (state.phase === "voting") {{ + showPhase("voting"); + const item = await api("/rooms/" + roomCode + "/voting-item", {{ + headers: {{ "x-session-token": sessionToken }}, + }}); + renderVotingItem(item); + }} + if (state.phase === "results") {{ showPhase("results"); loadResults(); }} + }} catch (err) {{ + setStatus(err.message); + }} + }} + + $("manual-resync").addEventListener("click", async () => {{ + if (!roomCode) return; + setStatus("Refreshing..."); + await resyncState(); + setStatus(""); + }}); + + $("restart-session").addEventListener("click", async () => {{ + if (!window.confirm("This ends the session for everyone and returns them to the start. Continue?")) return; + try {{ + await api("/rooms/" + roomCode + "/restart", {{ + method: "POST", + body: JSON.stringify({{ sessionToken }}), + }}); + }} catch (err) {{ + setStatus(err.message); + }} + }}); + + // ---- Resume a session across page refreshes ---- + + (async function resumeIfPossible() {{ + const saved = sessionStorage.getItem(STORAGE_KEY); + if (!saved) {{ showPhase("choice"); return; }} + try {{ + const parsed = JSON.parse(saved); + roomCode = parsed.roomCode; + participantId = parsed.participantId; + sessionToken = parsed.sessionToken; + isHost = parsed.isHost; + myDisplayName = parsed.myDisplayName || null; + await connectRealtime(); + // resyncState() sets the correct phase itself -- don't force + // "lobby" here, that would silently overwrite it (same bug + // fixed in enterLobby() above). + await resyncState(); + }} catch (err) {{ + clearSession(); + showPhase("choice"); + }} + }})(); +}})(); +""" From 1ed621e415287721938da7eaa24423f7b85ba526 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Thu, 6 Aug 2026 11:58:13 -0500 Subject: [PATCH 03/12] style: remove ruff checks Signed-off-by: Akshay Mestry --- kaamiki/extensions/slopcode.py | 8 ++++---- pyproject.toml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/kaamiki/extensions/slopcode.py b/kaamiki/extensions/slopcode.py index e3e2eec..e25ace4 100644 --- a/kaamiki/extensions/slopcode.py +++ b/kaamiki/extensions/slopcode.py @@ -124,7 +124,9 @@ def run(self) -> list[nodes.Node]: ) uid = f"km-slopcode-{id(self)}" - html = _render(uid=uid, api_base_url=api_base_url, headcount_max=headcount_max) + html = _render( + uid=uid, api_base_url=api_base_url, headcount_max=headcount_max + ) return [nodes.raw(text=html, format="html")] @@ -506,9 +508,7 @@ def _render(*, uid: str, api_base_url: str, headcount_max: int) -> str: # free to support given the existing theme's per-instance-uid # precedent in the archived interpreter extension) wouldn't collide. -_HTML_TEMPLATE: t.Final[ - str -] = """ +_HTML_TEMPLATE: t.Final[str] = """

        Slopcode

        Got a room code? Join below -- no sign-in needed.

        diff --git a/pyproject.toml b/pyproject.toml index 9053166..ad90901 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -297,8 +297,8 @@ deps = [ "ruff>=0.12.7", ] commands = [ - [ "ruff", "format", "." ], - [ "ruff", "check", ".", "--fix" ], + # [ "ruff", "format", "." ], + # [ "ruff", "check", ".", "--fix" ], ] [tool.tox.env.typecheck] From 16b6198e03af2679259ddfc7e920c8af75619661 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Thu, 6 Aug 2026 11:59:33 -0500 Subject: [PATCH 04/12] build: add .gitignore Signed-off-by: Akshay Mestry --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index c2ad2f6..c6e625c 100755 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,8 @@ venv/ archive/* linting.html *.bak + +# Azure Functions secrets (slopcode backend) -- connection strings +# live in here, must never be committed. azure/slopcode/local.settings.json.example +# is the committed template; this only ignores the real, filled-in file. +azure/ From 70bc7e00a26de3211e2e2f5973e5b0221d4551f3 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Thu, 6 Aug 2026 12:26:44 -0500 Subject: [PATCH 05/12] build: trigger ci build Signed-off-by: Akshay Mestry --- .gitignore | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index c6e625c..1a5e0da 100755 --- a/.gitignore +++ b/.gitignore @@ -139,11 +139,7 @@ venv/ # Project's mess .github/**.md +azure/* archive/* linting.html *.bak - -# Azure Functions secrets (slopcode backend) -- connection strings -# live in here, must never be committed. azure/slopcode/local.settings.json.example -# is the committed template; this only ignores the real, filled-in file. -azure/ From da1cb7bf03c8e3e6642c0147f8366af87501aab8 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Tue, 18 Aug 2026 11:29:02 -0500 Subject: [PATCH 06/12] fix: remove slopcode Signed-off-by: Akshay Mestry --- docs/source/miscellany/slopcode.rst | 26 - kaamiki/extensions/slopcode.py | 1126 --------------------------- 2 files changed, 1152 deletions(-) delete mode 100644 docs/source/miscellany/slopcode.rst delete mode 100644 kaamiki/extensions/slopcode.py diff --git a/docs/source/miscellany/slopcode.rst b/docs/source/miscellany/slopcode.rst deleted file mode 100644 index f564960..0000000 --- a/docs/source/miscellany/slopcode.rst +++ /dev/null @@ -1,26 +0,0 @@ -.. Author: Akshay Mestry -.. Created on: 03 August, 2026 -.. Last updated on: 03 August, 2026 - -:og:title: Slopcode -:og:description: Can you tell human writing from AI writing? A live, - multiplayer game to find out. -:og:type: article - -.. _miscellany-slopcode: - -=============================================================================== -:fas:`robot far` Slopcode -=============================================================================== - -.. rst-class:: lead - - Can you tell human writing from AI writing? Let's find out, live. - -This is a small live experiment: gather a few people, everyone pastes in a -chunk of text, and then everyone votes on whether each piece was written by a -human or by an AI -- with a reason. Host controls the pace; everyone sees the -results at the end. - -.. slopcode:: - :expected-headcount-max: 20 diff --git a/kaamiki/extensions/slopcode.py b/kaamiki/extensions/slopcode.py deleted file mode 100644 index e25ace4..0000000 --- a/kaamiki/extensions/slopcode.py +++ /dev/null @@ -1,1126 +0,0 @@ -"""\ -Slopcode Directive -================== - -Author: Akshay Mestry -Created on: 03 August, 2026 - -This module defines the `slopcode` directive for the Kaamiki Sphinx -Theme -- a live, multiplayer "spot the AI-generated text" game. - -Unlike every other directive in `kaamiki/extensions/`, this one is -deliberately monolithic: the directive class, the full HTML shell, the -component's CSS, and its client-side JS all live in this single file -as plain strings, rather than the theme's usual split across a -`.html.jinja` template plus entries appended to `theme.css`/`theme.js`. -That's an intentional, explicit deviation from the theme's convention -(every other directive keeps template/static assets in separate -files) -- everything this widget needs to look and behave like part of -the site lives in one place, so there is nothing to keep in sync -across files when it changes. - -The `slopcode` directive can be used in reStructuredText documents as -follows:: - - .. code-block:: rst - - .. slopcode:: - :expected-headcount-max: 20 - -GitHub Pages only serves static files -- there's no server-side code -execution here. So while this file renders the entire game's UI, it -cannot itself host a "room" that multiple visitors share live; every -visitor's browser independently runs the JS this directive emits, and -that JS talks over HTTPS/SignalR to a small Azure Functions backend -(see `azure/slopcode/function_app.py`) which is the actual source of -truth for who's in a room, what's been submitted, and how people -voted. This directive is the client, not the server. - -The deployed backend's base URL is read from `conf.py`'s -`html_context` as `slopcode_api_base_url` (matching how `author.py` -already pulls project-wide defaults from `html_context` rather than -hardcoding them), with a `:function-base-url:` directive option -available as a manual override on a specific invocation. -""" - -from __future__ import annotations - -import typing as t - -import docutils.nodes as nodes -import docutils.parsers.rst as rst - -if t.TYPE_CHECKING: - from sphinx.writers.html import HTMLTranslator - -name: t.Final[str] = "slopcode" - -# Matches SignalR Free_F1's hard 20-concurrent-connection ceiling -- -# the UI's own headcount cap can't promise a room size the backend is -# physically unable to support. -DEFAULT_HEADCOUNT_MAX: t.Final[int] = 20 - -SIGNALR_CLIENT_CDN_URL: t.Final[str] = ( - "https://cdn.jsdelivr.net/npm/@microsoft/signalr@8.0.7/" - "dist/browser/signalr.min.js" -) - - -class node(nodes.Element): - """Class to represent a custom node in the document tree. - - This class extends the `nodes.Element` from `docutils`, serving as - the container for the parsed information. The node will ultimately - be transformed into HTML or other output formats by the relevant - Sphinx translators. - """ - - -class directive(rst.Directive): - """Custom `slopcode` directive for reStructuredText. - - This class defines the behaviour of the `slopcode` directive. It - takes no required arguments -- its only job is to drop the game's - widget shell at a location in the page. - - The directive supports the following options:: - - - `function-base-url`: Override for the deployed Azure - Functions base URL. Normally this comes from `conf.py`'s - `html_context["slopcode_api_base_url"]`; this option exists - only as a per-invocation escape hatch. - - `expected-headcount-max`: Upper bound the host's - room-creation form won't let them exceed. Defaults to 20, - matching SignalR Free_F1's connection cap. - """ - - required_arguments = 0 - option_spec = { # noqa: RUF012 - "function-base-url": rst.directives.uri, - "expected-headcount-max": rst.directives.positive_int, - } - - def run(self) -> list[nodes.Node]: - """Parse directive options and create a `slopcode` node. - - Reads `conf.py`'s `html_context` for the deployed backend's - base URL, letting an explicit `:function-base-url:` option on - this specific invocation take precedence if one is given. - - :return: A list containing a single raw HTML node. - """ - ctx = self.state.document.settings.env.config.html_context - api_base_url = self.options.get( - "function-base-url", ctx.get("slopcode_api_base_url", "") - ) - headcount_max = self.options.get( - "expected-headcount-max", DEFAULT_HEADCOUNT_MAX - ) - if not api_base_url: - raise self.error( - "slopcode directive requires either a " - "':function-base-url:' option or " - "'slopcode_api_base_url' set in conf.py's html_context." - ) - - uid = f"km-slopcode-{id(self)}" - html = _render( - uid=uid, api_base_url=api_base_url, headcount_max=headcount_max - ) - return [nodes.raw(text=html, format="html")] - - -def visit(self: HTMLTranslator, node: node) -> None: - """Handle the entry processing of the `slopcode` node during HTML - generation. - - The `slopcode` node carries no attributes -- `run()` already - rendered the final HTML into a `nodes.raw` node, so there's - nothing left to do here. Present only to match the - `app.add_node(..., html=(visit, depart))` signature every - directive in this theme registers. - - :param self: The HTML translator instance. - :param node: The `slopcode` node being processed. - """ - - -def depart(self: HTMLTranslator, node: node) -> None: - """Handle the exit processing of the `slopcode` node during HTML - generation. - - :param self: The HTML translator instance. - :param node: The `slopcode` node being processed. - """ - - -def _render(*, uid: str, api_base_url: str, headcount_max: int) -> str: - """Render the widget's full HTML+CSS+JS as a single string. - - Every phase of the game (login, lobby, submission, voting, - results) is present in the DOM from first load; only one is ever - un-hidden at a time via the shared `.site-slopcode--hidden` class, - toggled by the JS below. This keeps DOM structure stable across - phase transitions rather than swapping innerHTML wholesale. - """ - css = _CSS_TEMPLATE - html_shell = _HTML_TEMPLATE.format(uid=uid) - script = _JS_TEMPLATE.format( - uid=uid, api_base_url=api_base_url, headcount_max=headcount_max - ) - return ( - f'
        \n' - f"\n" - f"{html_shell}\n" - f'\n' - f"\n" - f"
        \n" - ) - - -# ---------------------------------------------------------------------- -# CSS -# ---------------------------------------------------------------------- -# -# New `.site-slopcode*` BEM blocks, matching the theme's existing -# naming convention (`.site-youtube-card`, `.site-feedback-shell--split`, -# etc.) and reusing the site's existing design tokens (`--km-color-*`, -# `--km-radius-normal`, `--km-ease-smooth`, ...) rather than inventing -# a new visual language. Scoped entirely under `.site-slopcode` so it -# can't leak into or collide with the rest of the theme's CSS. - -_CSS_TEMPLATE: t.Final[str] = """ -.site-slopcode { - max-width: var(--km-layout-container, 861px); - margin: 2rem auto; - font-family: var(--km-font-sans, inherit); - color: hsl(var(--km-color-fg)); -} - -.site-slopcode--hidden { - display: none !important; -} - -.site-slopcode__card { - background-color: hsl(var(--km-color-surface)); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.75rem; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-normal, 200ms); -} - -.site-slopcode__title { - font-size: 1.15rem; - font-weight: 600; - margin: 0 0 1rem 0; - color: hsl(var(--km-color-fg-strong)); -} - -.site-slopcode__subtitle { - font-size: 0.9rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: -0.5rem 0 1rem 0; -} - -.site-slopcode__field { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin-bottom: 1rem; -} - -.site-slopcode__label { - font-size: 0.85rem; - font-weight: 500; -} - -.site-slopcode__input, -.site-slopcode__textarea { - background-color: hsl(var(--km-color-input, var(--km-color-bg))); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.6rem 0.75rem; - color: hsl(var(--km-color-fg)); - font-family: inherit; - font-size: 0.95rem; -} - -.site-slopcode__textarea { - min-height: 140px; - resize: vertical; -} - -.site-slopcode__button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - background-color: hsl(var(--km-color-primary)); - color: hsl(var(--km-color-bg)); - border: none; - border-radius: var(--km-radius-round, 500px); - padding: 0.55rem 1.25rem; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__button:hover { - opacity: 0.9; -} - -.site-slopcode__button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.site-slopcode__button--secondary { - background-color: transparent; - color: hsl(var(--km-color-fg)); - border: 1px solid hsl(var(--km-color-border)); -} - -.site-slopcode__error { - color: hsl(var(--km-color-red, 0 70% 55%)); - font-size: 0.85rem; - margin-top: 0.5rem; - min-height: 1.1rem; -} - -.site-slopcode__lobby-share { - display: flex; - align-items: center; - gap: 0.6rem; - background-color: hsl(var(--km-color-accent-bg)); - color: hsl(var(--km-color-accent-fg)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.75rem 1rem; - margin-bottom: 1.25rem; - font-family: var(--km-font-mono, monospace); - font-size: 1.1rem; - letter-spacing: 0.08em; -} - -.site-slopcode__lobby-count { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin-bottom: 0.75rem; -} - -.site-slopcode__participant-list { - list-style: none; - margin: 0 0 1.25rem 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.site-slopcode__participant { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); - font-size: 0.9rem; -} - -.site-slopcode__participant--host::after { - content: "Host"; - margin-left: auto; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you { - border: 1px solid hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you::before { - content: "You:"; - font-weight: 600; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__host-panel { - border-top: 1px dashed hsl(var(--km-color-border)); - margin-top: 1.25rem; - padding-top: 1.25rem; -} - -.site-slopcode__host-panel-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 0.5rem; -} - -.site-slopcode__voting-content { - background-color: hsl(var(--km-color-code-bg, var(--km-color-muted-bg))); - border: 1px solid hsl(var(--km-color-code-border, var(--km-color-border))); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; - white-space: pre-wrap; - font-size: 0.95rem; - line-height: 1.6; - max-height: 400px; - overflow-y: auto; -} - -.site-slopcode__voting-progress { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 1rem; -} - -.site-slopcode__voting-actions { - display: flex; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.site-slopcode__voting-verdict-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border-radius: var(--km-radius-normal, 0.75rem); - border: 2px solid hsl(var(--km-color-border)); - background-color: transparent; - color: hsl(var(--km-color-fg)); - padding: 0.75rem; - font-weight: 600; - cursor: pointer; - transition-property: border-color, background-color; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__voting-verdict-btn--ai.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-red, 0 70% 55%)); - background-color: hsl(var(--km-color-red, 0 70% 55%) / 0.12); -} - -.site-slopcode__voting-verdict-btn--human.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-green, 140 55% 45%)); - background-color: hsl(var(--km-color-green, 140 55% 45%) / 0.12); -} - -.site-slopcode__voting-tally { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: 1rem 0; -} - -.site-slopcode__host-vote-feed { - list-style: none; - margin: 0.5rem 0 0 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; - max-height: 220px; - overflow-y: auto; -} - -.site-slopcode__host-vote-feed-item { - font-size: 0.85rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__results-item { - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; -} - -.site-slopcode__results-item-content { - white-space: pre-wrap; - font-size: 0.9rem; - line-height: 1.6; - margin-bottom: 0.75rem; - max-height: 250px; - overflow-y: auto; -} - -.site-slopcode__results-item-tally { - display: flex; - gap: 1.25rem; - font-size: 0.9rem; - font-weight: 600; - margin-bottom: 0.75rem; -} - -.site-slopcode__results-item-votes { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.site-slopcode__results-item-vote { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.8); - padding: 0.4rem 0.6rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__radio-group { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.site-slopcode__radio-label { - display: flex; - align-items: center; - gap: 0.4rem; - font-size: 0.9rem; - cursor: pointer; -} -""" - - -# ---------------------------------------------------------------------- -# HTML shell -# ---------------------------------------------------------------------- -# -# Five phase containers, all present from first load, toggled by JS -# via `.site-slopcode--hidden`. `{uid}` scopes every element id so two -# invocations of this directive on one page (not the current plan, but -# free to support given the existing theme's per-instance-uid -# precedent in the archived interpreter extension) wouldn't collide. - -_HTML_TEMPLATE: t.Final[str] = """ -
        -

        Slopcode

        -

        Got a room code? Join below -- no sign-in needed.

        -
        - - -
        -
        - - -
        - -

        - -
        -

        Hosting? Sign in to create a room

        -
        - - -
        -
        - - -
        - -

        - -
        - - - -
        -
        -
        - -
        -

        Waiting room

        -
        Room code:
        -

        You are:

        -

        -
          -
          -

          Host controls

          - -
          -
          - -
          -

          Paste your text

          -

          Nobody else can see this until voting starts.

          -
          - -
          - -

          -
          -

          Host controls

          -

          - -
          -
          - -
          -

          Vote: AI or Human?

          -
          -

          -
          -
          - - -
          -
          - - -
          - -

          -
          -
          -

          You're done voting. Waiting for everyone else to finish...

          -
          -

          -
          -

          Host view -- votes as they arrive

          -
            -
            -
            - -
            -

            Results

            -

            -
            -
            - -
              -
              - - -
              -
              -

              Host controls

              - -
              -
              - -

              - -""" - - -# ---------------------------------------------------------------------- -# Client-side JS -# ---------------------------------------------------------------------- -# -# Plain vanilla JS, no bundler/build step (this repo has none, and -# Alpine.js' availability elsewhere in the theme isn't reliable enough -# to depend on here) -- state lives in a handful of top-level `let` -# bindings, phases are toggled via one `showPhase()` function, and the -# only "polling" that ever happens is a single one-shot resync call -# right after a dropped SignalR connection reconnects -- never a -# periodic timer, since a forgotten open tab polling for days is -# exactly the kind of abuse the backend's cost guardrails are meant to -# rule out. - -_JS_TEMPLATE: t.Final[str] = """ -(function() {{ - const UID = "{uid}"; - const API_BASE = "{api_base_url}"; - const HEADCOUNT_MAX = {headcount_max}; - const STORAGE_KEY = "slopcode-session-" + UID; - - const $ = (id) => document.getElementById(UID + "-" + id); - - let hostToken = null; - let roomCode = null; - let participantId = null; - let sessionToken = null; - let isHost = false; - let myDisplayName = null; - let hasSubmitted = false; - let selectedVerdict = null; - let connection = null; - let resultsItems = []; - let resultsIndex = 0; - let resultsShowingComments = false; - - function showPhase(phase) {{ - ["choice", "lobby", "submission", "voting", "results"].forEach((p) => {{ - const el = document.getElementById(UID + "-phase-" + p); - if (el) el.classList.toggle("site-slopcode--hidden", p !== phase); - }}); - }} - - function setStatus(message) {{ - const el = $("connection-status"); - if (el) el.textContent = message || ""; - }} - - function saveSession() {{ - sessionStorage.setItem(STORAGE_KEY, JSON.stringify({{ - roomCode, participantId, sessionToken, isHost, myDisplayName, - }})); - }} - - function clearSession() {{ - sessionStorage.removeItem(STORAGE_KEY); - roomCode = participantId = sessionToken = myDisplayName = null; - isHost = false; - }} - - async function api(path, options) {{ - options = options || {{}}; - const headers = Object.assign({{ "Content-Type": "application/json" }}, options.headers || {{}}); - const response = await fetch(API_BASE + path, Object.assign({{}}, options, {{ headers }})); - let body = null; - try {{ body = await response.json(); }} catch (e) {{ /* empty body is fine */ }} - if (!response.ok) {{ - const message = (body && body.error) || ("Request failed (" + response.status + ")"); - throw new Error(message); - }} - return body; - }} - - // ---- Login ---- - - $("login-submit").addEventListener("click", async () => {{ - const username = $("login-username").value.trim(); - const password = $("login-password").value; - $("login-error").textContent = ""; - try {{ - const result = await api("/login", {{ - method: "POST", - body: JSON.stringify({{ username, password }}), - }}); - hostToken = result.hostToken; - $("host-create-fields").classList.remove("site-slopcode--hidden"); - $("login-submit").disabled = true; - $("login-username").disabled = true; - $("login-password").disabled = true; - }} catch (err) {{ - $("login-error").textContent = err.message; - }} - }}); - - // ---- Create / join room ---- - - $("choice-create").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const headcount = parseInt($("choice-headcount").value, 10); - if (!headcount || headcount < 1 || headcount > HEADCOUNT_MAX) {{ - $("choice-error").textContent = "Enter a headcount from 1 to " + HEADCOUNT_MAX + "."; - return; - }} - try {{ - const result = await api("/rooms", {{ - method: "POST", - body: JSON.stringify({{ hostToken, expectedHeadcount: headcount }}), - }}); - roomCode = result.roomCode; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = true; - myDisplayName = "Host"; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - $("choice-join").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const code = $("choice-room-code").value.trim().toUpperCase(); - const displayName = $("choice-display-name").value.trim(); - if (!code) {{ - $("choice-error").textContent = "Enter a room code."; - return; - }} - try {{ - const result = await api("/rooms/" + code + "/join", {{ - method: "POST", - body: JSON.stringify({{ displayName: displayName || null }}), - }}); - roomCode = code; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = false; - myDisplayName = result.displayName; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - // ---- Lobby ---- - - async function enterLobby() {{ - await connectRealtime(); - // resyncState() sets the correct phase itself (lobby, submission, - // or voting -- e.g. if this join was the last one needed and the - // room already auto-started by the time this client catches up). - // Don't force "lobby" afterward; that would silently overwrite it. - await resyncState(); - }} - - function renderParticipants(participants, expectedHeadcount) {{ - const list = $("lobby-participants"); - list.innerHTML = ""; - participants.forEach((p) => {{ - const li = document.createElement("li"); - let className = "site-slopcode__participant"; - if (p.isHost) className += " site-slopcode__participant--host"; - if (p.participantId === participantId) className += " site-slopcode__participant--you"; - li.className = className; - li.textContent = p.displayName; - list.appendChild(li); - }}); - $("lobby-count").textContent = participants.length + " of " + expectedHeadcount + " joined"; - $("lobby-room-code").textContent = roomCode; - $("my-display-name").textContent = myDisplayName || ""; - $("lobby-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - $("lobby-start-submission").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-submission", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - // Don't wait on the PhaseChanged broadcast to update our own - // screen -- the action already succeeded server-side, so pull - // fresh state directly rather than depend on a round-trip - // message that may be delayed, dropped, or racing a fresh - // SignalR connection that hasn't finished joining its group yet. - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Submission ---- - - $("submission-submit").addEventListener("click", async () => {{ - $("submission-error").textContent = ""; - const content = $("submission-textarea").value.trim(); - if (!content) {{ - $("submission-error").textContent = "Paste something first."; - return; - }} - try {{ - await api("/rooms/" + roomCode + "/submit", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, content }}), - }}); - hasSubmitted = true; - $("submission-submit").disabled = true; - $("submission-textarea").disabled = true; - }} catch (err) {{ - $("submission-error").textContent = err.message; - }} - }}); - - $("submission-start-voting").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-voting", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - function enterSubmissionPhase() {{ - showPhase("submission"); - // Mirrors the equivalent toggle in renderVotingItem() for the - // voting phase's host panel -- without this, the host-only live - // submission count and "Start voting phase" button stay - // permanently hidden, since the section starts hidden in the - // markup and nothing else ever un-hides it. - $("submission-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - // ---- Voting ---- - - function selectVerdict(verdict) {{ - selectedVerdict = verdict; - $("voting-vote-ai").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "ai"); - $("voting-vote-human").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "human"); - $("voting-submit").disabled = false; - }} - - $("voting-vote-ai").addEventListener("click", () => selectVerdict("ai")); - $("voting-vote-human").addEventListener("click", () => selectVerdict("human")); - - $("voting-submit").addEventListener("click", async () => {{ - $("voting-error").textContent = ""; - if (!selectedVerdict) return; - const reason = $("voting-reason").value.trim(); - $("voting-submit").disabled = true; - $("voting-vote-ai").disabled = true; - $("voting-vote-human").disabled = true; - $("voting-reason").disabled = true; - try {{ - const next = await api("/rooms/" + roomCode + "/vote", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, verdict: selectedVerdict, reason }}), - }}); - renderVotingItem(next); - }} catch (err) {{ - $("voting-error").textContent = err.message; - $("voting-submit").disabled = false; - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - }} - }}); - - function renderVotingItem(data) {{ - $("voting-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - - if (data.done) {{ - $("voting-active").classList.add("site-slopcode--hidden"); - $("voting-waiting").classList.remove("site-slopcode--hidden"); - return; - }} - $("voting-active").classList.remove("site-slopcode--hidden"); - $("voting-waiting").classList.add("site-slopcode--hidden"); - - $("voting-progress").textContent = "Item " + (data.itemIndex + 1) + " of " + data.totalItems; - $("voting-content").textContent = data.content; - selectedVerdict = null; - $("voting-vote-ai").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-human").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - $("voting-reason").value = ""; - $("voting-submit").disabled = true; - $("voting-error").textContent = ""; - }} - - // ---- Results ---- - - async function loadResults() {{ - $("results-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - try {{ - const result = await api("/rooms/" + roomCode + "/results", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - resultsItems = result.items; - resultsIndex = 0; - resultsShowingComments = false; - renderResultsItem(); - }} catch (err) {{ - setStatus(err.message); - }} - }} - - function renderResultsItem() {{ - if (!resultsItems.length) {{ - $("results-progress").textContent = "No submissions."; - $("results-content").textContent = ""; - $("results-tally").textContent = ""; - $("results-votes").innerHTML = ""; - $("results-prev").disabled = true; - $("results-next").disabled = true; - return; - }} - const item = resultsItems[resultsIndex]; - $("results-progress").textContent = "Item " + (resultsIndex + 1) + " of " + resultsItems.length; - $("results-content").textContent = item.content; - $("results-tally").textContent = "AI: " + item.aiVotes + " Human: " + item.humanVotes; - - const votesList = $("results-votes"); - votesList.innerHTML = ""; - item.votes.forEach((v) => {{ - const li = document.createElement("li"); - li.className = "site-slopcode__results-item-vote"; - li.textContent = v.voterDisplayName + " voted " + v.verdict + " -- \\"" + v.reason + "\\""; - votesList.appendChild(li); - }}); - votesList.classList.toggle("site-slopcode--hidden", !resultsShowingComments); - $("results-toggle-comments").textContent = resultsShowingComments ? "Hide comments" : "Read comments"; - - $("results-prev").disabled = resultsIndex === 0; - $("results-next").disabled = resultsIndex === resultsItems.length - 1; - }} - - $("results-toggle-comments").addEventListener("click", () => {{ - resultsShowingComments = !resultsShowingComments; - renderResultsItem(); - }}); - - $("results-prev").addEventListener("click", () => {{ - if (resultsIndex === 0) return; - resultsIndex -= 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - $("results-next").addEventListener("click", () => {{ - if (resultsIndex >= resultsItems.length - 1) return; - resultsIndex += 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - // ---- Realtime (SignalR) ---- - - async function connectRealtime() {{ - if (connection) return; - const negotiateInfo = await api("/negotiate", {{ - method: "POST", - headers: {{ "x-participant-id": participantId }}, - body: JSON.stringify({{ roomCode, participantId }}), - }}).catch(() => null); - if (!negotiateInfo) {{ - setStatus("Couldn't connect to realtime updates. Refresh to retry."); - return; - }} - connection = new signalR.HubConnectionBuilder() - .withUrl(negotiateInfo.url, {{ accessTokenFactory: () => negotiateInfo.accessToken }}) - .withAutomaticReconnect() - .build(); - - connection.on("PresenceUpdated", (data) => renderParticipants(data.participants, data.expectedHeadcount)); - connection.on("PhaseChanged", async (data) => {{ - if (data.phase === "submission") enterSubmissionPhase(); - if (data.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (data.phase === "results") {{ showPhase("results"); loadResults(); }} - }}); - connection.on("SubmissionArrived", (data) => {{ - if (!isHost) return; - $("submission-host-count").textContent = data.submittedCount + " of " + data.totalParticipants + " submitted"; - }}); - connection.on("VoteTallyUpdated", (data) => {{ - $("voting-tally").textContent = data.votedCount + " of " + data.totalParticipants + " voted on that item"; - }}); - connection.on("VoteCastHostView", (data) => {{ - if (!isHost) return; - const li = document.createElement("li"); - li.className = "site-slopcode__host-vote-feed-item"; - li.textContent = data.voterDisplayName + ": " + data.verdict + " -- \\"" + data.reason + "\\""; - $("voting-host-feed").appendChild(li); - }}); - connection.on("ResultsReady", () => loadResults()); - connection.on("RoomRestarted", () => {{ - clearSession(); - window.location.reload(); - }}); - - async function joinSignalRGroups() {{ - await api("/rooms/" + roomCode + "/join-groups", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, participantId }}), - }}); - }} - - connection.onreconnecting(() => setStatus("Reconnecting...")); - connection.onreconnected(async () => {{ - // A reconnect gets a brand-new underlying connection id -- group - // membership doesn't carry over, so without rejoining here this - // client would silently stop receiving any broadcast (phase - // changes, results, everything) until the page is manually - // reloaded, even though resyncState() below makes it LOOK caught - // up at this one instant. - await joinSignalRGroups(); - setStatus(""); - await resyncState(); - }}); - connection.onclose(() => setStatus("Disconnected. Refresh the page to rejoin.")); - - await connection.start(); - await joinSignalRGroups(); - }} - - async function resyncState() {{ - try {{ - const state = await api("/rooms/" + roomCode + "/state", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - isHost = state.isHost; - const me = state.participants.find((p) => p.participantId === participantId); - if (me) {{ - myDisplayName = me.displayName; - saveSession(); - }} - renderParticipants(state.participants, state.expectedHeadcount); - if (state.phase === "lobby") showPhase("lobby"); - if (state.phase === "submission") enterSubmissionPhase(); - if (state.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (state.phase === "results") {{ showPhase("results"); loadResults(); }} - }} catch (err) {{ - setStatus(err.message); - }} - }} - - $("manual-resync").addEventListener("click", async () => {{ - if (!roomCode) return; - setStatus("Refreshing..."); - await resyncState(); - setStatus(""); - }}); - - $("restart-session").addEventListener("click", async () => {{ - if (!window.confirm("This ends the session for everyone and returns them to the start. Continue?")) return; - try {{ - await api("/rooms/" + roomCode + "/restart", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Resume a session across page refreshes ---- - - (async function resumeIfPossible() {{ - const saved = sessionStorage.getItem(STORAGE_KEY); - if (!saved) {{ showPhase("choice"); return; }} - try {{ - const parsed = JSON.parse(saved); - roomCode = parsed.roomCode; - participantId = parsed.participantId; - sessionToken = parsed.sessionToken; - isHost = parsed.isHost; - myDisplayName = parsed.myDisplayName || null; - await connectRealtime(); - // resyncState() sets the correct phase itself -- don't force - // "lobby" here, that would silently overwrite it (same bug - // fixed in enterLobby() above). - await resyncState(); - }} catch (err) {{ - clearSession(); - showPhase("choice"); - }} - }})(); -}})(); -""" From c34b8ba79fe8f53587732e7e9a17acf187b57fb2 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Tue, 18 Aug 2026 11:30:04 -0500 Subject: [PATCH 07/12] docs: upstream is this ai section Signed-off-by: Akshay Mestry --- docs/source/academia/is-this-ai.rst | 42 +++++++++++++++++++++++++++++ docs/source/conf.py | 11 +------- docs/source/index.rst | 4 +-- 3 files changed, 45 insertions(+), 12 deletions(-) create mode 100644 docs/source/academia/is-this-ai.rst diff --git a/docs/source/academia/is-this-ai.rst b/docs/source/academia/is-this-ai.rst new file mode 100644 index 0000000..3db7d8c --- /dev/null +++ b/docs/source/academia/is-this-ai.rst @@ -0,0 +1,42 @@ +.. Author: Akshay Mestry +.. Created on: 11 August, 2026 +.. Last updated on: 18 August, 2026 + +:og:title: Is this AI? +:og:description: A completely scientific* investigation into whether (real) + humans can actually tell AI writing from human writing. +:og:type: article +:fb:title: What did you think of this idea? +:fb:description: Make your guess. Tell me why. +:fb:button: Let me know! + +.. _academia-is-this-ai: + +=============================================================================== +:fas:`robot far` Is this AI? +=============================================================================== + +.. rst-class:: lead + + People think they can tell AI writing from human writing, they're probably + wrong. + +.. author:: @xames3 + :avatar: https://avatars.githubusercontent.com/u/90549089?v=4 + :target: https://github.com/xames3 + :background: https://m.media-amazon.com/images/M/MV5BMzFiYjI5YWItNjUwOC00MzllLWIwODItMGE0ZWNhODJkOTA0XkEyXkFqcGc@._V1_.jpg + https://static.wikia.nocookie.net/familyguyfanon/images/0/04/Clash_for_Mayors.jpg + +This is a small live experiment: gather a few people, everyone pastes in a +chunk of text, and then everyone votes on whether each piece was written by a +human or by an AI, with their optional reasons. + +.. iframe:: + :uid: km-css230 + :api-base-url: https://func-slopcode-294281165.azurewebsites.net/api + :local-api-base-url: http://localhost:7071/api + :local-dev: false + :expected-headcount-max: 20 + :file: ../assets/html/is-this-ai.html + :css: css/is-this-ai.css + :js: js/is-this-ai.js diff --git a/docs/source/conf.py b/docs/source/conf.py index 219b949..c75cfea 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 28 June, 2026 +Last updated on: 12 August, 2026 This file contains the configuration settings for building my static website using Sphinx, a popular Python documentation tool. Sphinx is a @@ -150,15 +150,6 @@ "show_searchbox": True, "show_sphinx": False, "show_toctree": True, - # Base URL of the deployed slopcode Azure Functions backend, read - # by the `slopcode` directive (kaamiki/extensions/slopcode.py). - # Update this after running azure/slopcode/provision.sh -- it - # prints the real Function App URL at the end, and again any time - # azure/slopcode/teardown.sh + provision.sh are re-run, since a - # fresh run generates a new random Function App name. - "slopcode_api_base_url": ( - "https://func-slopcode-294281165.azurewebsites.net/api" - ), "sidebar_buttons": { "Check my availability": { "link": "#", diff --git a/docs/source/index.rst b/docs/source/index.rst index 2487c74..da70c9a 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,6 +1,6 @@ .. Author: Akshay Mestry .. Created on: 22 February, 2025 -.. Last updated on: 28 June, 2026 +.. Last updated on: 12 August, 2026 :orphan: :og:title: Akshay's Corner @@ -166,7 +166,7 @@ and my position, when asked for any help. :titlesonly: academia/thoughts-on-teaching - miscellany/slopcode + academia/is-this-ai .. toctree:: :caption: Explained From 2994884dec4db7a109e3febce6048c8dee43cb76 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Tue, 18 Aug 2026 11:31:09 -0500 Subject: [PATCH 08/12] feat(iframe): upstream iframe directive Signed-off-by: Akshay Mestry --- kaamiki/extensions/__init__.py | 6 +- kaamiki/extensions/iframe.py | 227 +++++++++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 kaamiki/extensions/iframe.py diff --git a/kaamiki/extensions/__init__.py b/kaamiki/extensions/__init__.py index b7669cf..4cbbf8a 100644 --- a/kaamiki/extensions/__init__.py +++ b/kaamiki/extensions/__init__.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 04 May, 2026 +Last updated on: 12 August, 2026 This module manages Kaamiki Sphinx Theme's custom directive and roles. @@ -20,9 +20,9 @@ from . import author from . import button +from . import iframe from . import picture from . import repository -from . import slopcode from . import thumbnail from . import video from . import youtube @@ -33,9 +33,9 @@ directives: t.Sequence[types.ModuleType] = ( author, button, + iframe, picture, repository, - slopcode, thumbnail, video, youtube, diff --git a/kaamiki/extensions/iframe.py b/kaamiki/extensions/iframe.py new file mode 100644 index 0000000..4337b08 --- /dev/null +++ b/kaamiki/extensions/iframe.py @@ -0,0 +1,227 @@ +"""\ +iFrame Directive +================ + +Author: Akshay Mestry +Created on: 11 August, 2026 +Last updated on: 18 August, 2026 + +This module defines a custom `iframe` directive for the Kaamiki Sphinx +Theme. The directive allows adding an (optionally) customisable iframe +directly within the document. + +The `iframe` directive can be used in reStructuredText documents as +follows:: + + .. code-block:: rst + + .. iframe:: + :expected-count: 10 + + + +To keep large embeds out of the page's own reST source, the directive +also accepts a `:file:` option pointing at an external HTML fragment. +That fragment may in turn need its own CSS and/or JavaScript. Rather +than inlining `\n" - f"{html_shell}\n" - f'\n' - f"\n" - f"\n" - ) - - -# ---------------------------------------------------------------------- -# CSS -# ---------------------------------------------------------------------- -# -# New `.site-slopcode*` BEM blocks, matching the theme's existing -# naming convention (`.site-youtube-card`, `.site-feedback-shell--split`, -# etc.) and reusing the site's existing design tokens (`--km-color-*`, -# `--km-radius-normal`, `--km-ease-smooth`, ...) rather than inventing -# a new visual language. Scoped entirely under `.site-slopcode` so it -# can't leak into or collide with the rest of the theme's CSS. - -_CSS_TEMPLATE: t.Final[str] = """ -.site-slopcode { - max-width: var(--km-layout-container, 861px); - margin: 2rem auto; - font-family: var(--km-font-sans, inherit); - color: hsl(var(--km-color-fg)); -} - -.site-slopcode--hidden { - display: none !important; -} - -.site-slopcode__card { - background-color: hsl(var(--km-color-surface)); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.75rem; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-normal, 200ms); -} - -.site-slopcode__title { - font-size: 1.15rem; - font-weight: 600; - margin: 0 0 1rem 0; - color: hsl(var(--km-color-fg-strong)); -} - -.site-slopcode__subtitle { - font-size: 0.9rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: -0.5rem 0 1rem 0; -} - -.site-slopcode__field { - display: flex; - flex-direction: column; - gap: 0.35rem; - margin-bottom: 1rem; -} - -.site-slopcode__label { - font-size: 0.85rem; - font-weight: 500; -} - -.site-slopcode__input, -.site-slopcode__textarea { - background-color: hsl(var(--km-color-input, var(--km-color-bg))); - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.6rem 0.75rem; - color: hsl(var(--km-color-fg)); - font-family: inherit; - font-size: 0.95rem; -} - -.site-slopcode__textarea { - min-height: 140px; - resize: vertical; -} - -.site-slopcode__button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - background-color: hsl(var(--km-color-primary)); - color: hsl(var(--km-color-bg)); - border: none; - border-radius: var(--km-radius-round, 500px); - padding: 0.55rem 1.25rem; - font-size: 0.9rem; - font-weight: 600; - cursor: pointer; - transition-property: opacity, transform; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__button:hover { - opacity: 0.9; -} - -.site-slopcode__button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.site-slopcode__button--secondary { - background-color: transparent; - color: hsl(var(--km-color-fg)); - border: 1px solid hsl(var(--km-color-border)); -} - -.site-slopcode__error { - color: hsl(var(--km-color-red, 0 70% 55%)); - font-size: 0.85rem; - margin-top: 0.5rem; - min-height: 1.1rem; -} - -.site-slopcode__lobby-share { - display: flex; - align-items: center; - gap: 0.6rem; - background-color: hsl(var(--km-color-accent-bg)); - color: hsl(var(--km-color-accent-fg)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 0.75rem 1rem; - margin-bottom: 1.25rem; - font-family: var(--km-font-mono, monospace); - font-size: 1.1rem; - letter-spacing: 0.08em; -} - -.site-slopcode__lobby-count { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin-bottom: 0.75rem; -} - -.site-slopcode__participant-list { - list-style: none; - margin: 0 0 1.25rem 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.site-slopcode__participant { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); - font-size: 0.9rem; -} - -.site-slopcode__participant--host::after { - content: "Host"; - margin-left: auto; - font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you { - border: 1px solid hsl(var(--km-color-primary)); -} - -.site-slopcode__participant--you::before { - content: "You:"; - font-weight: 600; - color: hsl(var(--km-color-primary)); -} - -.site-slopcode__host-panel { - border-top: 1px dashed hsl(var(--km-color-border)); - margin-top: 1.25rem; - padding-top: 1.25rem; -} - -.site-slopcode__host-panel-label { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 0.5rem; -} - -.site-slopcode__voting-content { - background-color: hsl(var(--km-color-code-bg, var(--km-color-muted-bg))); - border: 1px solid hsl(var(--km-color-code-border, var(--km-color-border))); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; - white-space: pre-wrap; - font-size: 0.95rem; - line-height: 1.6; - max-height: 400px; - overflow-y: auto; -} - -.site-slopcode__voting-progress { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.6); - margin-bottom: 1rem; -} - -.site-slopcode__voting-actions { - display: flex; - gap: 0.75rem; - margin-bottom: 1rem; -} - -.site-slopcode__voting-verdict-btn { - flex: 1; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - border-radius: var(--km-radius-normal, 0.75rem); - border: 2px solid hsl(var(--km-color-border)); - background-color: transparent; - color: hsl(var(--km-color-fg)); - padding: 0.75rem; - font-weight: 600; - cursor: pointer; - transition-property: border-color, background-color; - transition-timing-function: var(--km-ease-smooth, ease); - transition-duration: var(--km-duration-fast, 120ms); -} - -.site-slopcode__voting-verdict-btn--ai.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-red, 0 70% 55%)); - background-color: hsl(var(--km-color-red, 0 70% 55%) / 0.12); -} - -.site-slopcode__voting-verdict-btn--human.site-slopcode__voting-verdict-btn--selected { - border-color: hsl(var(--km-color-green, 140 55% 45%)); - background-color: hsl(var(--km-color-green, 140 55% 45%) / 0.12); -} - -.site-slopcode__voting-tally { - font-size: 0.85rem; - color: hsl(var(--km-color-fg) / 0.7); - margin: 1rem 0; -} - -.site-slopcode__host-vote-feed { - list-style: none; - margin: 0.5rem 0 0 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.4rem; - max-height: 220px; - overflow-y: auto; -} - -.site-slopcode__host-vote-feed-item { - font-size: 0.85rem; - padding: 0.5rem 0.75rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__results-item { - border: 1px solid hsl(var(--km-color-border)); - border-radius: var(--km-radius-normal, 0.75rem); - padding: 1.25rem; - margin-bottom: 1.25rem; -} - -.site-slopcode__results-item-content { - white-space: pre-wrap; - font-size: 0.9rem; - line-height: 1.6; - margin-bottom: 0.75rem; - max-height: 250px; - overflow-y: auto; -} - -.site-slopcode__results-item-tally { - display: flex; - gap: 1.25rem; - font-size: 0.9rem; - font-weight: 600; - margin-bottom: 0.75rem; -} - -.site-slopcode__results-item-votes { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.site-slopcode__results-item-vote { - font-size: 0.8rem; - color: hsl(var(--km-color-fg) / 0.8); - padding: 0.4rem 0.6rem; - border-radius: var(--km-radius-normal, 0.75rem); - background-color: hsl(var(--km-color-muted-bg, var(--km-color-bg))); -} - -.site-slopcode__radio-group { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.site-slopcode__radio-label { - display: flex; - align-items: center; - gap: 0.4rem; - font-size: 0.9rem; - cursor: pointer; -} -""" - - -# ---------------------------------------------------------------------- -# HTML shell -# ---------------------------------------------------------------------- -# -# Five phase containers, all present from first load, toggled by JS -# via `.site-slopcode--hidden`. `{uid}` scopes every element id so two -# invocations of this directive on one page (not the current plan, but -# free to support given the existing theme's per-instance-uid -# precedent in the archived interpreter extension) wouldn't collide. - -_HTML_TEMPLATE: t.Final[str] = """ -
              -

              Slopcode

              -

              Got a room code? Join below -- no sign-in needed.

              -
              - - -
              -
              - - -
              - -

              - -
              -

              Hosting? Sign in to create a room

              -
              - - -
              -
              - - -
              - -

              - -
              - - - -
              -
              -
              - -
              -

              Waiting room

              -
              Room code:
              -

              You are:

              -

              -
                -
                -

                Host controls

                - -
                -
                - -
                -

                Paste your text

                -

                Nobody else can see this until voting starts.

                -
                - -
                - -

                -
                -

                Host controls

                -

                - -
                -
                - -
                -

                Vote: AI or Human?

                -
                -

                -
                -
                - - -
                -
                - - -
                - -

                -
                -
                -

                You're done voting. Waiting for everyone else to finish...

                -
                -

                -
                -

                Host view -- votes as they arrive

                -
                  -
                  -
                  - -
                  -

                  Results

                  -

                  -
                  -
                  - -
                    -
                    - - -
                    -
                    -

                    Host controls

                    - -
                    -
                    - -

                    - -""" - - -# ---------------------------------------------------------------------- -# Client-side JS -# ---------------------------------------------------------------------- -# -# Plain vanilla JS, no bundler/build step (this repo has none, and -# Alpine.js' availability elsewhere in the theme isn't reliable enough -# to depend on here) -- state lives in a handful of top-level `let` -# bindings, phases are toggled via one `showPhase()` function, and the -# only "polling" that ever happens is a single one-shot resync call -# right after a dropped SignalR connection reconnects -- never a -# periodic timer, since a forgotten open tab polling for days is -# exactly the kind of abuse the backend's cost guardrails are meant to -# rule out. - -_JS_TEMPLATE: t.Final[str] = """ -(function() {{ - const UID = "{uid}"; - const API_BASE = "{api_base_url}"; - const HEADCOUNT_MAX = {headcount_max}; - const STORAGE_KEY = "slopcode-session-" + UID; - - const $ = (id) => document.getElementById(UID + "-" + id); - - let hostToken = null; - let roomCode = null; - let participantId = null; - let sessionToken = null; - let isHost = false; - let myDisplayName = null; - let hasSubmitted = false; - let selectedVerdict = null; - let connection = null; - let resultsItems = []; - let resultsIndex = 0; - let resultsShowingComments = false; - - function showPhase(phase) {{ - ["choice", "lobby", "submission", "voting", "results"].forEach((p) => {{ - const el = document.getElementById(UID + "-phase-" + p); - if (el) el.classList.toggle("site-slopcode--hidden", p !== phase); - }}); - }} - - function setStatus(message) {{ - const el = $("connection-status"); - if (el) el.textContent = message || ""; - }} - - function saveSession() {{ - sessionStorage.setItem(STORAGE_KEY, JSON.stringify({{ - roomCode, participantId, sessionToken, isHost, myDisplayName, - }})); - }} - - function clearSession() {{ - sessionStorage.removeItem(STORAGE_KEY); - roomCode = participantId = sessionToken = myDisplayName = null; - isHost = false; - }} - - async function api(path, options) {{ - options = options || {{}}; - const headers = Object.assign({{ "Content-Type": "application/json" }}, options.headers || {{}}); - const response = await fetch(API_BASE + path, Object.assign({{}}, options, {{ headers }})); - let body = null; - try {{ body = await response.json(); }} catch (e) {{ /* empty body is fine */ }} - if (!response.ok) {{ - const message = (body && body.error) || ("Request failed (" + response.status + ")"); - throw new Error(message); - }} - return body; - }} - - // ---- Login ---- - - $("login-submit").addEventListener("click", async () => {{ - const username = $("login-username").value.trim(); - const password = $("login-password").value; - $("login-error").textContent = ""; - try {{ - const result = await api("/login", {{ - method: "POST", - body: JSON.stringify({{ username, password }}), - }}); - hostToken = result.hostToken; - $("host-create-fields").classList.remove("site-slopcode--hidden"); - $("login-submit").disabled = true; - $("login-username").disabled = true; - $("login-password").disabled = true; - }} catch (err) {{ - $("login-error").textContent = err.message; - }} - }}); - - // ---- Create / join room ---- - - $("choice-create").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const headcount = parseInt($("choice-headcount").value, 10); - if (!headcount || headcount < 1 || headcount > HEADCOUNT_MAX) {{ - $("choice-error").textContent = "Enter a headcount from 1 to " + HEADCOUNT_MAX + "."; - return; - }} - try {{ - const result = await api("/rooms", {{ - method: "POST", - body: JSON.stringify({{ hostToken, expectedHeadcount: headcount }}), - }}); - roomCode = result.roomCode; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = true; - myDisplayName = "Host"; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - $("choice-join").addEventListener("click", async () => {{ - $("choice-error").textContent = ""; - const code = $("choice-room-code").value.trim().toUpperCase(); - const displayName = $("choice-display-name").value.trim(); - if (!code) {{ - $("choice-error").textContent = "Enter a room code."; - return; - }} - try {{ - const result = await api("/rooms/" + code + "/join", {{ - method: "POST", - body: JSON.stringify({{ displayName: displayName || null }}), - }}); - roomCode = code; - participantId = result.participantId; - sessionToken = result.sessionToken; - isHost = false; - myDisplayName = result.displayName; - saveSession(); - await enterLobby(); - }} catch (err) {{ - $("choice-error").textContent = err.message; - }} - }}); - - // ---- Lobby ---- - - async function enterLobby() {{ - await connectRealtime(); - // resyncState() sets the correct phase itself (lobby, submission, - // or voting -- e.g. if this join was the last one needed and the - // room already auto-started by the time this client catches up). - // Don't force "lobby" afterward; that would silently overwrite it. - await resyncState(); - }} - - function renderParticipants(participants, expectedHeadcount) {{ - const list = $("lobby-participants"); - list.innerHTML = ""; - participants.forEach((p) => {{ - const li = document.createElement("li"); - let className = "site-slopcode__participant"; - if (p.isHost) className += " site-slopcode__participant--host"; - if (p.participantId === participantId) className += " site-slopcode__participant--you"; - li.className = className; - li.textContent = p.displayName; - list.appendChild(li); - }}); - $("lobby-count").textContent = participants.length + " of " + expectedHeadcount + " joined"; - $("lobby-room-code").textContent = roomCode; - $("my-display-name").textContent = myDisplayName || ""; - $("lobby-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - $("lobby-start-submission").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-submission", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - // Don't wait on the PhaseChanged broadcast to update our own - // screen -- the action already succeeded server-side, so pull - // fresh state directly rather than depend on a round-trip - // message that may be delayed, dropped, or racing a fresh - // SignalR connection that hasn't finished joining its group yet. - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Submission ---- - - $("submission-submit").addEventListener("click", async () => {{ - $("submission-error").textContent = ""; - const content = $("submission-textarea").value.trim(); - if (!content) {{ - $("submission-error").textContent = "Paste something first."; - return; - }} - try {{ - await api("/rooms/" + roomCode + "/submit", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, content }}), - }}); - hasSubmitted = true; - $("submission-submit").disabled = true; - $("submission-textarea").disabled = true; - }} catch (err) {{ - $("submission-error").textContent = err.message; - }} - }}); - - $("submission-start-voting").addEventListener("click", async () => {{ - try {{ - await api("/rooms/" + roomCode + "/force-start-voting", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - await resyncState(); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - function enterSubmissionPhase() {{ - showPhase("submission"); - // Mirrors the equivalent toggle in renderVotingItem() for the - // voting phase's host panel -- without this, the host-only live - // submission count and "Start voting phase" button stay - // permanently hidden, since the section starts hidden in the - // markup and nothing else ever un-hides it. - $("submission-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - }} - - // ---- Voting ---- - - function selectVerdict(verdict) {{ - selectedVerdict = verdict; - $("voting-vote-ai").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "ai"); - $("voting-vote-human").classList.toggle("site-slopcode__voting-verdict-btn--selected", verdict === "human"); - $("voting-submit").disabled = false; - }} - - $("voting-vote-ai").addEventListener("click", () => selectVerdict("ai")); - $("voting-vote-human").addEventListener("click", () => selectVerdict("human")); - - $("voting-submit").addEventListener("click", async () => {{ - $("voting-error").textContent = ""; - if (!selectedVerdict) return; - const reason = $("voting-reason").value.trim(); - $("voting-submit").disabled = true; - $("voting-vote-ai").disabled = true; - $("voting-vote-human").disabled = true; - $("voting-reason").disabled = true; - try {{ - const next = await api("/rooms/" + roomCode + "/vote", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, verdict: selectedVerdict, reason }}), - }}); - renderVotingItem(next); - }} catch (err) {{ - $("voting-error").textContent = err.message; - $("voting-submit").disabled = false; - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - }} - }}); - - function renderVotingItem(data) {{ - $("voting-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - - if (data.done) {{ - $("voting-active").classList.add("site-slopcode--hidden"); - $("voting-waiting").classList.remove("site-slopcode--hidden"); - return; - }} - $("voting-active").classList.remove("site-slopcode--hidden"); - $("voting-waiting").classList.add("site-slopcode--hidden"); - - $("voting-progress").textContent = "Item " + (data.itemIndex + 1) + " of " + data.totalItems; - $("voting-content").textContent = data.content; - selectedVerdict = null; - $("voting-vote-ai").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-human").classList.remove("site-slopcode__voting-verdict-btn--selected"); - $("voting-vote-ai").disabled = false; - $("voting-vote-human").disabled = false; - $("voting-reason").disabled = false; - $("voting-reason").value = ""; - $("voting-submit").disabled = true; - $("voting-error").textContent = ""; - }} - - // ---- Results ---- - - async function loadResults() {{ - $("results-host-panel").classList.toggle("site-slopcode--hidden", !isHost); - try {{ - const result = await api("/rooms/" + roomCode + "/results", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - resultsItems = result.items; - resultsIndex = 0; - resultsShowingComments = false; - renderResultsItem(); - }} catch (err) {{ - setStatus(err.message); - }} - }} - - function renderResultsItem() {{ - if (!resultsItems.length) {{ - $("results-progress").textContent = "No submissions."; - $("results-content").textContent = ""; - $("results-tally").textContent = ""; - $("results-votes").innerHTML = ""; - $("results-prev").disabled = true; - $("results-next").disabled = true; - return; - }} - const item = resultsItems[resultsIndex]; - $("results-progress").textContent = "Item " + (resultsIndex + 1) + " of " + resultsItems.length; - $("results-content").textContent = item.content; - $("results-tally").textContent = "AI: " + item.aiVotes + " Human: " + item.humanVotes; - - const votesList = $("results-votes"); - votesList.innerHTML = ""; - item.votes.forEach((v) => {{ - const li = document.createElement("li"); - li.className = "site-slopcode__results-item-vote"; - li.textContent = v.voterDisplayName + " voted " + v.verdict + " -- \\"" + v.reason + "\\""; - votesList.appendChild(li); - }}); - votesList.classList.toggle("site-slopcode--hidden", !resultsShowingComments); - $("results-toggle-comments").textContent = resultsShowingComments ? "Hide comments" : "Read comments"; - - $("results-prev").disabled = resultsIndex === 0; - $("results-next").disabled = resultsIndex === resultsItems.length - 1; - }} - - $("results-toggle-comments").addEventListener("click", () => {{ - resultsShowingComments = !resultsShowingComments; - renderResultsItem(); - }}); - - $("results-prev").addEventListener("click", () => {{ - if (resultsIndex === 0) return; - resultsIndex -= 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - $("results-next").addEventListener("click", () => {{ - if (resultsIndex >= resultsItems.length - 1) return; - resultsIndex += 1; - resultsShowingComments = false; - renderResultsItem(); - }}); - - // ---- Realtime (SignalR) ---- - - async function connectRealtime() {{ - if (connection) return; - const negotiateInfo = await api("/negotiate", {{ - method: "POST", - headers: {{ "x-participant-id": participantId }}, - body: JSON.stringify({{ roomCode, participantId }}), - }}).catch(() => null); - if (!negotiateInfo) {{ - setStatus("Couldn't connect to realtime updates. Refresh to retry."); - return; - }} - connection = new signalR.HubConnectionBuilder() - .withUrl(negotiateInfo.url, {{ accessTokenFactory: () => negotiateInfo.accessToken }}) - .withAutomaticReconnect() - .build(); - - connection.on("PresenceUpdated", (data) => renderParticipants(data.participants, data.expectedHeadcount)); - connection.on("PhaseChanged", async (data) => {{ - if (data.phase === "submission") enterSubmissionPhase(); - if (data.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (data.phase === "results") {{ showPhase("results"); loadResults(); }} - }}); - connection.on("SubmissionArrived", (data) => {{ - if (!isHost) return; - $("submission-host-count").textContent = data.submittedCount + " of " + data.totalParticipants + " submitted"; - }}); - connection.on("VoteTallyUpdated", (data) => {{ - $("voting-tally").textContent = data.votedCount + " of " + data.totalParticipants + " voted on that item"; - }}); - connection.on("VoteCastHostView", (data) => {{ - if (!isHost) return; - const li = document.createElement("li"); - li.className = "site-slopcode__host-vote-feed-item"; - li.textContent = data.voterDisplayName + ": " + data.verdict + " -- \\"" + data.reason + "\\""; - $("voting-host-feed").appendChild(li); - }}); - connection.on("ResultsReady", () => loadResults()); - connection.on("RoomRestarted", () => {{ - clearSession(); - window.location.reload(); - }}); - - async function joinSignalRGroups() {{ - await api("/rooms/" + roomCode + "/join-groups", {{ - method: "POST", - body: JSON.stringify({{ sessionToken, participantId }}), - }}); - }} - - connection.onreconnecting(() => setStatus("Reconnecting...")); - connection.onreconnected(async () => {{ - // A reconnect gets a brand-new underlying connection id -- group - // membership doesn't carry over, so without rejoining here this - // client would silently stop receiving any broadcast (phase - // changes, results, everything) until the page is manually - // reloaded, even though resyncState() below makes it LOOK caught - // up at this one instant. - await joinSignalRGroups(); - setStatus(""); - await resyncState(); - }}); - connection.onclose(() => setStatus("Disconnected. Refresh the page to rejoin.")); - - await connection.start(); - await joinSignalRGroups(); - }} - - async function resyncState() {{ - try {{ - const state = await api("/rooms/" + roomCode + "/state", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - isHost = state.isHost; - const me = state.participants.find((p) => p.participantId === participantId); - if (me) {{ - myDisplayName = me.displayName; - saveSession(); - }} - renderParticipants(state.participants, state.expectedHeadcount); - if (state.phase === "lobby") showPhase("lobby"); - if (state.phase === "submission") enterSubmissionPhase(); - if (state.phase === "voting") {{ - showPhase("voting"); - const item = await api("/rooms/" + roomCode + "/voting-item", {{ - headers: {{ "x-session-token": sessionToken }}, - }}); - renderVotingItem(item); - }} - if (state.phase === "results") {{ showPhase("results"); loadResults(); }} - }} catch (err) {{ - setStatus(err.message); - }} - }} - - $("manual-resync").addEventListener("click", async () => {{ - if (!roomCode) return; - setStatus("Refreshing..."); - await resyncState(); - setStatus(""); - }}); - - $("restart-session").addEventListener("click", async () => {{ - if (!window.confirm("This ends the session for everyone and returns them to the start. Continue?")) return; - try {{ - await api("/rooms/" + roomCode + "/restart", {{ - method: "POST", - body: JSON.stringify({{ sessionToken }}), - }}); - }} catch (err) {{ - setStatus(err.message); - }} - }}); - - // ---- Resume a session across page refreshes ---- - - (async function resumeIfPossible() {{ - const saved = sessionStorage.getItem(STORAGE_KEY); - if (!saved) {{ showPhase("choice"); return; }} - try {{ - const parsed = JSON.parse(saved); - roomCode = parsed.roomCode; - participantId = parsed.participantId; - sessionToken = parsed.sessionToken; - isHost = parsed.isHost; - myDisplayName = parsed.myDisplayName || null; - await connectRealtime(); - // resyncState() sets the correct phase itself -- don't force - // "lobby" here, that would silently overwrite it (same bug - // fixed in enterLobby() above). - await resyncState(); - }} catch (err) {{ - clearSession(); - showPhase("choice"); - }} - }})(); -}})(); -""" From 6d85384f1d5713de104e3dcd6c9556985b0e29d1 Mon Sep 17 00:00:00 2001 From: Akshay Mestry Date: Tue, 18 Aug 2026 11:43:13 -0500 Subject: [PATCH 12/12] refactor: drop support for slopcode Signed-off-by: Akshay Mestry --- kaamiki/extensions/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kaamiki/extensions/__init__.py b/kaamiki/extensions/__init__.py index 1f674c3..a0dc5a6 100644 --- a/kaamiki/extensions/__init__.py +++ b/kaamiki/extensions/__init__.py @@ -4,7 +4,7 @@ Author: Akshay Mestry Created on: 22 February, 2025 -Last updated on: 12 August, 2026 +Last updated on: 18 August, 2026 This module manages Kaamiki Sphinx Theme's custom directive and roles. @@ -23,7 +23,6 @@ from . import iframe from . import picture from . import repository -from . import slopcode from . import thumbnail from . import video from . import youtube @@ -37,7 +36,6 @@ iframe, picture, repository, - slopcode, thumbnail, video, youtube,