DEV Community

Jane Ori
Jane Ori

Posted on

100% CSS: Fetch and Exfiltrate 512 bits of Server-Generated Data Embedded in an Animated SVG

This is a direct followup to to Getting 32 bit API Response Data in CSS

In CSS, 16 bits of response data, placed both in the intrinsic width and in the intrinsic height, was a huge improvement from not being able to get API response data at all (without JavaScript)...

However, in the days after I figured it out, my mind leaned in one direction:
It would be way more fun if it was 16 bits 32 times instead of just twice.

Gif recording of a harsh terminal app setting several integer values to an array of variables one at a time

Packing 512 bits into an SVG for Exfiltration

I was meditating before bed and got struck with another inspired thought -

"What if it was possible for the image document itself to animate its own intrinsic size?"

The usual chain of realizations after an inspired thought each flashed with understanding on how it could be accomplished... I just needed to figure out if such an image type existed.

I grabbed my phone and and searched across all the animated image formats I knew of, seeing if any of them were capable of it. svg, webp, apng, gif, maybe weird video formats? I couldn't find any.

In the morning, something in me said to keep digging at SVG.

I tried embedded CSS, media queries, use'd defs, more use'd defs, diving into the numerous SVG animate docs, trying to trick them, and read up on other ideas and animation related properties - nothing could let me set height or width.

But that last link made me think...

...what about viewBox? I'd have some other hurdles to tackle but... Is that possible to animate?

vvv
IT IS!!
^^^

Sorting out the solution space

Now the problem is, if you don't set width and height attributes on the root svg element then try to use the svg as content on a pseudo, it renders 0px x 0px because it's a vector document and no longer has an intrinsic size.

So I searched and added preserveAspectRatio to it... Still 0x0... but then in my CSS, I pinned the width to 10px and let the preserved aspect ratio of the viewBox determine the height (which I can change with an animation embedded in the SVG) aaand... the html element containing it grew to the expected height.

:3

If there was only one frame, this took my original 32 bits and cut it in half; since only one dimension can be exfiltrated while the other is static.

BUT! Now, my 2nd dimension was time and the first is at the mercy of time, so there's more than enough data to be had.

How exciting!

I learned all I could about how to control the animation in SVGs and whipped up a server side script to generate my first animated SVG:

<?php
  header('Content-type: image/svg+xml');

  $data = array(
    400,
    450,
    150,
    20,
    175
  );

  $datalen = count($data);

  $viewBoxXYWidth = '0 0 10 ';

  $frames = array_map(function ($val, $index) use ($viewBoxXYWidth) {
      return $viewBoxXYWidth . ((string) ($val));
  }, $data, range(1, $datalen));

  $dur = $datalen * 0.33; // total seconds

  $keytimeStep = 1 / ($datalen); // uniform portion per frame

  $keytimes = implode("; ", array_map(function ($index) use ($keytimeStep) {
      return ($index * $keytimeStep);
  }, range(0, $datalen - 1)));

  $values = implode("; ", $frames); 

  echo '<svg viewBox="0 0 10 100" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">
    <animate
      attributeName="viewBox"
      dur="' . $dur . 's"
      fill="freeze"
      begin="0.1s;"
      values="' . $values . '" 
      keytimes="' . $keytimes . '"
      repeatCount="indefinite"
      calcMode="discrete"
    />
  </svg>';
?>
Enter fullscreen mode Exit fullscreen mode

