Aneurin Barker Snook преди 1 година
родител
ревизия
7c6719c67d
променени са 7 файла, в които са добавени 491 реда и са изтрити 347 реда
  1. 17 7
      index.html
  2. 282 1
      public/skills.json
  3. 58 2
      src/components/Skills.ts
  4. 0 5
      src/lib/request/index.ts
  5. 0 238
      src/lib/request/lib.ts
  6. 0 93
      src/lib/request/types.ts
  7. 134 1
      src/style.scss

+ 17 - 7
index.html

@@ -50,7 +50,7 @@
         </p>
       </section>
 
-      <section id="skills">
+      <section id="skills" x-data="skills">
         <header>
           <h2>Skills</h2>
         </header>
@@ -60,14 +60,24 @@
           If you have any questions, or something you need isn't on the list, <a href="#contact">get in touch</a> and I'd be happy to talk about it.
         </p>
 
-        <ul x-data="skills">
-          <template x-for="skill in skills">
+        <nav id="skills-tags" class="tags">
+          <template x-for="tag in tags" :key="tag">
+            <a x-bind:class="tagClass(tag)" x-on:click="setCurrentTag" x-text="tag" x-bind:href="`?tag=${tag}`" x-bind:data-tag="tag"></a>
+          </template>
+        </nav>
+
+        <ul id="skills-list">
+          <template x-for="skill in visibleSkills">
             <li>
-              <span class="name" x-text="skill.name"></span>
-              <span class="description" x-text="skill.description"></span>
+              <header>
+                <h3 x-text="skill.name"></h3>
+              </header>
+
+              <p class="description" x-text="skill.description"></p>
+
               <nav class="tags">
-                <template x-for="tag in skill.tags">
-                  <a x-text="tag" x-bind:href="`?tag=${tag}#skills`"></a>
+                <template x-for="tag in skill.tags" :key="tag">
+                  <a x-bind:class="tagClass(tag)" x-on:click="setCurrentTag" x-text="tag" x-bind:href="`?tag=${tag}#skills`" x-bind:data-tag="tag"></a>
                 </template>
               </nav>
             </li>

+ 282 - 1
public/skills.json

