~repos /website

#astro#js#html#css

git clone https://pyrossh.dev/repos/website.git

木 Personal website of pyrossh. Built with astrojs, shiki, vite.


94a7e7c3 pyrossh

1 year ago
cleanup
cv.png ADDED
Binary file
islands/LemonDrop.tsx CHANGED
@@ -1,81 +1,14 @@
1
1
  import { useEffect, useRef } from "preact/hooks";
2
2
  import { useSignal } from "@preact/signals";
3
3
 
4
- export interface Spring {
5
- p: number;
6
- v: number;
7
- }
8
-
9
- export class WaveTank {
10
- springs = [] as Spring[];
11
- waveLength = 100;
12
- k = 0.02;
13
- damping = 0.02;
14
- spread = 0.02;
15
-
16
- constructor() {
17
- for (let i = 0; i < this.waveLength; i++) {
18
- this.springs[i] = {
19
- p: 0,
20
- v: 0,
21
- };
22
- }
23
- }
24
-
25
- update(springs: Spring[]) {
26
- for (const i of springs) {
27
- const a = -this.k * i.p - this.damping * i.v;
28
- i.p += i.v;
29
- i.v += a;
30
- }
31
-
32
- const leftDeltas = [];
33
- const rightDeltas = [];
34
-
35
- for (let t = 0; t < 8; t++) {
36
- for (let i = 0; i < springs.length; i++) {
37
- const prev = springs[(i - 1 + springs.length) % springs.length];
38
- const next = springs[(i + 1) % springs.length];
39
-
40
- leftDeltas[i] = this.spread * (springs[i].p - prev.p);
41
- rightDeltas[i] = this.spread * (springs[i].p - next.p);
42
- }
43
-
44
- for (let i = 0; i < springs.length; i++) {
45
- const prev = springs[(i - 1 + springs.length) % springs.length];
46
- const next = springs[(i + 1) % springs.length];
47
- prev.v += leftDeltas[i];
48
- next.v += rightDeltas[i];
49
- prev.p += leftDeltas[i];
50
- next.p += rightDeltas[i];
51
- }
52
- }
53
- }
54
- }
55
-
56
4
  function easeInCirc(x: number) {
57
5
  return 1 - Math.sqrt(1 - Math.pow(x, 2));
58
6
  }
59
7
 