(why php?! - because I already had a server I've been paying for for years, set up to run php out the gate.... And even though I've earned a wonderful living knowing JavaScript and node really well, sometimes it's fun to look up every single function, operator, and syntax to progress through something you know you can do without knowing specifics. lol)

Now let's fork my first CodePen from the previous article to see CSS responding to and changing size --vars as the SVG ticks along:

Confirmed! We can read the size change. Like in the previous article, in the end, it'll be using the view-timeline measurement technique instead this tan(atan2()) technique.

It cooks my cpu so we'll want to remove it from content when the exfiltration completes.

Conceptually, how to procedurally exfiltrate 1D + time

The demo above isn't very useful on its own. It reports a copy of the height whenever it's there but we need to save it... and what good is a bunch of 16 bit values if you don't know and can't trust the order?

I know I can accumulate values in CSS with the CPU Hack and mathematically determine which --var gets incoming values updated instead of just holding its previous value, so I won't worry about CSS specifically. How, in general, could we exfiltrate 1D over time?

Sentinel values to the rescue!

The size of the measuring area we use doesn't HAVE to be limited to 16 bits even if I want to limit the data packets themselves to 16 bits. So we can pack some sentinels in there too. css-api-fetch ships with the ability to handle values up to 99999, which is well above 65535 (the 16 bit ceiling).

So what do we need to know?

What problems might we run into?

If two values in our data are the same back-to-back, we need an interruption to know it's two distinct packets. I already decided we'll be aiming for 512 bits, so we need the SVG's animation to have a maximum of 32 16-bit data frames, with sentinel frames in between...

If the CPU is feeling heavy, the SVG animation may appear to skip discrete steps entirely. That means we need some way of always knowing what step we're on. So rather than a single "between data frames" sentinel, let's use the data index (1 based like CSS nth-* selectors) as the sentinel value, making it its own discrete step before the discrete step showing the data for that index.

Sentinel index -> data -> sentinel index -> data ...

That lets us know when it loops too, potentially when we hit sentinel 1.

But how do we know that it didn't skip to a different data frame and accidentally have us record it in the wrong slot?

We need to let it loop and keep going until it's right, and the best way to know if data is correct is a checksum! So we need another data frame, and a sentinel for that value.

Creating the Checksum Algorithm

I could use css-bin-bits to XOR all the data, but it's quite heavy and isn't needed anywhere else - let's settle on an alternative that's simple to do in CSS.

Mathematically, if you take a 16 bit value, divide it by 256 (floor to integer), and take the 16 bit value again modulo by 256, you get the high and low bytes. Add those 8 bit values together and you're at 9 bits. This feels like a reasonable checksum approach, let's circle back to this though.

We don't have to stay in 16 bit range to compute the checksum as long as the final checksum is 16 bits, so let's just sum all (up-to) 32 values.

We've got to be careful about incorrect storage writes due to skipped frames though, so let's add the even index values twice so there's some semblance of order.

That sum, 16 bit values, 32 times, plus an additional 16 times, is about 22 bits. Divide and module 11 bits on each side circling back to the earlier thought, then add those together, giving 12 bits as our checksum response.

Seems reasonable... It's not completely error proof but the SVG would have to be skipping several steps to mess it up in a way to MAYBE generate the same checksum now... In any case, let's also send back the data length and include that in the checksum as well, just by adding it as the last step of our checksum. The max data length (number of 16 bit values we want to handle) is only 32, so adding the length value to the 12 bits doesn't come anywhere near pushing us over 16 bits. Yay!

spoiler: this is what I did but CSS became lossy somewhere around 21 bits so I split it up and effectively did the same algorithm but in smaller chunks at a time. Server side uses the alg exactly as described.

Technically with the setup we've described, it doesn't matter what the order is in the animation as long as each sentinel tells you what index the next frame is supposed to be in the data.

One more thing, let's put the data length value first in the response and add a sentinel for that too (sentinel in the SVG animation before the value, as with the rest of the data).

That's 34 sentinels. The SVG viewBox height can't be 0 and CSS will benefit from allowing 0 to represent no data internally, so let's say we have 35 sentinels with 0 deliberately unused.

All data frames now get embedded in the SVG with 35 added to their value. Length and checksum data values ALSO get 35 added to the viewbox value. viewBox heights in the SVG Animation representing the sentinels will have values 0 to 34 (skipping 0) and each tell us exactly what the next frame in the SVG Animation represents.

CSS side, we just check if the raw measurement is greater than 34, it's data so subtract 35 from it, if it's less than 35, it's a sentinel.

A meme picture of Charlie Day from It's Always Sunny in Philadelphia with a crazed look standing in front of a board covered in paper with red lines connecting them hectically

Beginning to Exfiltrate the 512 bits with CSS

After I finished the PHP side to generate the SVG animation as detailed, I thought on specific ways to begin the CSS for this exfiltration process.

Here's the PHP code!
<?php
  header('Content-type: image/svg+xml');

  $data = array(
    400,
    450,
    150,
    20,
    175
  );

  $datalen = count($data);

  $viewBoxXYWidth = '0 0 10 ';

  // add 35 to all the values so we can use 0 to 34 for sentinels. 0 = CSS-side sentinel, 1-32 = data frames, 33 = length, 34 = checksum
  $frames = array_map(function ($val, $index) use ($viewBoxXYWidth) {
      return ($viewBoxXYWidth . ((string) $index) . '; ' . $viewBoxXYWidth . ((string) ($val + 35)));
  }, $data, range(1, $datalen)); // 1 up to 32 = indicator that next frame is the value(+35) for that index(1-based)

  // no matter how many are in the array, '33' indicates the next frame is data length, which is used in the checksum too
  array_unshift($frames, $viewBoxXYWidth . '33; ' . $viewBoxXYWidth . ((string) ($datalen + 35))); // + 35 b/c data
  // unshift so the length is (hopefully) the first value read and a sense of progress can be reported

  $fullsum = 0;

  for ($x = 0; $x <= ($datalen - 1); $x++) {
    // double the odd ones so there's some semblance of order accounted for
    // the odd ones with 0 based index is the even ones on the CSS side
    $fullsum += ($data[$x] + (($x & 1) * $data[$x]));
  }

  $checksum = floor($fullsum / 2048) + ($fullsum % 2048) + $datalen + 35; // + 35 because it's data

  // no matter how many are in the array, '34' indicates the next frame is checksum
  array_push($frames, $viewBoxXYWidth . '34; ' . $viewBoxXYWidth . $checksum);

  $actualNumItems = count($frames) * 2;

  $dur = $actualNumItems * 0.33; // total seconds

  $keytimeStep = 1 / ($actualNumItems); // uniform portion per frame

  $keytimes = implode("; ", array_map(function ($index) use ($keytimeStep) {
      return ($index * $keytimeStep);
  }, range(0, $actualNumItems - 1)));

  $values = implode("; ", $frames); 

  echo '<svg viewBox="0 0 10 100" preserveAspectRatio="xMinYMin meet" xmlns="http://www.w3.org/2000/svg">
    <animate
      attributeName="viewBox"
      dur="' . $dur . 's"
      fill="freeze"
      begin="0.1s;"
      values="' . $values . '" 
      keytimes="' . $keytimes . '"
      repeatCount="indefinite"
      calcMode="discrete"
    />
  </svg>';
?>
Enter fullscreen mode Exit fullscreen mode

There are a few ways to accomplish this in CSS and potentially many more coming with recent spec additions.

My first approach is the easiest conceptually - using a view-timeline for every piece of data and doing the same thing over and over. It was working but I groaned through my progress in displeasure with how gross it was. That'll be nearly 40 animations on :root if I continued.

So I went to sleep.

When I woke up, I laid there several moments looking out the window smiling with that just-woke-up-or-meditated buzzy feeling, then a firehose of thoughts rushed into my head. I rolled over, grabbed my notebook and the closest pen, sat up in bed, and started writing down the algorithm to exfiltrate it with just 6 CSS animations.

my chicken scratch handwriting on a single piece of lined notebook paper with arrows pointing to a couple of nested boxes detailing the method described below

Literally solved on paper; This is EXACTLY how it's implemented.

I got up, got on my computer, ignored my previous work, and opened a new CodePen.

I set up the 4 html elements indicated among the chicken scratch there, then flooded the CSS panel with notes around 4 empty class selectors corresponding to them. It won't be on :root now but we can place anything that relies on it inside.

Not a single piece of functionality was added until the notes were copied from the paper and written in more specific detail in CodePen.

When I was done, I just read the notes and started implementing what they said, all the way to the final working result.

(I wrote "20" instead of "35" because I was going to test with 256 bits)

I'll dive into how it works. Because of view-timeline and timeline-scope, you can set data up to flow in in kind of a Klein Bottle shape if you can picture the surface animating and getting sucked into the "hole", back up to the narrow top to poor down over the surface again, gaining size and complexity through dom layers, and then loop back up through the blackhole into the higher consciousness (:root).

It's mostly vertically cyclic (rather than mostly horizontally cyclic or statically cyclic)

Exfiltrating the 512 bits with CSS

The notes we slightly tweaked and made more clear:

256 test -> 512 final

and I added a presentation node inside which is great because I can show the internals too as the algorithm executes.

but here is the final notes without all the implementation and presentation noise. This exactly describes how it all works.

It may not be in good form for an article to have this many details embedded externally but I will be breaking down each chunk to show how it's implemented.

Main Controller

At the top of this structure is 4 timeline values and their animations. So let's just plop those in.

The key piece of data flow that this enables, is it gives us the ability to lift data nested deep in the DOM back up to a host (timeline scope). It's not efficient, so we want to limit how often we do this. Each registered property and it's animation can vertically host a single piece of data. The data's value is determined by the inline or block view position of an element somewhere deep in the structure - we'll get to that part later.

(see the looping example previously embedded above for a clearer picture of the data flow)

The four pieces of data we're lifting here are:

--xfl-cpu-phase - this is a numeric value 0 to 4 that signals what phase of the CPU Hack is currently being executed. (a single 'frame' of the CPU Hack is 4 to 5 CSS render frames, one loop of the phases 'ticks' the CPU Hack) I will demonstrate this more specifically later in this article.

--xfl-raw-data - this hosts the height of the SVG wherever the SVG is in its animation cycle. Our raw data. As said before, if this value is less than 35, this discrete step of the SVG animation is a sentinel value. If it's greater than 34, this discrete step of the SVG animation is our 16 bit value + 35, which corresponds to what the previous sentinel indicated.

--xfl-data-type - this is the most recent sentinel value. This value does not change until the next sentinel is encountered. There is a 1 CSS frame delay from setting --xfl-raw-data to setting this value.

--xfl-data-value - this is the current data value after 35 was subtracted from the raw value, or 0 if we haven't reached this step of the sequence yet. There is a 1 CSS frame delay from setting --xfl-data-type to setting this value.

I've also preemptively wrapped svg-animation-current-state-reporter in a condition that only has functionality and only loads the animated SVG while the process is incomplete. (so all the internals are removed from memory and the heavy animated svg is removed from rendering when we finish)

The keyframe values go from a max value for that piece of data to 0. I'll explain later why these are backwards - look for the image of Uno Reverse cards.

CPU Exfiltrator

Next we set up the basic boilerplate for a CPU Hack

The CPU Hack boilerplate is just following a variable name pattern to set up capture and hoist animations.

If we have 1 integer --xfl\\1 that we want to cycle horizontally (over time), we register it and we set up capture and hoist animations like so:

@keyframes capture {
  0%, 100% {
    --xfl\\1-captured: var(--xfl\\1);
  }
}

@keyframes hoist {
  0%, 100% {
    --xfl\\1-hoist: var(--xfl\\1-captured, 0);
  }
}
Enter fullscreen mode Exit fullscreen mode

Then complete the cyclic assignment on the .cpu-exfiltrator element where the two CPU animations are hosted. I'll do it for just one of the values for now:

  --xfl\\1: calc(
    var(--xfl\\1-hoist, 0) + 1
  );
Enter fullscreen mode Exit fullscreen mode

In Chrome, they won't statically cycle (become the initial value) unless both animations are running at the same time. Which is a FANTASTIC side effect of what's likely an optimization of paused animations setting numeric properties.

Finally, since we're using a new automatic version of the CPU Hack (you don't have to :hover to cycle the phases like in the original hack), we wire in the --xfl-cpu-phase var from previous (hosted on the parent element here, so we can use style queries to respond to it) and control the play state of our animations.

