From 0455b209cefe33af287bf8c3852e56c0a206cb39 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Mon, 16 Mar 2026 21:39:13 +0100 Subject: [PATCH] feat: add zstd compression support and compression benchmarks - Add compress-bench.sh script for comparing compression algorithms - Add docker-compose for compression benchmarking environment - Pre-compress static assets with brotli, gzip, and zstd --- benchmark/compress-bench.sh | 328 +++++++++++++++++++++++ benchmark/docker-compose.compression.yml | 136 ++++++++++ public/index.html.br | Bin 0 -> 787 bytes public/index.html.gz | Bin 0 -> 941 bytes public/index.html.zst | Bin 0 -> 1002 bytes public/style.css.br | Bin 0 -> 1111 bytes public/style.css.gz | Bin 0 -> 1221 bytes public/style.css.zst | Bin 0 -> 1289 bytes 8 files changed, 464 insertions(+) create mode 100755 benchmark/compress-bench.sh create mode 100644 benchmark/docker-compose.compression.yml create mode 100644 public/index.html.br create mode 100644 public/index.html.gz create mode 100644 public/index.html.zst create mode 100644 public/style.css.br create mode 100644 public/style.css.gz create mode 100644 public/style.css.zst diff --git a/benchmark/compress-bench.sh b/benchmark/compress-bench.sh new file mode 100755 index 0000000..9f8a480 --- /dev/null +++ b/benchmark/compress-bench.sh @@ -0,0 +1,328 @@ +#!/usr/bin/env bash +# ============================================================================= +# compress-bench.sh — Compression benchmark suite +# +# Benchmarks static-web serving pre-compressed files with different encodings. +# Tests: no compression (baseline), gzip, brotli, zstd, and on-the-fly gzip. +# +# Usage: +# ./benchmark/compress-bench.sh [OPTIONS] +# +# Options: +# -c Connections (default: 50) +# -n Total requests (default: 100000) +# -d Duration in seconds — overrides -n when set +# -k Keep containers running after benchmark (default: tear down) +# -h Show this help +# +# Requirements: +# - docker + docker compose +# - bombardier (https://github.com/codesenberg/bombardier) +# Install: brew install bombardier OR go install github.com/codesenberg/bombardier@latest +# - Pre-compressed files in public/ (index.html.gz, index.html.br, index.html.zst) +# Run: gzip -k -9 public/index.html && brotli -k -9 public/index.html && zstd -k public/index.html +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE_FILE="${SCRIPT_DIR}/docker-compose.compression.yml" +RESULTS_DIR="${SCRIPT_DIR}/results" + +# ---------- defaults --------------------------------------------------------- +CONNECTIONS=50 +REQUESTS=100000 +DURATION="" # empty = use -n mode; set seconds e.g. 30 to use -d mode +KEEP=false + +# ---------- colours ---------------------------------------------------------- +RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +BLUE='\033[0;34m' + +# ---------- arg parse -------------------------------------------------------- +usage() { + grep '^#' "$0" | grep -v '^#!/' | sed 's/^# \{0,2\}//' + exit 0 +} + +while getopts "c:n:d:kh" opt; do + case $opt in + c) CONNECTIONS="$OPTARG" ;; + n) REQUESTS="$OPTARG" ;; + d) DURATION="$OPTARG" ;; + k) KEEP=true ;; + h) usage ;; + *) echo "Unknown option -$OPTARG"; exit 1 ;; + esac +done + +# ---------- dependency checks ------------------------------------------------ +check_deps() { + local missing="" + command -v docker >/dev/null 2>&1 || missing="$missing docker" + command -v bombardier >/dev/null 2>&1 || missing="$missing bombardier" + + if [ -n "$missing" ]; then + echo -e "${RED}Missing dependencies:${missing}${RESET}" + echo "" + echo "Install bombardier: brew install bombardier" + echo " OR go install github.com/codesenberg/bombardier@latest" + exit 1 + fi +} + +# ---------- servers (parallel indexed arrays — bash 3 compatible) ------------- +# Each server is configured to serve a specific encoding type +# We use Accept-Encoding header to request specific encoding from same server +SERVER_NAMES=( "no-compress" "gzip-precompressed" "brotli-precompressed" "zstd-precompressed" "gzip-onthefly" "zstd-onthefly" ) +SERVER_URLS=( "http://localhost:9001/index.html" "http://localhost:9002/index.html" "http://localhost:9003/index.html" "http://localhost:9004/index.html" "http://localhost:9005/index.html" "http://localhost:9006/index.html" ) +# Accept-Encoding header to use for each server +ACCEPT_ENCODING=( "" "gzip" "br" "zstd" "gzip" "zstd" ) +SERVER_COUNT=6 + +# ---------- helpers ---------------------------------------------------------- +wait_for_server() { + local name=$1 + local url=$2 + local max=30 + local i=0 + printf " Waiting for %-22s" "${name}..." + while ! curl -sf -o /dev/null "$url" 2>/dev/null; do + sleep 1 + i=$((i + 1)) + if [ "$i" -ge "$max" ]; then + echo -e " ${RED}TIMEOUT${RESET}" + return 1 + fi + printf "." + done + echo -e " ${GREEN}ready${RESET}" +} + +run_bombardier() { + local url=$1 + local accept_enc=$2 + + if [ -n "$DURATION" ]; then + if [ -n "$accept_enc" ]; then + bombardier -c "$CONNECTIONS" -d "${DURATION}s" -l --print r -H "Accept-Encoding: $accept_enc" "$url" 2>/dev/null + else + bombardier -c "$CONNECTIONS" -d "${DURATION}s" -l --print r "$url" 2>/dev/null + fi + else + if [ -n "$accept_enc" ]; then + bombardier -c "$CONNECTIONS" -n "$REQUESTS" -l --print r -H "Accept-Encoding: $accept_enc" "$url" 2>/dev/null + else + bombardier -c "$CONNECTIONS" -n "$REQUESTS" -l --print r "$url" 2>/dev/null + fi + fi +} + +# Extract Reqs/sec average from bombardier output +parse_rps() { + awk '/Reqs\/sec/{print $2; exit}' +} + +# Extract p50 latency +parse_p50() { + awk '/50\%/{print $2; exit}' +} + +# Extract p99 latency +parse_p99() { + awk '/99\%/{print $2; exit}' +} + +# Extract bytes transferred +parse_bytes() { + awk '/Total data/{print $4; exit}' +} + +# ---------- main ------------------------------------------------------------- +main() { + check_deps + + mkdir -p "$RESULTS_DIR" + + echo "" + echo -e "${BOLD}╔════════════════════════════════════════════════════════════════════╗${RESET}" + echo -e "${BOLD}║ Compression Benchmark Suite ║${RESET}" + echo -e "${BOLD}╚════════════════════════════════════════════════════════════════════╝${RESET}" + echo "" + + if [ -n "$DURATION" ]; then + echo -e " ${CYAN}Mode: duration ${DURATION}s${RESET}" + else + echo -e " ${CYAN}Mode: ${REQUESTS} requests${RESET}" + fi + echo -e " ${CYAN}Connections: ${CONNECTIONS}${RESET}" + echo -e " ${CYAN}Tool: $(bombardier --version 2>&1 | head -1)${RESET}" + echo -e " ${CYAN}Date: $(date -u '+%Y-%m-%d %H:%M:%S UTC')${RESET}" + echo "" + + # ---- start containers ----------------------------------------------------- + echo -e "${BOLD}→ Starting containers...${RESET}" + docker compose -f "$COMPOSE_FILE" up -d --build 2>&1 | \ + grep -E '(building|built|pulling|pulled|started|created|Built|Started|Created)' || true + echo "" + + # ---- wait for readiness --------------------------------------------------- + echo -e "${BOLD}→ Waiting for servers to be ready...${RESET}" + i=0 + while [ $i -lt $SERVER_COUNT ]; do + wait_for_server "${SERVER_NAMES[$i]}" "${SERVER_URLS[$i]}" + i=$((i + 1)) + done + echo "" + + # ---- warmup pass ---------------------------------------------------------- + echo -e "${BOLD}→ Warming up (10 000 requests each)...${RESET}" + i=0 + while [ $i -lt $SERVER_COUNT ]; do + printf " %-22s" "${SERVER_NAMES[$i]}" + if [ -n "${ACCEPT_ENCODING[$i]}" ]; then + bombardier -c "$CONNECTIONS" -n 10000 --print i -H "Accept-Encoding: ${ACCEPT_ENCODING[$i]}" "${SERVER_URLS[$i]}" >/dev/null 2>&1 + else + bombardier -c "$CONNECTIONS" -n 10000 --print i "${SERVER_URLS[$i]}" >/dev/null 2>&1 + fi + echo -e " ${GREEN}done${RESET}" + i=$((i + 1)) + done + echo "" + + # ---- benchmark each server ------------------------------------------------ + echo -e "${BOLD}→ Running compression benchmarks...${RESET}" + echo "" + + # Parallel indexed result arrays + RPS=() + P50=() + P99=() + BYTES=() + + i=0 + while [ $i -lt $SERVER_COUNT ]; do + name="${SERVER_NAMES[$i]}" + url="${SERVER_URLS[$i]}" + accept="${ACCEPT_ENCODING[$i]}" + out_file="${RESULTS_DIR}/compress-${name}.txt" + + echo -e " ${BOLD}[ ${name} ]${RESET} ${url}" + if [ -n "$accept" ]; then + echo -e " ${BLUE}Accept-Encoding: ${accept}${RESET}" + fi + echo -e " ─────────────────────────────────────────────" + + raw=$(run_bombardier "$url" "$accept" | tee "$out_file") + + rps=$(echo "$raw" | parse_rps) + p50=$(echo "$raw" | parse_p50) + p99=$(echo "$raw" | parse_p99) + bytes=$(echo "$raw" | parse_bytes) + + RPS[$i]="${rps:-0}" + P50[$i]="${p50:-N/A}" + P99[$i]="${p99:-N/A}" + BYTES[$i]="${bytes:-0}" + + echo "" + i=$((i + 1)) + done + + # ---- rank by req/s (simple insertion sort, bash 3 compatible) ------------- + # Build a sorted index array (descending by RPS) + SORTED_IDX=() + i=0 + while [ $i -lt $SERVER_COUNT ]; do + SORTED_IDX[$i]=$i + i=$((i + 1)) + done + n=${#SORTED_IDX[@]} + i=1 + while [ $i -lt $n ]; do + key_idx=${SORTED_IDX[$i]} + key_rps=${RPS[$key_idx]} + j=$((i - 1)) + while [ $j -ge 0 ]; do + cmp_idx=${SORTED_IDX[$j]} + cmp_rps=${RPS[$cmp_idx]} + # Compare floats via awk + if awk "BEGIN{exit !($cmp_rps < $key_rps)}" 2>/dev/null; then + SORTED_IDX[$((j + 1))]=${SORTED_IDX[$j]} + j=$((j - 1)) + else + break + fi + done + SORTED_IDX[$((j + 1))]=$key_idx + i=$((i + 1)) + done + + echo -e "${BOLD}╔══════════════════════════════════════════════════════════════════════════════════════════╗${RESET}" + echo -e "${BOLD}║ Results Summary ║${RESET}" + echo -e "${BOLD}╠══════════════════════════════════════════════════════════════════════════════════════════╣${RESET}" + printf "${BOLD}║ %-4s %-22s %10s %10s %10s %12s ║${RESET}\n" \ + "#" "Server" "Req/sec" "p50 lat" "p99 lat" "Transferred" + echo -e "${BOLD}╠══════════════════════════════════════════════════════════════════════════════════════════╣${RESET}" + + rank=1 + for idx in "${SORTED_IDX[@]}"; do + name="${SERVER_NAMES[$idx]}" + rps="${RPS[$idx]}" + p50="${P50[$idx]}" + p99="${P99[$idx]}" + bytes="${BYTES[$idx]}" + + if [ "$rank" -eq 1 ]; then + colour="$GREEN"; medal="1st" + elif [ "$rank" -eq 2 ]; then + colour="$YELLOW"; medal="2nd" + elif [ "$rank" -eq 3 ]; then + colour="$YELLOW"; medal="3rd" + else + colour="$RESET"; medal="${rank}th" + fi + + printf "${colour}║ %-4s %-22s %10s %10s %10s %12s ║${RESET}\n" \ + "$medal" "$name" "$rps" "$p50" "$p99" "$bytes" + rank=$((rank + 1)) + done + + echo -e "${BOLD}╚══════════════════════════════════════════════════════════════════════════════════════════╝${RESET}" + echo "" + echo -e " Full results saved to: ${CYAN}${RESULTS_DIR}/compress-*.txt${RESET}" + echo "" + + # ---- compression ratio summary -------------------------------------------- + echo -e "${BOLD}→ Compression effectiveness:${RESET}" + echo "" + + # Get uncompressed file size + if [ -f "${SCRIPT_DIR}/../public/index.html" ]; then + uncompressed_size=$(stat -f%z "${SCRIPT_DIR}/../public/index.html" 2>/dev/null || stat -c%s "${SCRIPT_DIR}/../public/index.html" 2>/dev/null || echo "0") + echo -e " ${CYAN}Uncompressed: ${uncompressed_size} bytes${RESET}" + + for ext in gz br zst; do + if [ -f "${SCRIPT_DIR}/../public/index.html.${ext}" ]; then + compressed_size=$(stat -f%z "${SCRIPT_DIR}/../public/index.html.${ext}" 2>/dev/null || stat -c%s "${SCRIPT_DIR}/../public/index.html.${ext}" 2>/dev/null || echo "0") + ratio=$(awk "BEGIN {printf \"%.1f\", ($uncompressed_size - $compressed_size) / $uncompressed_size * 100}") + echo -e " ${CYAN}.${ext} compressed: ${compressed_size} bytes (${ratio}% reduction)${RESET}" + fi + done + fi + echo "" + + # ---- teardown ------------------------------------------------------------- + if [ "$KEEP" = "false" ]; then + echo -e "${BOLD}→ Tearing down containers...${RESET}" + docker compose -f "$COMPOSE_FILE" down --remove-orphans 2>&1 | \ + grep -E '(Stopped|Removed|Removing|error)' || true + echo "" + else + echo -e " ${YELLOW}Containers left running (-k flag). Stop with:${RESET}" + echo -e " docker compose -f benchmark/docker-compose.compression.yml down" + echo "" + fi +} + +main "$@" diff --git a/benchmark/docker-compose.compression.yml b/benchmark/docker-compose.compression.yml new file mode 100644 index 0000000..2f176b0 --- /dev/null +++ b/benchmark/docker-compose.compression.yml @@ -0,0 +1,136 @@ +# docker-compose.compression.yml +# Spins up static-web with different compression configurations to benchmark +# the performance impact of each encoding type. +# +# Port map: +# 9001 → static-web (no compression, baseline) +# 9002 → static-web (gzip pre-compressed) +# 9003 → static-web (brotli pre-compressed) +# 9004 → static-web (zstd pre-compressed) +# 9005 → static-web (on-the-fly gzip) +# 9006 → static-web (on-the-fly zstd) +# +# Run via: docker compose -f benchmark/docker-compose.compression.yml up -d +# Then run: ./benchmark/compress-bench.sh + +name: static-web-compression + +services: + + # ------------------------------------------------------------------------- + # static-web — no compression (baseline) + # ------------------------------------------------------------------------- + no-compress: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9001:8080" + volumes: + - ../public:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "--no-compress", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # static-web — gzip pre-compressed (.gz sidecar files) + # ------------------------------------------------------------------------- + gzip-precompressed: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9002:8080" + volumes: + - ../public:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # static-web — brotli pre-compressed (.br sidecar files) + # ------------------------------------------------------------------------- + brotli-precompressed: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9003:8080" + volumes: + - ../public:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # static-web — zstd pre-compressed (.zst sidecar files) + # ------------------------------------------------------------------------- + zstd-precompressed: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9004:8080" + volumes: + - ../public:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # static-web — on-the-fly gzip (no pre-compressed files) + # ------------------------------------------------------------------------- + gzip-onthefly: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9005:8080" + volumes: + - ../public-uncompressed:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # static-web — on-the-fly zstd (no pre-compressed files) + # ------------------------------------------------------------------------- + zstd-onthefly: + build: + context: .. + dockerfile: benchmark/Dockerfile.static-web + ports: + - "9006:8080" + volumes: + - ../public-uncompressed:/public:ro + environment: + - SW_ROOT=/public + - SW_ADDR=0.0.0.0:8080 + - GOMAXPROCS=0 + command: ["static-web", "--quiet", "/public"] + restart: unless-stopped + + # ------------------------------------------------------------------------- + # Uncompressed public directory (for on-the-fly compression tests) + # ------------------------------------------------------------------------- + public-uncompressed: + image: alpine:latest + volumes: + - ../public:/public:ro + command: ["sh", "-c", "cp -r /public /public-raw && rm /public-raw/*.gz /public-raw/*.br /public-raw/*.zst && mv /public-raw /public"] + restart: unless-stopped diff --git a/public/index.html.br b/public/index.html.br new file mode 100644 index 0000000000000000000000000000000000000000..21ebb9bc6cdd20c60894ff92fbaf4c1b2cd4c0e1 GIT binary patch literal 787 zcmV+u1MK|4uwVcn{^zp!GwMyiUG1TlEaP*Ve|y=IY^RLJRtpZ{L8Ige!~cJ;#xNwo z0ER;JcJ|bS#?s`xcmG&aTWesBmulXEm9;9%b>=Uaqyo+6r-uX-ctvYiN$cl~%mNN| zV73@X5|w*-&g?F-smi&p*cckR6$;0^&&3+#rS^@@Rm?g^!Bds2tKZXNJ7xHNc&JSP z>DY!pY>nbej@;CABNmJBO2rS+B}c5gE-nv!9~p;Wb9Xk7|Ec9FWE z{DVVH2ys};GevSW8!*29{J2e1T2cy~!RE}W^0FODw_ZmsJ;p;_5;WpN92A%ruiK)_ zc(Vy8O|!He1KTPF3mfzira5?r5lh@BA0H^ zX*cb2KjWRYu?Cv00Wp5cjvb{W!&T=wZI@8IhHEQ(^8w=YZ32ee=YPYc|T?2SZpniSledHbK;z0v@f_ur;(CW<}pMTEiHpmTdQyk zGUyJ$rvZut0QSH7*p(~KkzFkWN<(45;YjJ6c4S^7x9>)75w`xDej*QQx-#Cb&~i0p zVuSJ~QQO85-Z@^qLN~Oj$Nn*#-`>?AY_3S-ZX1qs?kQ%Y$lEBwOHUogFM3ZphBftk zMfGL)h$`Zg>Rp&5g-k1QrEgmWYGEE`O3`(k<(vz&8yz&!Zggwlx<6I3gHlWEoL^tQ zV?_Uq+T}aqc&Fa!4{+c}FGCgGRu9%dh9O8HW*oEKumOq|q!cMEWf(Fb%T$CJ#vDp9 z>Nv&ryzB2(-?Ju9ZCJyexUOOE>Q>ZnEF5v<49<|#)RfKLA&z3x*2b%~4as3TkeJ77 zLs}N$xHvgFzPkny{~Uim5GO~@!R-YUOJ|Jvk=d%qmAyBZpr$>Jrw^@2y$DPz*PTIg7qpkq(gA>+;RNkZQ-bpR)e?{{MZ! R;t?TZMuH+i{egxWVimTahcEyD literal 0 HcmV?d00001 diff --git a/public/index.html.gz b/public/index.html.gz new file mode 100644 index 0000000000000000000000000000000000000000..dbb3010d1f996cb2e6ac83ddc20e1e65d574f7a2 GIT binary patch literal 941 zcmV;e15*4SiwFojf3j%;18Ht#Wq2-VbZu+^wO7rO<1`Sy&r>vp;hu(oOzUa0bYT&lT3alJCjO^iygQ6=j%_c zRww!7oA;O54}&Wxe5um2qCDJ+iqK@_*Lh&$K1O5z3ZBsXH7o;Bt|PJt`tTt6UB7&Jaf z>?)>L^2(aB@@t zg8}UnEl5Y--WoklZLPJ`V^UpwZ~5_T9HdH4@jzI)GJLWEv?~wWeF)Mxh7}AwGD}Ch z7L)1H%}!<$oChh9{3CL|ncj}tlquqpEw{GtzH+bQcr1NU=Mgt${59hf)MDuA-Rs8d zy$RA@SDQ#+yjeml-Qw~H%*~0Y*%+S&-BJ>yZ_UZ!q@%O*97C@!&M(eS4b^PsqJo}E zOvo}wqqwSbC3(Cz=4mx$u~Kv%z!y~ARm=1dxp=Igdq^t6nhNHowlI>^NoeD|*~9Q| z#wU~A0PGzCu*LQmkkkTyikc?9bh0AOZ8CQnNNU_8c~C*1kbV!yg@eeOHA-Ta*N!Ce z1O5-y!&$|0spw8Y_e9FOxx%v3l07e0xSmQ_>afJp*g4$X-gRJ+Xl+@p5VGv%p-PTG zKo^fi=hh(hBN%^>6&M}*0>hD-!wVp@aAl1GoD|5}vaU}6nx~ z*@5dU8^k@BN(OIk2H&3mfM$xZJ77qgPPB+IX^fc22+D&}>(Oou`6<*RTWR2>;<#0fc_qRi0YM2dA0N{{@4(aF5 z6^t;xDFtYc3^n@BgXK;R$AB1HYCWS`JqKd=-P`NCK{LAbNe!Q~ zvib^KQF~!#dKb?|Ybfl>H6`ICoda5;g17C=ci@oMBKq{vbhWiC40kJ7)QRb0y8rpt zEtBJ>lS8zcdF7)Wab%1~8*b$Gjy4fyU2ZR8J0EqzBq?hEYGP|v%$JW%!@onZ%!kNGs zF^;MAEpBOz)Rfxx-Bj&3`P!P;bSvr6F;PpD=g(iAj_*IQIGT?9suJ1XrEf#&=UKys zdgzkYyzH0rZF@Q|Nqg3swphAt6p90ALTkP&W$G<|Tgms^}>Iot_~35o==zxuRrTI6hEj2+W(nbPransYp=$Nxat z)nf+Ens13P>V0x9LB0MdW}Fr|`_~mFy_`1bDyya>*Ey#t)tfQu%%jsLe#cDGrNpQ+ z$w|L5TYYI~w5zj$g9xWpllJ4QZsyQ2PNTC@>^lgt}`zr;Oz}AVOibZhXp)9y~ zCjO2P!C0{D**;tZf-bgEU~#P5$Ku!r1B%bDq1T12G`*I8Cq;bT-?1((GGRytF-5HcztiwDns2(gf7WWt5Tg9nrPF`XKd z^zG=|aC;-oJpM1jz*9!D{gw@ z8kh)xlc>RwF$kHG5fPCjNgFUiVvM4SI}+g0C=v%b62y!WAZ3uLU;>Gcf{A-73w0Wc zL~=$l?9c&;C8H}b=>M~j|1km=iVes<&_Jx1kVKkw{T3Y(jm4)WbKGYoTa(KJ@>F$Rlu6J8J!u@#idPnpDn1saI2Q2c6J{*zp8~ws^h*o4b3#jp9X}9>gN& z(6Vun3r$gye95(B6I42Df$$x>CSJwO+@ecgI0M(wKz%C8TUm8wV&G$0t$eD2Doh{m zRlH(x>{i8WQ!Fn{X{H)7^r;vn9G)n10Y&isYSnscSdJI1p#|HLbcI?7GOw%-BJ-F+ YVos502a62MmCpHzd#|bM1Be@fn7ef4-T(jq literal 0 HcmV?d00001 diff --git a/public/style.css.br b/public/style.css.br new file mode 100644 index 0000000000000000000000000000000000000000..f557a7ad23afd633d45733b0468bb17bb59c2493 GIT binary patch literal 1111 zcmV-d1gQJLU}OLwK0jU6(^P-OAtvmJcUxUi!GvUqKprq+{=ds#y(lEY;4lhdktoHx z?`6*UnNWI{Ey)a}b%$bi-P+QOTdCKE{PuRO7##6qPhIv(@ya==m|Gu>bn*;o58!!6 zn}Oqf<4-xfzbQc}M-mK)q4nUE5YFFk;Y%>BwQQZ=+Pm04#)m$9G4~>3V}09g?*@F zpNEx;Xa+qxibdYW-aWu@KbLZC(T19#B-wo?w1|V%Y(f3q|F9D1Y3MESvpaqT-2aZ% z=|u;-Su~S=aIcP-D z%lnsHNu*70bCGt?f!5XRg)^)v`lY3+lylnA#`WCQ=_Qwty+rv2WycjGsz4aWG!~^)3q?HRg5ud1p024Y3RG=UHNHcrOeX>Z} zgYnHO9I`iRL9s~Y`4l)m?NlM)!K`0>a*`uT8jLmqTCZ7nfnkhI9G>U-MtIdD@vj>3 z1Bf}tv{M$=iM=zUVcO#7S4a9`2PQwAHj|eS)lzI^R@3p;otV+X28>RL$nR;E&D)xB z8;p3<%#k*6JRD_Wo{hx!f*24(if&NIqZi#yyH3{!$p63%Qao2VO`DQ7360Xs*owwQ zgVkHdnOOm>v2tsbd&sd@aELhp74^V2bhbR@vtImI2qcAodu6R;*WvgB(*{S4R{}nPCAI(n literal 0 HcmV?d00001 diff --git a/public/style.css.gz b/public/style.css.gz new file mode 100644 index 0000000000000000000000000000000000000000..870cb8048cad0ade476a02ccc696e7f8c70b046a GIT binary patch literal 1221 zcmV;$1Uma4iwFoHW~^xf19NnFY-KKEb8`TVR?TkXI1Ik$DTFBoXuDBrC(WOG8KA)4 z=Cn^B+p?pLEg7<$He1ZQFGb4EpQqCv9F?L(^5aK}k5l%TB&jTnmADjzla_t3)V%Xn z{ik%9z}n{0auB_+TG&$Qghel`5qXaLu>LifB-R+mJ|>KDo`Mwr?xH-77q!t z%OK5;LemZFZ59;;NsqFs%$v6+7wO_TCaI-cL0W8<@$)uVGuoiIQ@Cy$@P5j^{qr~b z3IBeZvY%2{1|+Yd%|xyQdo{TP`K!^bsYE+v!?~9UV6<~}H3dBLBS5^9s@y@4aTI+= z1W6923!@z`M6If0!VbdTlP?e6tyEStyn|Cj& zl$LxjK3jR&kjv+&d1v#WnX9&`1o%)?5(WO3=gP{=DFf(&&92sdM^viRTsc{{q|RF> ztn*v{cCAyzk!RqF)L=7%ue5%vR+^u|aM+!Wsgsj$;=0cINtbw;bkgX=~ewkDH`uVFm~?MY$a>7N7w$v!u7!=?hKrq z!$HX43^Q$s`qwHE>3F|=syfS@*-IUKZmg*rD4)wTBHtfgSl2@RfwzEr!n6UOy*0BB zb}r6fHNY9Kp>$zxHGE}w1+VcYtiIKtDtSOF(%KO#7X6|-a#fFL^uC9f(Ur6^*`5NX z<;N(aL!%(RhxQNyFQKMX-m@O%XDYms1)#I|qn?FI?I8av%JxA0c;%^oLAa2*P!55V zSg*(kjDfx!U^15CIl04cF*_aPPPvMRItJWT@y4NerE{JOKs0oE7HvE~8S@p-ofW!; zUS21xYZ_@Yp!iudsd3K0UhMgbj_eQPg+lc(A8i=ha8w>Y^&NY_&MLk4FGMJ-9zufI zUvm)EHFKzWdEU$#;dUHVt}WSM=MB*2sg(lE;13s7)i($yO7;W<%S%bD^MZ?jmB#2W zA=mBImZ`X3EvE5iIo(9lSvheV<}Y0*#C>3kN~Po<58%LE1Ot zVGqX@!Y1v&x4s1neAL=66a;en&3cjlj`s_!bEika9(|8a&mtNN?dj|N?{|F-$TYnD zpG_9$Y+I1}_)LLm&1Gh6$R@r>f9&K$zJxVK^?_o)Qh^c_1)5|~YcM>W)&rc$&K%@L zpB;JHu00P@%6YC13eG`aC}3cfp)y(;X?VZ;aR&Vm9vQ?Of0B-VR+(==CmMY?$TA-W zl`r)Fz68YWfg$MMB*vfWal6%lY)OlAPm|ZFa%-xoy@`v-Dn}0ZO5>oA|KQ4|~?`w|UC^23#$72V%TI>VppKaur14I1*O9Rj%|_$;xVyVU@PwV;iTpiHnoM2mPAP!J=U+VZ7$S^R^F=Hb<7I z=t+y4ibyURU;^zVZ<7csH?b;t`j-I5dEP37f(tq2lgH7SSXIcfRF=mX>LOFg31ekm_c$L??M9j7ZoJ%C3!*bwHBH^Hy=K9I>TN>YdYitC}Y&U03!uoJMl9 z)_r1ym9ggTWzOPRyip52*NL|}ZDbEdroZMxiAWrySMYWB4#;*6JW1FrmH#mi;btYv zw<>L1%6h5w#{lJZR@_r=gzMX40)xMsYeH|6E2F%i+C?)RwbVsTOJ%9t@ee2jwnC6V zDtXfF-X|@$EER}dvjH*6Jl){E4@%%$2}9Z$3O&ssJE-YtD^ z`X;G1r!}Z{WbedsZ*Xqm+vt#A#xS6eV=rb!vG>4ZV?Gi4IqVgJE9tBsDrro-8FQ!= z%Cbgna0v{r_IscVN6q(^lT(+hwJiml{9b$2INCxwiyE>MKnP3j%skA&{)L;~yM=8(Zd1U4QiekVq=+ia9Ry;J2 zF_mlqdsl?Oen5X~6N(GxwRQ&ET;OQ*REB6p86!$#b+~7BmOcS#*}2PEUEXM%QMiFjW%e3$Myj9)B*zGq*3$ zW;B@0C!dB01hLgDP*AlImx*d~EZ?_&GnoBhw1IoFn4Xk(kiQgU*fD`nWJCgP`&5?E zc;}sBDAUU=ZykJP9JFa>3{Q56I*Tc5Ny-TtH_pB@2srE*r7^BffRq=9j#1A_e)QBz zk1KAUOiJ8