@@ -1,7 +1,8 @@
 [
   {
     "name": "Adobe Commerce (Magento 2)",
-    "tags": ["framework", "php", "js", "ecommerce"]
+    "tags": ["php", "js", "ecommerce", "framework", "platform"],
+    "description": "I have delivered beautiful and powerful Magento websites for boutique and major retailers, often developing complex integrations with other platforms."
   },
   {
     "name": "Adobe Illustrator",
@@ -9,6 +10,286 @@
   },
   {
     "name": "Adobe Photoshop",
+    "tags": ["design"],
+    "description": "I have used Photoshop all my career to create assets for the web, including UI, imagery, and other promotional content."
+  },
+  {
+    "name": "Affinity Designer",
+    "tags": ["design"],
+    "description": "I use Designer to work with vector-based imagery and create icons."
+  },
+  {
+    "name": "Affinity Publisher",
+    "tags": ["design"]
+  },
+  {
+    "name": "Agile",
+    "tags": ["knowledge"],
+    "description": "I am comfortable working in an agile environment."
+  },
+  {
+    "name": "Apache",
+    "tags": ["framework"],
+    "description": "I have set up many websites using PHP or JS using an Apache front proxy. I prefer nginx these days but Apache is easier to use in some respects."
+  },
+  {
+    "name": "ArangoDB",
+    "tags": ["database"],
+    "description": "ArangoDB is a fantastic NoSQL graph database which offers a very accessible and powerful query language, AQL. I have used it for many large-scale Node server applications."
+  },
+  {
+    "name": "Backend development",
+    "tags": ["knowledge"],
+    "description": "I have extensive backend engineering experience from building numerous server apps and integrations throughout my career."
+  },
+  {
+    "name": "BDD",
+    "tags": ["knowledge"]
+  },
+  {
+    "name": "Bitbucket",
+    "tags": ["operations", "platform"],
+    "description": "I have used Bitbucket for version control (git) and CI/CD via Pipelines. It integrates well with Jira, too."
+  },
+  {
+    "name": "Cassandra",
+    "tags": ["database"]
+  },
+  {
+    "name": "CI/CD",
+    "tags": ["operations", "knowledge"]
+  },
+  {
+    "name": "Confluence",
+    "tags": ["platform"]
+  },
+  {
+    "name": "CSS/SASS",
+    "tags": ["design", "language"],
+    "description": "I write native and semantic CSS/SASS like it's poetry."
+  },
+  {
+    "name": "Docker",
+    "tags": ["operations", "language", "platform"],
+    "description": "Containerization is a boon to any hardworking engineer that needs to run a bunch of different stuff without taking chances on their host OS. Docker works great in production, too."
+  },
+  {
+    "name": "Express",
+    "tags": ["js", "framework"],
+    "description": "Express provides a very developer-friendly web server that's easy to scale horizontally and extend."
+  },
+  {
+    "name": "Figma",
+    "tags": ["design"]
+  },
+  {
+    "name": "Frontend development",
+    "tags": ["knowledge"],
+    "description": "I am well-versed in past and present frontend and UX trends and try to keep up with all major frameworks, including React and Vue."
+  },
+  {
+    "name": "Full-stack development",
+    "tags": ["knowledge"],
+    "description": "I am adept at uniting frontend and backend development with accessible connectors and protocols."
+  },
+  {
+    "name": "git",
+    "tags": ["operations", "language"],
+    "description": "All development has been significantly easier ever since git joined my workflow. I work with greater confidence, knowing that I can always look to the past to guide the future."
+  },
+  {
+    "name": "GitHub",
+    "tags": ["operations", "platform"],
+    "description": "GitHub offers increasing value the more you use it, and its Actions are so easy to work with to enable CI/CD even for small projects. Always my first stop for hosted version control."
+  },
+  {
+    "name": "Go",
+    "tags": ["language"],
+    "description": "I adore Golang's simplicity - it makes networked applications quick to put together with minimal fuss and easy to understand when you revisit them again in the future."
+  },
+  {
+    "name": "Google Analytics",
+    "tags": ["platform"],
+    "description": "I have provided basic and custom Google Analytics integrations for many clients, enabling them to better understand and react to customer behaviours."
+  },
+  {
+    "name": "Google Docs/Drive",
+    "tags": ["office"],
+    "description": "Google offer a very competitive office suite which fits 95% of my use cases perfectly. It makes it easy to share documentation and resources with my clients, and collaborative editing works exceptionally well."
+  },
+  {
+    "name": "Grafana",
+    "tags": ["operations", "platform"],
+    "description": "I use Prometheus and Grafana to aggregate metrics from different applications and create useful visualisations of their availability, behaviour, and performance."
+  },
+  {
+    "name": "GraphQL",
+    "tags": ["language", "knowledge"],
+    "description": "I have worked with GraphQL in Magento and Uniswap integrations. It's not my preference of API for technical and cost/benefit reasons but when it works, it's very clever."
+  },
+  {
+    "name": "gRPC",
+    "tags": ["framework"]
+  },
+  {
+    "name": "HTML5/XHTML/XML",
+    "tags": ["language"],
+    "description": "HTML was my first web language, and it remains of critical importance for SEO and accessibility even in the era of web apps."
+  },
+  {
+    "name": "Ionic Framework",
+    "tags": ["js", "framework"],
+    "description": "I led a team of four developers building a Magento-based web app for a major retailer. Ionic was a strong choice to use off-the-shelf components and accelerate our wireframing."
+  },
+  {
+    "name": "JavaScript (ES6)",
+    "tags": ["js", "language"],
+    "description": "I am fluent in JavaScript, though I prefer to work in TypeScript wherever possible. It's a fine language for simple cases and offers much more power when augmented by type hints, especially in Intellisense."
+  },
+  {
+    "name": "Jenkins",
+    "tags": ["operations", "platform"],
+    "description": "I have managed Jenkins instances for clients which handle testing, building, and deploying applications and creating backups across their network."
+  },
+  {
+    "name": "Jira",
+    "tags": ["platform"],
+    "description": "Despite its complexity, Jira is a great tool for managing projects, particularly when its integrations are well-used."
+  },
+  {
+    "name": "Laravel",
+    "tags": ["php", "framework"]
+  },
+  {
+    "name": "LevelDB",
+    "tags": ["database"],
+    "description": "LevelDB is a nice, simple key-value store that makes it easy to whip up a self-contained application without using an external storage service. Its speed makes up for certain limitations such as single read and write."
+  },
+  {
+    "name": "LibreOffice",
+    "tags": ["office"]
+  },
+  {
+    "name": "MariaDB",
+    "tags": ["database"],
+    "description": "MariaDB is my go-to when I need a MySQL server for Magento or WordPress work."
+  },
+  {
+    "name": "MetaMask",
+    "tags": ["web3", "framework"]
+  },
+  {
+    "name": "Microsoft Office",
+    "tags": ["office"]
+  },
+  {
+    "name": "MongoDB",
+    "tags": ["js", "database"],
+    "description": "MongoDB works really well with Node.js applications, making the most of dynamic typing and offering a powerful declarative query API."
+  },
+  {
+    "name": "MySQL/SQL",
+    "tags": ["database"],
+    "description": "A popular choice for many applications in need of RDBMS. SQL can accomplish so much and so quickly when properly utilized."
+  },
+  {
+    "name": "New Relic",
+    "tags": ["operations", "platform"]
+  },
+  {
+    "name": "Next.js",
+    "tags": ["js", "framework"]
+  },
+  {
+    "name": "nginx",
+    "tags": ["framework"],
+    "description": "A very elegant and efficient HTTP server which I favour for most applications, particularly when combined with automatic SSL certificate issuance."
+  },
+  {
+    "name": "Node-RED",
+    "tags": ["operations", "language"]
+  },
+  {
+    "name": "Node.js",
+    "tags": ["js", "language"],
+    "description": "I am a Node aficionado and I try to make the most of the expansive standard library, but when deadlines loom, there's a million packages on npm to speed things up a bit."
+  },
+  {
+    "name": "PHP",
+    "tags": ["php", "language"],
+    "description": "I have been with PHP since version 5.6. While it hasn't completely shaken off its past, modern versions are much more approachable with better typing features and strong, structured OOP capabilities."
+  },
+  {
+    "name": "Prometheus",
+    "tags": ["operations", "platform"],
+    "description": "I use Prometheus and Grafana to aggregate metrics from different applications and create useful visualisations of their availability, behaviour, and performance."
+  },
+  {
+    "name": "React",
+    "tags": ["js", "framework"],
+    "description": "Functional React is a brilliant way to build complex web apps with interlocking functionality. Hooks make it easy to separate concerns by isolating internal logic from the UX."
+  },
+  {
+    "name": "REST API",
+    "tags": ["knowledge"]
+  },
+  {
+    "name": "SOAP",
+    "tags": ["framework"]
+  },
+  {
+    "name": "Shell",
+    "tags": ["operations", "language"],
+    "description": "I use a terminal every day in the course of my work, whether on my own system or in a remote SSH session. My hands-on experience managing servers supports my ability to implement CI/CD."
+  },
+  {
+    "name": "Sketch",
     "tags": ["design"]
+  },
+  {
+    "name": "Solidity",
+    "tags": ["web3", "language"]
+  },
+  {
+    "name": "Strangler pattern",
+    "tags": ["knowledge"]
+  },
+  {
+    "name": "Tailwind CSS",
+    "tags": ["framework"],
+    "description": "While I prefer to write my own (poetic) CSS for my own projects, Tailwind provides cohesion and compartmentalisation of styles - a benefit to larger teams and busy projects with high code turnover."
+  },
+  {
+    "name": "TDD",
+    "tags": ["knowledge"]
+  },
+  {
+    "name": "TypeScript",
+    "tags": ["js", "language"],
+    "description": "TypeScript is the perfect solution for code correctness in JavaScript apps. It has enabled me to build things of vastly greater scope and complexity without losing track of the flow of data."
+  },
+  {
+    "name": "Umami [Analytics]",
+    "tags": ["platform"]
+  },
+  {
+    "name": "Vue.js",
+    "tags": ["js", "framework"]
+  },
+  {
+    "name": "WebSockets",
+    "tags": ["js", "framework", "language"]
+  },
+  {
+    "name": "web3.js",
+    "tags": ["js", "framework"]
+  },
+  {
+    "name": "WooCommerce",
+    "tags": ["php", "js", "ecommerce", "framework", "platform"]
+  },
+  {
+    "name": "WordPress",
+    "tags": ["php", "js", "ecommerce", "framework", "platform"]
   }
 ]