60
- const waveTank = new WaveTank();
61
-
62
8
  function LemonDrop() {
63
- const SVG_WIDTH = 100;
64
9
  const counter = useSignal(0);
65
10
  const dropy = useSignal(60);
66
- const width = useSignal(SVG_WIDTH);
67
- const widthRef = useRef(width.value);
68
- const springs = useSignal(waveTank.springs);
69
11
  const requestIdRef = useRef<number>();
70
- const grid = SVG_WIDTH / waveTank.waveLength;
71
- const points = [
72
- [0, 100],
73
- [0, 0],
74
- ...springs.value.map((x, i) => [i * grid, x.p]),
75
- [width.value, 0],
76
- [width.value, 100],
77
- ];
78
- const springsPath = `${points.map((x) => x.join(",")).join(" ")}`;
79
12
  const juice = `M18 ${63 + counter.value} C15 ${63 + counter.value} 16 ${
80
13
  63 + counter.value
81
14
  } 12 61L9 56C2 33 62 -3 80 12C103 27 44 56 29 58C27 58 25 59 24 61C20 ${
@@ -93,49 +26,13 @@ function LemonDrop() {
93
26
  counter.value = easeInCirc(1 - saw) * amp * 0.1;
94
27
  dropy.value = 70 + Math.pow(saw - 0.6, 2) * 10000;
95
28
  }
96
- }
97
-
98
- function update(timestamp: number) {
99
- updateJuice(timestamp);
100
- waveTank.update(waveTank.springs);
101
- springs.value = [...waveTank.springs];
102
-
103
- const offset = 500;
104
- const saw = (timestamp + offset) / 2000 -
105
- Math.floor((timestamp + offset) / 2000);
106
- if (saw < 0.01) {
107
- drop();
108
- }
109
- requestIdRef.current = globalThis.requestAnimationFrame(update);
29
+ requestIdRef.current = globalThis.requestAnimationFrame(updateJuice);
110
- }
111
-
112
- function resize() {
113
- width.value = document.body.clientWidth;
114
- }
115
-
116
- function drop() {
117
- const dropPosition = Math.round(
118
- ((widthRef.current / 2 - 30) / widthRef.current) * 100,
119
- );
120
- waveTank.springs[dropPosition].p = -60;
121
30
  }
122
31
 
123
32
  useEffect(() => {
124
- widthRef.current = width.value;
125
- }, [width.value]);
126
-
127
- useEffect(() => {
128
- const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
129
- if (mediaQuery.matches) {
130
- return;
131
- }
132
-
133
- requestIdRef.current = requestAnimationFrame(update);
33
+ requestIdRef.current = requestAnimationFrame(updateJuice);
134
- globalThis.addEventListener("resize", resize);
135
- resize();
136
34
 
137
35
  return () => {
138
- globalThis.removeEventListener("resize", resize);
139
36
  if (requestIdRef.current !== undefined) {
140
37
  cancelAnimationFrame(requestIdRef.current);
141
38
  }
routes/index.tsx CHANGED
@@ -103,22 +103,10 @@ export default function Home() {
103
103
  <HardwareIcon size="32" />
104
104
  <h2>Hardware</h2>
105
105
  </div>
106
- <ul class="grid gap-2 grid-cols-2 text-center mt-4 ml-2 heir-a:text-blue-900 child:bg-gray-200 child:p-1">
106
+ <ul class="grid gap-2 grid-cols-1 text-center mt-4 heir-a:text-blue-900 child:bg-gray-200 child:p-1">
107
- <li>
108
- <strong>Laptop:</strong> M2 Macbook Air
109
- </li>
110
- <li>
111
- <strong>CPU:</strong> Apple Silicon M2
112
- </li>
113
- <li>
114
- <strong>RAM:</strong> 16GB
107
+ <li>M2 Macbook Air</li>
115
- </li>
116
- <li>
117
- <strong>SSD:</strong> 512GB
118
- </li>
119
108
  <li>Raspberry Pi 4B</li>
120
109
  <li>Raspberry Pi Zero 2W</li>
121
- <li>M5Stack</li>
122
110
  </ul>
123
111
  </section>
124
112
  <section>
@@ -126,7 +114,7 @@ export default function Home() {
126
114
  <SoftwareIcon size="32" />
127
115
  <h2>Software</h2>
128
116
  </div>
129
- <ul class="grid gap-2 grid-cols-3 text-center mt-4 ml-2 heir-a:text-blue-900 child:bg-gray-200 child:p-1">
117
+ <ul class="grid gap-2 grid-cols-3 text-center mt-4 heir-a:text-blue-900 child:bg-gray-200 child:p-1">
130
118
  <li>
131
119
  <a
132
120
  href="https://github.com/exelban/stats"
@@ -142,7 +130,7 @@ export default function Home() {
142
130
  target="_blank"
143
131
  rel="noopener noreferrer"
144
132
  >
145
- Brave Browser
133
+ Brave
146
134
  </a>
147
135
  </li>
148
136
  <li>
@@ -169,7 +157,7 @@ export default function Home() {
169
157
  target="_blank"
170
158
  rel="noopener noreferrer"
171
159
  >
172
- Fish Shell
160
+ Fish
173
161
  </a>
174
162
  </li>
175
163
  <li>
@@ -187,7 +175,7 @@ export default function Home() {
187
175
  target="_blank"
188
176
  rel="noopener noreferrer"
189
177
  >
190
- Color Slurp
178
+ Slurp
191
179
  </a>
192
180
  </li>
193
181
  <li>
@@ -206,7 +194,7 @@ export default function Home() {
206
194
  <TreeIcon size="36" />
207
195
  <h2>Interests</h2>
208
196
  </div>
209
- <ul class="grid gap-2 grid-cols-3 text-center mt-4 ml-2 child:bg-slate-100 child:p-1">
197
+ <ul class="grid gap-2 grid-cols-3 text-center mt-4 child:bg-slate-100 child:p-1">
210
198
  <li>HTML</li>
211
199
  <li>CSS</li>
212
200
  <li>Tailwind</li>
@@ -226,7 +214,7 @@ export default function Home() {
226
214
  <ContactIcon size="36" />
227
215
  <h2>Contact</h2>
228
216
  </div>
229
- <ul class="grid gap-2 grid-cols-1 text-lg text-left mt-4 ml-2 heir-strong:mr-2 child:bg-slate-100 child:p-2">
217
+ <ul class="grid gap-2 grid-cols-1 text-lg text-left mt-4 heir-strong:mr-2 child:bg-slate-100 child:p-2">
230
218
  <li>
231
219
  <strong>Email:</strong>
232
220
  <span>pyros2097@gmail.com</span>
routes/posts.tsx CHANGED
@@ -1,5 +1,49 @@
1
1
  import { Head } from "$fresh/runtime.ts";
2
2
 
3
+ // const files = await Astro.glob("./**/*.{md,mdx}");
4
+ // const posts = files.map(({ frontmatter: item }) => ({
5
+ // id: item.title.toLowerCase().replaceAll(" ", "-"),
6
+ // title: item.title,
7
+ // date: new Date(item.date),
8
+ // }));
9
+ // ---
10
+
11
+ // <style>
12
+ // .container {
13
+ // display: flex;
14
+ // flex-direction: column;
15
+ // min-height: calc(100vh - 120px);
16
+ // }
17
+
18
+ // .row {
19
+ // display: flex;
20
+ // flex-direction: row;
21
+ // align-items: center;
22
+ // margin-top: 1.5rem;
23
+ // line-height: 1.5rem;
24
+
25
+ // & span {
26
+ // width: 9rem;
27
+ // }
28
+
29
+ // & a {
30
+ // margin-left: 2rem;
31
+ // text-decoration: none;
32
+ // color: black;
33
+ // border-bottom: 2px solid black;
34
+
35
+ // &:hover,
36
+ // &:visited {
37
+ // text-decoration: none;
38
+ // }
39
+
40
+ // @media (--mobile) {
41
+ // margin-left: 0rem;
42
+ // }
43
+ // }
44
+ // }
45
+ // </style>
46
+
3
47
  export default function Posts() {
4
48
  return (
5
49
  <div class="mx-auto">
@@ -8,6 +52,45 @@ export default function Posts() {
8
52
  <meta name="description" content="Peter John's Posts" />
9
53
  </Head>
10
54
  <div class="px-4 py-40 mx-auto">TBD</div>
55
+ {
56
+ /* <div slot="body" class="container">
57
+ {
58
+ posts.map((post) => (
59
+ <div class="row">
60
+ <span>{post.date.toISOString().split("T")[0]}</span>
61
+ <a href={`/blog/${post.date.getFullYear()}/${post.id}`}>
62
+ {post.title}
63
+ </a>
64
+ </div>
65
+ ))
66
+ }
67
+ </div> */
68
+ }
11
69
  </div>
12
70
  );
13
71
  }
72
+
73
+ // ---
74
+ // const { title, description, image, date, tags } = Astro.props.frontmatter;
75
+ // ---
76
+
77
+ // <title slot="head">pyros.sh | {title}</title>
78
+ // <meta slot="head" name="description" content={description} />
79
+ // <meta slot="head" name="keywords" content={tags} />
80
+ // <div slot="body" class="post-page">
81
+ // <div class="title-container">
82
+ // <div>
83
+ // <h1>{title}</h1>
84
+ // <h2>{description}</h2>
85
+ // </div>
86
+ // <div class="date">
87
+ // <h3>{date}</h3>
88
+ // </div>
89
+ // </div>
90
+ // <div class="tags-container">
91
+ // {tags.map((text) => <Tag text={text} />)}
92
+ // </div>
93
+ // <div>
94
+ // <slot />
95
+ // </div>
96
+ // </div>
static/posts/eyecandy-golang-error-reporting.md ADDED
@@ -0,0 +1,91 @@
1
+ ---
2
+ layout: ../../../layouts/post.astro
3
+ title: Eyecandy golang error reporting
4
+ description: Better error messages logging in golang
5
+ image: ../../../assets/images/terminal1.png
6
+ date: September 17, 2016
7
+ tags:
8
+ - golang
9
+ - error
10
+ - formatting
11
+ ---
12
+
13
+ We at playlyfe wanted to get an email report as soon as an error occurred on our production servers. Since golang does not have
14
+ stack traces with its inbuilt error mechanism we had to find a quick and simple solution which wouldn’t require too much refactoring
15
+ of our existing codebase. So this is how we went about accomplishing this task. First we decided to wrap our errors so that we can
16
+ get the runtime stack whenever an error occurs.
17
+
18
+ We first started using this, https://github.com/go-errors/errors
19
+ but soon found out that it wasn’t exactly suited for our use case. So we created this minimalistic and easy approach to wrap all our
20
+ existing errors. First we decided to wrap our errors so that we can get the runtime stack whenever an error occurs.
21
+
22
+ ```go
23
+ package utils
24
+
25
+ import (
26
+ "bytes"
27
+ "database/sql"
28
+ "runtime"
29
+ )
30
+
31
+ type WrappedError struct {
32
+ Err error
33
+ StackTrace string
34
+ }
35
+
36
+ func(e * WrappedError) Error() string {
37
+ return e.Err.Error()
38
+ }
39
+
40
+ func E(e error) error {
41
+ switch e.(type) {
42
+ case *WrappedError:
43
+ return e
44
+ case nil:
45
+ return nil
46
+ default:
47
+ stackTrace := make([]byte , 1 << 16)
48
+ runtime.Stack(stackTrace, false)
49
+ buffer := &bytes.Buffer{}
50
+ for _ , a := range stackTrace {
51
+ if a != 0 {
52
+ buffer.WriteByte(a)
53
+ }
54
+ }
55
+ return &WrappedError {
56
+ Err: e,
57
+ StackTrace: buffer.String(),
58
+ }
59
+ }
60
+ }
61
+ ```
62
+
63
+ But then just sending the error stack in plain format to our emails wasn’t going to be nice to read at all. We needed more
64
+ information like context and session information and things like that. On top of that I also wanted to make our stack traces
65
+ prettier so that it would easier to figure out where the error started from.
66
+
67
+ So to parse the error stack trace I found this cool library which does that for and on top of that it also themes it very well
68
+ https://github.com/maruel/panicparse
69
+
70
+ But then it didn’t properly expose an API to do it properly and after a few dabblings here,
71
+ https://github.com/maruel/panicparse/issues/8
72
+ with the developer and +1’s we got a proper api which I could use.
73
+ And now I haz got a prettier stack traces like this,
74
+
75
+ ![Terminal 1](../../../assets/images/terminal1.png)
76
+
77
+ So great I got ANSI coloring setup and the errors look great in our console but what about our
78
+ mails. Of course this wasn’t going to work since emails primarily render text and HTML only, and
79
+ ANSI color codes was going to make our messages a mess.
80
+
81
+ So then I went about digging github for an ANSI terminal codes to HTML converter so that it would
82
+ look exactly like this in my mail. And then I found this cool go library which does that,
83
+ https://github.com/buildkite/terminal
84
+
85
+ Now all emails require inline CSS or else they wouldn’t work so then I had to find out a way to do that too.
86
+ And this was it,
87
+ https://github.com/aymerick/douceur
88
+
89
+ Finally after messing around with so many libraries I got around to getting it to work and this is how it looks in my email,
90
+
91
+ ![Email 1](../../../assets/images/email1.png)
static/posts/gopibot-to-the-rescue.md ADDED
@@ -0,0 +1,139 @@
1
+ ---
2
+ layout: ../../../layouts/post.astro
3
+ title: Gopibot to the rescue
4
+ description: A slackbot for deploying your applications (chatops)
5
+ image: ../../../assets/images/gopibot.png
6
+ date: October 04, 2017
7
+ tags:
8
+ - nodejs
9
+ - slack
10
+ - bot
11
+ - chatops
12
+ ---
13
+
14
+ High Ho Gopibot away!
15
+
16
+ Everybody please meet Gopibot our chatops bot which I built at Numberz to help us deploy our countless microservices to QA.
17
+
18
+ ![Gopibot 1](../../../assets/images/gopibot.png)
19
+
20
+ So here is the backstory,
21
+ I was one of the developers who had access to our QA and Prod servers and the other person was the Head of Engineering and he is generally a busy guy.
22
+ So whenever there is a change that needs to be deployed everyone comes to me and tells me to deploy their microservice/frontend to the QA and blatantly
23
+ interrupts my awesome coding cycle.
24
+
25
+ Alright then, I break off from my flow, ssh into the server and start running the deploy command. And all of you jsdev wannabes who have worked with
26
+ react and webpack will know the horrors about deploying frontend code right. It takes forever so I have to wait there looking at the console along with
27
+ the dev who wanted me to deploy it (lets call him kokill for now). So kokill and I patiently wait for the webpack build to finish. 1m , 2m, 3m and WTH
28
+ 15m. And then its built and the new frontend is deployed to QA. YES! Now I can continue with my work. But wait then some other dev comes likes call him
29
+ (D-Ne0) and he asks to deploy something else and again the same process of ssh’ing the server and another wait. This got repetitive and irritating. Then
30
+ I started searching for solutions to the problem and looked high and low and thought that CI/CD is the only thing that can solve this problem. But then
31
+ I saw something new called ChatOps where developers have chatbots to talk to automate this manual work. Just like we have bots these days to help you
32
+ out in your work like getting your laundry, grocery and making orders.
33
+
34
+ So I decided to take a shot at this in my free time. And it seems it was simpler than I thought and decided to use Slack our primary team communication
35
+ platform. We used it daily for everything and I thought why not have a specific channel just where the bot resides and people could talk to the bot.
36
+
37
+ Since we are typically a nodejs shop I decided to find a way to send messages to a slack bot. And slack has this really great sdk for nodejs.
38
+ https://github.com/slackapi/node-slack-sdk
39
+ First I went and created the bot in my slack team settings. And then wrote a script which would allow it to read messages from the channel it was added.
40
+
41
+ Here is the simple script,
42
+
43
+ ```js
44
+ const RtmClient = require("@slack/client").RtmClient;
45
+ const RTM_EVENTS = require("@slack/client").RTM_EVENTS;
46
+ const CLIENT_EVENTS = require("@slack/client").CLIENT_EVENTS;
47
+ const bot_token = process.env.SLACK_BOT_TOKEN;
48
+
49
+ const rtm = new RtmClient(bot_token);
50
+ const COMMANDS = {
51
+ web: "ssh -i qa.pem user@url docker pull image-name && docker rm -f container-id && docker run -d image-name",
52
+ };
53
+ let deploymentInProgress = false;
54
+ let counter = 0;
55
+
56
+ rtm.on(RTM_EVENTS.MESSAGE, (event) => {
57
+ console.log("Got event", event);
58
+ if (
59
+ (event.subtype === "message_changed" || event.subtype === "message_deleted") &&
60
+ event.text &&
61
+ event.text.indexOf("$slackBotId") > -1
62
+ ) {
63
+ return rtm.sendMessage("Please dont change the message and expect me to correct your past mistakes", event.channel);
64
+ }
65
+ if (event.subtype) {
66
+ return;
67
+ }
68
+ if (event.type === "message" && event.text && event.text.indexOf("$slackBotId") > -1) {
69
+ if (deploymentInProgress === true) {
70
+ counter = counter + 1;
71
+ if (counter > 3) {
72
+ counter = 0;
73
+ return rtm.sendMessage("Stop bugging me noob or I'll tell to raise you bugs", event.channel);
74
+ }
75
+ return rtm.sendMessage("I am already processing a deploy request please wait", event.channel);
76
+ }
77
+ var input = event.text.trim().replace("$slackBotId ", "");
78
+ console.log("Got input", input);
79
+ const arr = input.split(" ");
80
+ arr.forEach((word) => {
81
+ word = word.replace(/\s/g, "");
82
+ });
83
+ const currCommand = arr[0];
84
+ if (COMMANDS.indexOf(currCommand) > -1) {
85
+ rtm.sendMessage("Starting to deploy ${currCommand}", event.channel);
86
+ const ssh = spawn(COMMANDS[currCommand]);
87
+ deploymentInProgress = true;
88
+ ssh.stdout.on("data", (data) => {
89
+ rtm.sendMessage(data, event.channel);
90
+ });
91
+ ssh.stderr.on("data", (data) => {
92
+ rtm.sendMessage(data, event.channel);
93
+ });
94
+ ssh.on("close", (code) => {
95
+ deploymentInProgress = false;
96
+ if (code === 0) {
97
+ console.log("Deployed Successfully", currCommand);
98
+ rtm.sendMessage("Deployed Successfully " + currCommand, event.channel);
99
+ } else {
100
+ console.log("child process exited with code ", code);
101
+ rtm.sendMessage("child process exited with code " + code);
102
+ }
103
+ });
104
+ return;
105
+ } else {
106
+ counter = counter + 1;
107
+ if (counter > 3) {
108
+ counter = 0;
109
+ return rtm.sendMessage("Stop bugging me noob or I'll tell <@U30TXGLS1|gopi> to raise you bugs", event.channel);
110
+ }
111
+ return rtm.sendMessage(
112
+ `command '${event.text} ' not found.You need to specify one of these commands [${COMMANDS.map((v, k) => k).join(
113
+ ","
114
+ )} ]`,
115
+ event.channel
116
+ );
117
+ }
118
+ }
119
+ });
120
+ console.log("Starting deploybot");
121
+ rtm.start();
122
+ ```
123
+
124
+ So what the bot does is when someone mentions the bot with a command to run. It first checks if the command is defined in our COMMANDS map and then if
125
+ it is, it executes the corresponding shell command for it on our QA server and then gives back progress/error/finished messages back to the channel so
126
+ that everyone will be notified that someone had done a deployment. This is how it looks like,
127
+
128
+ ![Gopibot 2](../../../assets/images/gopibot.png)
129
+
130
+ Anyways to just have a boring bot that just runs boring commands was kinda boring. I thought of spicing up the bot interaction by making it say weird
131
+ things if you keep giving it invalid commands. Making it more of a life like bot.
132
+
133
+ Initially the bot was called deploybot and had a rocket icon but then there was our QA/Bug creator/Hell Raiser/Injoker in our team so I thought creating
134
+ a mini him would be better and give the bot a real person’s personality and it worked and people kind a started talking to bot some random stuff and
135
+ all.
136
+
137
+ Further on we can maybe introduce natural language processing and deep learning to make the bot learn from our messages and not just take a single
138
+ command. Like instead of me saying @gopibot cfm I can say @gopibot please deploy our cashflow server or please revert the deployment to the previous
139
+ version and things like that.
static/styles.css CHANGED
@@ -5,4 +5,101 @@
5
5
  @page {
6
6
  size: A4;
7
7
  margin: 0;
8
+ }
9
+
10
+ .post-page {
11
+ display: flex;
12
+ flex-direction: column;
13
+ width: 100%;
14
+
15
+ & code {
16
+ font-family: Menlo, Monaco, Courier New, monospace;
17
+ font-size: 0.9em;
18
+ }
19
+
20
+ & p {
21
+ margin-top: 1rem;
22
+ margin-bottom: 1rem;
23
+ }
24
+
25
+ & pre {
26
+ max-width: 64rem;
27
+ font-family: monospace;
28
+ font-size: 14px;
29
+ border-radius: 16px;
30
+ padding: 16px;
31
+ margin: 8px;
32
+ line-height: 20px;
33
+ overflow-x: auto;
34
+ }
35
+
36
+ & img {
37
+ width: auto;
38
+ @media (--mobile) {
39
+ width: 100%;
40
+ }
41
+ }
42
+
43
+ & .title-container {
44
+ display: flex;
45
+ flex: 1;
46
+ font-family: serif;
47
+ flex-direction: column;
48
+
49
+ & h1 {
50
+ color: var(--black-light);
51
+ margin: 0;
52
+ line-height: 3rem;
53
+ }
54
+
55
+ & h2 {
56
+ color: var(--yellow-dark);
57
+ font-size: 1.5rem;
58
+ font-weight: 500;
59
+ margin-top: 20px;
60
+ margin-bottom: 20px;
61
+ }
62
+
63
+ & h3 {
64
+ color: var(--black-light);
65
+ font-size: 1.5rem;
66
+ font-weight: 500;
67
+ margin: 0;
68
+ }
69
+
70
+ & .date {
71
+ display: flex;
72
+ flex: 1;
73
+ flex-direction: row;
74
+ justify-content: flex-end;
75
+ margin-right: var(--space-10);
76
+
77
+ @media (--mobile) {
78
+ justify-content: flex-start;
79
+ margin-top: 0.5rem;
80
+ margin-bottom: 0.5rem;
81
+ }
82
+ }
83
+ }
84
+
85
+ & .tags-container {
86
+ margin-top: var(--space-1);
87
+ margin-bottom: var(--space-1);
88
+
89
+ @media (--mobile) {
90
+ margin-top: var(--space-4);
91
+ margin-bottom: var(--space-4);
92
+ }
93
+ }
94
+ }
95
+
96
+
97
+ .tag {
98
+ background-color: var(--black-light);
99
+ color: white;
100
+ display: inline-block;
101
+ padding-left: 8px;
102
+ padding-right: 8px;
103
+ text-align: center;
104
+ margin-right: 1rem;
8
105
  }