We also output --cpu-next-phase which will later be lifted back to the top and set the next value for --xfl-cpu-phase using its view position and timeline scope.

I've added an additional phase to keep the CPU Hack paused until the SVG Animation measurement has successfully locked in the next --xfl-data-type

  @container style(--xfl-cpu-phase: 4) {
    animation-play-state: paused, paused;
    --cpu-next-phase: calc(
      min(1, var(--xfl-data-type)) * 4
    );
  }
Enter fullscreen mode Exit fullscreen mode

(as it is now, data-type is still always 0 so once next phase is wired in, this will already be looping the CPU Hack. Once we have a data-type sentinel it won't loop until we've cleared it deliberately with the 0 sentinel)

Later, we'll also add the noted condition to prevent CPU phase 1 from starting until data is all in place. That will ensure that between data type (sentinel) being locked in and data value (raw - 35) being locked in, we want to leave the CPU Hack in its capture phase. So it's "ready to be ready" as Abraham Hicks might say.

I'll go ahead and register all the 32 values plus checksum and length that we expect the SVG Animation to report.

Since registration of --xfl\\1 to --xfl\\32 is a big block and the CPU animations are just boilerplate too, I'll move all those to the bottom of the hack setup to be ignored moving forward.

Automatic CPU Hack

This wires the next cpu phase up to the --xfl-cpu-phase value