+ 58 - 2
src/components/Skills.ts

@@ -3,23 +3,79 @@ import request, { ErrorResponse } from '@annybs/request-js'
 
 export default function Skills() {
   interface State {
+    currentTag: string | null
     skills: Skill[]
+    tags: string[]
+    visibleSkills: Skill[]
 
     init(): void
+    setCurrentTag(e: MouseEvent): void
+    tagClass(value: string): string
+  }
+
+  function readCurrentTag() {
+    const usp = new URLSearchParams(window.location.search)
+    return usp.get('tag')
+  }
+
+  function updateVisibleSkills(this: State) {
+    const tag = this.currentTag
+    if (tag) this.visibleSkills = this.skills.filter(skill => skill.tags.includes(tag))
+    else this.visibleSkills = this.skills
   }
 
   const state: State = {
+    currentTag: null,
     skills: [],
+    visibleSkills: [],
+
+    get tags() {
+      return this.skills
+        .reduce((tags, skill) => {
+          for (const tag of skill.tags) {
+            if (tags.indexOf(tag) === -1) tags.push(tag)
+          }
+          return tags
+        }, <string[]>[])
+        .sort()
+    },
 
     async init() {
+      const tag = readCurrentTag()
+      this.currentTag = tag
+
       try {
-        const res = await request.get(`//${document.location.host}/skills.json`).send()
-        this.skills = res.json as Skill[]
+        const res = await request.get(`//${document.location.host}/skills.json`)
+        this.skills = (res.json as Skill[]).sort((a, b) => a.name.localeCompare(b.name))
+        updateVisibleSkills.apply(this)
       } catch (err) {
         const e = err as ErrorResponse
         console.error(e.name, e.message)
       }
     },
+
+    setCurrentTag(e) {
+      const tag = (e.target as HTMLAnchorElement)?.dataset?.tag
+      if (!tag) return
+
+      e.preventDefault()
+
+      if (tag === this.currentTag) {
+        window.history.pushState({ tag }, '', `${window.location.pathname}#skills`)
+        this.currentTag = null
+      } else {
+        window.history.pushState({ tag }, '', `${window.location.pathname}?tag=${tag}#skills`)
+        this.currentTag = tag
+      }
+
+      updateVisibleSkills.apply(this)
+      return tag
+    },
+
+    tagClass(value: string) {
+      if (value === this.currentTag) return 'tag active'
+      return 'tag'
+    },
   }
 
   return state

+ 0 - 5
src/lib/request/index.ts

@@ -1,5 +0,0 @@
-export * from './lib'
-export type * from './types'
-
-import * as request from './lib'
-export default request

+ 0 - 238
src/lib/request/lib.ts

@@ -1,238 +0,0 @@
-import type * as types from './types'
-
-function error(xhr: XMLHttpRequest, reason?: unknown): types.ErrorResponse {
-  console.log(xhr, reason)
-  let name = 'Error'
-  let message = xhr.statusText
-  let stack: string | undefined
-
-  if (reason instanceof Error) {
-    const err = reason as Error
-    name = err.name
-    message = err.message
-    stack = err.stack
-  }
-
-  return {
-    name,
-    message,
-    stack,
-    xhr,
-  }
-}
-
-export function request() {
-  interface Data {
-    body?: Blob | string
-    headers: Record<string, string>
-    method: types.Method
-    ok: (number | [number, number])[]
-    params?: URLSearchParams
-    timeout?: number
-    url: string
-  }
-
-  const data = <Data>{
-    headers: {},
-    ok: [[200, 299]],
-  }
-
-  const req: types.Request = {
-    bearer(value) {
-      req.header('authorization', `Bearer ${value}`)
-
-      return req
-    },
-
-    blob(value) {
-      data.body = value
-
-      return req
-    },
-
-    body(value: Blob | string | types.AnyObject) {
-      if (typeof value === 'string') {
-        data.body = value
-      } else if (value instanceof Blob) {
-        data.body = value
-      } else {
-        return req.json(value)
-      }
-
-      return req
-    },
-
-    header(name, value) {
-      data.headers[name] = value
-
-      return req
-    },
-
-    json(value) {
-      data.body = JSON.stringify(value)
-      req.header('content-type', 'application/json')
-
-      return req
-    },
-
-    method(value) {
-      data.method = value
-
-      return req
-    },
-
-    ok(value) {
-      data.ok.push(value)
-
-      return req
-    },
-
-    param(name, value) {
-      if (!data.params) data.params = new URLSearchParams()
-
-      data.params.append(name, value)
-
-      return req
-    },
-
-    params(value) {
-      if (!data.params) data.params = new URLSearchParams()
-
-      if (value instanceof URLSearchParams) {
-        for (const [name, val] of value.entries()) {
-          data.params.append(name, val)
-        }
-      } else {
-        for (const name in value) {
-          data.params.append(name, value[name])
-        }
-      }
-
-      return req
-    },
-
-    string(value) {
-      data.body = value
-
-      return req
-    },
-
-    timeout(value) {
-      data.timeout = value
-
-      return req
-    },
-
-    url(value) {
-      data.url = value
-
-      return req
-    },
-
-    send() {
-      return new Promise((resolve, reject) => {
-        const xhr = new XMLHttpRequest()
-
-        xhr.addEventListener('load', () => {
-          const statusOk = data.ok.reduce((prev, value) => {
-            if (!prev) return prev
-            if (typeof value === 'number') return xhr.status === value
-            return xhr.status >= value[0] && xhr.status <= value[1]
-          }, true)
-
-          if (statusOk) {
-            resolve(response(xhr))
-          } else {
-            reject(error(xhr))
-          }
-        })
-
-        xhr.addEventListener('error', (reason) => {
-          reject(error(xhr, reason))
-        })
-
-        if (data.timeout) xhr.timeout = data.timeout
-
-        let url = data.url
-        if (data.params) {
-          const query = data.params.toString()
-          if (url.match('?')) url += `&${query}`
-          else url += `?${query}`
-        }
-        xhr.open(data.method, url)
-
-        xhr.send(data.body)
-      })
-    },
-
-    then(resolve, reject) {
-      return this.send().then(resolve).catch(reject)
-    },
-  }
-
-  return req
-}
-
-function response(xhr: XMLHttpRequest) {
-  const res: types.Response = {
-    xhr,
-
-    get blob() {
-      if (xhr.responseType === 'blob') return xhr.response
-      throw new Error('responseType is not blob')
-    },
-
-    header(name) {
-      return xhr.getResponseHeader(name)
-    },
-
-    headers() {
-      return xhr.getAllResponseHeaders()
-        .split(/\r\n/)
-        .map((line) => {
-          const [name, value] = line.split(':')
-          return { name, value }
-        })
-        .reduce((hs, { name, value }) => {
-          hs[name] = value
-          return hs
-        }, <Record<string, string>>{})
-    },
-
-    get json() {
-      if (xhr.responseType === 'json') return xhr.response
-      if (xhr.responseType === 'text' || !xhr.responseType) return JSON.parse(xhr.responseText)
-      throw new Error('responseType is not json or text')
-    },
-
-    get string() {
-      return xhr.responseText
-    },
-  }
-
-  return res
-}
-
-export function del(url: string) {
-  return request().method('DELETE').url(url)
-}
-
-export function get(url: string) {
-  return request().method('GET').url(url)
-}
-
-export function options(url: string) {
-  return request().method('OPTIONS').url(url)
-}
-
-export function patch(url: string) {
-  return request().method('PATCH').url(url)
-}
-
-export function post(url: string) {
-  return request().method('POST').url(url)
-}
-
-export function put(url: string) {
-  return request().method('PUT').url(url)
-}

+ 0 - 93
src/lib/request/types.ts

@@ -1,93 +0,0 @@
-export type AnyObject = Array<unknown> | Record<string | number | symbol, unknown>
-
-export interface ErrorResponse extends Error {
-  xhr: XMLHttpRequest
-}
-
-/** HTTP request method. */
-export type Method = 'DELETE' | 'GET' | 'OPTIONS' | 'PATCH' | 'POST' | 'PUT'
-
-/** Request data. */
-export interface Request extends PromiseLike<Response> {
-  /** Set the bearer token for the `authorization` header. */
-  bearer(value: string): Request
-
-  /** Unambiguously set a Blob as the request body. */
-  blob(value: Blob): Request
-
-  /** Set a Blob as the request body. */
-  body(value: Blob): Request
-  /** Set a string as the request body. */
-  body(value: string): Request
-  /**
-   * Encode an object as the request body.
-   * This will automatically set a `content-type: application/json` header.
-   */
-  body<T extends AnyObject>(value: T): Request
-
-  /** Set a request header. */
-  header(name: string, value: string): Request
-
-  /**
-   * Unambiguously encode an object as the request body.
-   * This will automatically set a `content-type: application/json` header.
-   */
-  json<T extends AnyObject>(value: T): Request
-
-  /** Set the request method. */
-  method(value: Method): Request
-
-  /**
-   * Add an "OK" response status code, preventing a client error being thrown if that status code is received.
-   * Status codes in the `2xx` range are always OK.
-   */
-  ok(value: number): Request
-  /**
-   * Add a range of "OK" response status codes, preventing a client error being thrown if a status code is received within that range.
-   * Status codes in the `2xx` range are always OK.
-   */
-  ok(start: number, end: number): Request
-
-  /** Set a request URL parameter. */
-  param(name: string, value: string): Request
-
-  /** Set request URL parameters. */
-  params(data: Record<string, string>): Request
-  /** Set request URL parameters. */
-  params(data: URLSearchParams): Request
-
-  /** Unambiguously set a string as the request body. */
-  string(value: string): Request
-
-  /** Set the request timeout (milliseconds). */
-  timeout(value: number): Request
-
-  /** Set the request URL. */
-  url(value: string): Request
-
-  /** Send the HTTP request via XMLHttpRequest. */
-  send(): Promise<Response>
-}
-
-export interface Response {
-  /** XMLHttpRequest object. */
-  xhr: XMLHttpRequest
-
-  /** Retrieve the response body as a Blob. */
-  blob: Blob
-
-  /** Get a response header. */
-  header(name: string): string | null
-
-  /** Get all response headers. */
-  headers(): Record<string, string>
-
-  /**
-   * Parse the response body from JSON.
-   * This can be cast to any type using the `as` keyword.
-   */
-  json: unknown
-
-  /** Retrieve the response body as a string. */
-  string: string
-}

+ 134 - 1
src/style.scss

@@ -1,13 +1,19 @@
+$bp-tablet: 600px;
+$bp-desktop: 900px;
+$bp-super: 1200px;
+
 :root {
   font-family: 'Roboto Mono', 'Courier New', Courier, monospace;
   font-size: 12pt;
 
   --bg-color: #e9e9e9;
+  --border-color: #ccc;
   --cta-color: #e24350;
   --fg-color: #333333;
 }
 
-html, body {
+html,
+body {
   background-color: var(--bg-color);
   color: var(--fg-color);
 
@@ -38,12 +44,24 @@ h1 {
   font-size: 2rem;
   font-weight: 700;
   line-height: 1em;
+  margin: 0;
+  padding: 0;
 }
 
 h2 {
   font-size: 1.4rem;
   font-weight: 700;
   line-height: 1em;
+  margin: 0;
+  padding: 0;
+}
+
+h3 {
+  font-size: 1rem;
+  font-weight: 700;
+  line-height: 1em;
+  margin: 0;
+  padding: 0;
 }
 
 p {
@@ -77,3 +95,118 @@ main {
     text-transform: uppercase;
   }
 }
+
+#skills {
+  #skills-tags {
+    margin-top: 2rem;
+  }
+
+  .tags {
+    display: flex;
+    flex-direction: row;
+    flex-wrap: wrap;
+    gap: 0.5rem;
+
+    .tag {
+      border-radius: 0.3rem;
+      border-width: 0;
+      display: inline-block;
+      padding: 0.1rem 0.4rem;
+    }
+
+    @mixin tag($bg, $fg) {
+      background-color: $bg;
+      color: $fg;
+
+      &.active {
+        background-color: var(--bg-color);
+        border-bottom: 0.2rem solid $bg;
+        border-radius: 0;
+        color: $bg;
+      }
+    }
+
+    [data-tag="database"] {
+      @include tag(red, white);
+    }
+
+    [data-tag="design"] {
+      @include tag(darkgreen, white);
+    }
+
+    [data-tag="ecommerce"] {
+      @include tag(orange, black);
+    }
+
+    [data-tag="framework"] {
+      @include tag(teal, white);
+    }
+
+    [data-tag="js"] {
+      @include tag(green, white);
+    }
+
+    [data-tag="knowledge"] {
+      @include tag(grey, black);
+    }
+
+    [data-tag="language"] {
+      @include tag(navy, white);
+    }
+
+    [data-tag="office"] {
+      @include tag(maroon, white);
+    }
+
+    [data-tag="operations"] {
+      @include tag(sienna, white);
+    }
+
+    [data-tag="php"] {
+      @include tag(purple, white);
+    }
+
+    [data-tag="platform"] {
+      @include tag(indigo, white);
+    }
+
+    [data-tag="web3"] {
+      @include tag(black, white);
+    }
+
+    .tag.active {
+      font-style: italic;
+    }
+  }
+
+  #skills-list {
+    border-bottom: solid 0.2rem var(--border-color);
+    border-top: solid 0.2rem var(--border-color);
+    display: grid;
+    gap: 1.5rem;
+    grid-template-columns: 1fr;
+    list-style: none;
+    padding: 2rem 0;
+
+    @media screen and (min-width: $bp-tablet) {
+      grid-template-columns: 1fr 1fr;
+    }
+
+    @media screen and (min-width: $bp-desktop) {
+      grid-template-columns: 1fr 1fr 1fr;
+    }
+
+    @media screen and (min-width: $bp-super) {
+      grid-template-columns: 1fr 1fr 1fr 1fr;
+    }
+
+    .description {
+      font-size: 0.8rem;
+    }
+
+    .tags {
+      font-size: 0.8rem;
+      padding: 0.1rem 0.2rem;
+    }
+  }
+}