.cpu-phase-cycle-request0r {
  display: block;
  position: absolute;
  overflow: hidden;
  background: hotpink;
  width: 100px;
  height: 10px;
  bottom: 100vh;
  &::before {
    content: "";
    position: absolute;
    left: 0px;
    top: 0px;
    height: 100%;
    width: calc(25px * var(--cpu-next-phase));
    background: lime;
    view-timeline: --xfl-cpu-phase inline;
  }
}
Enter fullscreen mode Exit fullscreen mode

There's some CSS boilerplate here to make the element be a scroll container and put it off screen. The important part is:

view-timeline: --xfl-cpu-phase inline;

which says where the right edge of this pseudo element falls in its 100px wide parent, wire it up as a "progress" from the left to our animation that moves from 0 to 4... So 25px is 25% complete, which maps to 1 when 25% is between 0 and 4.

picture of two 'reverse' cards from Uno image sourced from a google search leading to twitter

TECHNICALLY the animation is 4 to 0 and TECHNICALLY it's measuring from the right edge of the pseudo as view progress towards the right. So 25px wide pseudo is 75% from the right of its 100px wide scroll parent and maps to a value of 1 when 75% is between 4 and 0.

It's easier to understand if you don't cognitively process the reverse reverse math and just accept the end result is a simple progress 0 to 4 because the max value in the animation is 4 (again ignoring that the animation starts at 4).

Let's also write the ready state that holds the CPU in phase 0 until the data is ready. The note is on line 64 of our demos:

Data ready = data-type > 0 && raw-frame-data - 35 === data-value

  --xfl-data-is-ready: calc(
    min(1, var(--xfl-data-type, 0)) *
    (1 - min(1, max(
      (var(--xfl-raw-data, 0) - 35) - var(--xfl-data-value, 0),
      var(--xfl-data-value, 0) - (var(--xfl-raw-data, 0) - 35)
    )))
  );
  @container style(--xfl-cpu-phase: 0) {
    animation-play-state: running, paused;
    --cpu-next-phase: var(--xfl-data-is-ready);
  }
Enter fullscreen mode Exit fullscreen mode

Wait, === in CSS?

These are quite outdated now and I'd do them differently today, written before clamp() was baseline, but I always open this old codepen to mindlessly copy paste numeric comparators when I need them. It'd be a good article to update these and explain them but here you go in the meantime: https://codepen.io/propjockey/pen/YzZMNaz

Reading the SVG Animation

This is initially VERY similar to the CPU Exfiltrator section because it's the same technique, measuring and moving data from here up the DOM to where it's hosted (scope).

We'll be measuring and reporting the last 3 values for the base element we set up initially.

On ::before we'll render the SVG and set --xfl-raw-data using block view position which is the measurement of the animated SVG's height. (remember, we'll pin the width to 10px)

On ::after we'll set --xfl-data-type inline (sentinel values 0 to 34) and --xfl-data-value block (16 bit values).

The parent will need to be wide enough to render the SVG (at least 10px) and accurately provide measurements for the sentinel values (0 to 34).

The parent will also need to be tall enough to measure 16 bit values (+ 35). Since we set a max value in the first step of 100k, we'll just use that even though it's about 30% bigger than we need.

And move it off screen to the top and left so it doesn't cause scrollbars.

Therefore,

.svg-animation-current-state-reporter

gets

  position: absolute;
  background: rgba(255, 0, 0, 0.5);
  width: 34px;
  /* --xfl-data-type keyframes are 34 to 0 b/c max data-type = 34 */
  height: calc(var(--xfl-max) * 1px);
  bottom: calc(var(--xfl-max) * 1px + 100vh);
  left: calc(-100vw - 100px);
  overflow: hidden;
  font-size: 0px!important;
  line-height: 0px!important;
  margin: 0px;
  padding: 0px;
Enter fullscreen mode Exit fullscreen mode

and before becomes

    &::before {
      content: url('http://css-api.propjockey.io/api.svg.php?');
      position: absolute;
      background: lime;
      left: 0px;
      top: 0px;
      width: 10px!important;
      --height: 2135px;
      font-size: 0px!important;
      line-height: 0px!important;
      view-timeline: --xfl-raw-data block;
    }
Enter fullscreen mode Exit fullscreen mode

and ::after gets

  position: absolute;
  width: 1px;
  height: 1px;
  left: calc(
    var(--TODO_SENTINEL_VALUE, 0) * 1px - 1px
  );
  top: calc(
    var(--TODO_DATA_VALUE, 0) * 1px - 1px
  );
  background: black;
  view-timeline: --xfl-data-type inline, --xfl-data-value block;
Enter fullscreen mode Exit fullscreen mode

Essentially the storage medium here for these after values is the view position of a 1px square pseudo against its parent scroll container. We subtract 1px in the left and top calcs because the pseudo itself is 1x1 and we want it to report 0 when the corresponding value is 0.

This is all very similar to what was done previously.

There are several complexities to how we calculate what these values should be, as the note indicates:

/* there's 4 stages here
// 1) cpu tells us to reset to 0s
// 2) cpu is in phase 0 (capture running) and stays until these stages finish
//   a. if svg animation frame (based on raw) is type, set type else use prev
//   3. if svg animation frame (based on raw) is data, set data else use prev
// 4) CPU is executing, our job is to hold prev values and ignore the SVG ani
*/
Enter fullscreen mode Exit fullscreen mode

The key to understanding how to solve this is that any comparator value is set to either 0 or 1. 1 if it's true, 0 if it's false. Then multiply it by some value. If it's false, the result stays 0, otherwise it becomes whatever the value is.

Ana Tudor goes into great depth of how this idea works here

Then, if we do that comparison twice, with a different or opposite comparison for the second value, and add them together (Ensuring at most one of the comparators is 1) then the addition of two of them is just saying "else if".

if not ready * use the old value + else
if is ready * use this new value

This is how we hold onto the sentinel value through the duration of the SVG Animation's discrete step for the value after the type has already been reported.

The CSS code implementing it begins on line 191 here, just above the big block of --xfl\\... property registrations we put towards the bottom
@property --xfl\\1 { syntax: "<integer>"; initial-value: 0; inherits: true; }
...
and it contains additional notes:

Setting specific CSS --var values (addressed assignments)

The logic we just touched on is exactly the same concept we use for all of the --xfl\\1, 2, 32 values.

  --xfl-set\\1: calc(
    (1 - min(1, max(
      1 - var(--xfl-data-type, 0),
      var(--xfl-data-type, 0) - 1
    ))) * var(--xfl-data-is-ready)
  );
  --xfl\\1: calc(
    var(--xfl-set\\1) * var(--xfl-data-value, 0) +
    (1 - var(--xfl-set\\1)) * var(--xfl\\1-hoist, 0)
  );
Enter fullscreen mode Exit fullscreen mode

You read --xfl-set\\1 as if --xfl-data-type gte 1 use --xfl-data-is-ready with an implied else 0

--xfl-data-is-ready was established earlier as a flag holding us in phase 0 until it's time to flip to phase 1.

That means our condition is && logic. Both flags must be 1 to pass.

Then you continue to read --xfl\\1 as if --xfl-set\\1 use --xfl-data-value (the current SVG Animation value) else if NOT --xfl-set\\1 use --xfl\\1-hoist (the previous value the CPU hack was holding for --xfl\1)

This is highly repetitive, and describes almost the entirety of the rest of this exfiltration.

The final steps are running basic calc() and mod() math to build the checksum as described earlier, then adding if the computed checksum === the checksum embedded in the SVG Animation to the CPU Hack so we know when it's complete. All more of the same.

So now it's time. :)

Presenting: The CSS Animated SVG Exfiltration Hack

Because I wanted to show each and every piece of this hack one value per element, the presentation for this is obnoxiously heavy. Over 2000 lines of HTML and over 400 lines of CSS. Plus I'm using css-bin-bits to convert each register to binary, etc.

(Click rerun in the bottom right of the codepen frame to see it happen in real time!)

No JavaScript!


Open Contact 👽

Please do reach out if you think this is neat and wish to learn more! Always happy to answer questions.

PropJockey.io CodePen DEV Blog GitHub Mastodon
PropJockey.io CodePen DEV Blog GitHub Mastodon

🦋@JaneOri.PropJockey.io

𝕏@Jane0ri

Top comments (0)