Profile
Back to NewsBack
GitHub Trending 24 min
Reader Mode
home-operations/kromgo: Build badges and graphs from PromQL and share them in your READMEs

home-operations/kromgo: Build badges and graphs from PromQL and share them in your READMEs

14 hours ago

Kromgo

CI</a> Release</a> License</a> Discord</a>

Safely expose individual Prometheus metric values to the public web. Define named endpoints backed by PromQL queries and serve them as SVG badges, themed SVG/PNG graphs, or JSON — without exposing your Prometheus instance directly.

Badges render as shields.io-style SVG, so you can embed /badges/{id} straight into an tag — no shields.io round-trip required (though it's still supported via ?format=shields).

How it works

kromgo sits between the public web and your Prometheus. You define two kinds of endpoint:

  • Badges (/badges/{id}) render an instant value as an SVG badge, shields.io JSON, or kromgo JSON.
  • Graphs (/graphs/{id}) render a time series as a themed SVG/PNG chart or JSON.
Each maps a URL path to a PromQL query. Only the endpoints you define are reachable — Prometheus itself is never exposed.

The root path / serves a gallery that previews every endpoint next to its copy-paste Markdown snippet — handy for grabbing a badge for a README.

Quick start

docker run -d \
  -e KROMGO_PROMETHEUS_URL=http://prometheus:9090 \
  -v /path/to/config.yaml:/config/config.yaml \
  -p 8080:8080 \
  ghcr.io/home-operations/kromgo:latest

Then embed or query a badge:

<img src="http://localhost:8080/badges/node_cpu_usage" />

Docker Compose

services:
  kromgo:
    image: ghcr.io/home-operations/kromgo:latest
    environment:
      KROMGO_PROMETHEUS_URL: http://prometheus:9090
    volumes:
      - ./config.yaml:/config/config.yaml:ro
    ports:
      - "8080:8080"

Kubernetes (Helm)

kromgo publishes an OCI Helm chart to oci://ghcr.io/home-operations/charts/kromgo:

helm install kromgo oci://ghcr.io/home-operations/charts/kromgo \
  --namespace kromgo --create-namespace \
  --set config.prometheus='http://prometheus-operated.monitoring.svc.cluster.local:9090'

The config value is rendered verbatim into a ConfigMap mounted at /config/config.yaml, so the config schema below maps directly onto it. Notable values (see charts/kromgo/values.yaml):

| Value | Purpose | | ------------------------------------------ | ----------------------------------------------------------------------------- | | config.prometheus | Prometheus URL kromgo queries | | config.badges / config.graphs | the endpoint definitions (same schema as the config file) | | existingConfigMap | mount a ConfigMap you manage elsewhere instead of rendering config | | secret.prometheusUrl / .existingSecret | inject KROMGO_PROMETHEUS_URL from a Secret when the URL carries credentials | | ingress.enabled | expose the app via an Ingress | | httpRoute.enabled | expose the app via a Gateway API HTTPRoute (set parentRefs + hostnames) | | monitoring.serviceMonitor.enabled | scrape /metrics on the metrics port (Prometheus Operator) |

Every value is documented in the chart's generated README, charts/kromgo/README.md, built from values.yaml — which also ships a values.schema.json for editor autocompletion and helm install-time validation.

Configuration

kromgo reads its endpoint definitions from /config/config.yaml inside the container. Mount your config file there (or pass -config /path/to/config.yaml).

Minimal example:

badges:
  - id: node_cpu_usage
    query: "round(cluster:node_cpu:ratio_rate5m * 100, 0.1)"
    valueExpr: string(result) + "%"

A JSON Schema for editor validation is published at config.schema.json; point your editor's YAML language server at it for inline completion and validation.

Environment variables

| Variable | Required | Default | Description | | ----------------------------- | -------- | ------- | -------------------------------------------- | | KROMGO_PROMETHEUS_URL | yes | — | URL of your Prometheus instance | | KROMGO_SERVER_HOST | no | _(all)_ | Bind host; empty = all families (dual-stack) | | KROMGO_SERVER_PORT | no | 8080 | Port for the main server | | KROMGO_METRICS_HOST | no | _(all)_ | Bind host for the metrics listener | | KROMGO_METRICS_ENABLED | no | true | Serve Prometheus /metrics; off ⇒ no listener | | KROMGO_METRICS_PORT | no | 8081 | Metrics listen port (/metrics only) | | KROMGO_SERVER_LOGGING | no | false | Enable HTTP request access logging | | KROMGO_SERVER_READ_TIMEOUT | no | — | HTTP read timeout (e.g. 5s) | | KROMGO_SERVER_WRITE_TIMEOUT | no | — | HTTP write timeout (e.g. 10s) | | KROMGO_QUERY_TIMEOUT | no | 30s | Timeout applied to each Prometheus query | | KROMGO_LOG_LEVEL | no | info | Log level: debug, info, warn, error | | KROMGO_LOG_FORMAT | no | json | Log format: json or text |

Defaults

defaults sets the baseline for the per-endpoint fields that support it; each endpoint overrides the same-named field. All keys are optional.

defaults:
  badge:
    font: dejavu-sans # dejavu-sans (default, shields.io-style), dejavu-sans-bold, comic-neue, comic-neue-bold
    size: 11 # badge font size in points
    style: flat # flat (default), flat-square, plastic, or for-the-badge
    gallery:
      hidden: false # list badges in the gallery (default); true hides them
  graph:
    maxDuration: 1h # cap on a graph's requested window ("0" = unlimited)
    width: 600 # image width in px
    height: 200 # image height in px
    legend: true # show the series legend
    theme: light # color theme — see Themes below
    font: dejavu-sans # text font — see Themes below
    gallery:
      hidden: false # list graphs in the gallery (default); true hides them

The gallery page itself is toggled separately at the top level — see Gallery.

Badges

Each entry under badges: defines an instant-value endpoint at /badges/{id}.

| Field | Required | Description | | ------------ | -------- | ------------------------------------------------------------------------------------ | | id | yes | URL path segment — cpuGET /badges/cpu | | query | yes | PromQL expression returning a single scalar or vector value | | title | no | Display label on the badge (defaults to id) | | type | no | instant (default) or range — see Range badges | | range | no\* | Range-query window when type: range | | valueExpr | no | CEL expression for the displayed string — see Value and color | | colorExpr | no | CEL expression for the color — see Value and color | | labelColor | no | Left-segment (label) color — a name or hex; a fixed value, not a CEL expression | | style | no | flat (default), flat-square, plastic, or for-the-badge (see below) | | icon | no | An icon on the SVG badge, e.g. mdi:server-outline or si:kubernetes — see below | | gallery | no | Per-badge gallery settings, e.g. gallery: {hidden: true} — see Gallery |

Styles

flat (the default), flat-square, and plastic are the familiar shields.io looks — a 20px badge that honors defaults.badge.font/size. for-the-badge is the chunky shields variant: a fixed 28px badge with uppercased, letter-spaced text and a bold value segment. Being a faithful port of shields' own geometry, it is fixed-size — it ignores defaults.badge.font/size (the value's bold weight is the configured face's bold companion). It pairs naturally with an icon and, with no title, collapses to a single logo+value segment — the shields "empty label" form:

badges:
  - id: kubernetes
    query: kubernetes_build_info
    valueExpr: labels[?"git_version"].orValue("unknown")
    style: for-the-badge
    icon: si:kubernetes
    colorExpr: '"blue"'

Icons

icon renders an icon on the left of the SVG badge, written as : for one of two sets:

It is SVG-only — the shields and json formats have no icon field and ignore it. With a title, the icon sits to its left on the label segment, drawn to contrast with the label background (white on the default grey, dark on a light labelColor). With an icon and no title, the badge collapses to a single segment — the icon and value share one color and there's no separate label box (the id fallback is suppressed), mirroring shields.io's empty-label form. To instead keep a separate (colored) icon segment with no text, set title: " " (a single space).
badges:
  - id: nodes
    query: count(kube_node_info)
    icon: mdi:server-outline
    title: Nodes
  - id: version
    query: kubernetes_build_info
    icon: si:kubernetes
    title: Kubernetes

Both entire sets are embedded in the binary — no network or disk access at runtime — so any mdi: from the MDI library (~7,400 glyphs, e.g. mdi:database-outline, mdi:rocket-launch) or any si: from Simple Icons (~3,400 logos, e.g. si:docker, si:grafana, si:prometheus) works. The sets are stored compressed (~0.8 MB MDI, ~1.9 MB Simple Icons) and each is decoded into memory only on first use. An unknown set or name fails fast at startup. The icon data is built from the @mdi/svg and simple-icons npm packages at build time (not committed) — see Building from source.

Range badges

By default a badge's value comes from an instant query at "now". Set type: range to instead run a range query over a window and reduce it to a single value — useful for averages, peaks, or comparing against an earlier period. The window is end = now - offset, start = end - last.

badges:
  - id: cpu_prev_week_avg
    type: range
    query: "cluster:node_cpu:ratio_rate5m * 100"
    range:
      last: "7d" # window length (required)
      offset: "7d" # shift the window back; here: 14d ago .. 7d ago (default: ends now)
      step: "1h" # resolution (default: last/100, min 1m)
      reduce: avg # last (default), first, avg, min, max, sum
    valueExpr: string(result) + "%"

reduce collapses each series to one value; non-finite samples (NaN/Inf) are skipped.

Value and color

valueExpr and colorExpr are CEL expressions (the Expr suffix marks the CEL-evaluated fields; query is PromQL and labelColor is a static value). CEL is sandboxed (no environment, file, or network access) and compiled once at startup, so a malformed expression fails fast rather than per request. Each expression receives two variables:

| Variable | Type | Description | | -------- | --------------------- | -------------------------------------------------------- | | result | double | The sample value (for type: range, the reduced value). | | labels | map(string, string) | The sample's labels, e.g. labels["instance"]. |

  • valueExpr must return a string — the message shown on the badge. Defaults to string(result).
  • colorExpr must return a string — a shields.io color name (green,
orange, red, blue, grey, …) or a hex value like "#dd4343". Omit for no color.

Text color adapts to the background for legibility — dark text on light colors, white on dark — the same way shields.io does, so a light custom colorExpr stays readable. Every badge also carries role="img", an aria-label, and a </code> (<code>"label: message"</code>) for screen readers and tooltips.</p> <pre><code class="yaml">badges: # numeric value with a unit + threshold coloring - id: cpu query: "round(avg(...) * 100, 0.1)" valueExpr: string(result) + "%" colorExpr: 'result < 35 ? "green" : result < 75 ? "orange" : "red"' <p># value taken from a label, falling back if it's absent - id: version query: 'label_replace(build_info, "v", "$1", "version", "v(.+)")' valueExpr: labels[?"v"].orValue("unknown")</p> <p># guard a possibly-NaN ratio (e.g. divide-by-zero) before formatting - id: hit_ratio query: cache_hits / (cache_hits + cache_misses) valueExpr: 'math.isNaN(result) ? "n/a" : humanizeFloat(math.round(result * 100.0)) + "%"'</p> <p># enum → text + color - id: ceph_health query: ceph_health_status valueExpr: 'result == 0.0 ? "Healthy" : result == 1.0 ? "Warning" : "Critical"' colorExpr: 'result == 0.0 ? "green" : result == 1.0 ? "orange" : "red"'</code></pre></p> <p>Besides CEL's built-ins (arithmetic, comparisons, ternary <code>?:</code>, <code>in</code>) the environment enables:</p> <ul><li>the <strong><code>strings</code></strong> extension — <code>startsWith</code>, <code>matches</code>, <code>replace</code>, <code>substring</code>, <code>upperAscii</code>, …</li> <li>the <strong><code>math</code></strong> extension — <code>math.round</code>, <code>math.abs</code>, <code>math.floor</code>/<code>ceil</code>, <code>math.least</code>/<code>greatest</code></li></ul> (clamping), and <code>math.isNaN</code>/<code>isInf</code>/<code>isFinite</code> to guard non-finite values (Prometheus returns <code>NaN</code> for e.g. division by zero, which would otherwise render literally on the badge); <ul><li><strong>optional types</strong> — <code>labels[?"k"].orValue("default")</code> for a label that may be absent.</li></ul> On top of those, these formatting helpers are available (hand-rolled — kromgo has no external humanize dependency, so the output is exactly as below): <p>| Function | Example | Result | Notes | | ------------------------------ | --------------------------------- | --------- | ---------------------------------------------------------- | | <code>humanize(result)</code> | <code>humanize(93166031.0)</code> | <code>93.17M</code> | SI metric prefixes (powers of 1000), 4 sig figs, unit-less | | <code>humanizeBytes(result)</code> | <code>humanizeBytes(1500000.0)</code> | <code>1.5MB</code> | SI decimal units (powers of 1000), no space | | <code>humanizeCommas(result)</code> | <code>humanizeCommas(157121.0)</code> | <code>157,121</code> | comma thousands grouping | | <code>humanizeFloat(result)</code> | <code>humanizeFloat(2.50)</code> | <code>2.5</code> | plain decimal, trailing zeros stripped | | <code>humanizeDuration(result)</code> | <code>humanizeDuration(9000.0)</code> | <code>2h30m</code> | <strong>seconds</strong> → compact time span | | <code>humanizeDurationDays(result)</code> | <code>humanizeDurationDays(5961600.0)</code> | <code>69d</code> | <strong>seconds</strong> → whole days, no roll-up |</p> <p><code>humanizeDuration</code> takes <strong>seconds</strong> (so it drops onto a <code>time() - created_ts</code> query directly) and adapts to the magnitude, emitting the up-to-three most-significant units — <code>90</code> → <code>1m30s</code>, <code>9000</code> → <code>2h30m</code>, <code>40348800</code> → <code>1y3mo12d</code>. Months render as <code>mo</code> so they never collide with minutes (<code>m</code>) in the same string.</p> <p>For <strong>coloring</strong>, <code>colorScale(result, steps, colors)</code> maps a number to a shields.io color name, so a <code>colorExpr</code> doesn't need a hand-written chain of ternaries. It returns <code>colors[i]</code> at the first <code>result < steps[i]</code>, otherwise the last color — so <code>colors</code> has one more entry than <code>steps</code>. Write the thresholds as <strong>decimals</strong> (<code>35.0</code>, not <code>35</code>); an integer literal fails to compile.</p> <pre><code class="yaml"># instead of colorExpr: 'result < 35 ? "green" : result < 75 ? "orange" : "red"' <h1>use</h1> colorExpr: 'colorScale(result, [35.0, 75.0], ["green", "orange", "red"])'</code></pre> <p>For a percentage — say red below 80, green by 100 — just list the cutoffs and their colors:</p> <pre><code class="yaml">colorExpr: 'colorScale(result, [80.0, 90.0, 100.0], ["red", "yellow", "green", "brightgreen"])'</code></pre> <p>Two gotchas around <code>result</code> (a <code>double</code>):</p> <ul><li><strong>Numeric literals.</strong> Ordered comparisons accept plain integers — <code>result < 35</code> works (kromgo</li></ul> enables CEL's cross-type numeric comparisons). Equality and arithmetic do <strong>not</strong>: write a decimal literal there, e.g. <code>result == 0.0</code> (not <code>== 0</code>) and <code>result <em> 100.0</code> (not <code></em> 100</code>). A mismatch is a compile error caught at startup, not a runtime surprise. <ul><li><strong>Missing labels.</strong> Indexing a label that isn't present errors. Use optional indexing —</li></ul> <code>labels[?"k"].orValue("n/a")</code> — or the ternary <code>"k" in labels ? labels["k"] : "n/a"</code>. <h3>Graphs</h3> <p>Each entry under <code>graphs:</code> defines a time-series endpoint at <code>/graphs/{id}</code>. Defining a graph is the opt-in to expose range data for that query — there is no separate enable flag. Charts are rendered by <a href="https://github.com/go-analyze/charts" target="_blank" rel="noopener">go-analyze/charts</a> as <strong>SVG</strong> (default) or <strong>PNG</strong> (<code>?format=png</code>).</p> <p>| Field | Required | Description | | ------------- | -------- | ------------------------------------------------------------------------------------- | | <code>id</code> | yes | URL path segment — <code>cpu</code> → <code>GET /graphs/cpu</code> | | <code>query</code> | yes | PromQL expression run as a range query | | <code>title</code> | no | Display label (defaults to <code>id</code>) | | <code>maxDuration</code> | no | Cap on the requested window (overrides <code>defaults.graph.maxDuration</code>) | | <code>width</code> | no | Image width in px (overrides <code>defaults.graph.width</code>) | | <code>height</code> | no | Image height in px (overrides <code>defaults.graph.height</code>) | | <code>legend</code> | no | Show the series legend (overrides <code>defaults.graph.legend</code>) | | <code>fill</code> | no | Fill a translucent area beneath the line(s) (overrides <code>defaults.graph.fill</code>) | | <code>theme</code> | no | Color theme (overrides <code>defaults.graph.theme</code>) — see <a href="#themes-and-fonts" target="_blank" rel="noopener">Themes</a> | | <code>font</code> | no | Text font (overrides <code>defaults.graph.font</code>) — see <a href="#themes-and-fonts" target="_blank" rel="noopener">Themes</a> | | <code>valueExpr</code> | no | CEL expression formatting the y-axis labels (overrides <code>defaults.graph.valueExpr</code>) | | <code>yMin</code>/<code>yMax</code> | no | Pin the y-axis range instead of auto-fitting (overrides <code>defaults.graph.yMin</code>/<code>yMax</code>) | | <code>markLine</code> | no | Dashed reference lines: any of <code>average</code>, <code>min</code>, <code>max</code>, <code>median</code> (first series only) | | <code>gallery</code> | no | Per-graph gallery settings, e.g. <code>gallery: {hidden: true}</code> — see <a href="#gallery" target="_blank" rel="noopener">Gallery</a> |</p> <pre><code class="yaml">graphs: - id: node_cpu_usage query: "cluster:node_cpu:ratio_rate5m * 100" maxDuration: "30d" width: 800 theme: catppuccin-mocha</code></pre> <p>By default the y-axis labels use the chart library's numeric formatting, which can show fractional ticks (e.g. <code>42.8</code>) even when the underlying values are whole numbers. <code>valueExpr</code> overrides this: like a badge's <a href="#value-and-color" target="_blank" rel="noopener"><code>valueExpr</code></a>, it's a CEL expression over <code>result</code> (here, the y-axis tick value) that returns the label string, with the same <a href="#value-and-color" target="_blank" rel="noopener">humanizer functions</a> available. It formats <strong>only the y-axis labels</strong> — the legend shows series names, and <code>?format=json</code> keeps the raw numbers.</p> <pre><code class="yaml">graphs: - id: cluster_pod_count_graph title: Running Pods query: sum(kube_pod_status_phase{phase="Running"}) maxDuration: 7d valueExpr: string(int(result)) + " pods" # integer ticks; drop the suffix for bare integers</code></pre> <p>For axis context, pin the range with <code>yMin</code>/<code>yMax</code> (e.g. <code>yMin: 0</code>, <code>yMax: 100</code> for a percentage) rather than letting it auto-fit, and add dashed reference lines with <code>markLine</code> (<code>average</code>, <code>min</code>, <code>max</code>, or <code>median</code>). Mark lines are computed by the chart library — there's no static-threshold line, and they render for the first series only:</p> <pre><code class="yaml">graphs: - id: cluster_cpu_graph title: CPU Usage query: avg(cluster:node_cpu:ratio_rate5m) * 100 valueExpr: string(int(result)) + "%" yMin: 0 yMax: 100 markLine: [average]</code></pre> <p>The time window is chosen by these query parameters:</p> <p>| Parameter | Default | Description | | --------- | ---------- | ------------------------------------------------------------------------ | | <code>last</code> | — | Shorthand window ending now, e.g. <code>last=7d</code> (supports <code>s/m/h/d/y</code> units) | | <code>start</code> | end − 1h | Window start — Unix timestamp or RFC3339 | | <code>end</code> | now | Window end — Unix timestamp or RFC3339 | | <code>step</code> | window/100 | Resolution between points (min <code>1m</code>); supports <code>s/m/h/d/y</code> units |</p> <p>The rendering fields <code>width</code>, <code>height</code>, <code>legend</code>, <code>fill</code>, <code>yMin</code>/<code>yMax</code>, and <code>theme</code>, plus the output <code>format</code> (<code>svg</code>/<code>png</code>), may also be overridden per request via query parameters, e.g. <code>/graphs/node_cpu_usage?theme=dracula&fill=true&ymax=100&format=png&last=24h</code>. (<code>font</code>, <code>valueExpr</code>, and <code>markLine</code> are config-only — resolved/compiled once at startup.)</p> <h4>Themes and fonts</h4> <p><code>theme</code> accepts a <a href="https://github.com/go-analyze/charts" target="_blank" rel="noopener">go-analyze/charts</a> built-in or one of kromgo's bundled palettes (an unknown value falls back to the default):</p> <ul><li><strong>Built-in:</strong> <code>light</code> (default), <code>dark</code>, <code>vivid-light</code>, <code>vivid-dark</code>, <code>grafana</code>, <code>ant</code>,</li></ul> <code>nature-light</code>, <code>nature-dark</code>, <code>retro</code>, <code>ocean</code>, <code>slate</code>, <code>gray</code>, <code>winter</code>, <code>spring</code>, <code>summer</code>, <code>fall</code>. <ul><li><strong>Bundled:</strong> <code>catppuccin-latte</code>, <code>catppuccin-frappe</code>, <code>catppuccin-macchiato</code>, <code>catppuccin-mocha</code></li></ul> (via the official <a href="https://github.com/catppuccin/go" target="_blank" rel="noopener">catppuccin/go</a> palette), <code>dracula</code>, <code>monokai</code>, <code>night-owl</code>. <p><code>font</code> accepts one of:</p> <ul><li><strong><code>dejavu-sans</code></strong> (the default) / <strong><code>dejavu-sans-bold</code></strong> — the free, metric-compatible stand-in for the</li></ul> Verdana that <a href="https://shields.io" target="_blank" rel="noopener">shields.io</a> renders with. Vendored via npm (<code>dejavu-fonts-ttf</code>). <ul><li><strong><code>comic-neue</code></strong> / <strong><code>comic-neue-bold</code></strong> — a free Comic Sans alternative (Google Fonts, via</li></ul> <code>@expo-google-fonts/comic-neue</code>), for when a badge wants some personality. <p>Both faces are compiled in by <code>cmd/genassets</code> (kept current by Renovate). Badges and graphs default to <code>dejavu-sans</code> (shields.io-style — 11 px text, 20 px tall); set <code>font:</code> to opt into the others. Fonts are compiled into the binary — there's no reading from disk, so add a face by vendoring it (npm) and PRing it into the registry. An unknown name fails fast at startup.</p> <h2>Gallery</h2> <p><code>GET /</code> serves a gallery: a responsive page (up to three columns, collapsing to one on mobile) that previews every visible badge and graph and shows the copy-pasteable Markdown snippet for each — the preview is rendered from that same snippet with <a href="https://github.com/markedjs/marked" target="_blank" rel="noopener">marked</a>, so what you see is what a GitHub README will show. Snippet URLs are absolute, built from the request host (a reverse proxy's <code>X-Forwarded-Proto</code> is honored for the scheme).</p> <p>The page is self-contained: its JavaScript and CSS are embedded in the binary and served from <code>/assets/</code> — no external CDN — so it works air-gapped and keeps a strict <code>script-src 'self'</code> Content-Security-Policy. See <a href="#building-from-source" target="_blank" rel="noopener">Building from source</a> for how the assets are vendored.</p> <p><strong>Enable / disable.</strong> The gallery is on by default. Turn it off with a top-level <code>gallery.enabled: false</code>, which serves a minimal landing page at <code>/</code> instead (the badge and graph endpoints are unaffected):</p> <pre><code class="yaml">gallery: enabled: false</code></pre> <p><strong>Which endpoints appear.</strong> Every endpoint is listed by default. Hide one with a per-endpoint <code>gallery.hidden: true</code>, or flip the default per type under <code>defaults.badge.gallery</code> / <code>defaults.graph.gallery</code>:</p> <pre><code class="yaml">defaults: badge: gallery: hidden: true # hide badges from the gallery by default… badges: - id: cpu query: "..." gallery: hidden: false # …but list this one</code></pre> <p>When nothing is visible the gallery shows a short hint instead.</p> <h2>Favicon</h2> <p>kromgo is self-contained (see <a href="#gallery" target="_blank" rel="noopener">Gallery</a> above) and ships no default favicon, so <code>GET /favicon.ico</code> 404s unless you configure one. Set a top-level <code>favicon</code> to a base64-encoded PNG, GIF, or ICO image and kromgo serves it at <code>/favicon.ico</code> and links it from both the gallery and the landing page:</p> <pre><code class="yaml">favicon: iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=</code></pre> <p>The Content-Type is detected from the decoded bytes, so no separate <code>favicon</code> MIME/format field is needed — an unsupported or malformed value fails at startup like any other config error (see <a href="#configuration" target="_blank" rel="noopener">Configuration</a>).</p> <h2>API reference</h2> <p>| Route | Default response | Variants | | ------------------ | ----------------------- | ------------------------------------------------------------------ | | <code>GET /badges/{id}</code> | SVG badge (<code>?style=…</code>) | <code>?format=shields</code> → shields.io JSON · <code>?format=json</code> → kromgo JSON | | <code>GET /graphs/{id}</code> | SVG chart (<code>?theme=…</code>) | <code>?format=png</code> → PNG image · <code>?format=json</code> → time-series data | | <code>GET /</code> | HTML gallery | landing page when <code>gallery.enabled: false</code> | | <code>GET /assets/…</code> | Embedded gallery JS/CSS | | | <code>GET /favicon.ico</code> | The configured favicon | 404 when <code>favicon</code> is unset (see <a href="#favicon" target="_blank" rel="noopener">Favicon</a>) |</p> <p><strong><code>/badges/{id}</code></strong> (default SVG):</p> <pre><code class="html"><img src="http://localhost:8080/badges/node_cpu_usage" /></code></pre> <p><strong><code>?format=shields</code></strong> — the <a href="https://shields.io/badges/endpoint-badge" target="_blank" rel="noopener">shields.io Endpoint Badge</a> schema:</p> <pre><code class="json">{ "schemaVersion": 1, "label": "node_cpu_usage", "message": "17.5%", "color": "green" }</code></pre> <p><strong><code>?format=json</code></strong> — kromgo's native JSON (rendered string plus the raw number and labels):</p> <pre><code class="json">{ "id": "node_cpu_usage", "title": "CPU", "value": "17.5%", "color": "green", "result": 17.5, "labels": {} }</code></pre> <p><strong><code>/graphs/{id}?format=json</code></strong> — the raw time series:</p> <pre><code class="json">{ "id": "node_cpu_usage", "title": "CPU", "start": 1702578219, "end": 1702664619, "step": 60, "series": [{ "labels": { "instance": "node-1" }, "data": [{ "t": 1702578219, "v": 17.5 }] }] }</code></pre> <h2>Ports</h2> <p>| Port | Purpose | | ------ | --------------------------------------------------------------------- | | <code>8080</code> | Main server — badge/graph endpoints + <code>/healthz</code>, <code>/readyz</code> probes | | <code>8081</code> | Metrics server — <code>/metrics</code> (Prometheus); optional, off ⇒ no listener |</p> <p>The health server's <code>/metrics</code> endpoint exposes Go runtime metrics plus <code>kromgo_requests_total{kind, id, format}</code> — a counter of requests handled, broken down by endpoint kind (<code>badge</code>/<code>graph</code>), id, and response format.</p> <h2>Rate limiting</h2> <p>kromgo does not rate limit itself — it's meant to sit behind a reverse proxy on the public web, and proxies do this better (shared limits across replicas, per-IP buckets, burst handling, <code>429</code> responses). Configure it there. Examples for limiting <code>/</code> traffic to kromgo on <code>:8080</code>:</p> <p><strong>nginx</strong> — in the <code>http {}</code> block, then reference the zone in your <code>location</code>:</p> <pre><code class="nginx">limit_req_zone $binary_remote_addr zone=kromgo:10m rate=10r/s; <p>server { location / { limit_req zone=kromgo burst=20 nodelay; proxy_pass http://kromgo:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }</code></pre></p> <p><strong>Caddy</strong> — requires the <a href="https://github.com/mholt/caddy-ratelimit" target="_blank" rel="noopener">caddy-ratelimit</a> module (<code>xcaddy build --with github.com/mholt/caddy-ratelimit</code>):</p> <pre><code class="caddyfile">kromgo.example.com { rate_limit { zone kromgo { key {remote_host} events 10 window 1s } } reverse_proxy kromgo:8080 }</code></pre> <p><strong>Envoy</strong> — the built-in <a href="https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/local_rate_limit_filter" target="_blank" rel="noopener">local rate limit</a> HTTP filter (100 requests/minute per listener):</p> <pre><code class="yaml">http_filters: - name: envoy.filters.http.local_ratelimit typed_config: "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit stat_prefix: kromgo_rate_limiter token_bucket: max_tokens: 100 tokens_per_fill: 100 fill_interval: 60s filter_enabled: default_value: { numerator: 100, denominator: HUNDRED } filter_enforced: default_value: { numerator: 100, denominator: HUNDRED }</code></pre> <p><strong>Traefik v3</strong> — a <code>rateLimit</code> middleware attached to the router (dynamic file config; the Kubernetes <code>Middleware</code> CRD takes the same <code>rateLimit</code> spec):</p> <pre><code class="yaml">http: middlewares: kromgo-ratelimit: rateLimit: average: 10 burst: 20 period: 1s routers: kromgo: rule: Host(<code>kromgo.example.com</code>) service: kromgo middlewares: - kromgo-ratelimit</code></pre> <h2>Caching</h2> <p>Caching has two halves. kromgo owns the half only it can know — <strong>how long a value stays fresh</strong> — and emits a <code>Cache-Control</code> header so the other half (a browser, CDN, or GitHub's camo image proxy) knows how long to store the response. One policy applies to every endpoint; it is configured at the top level under <code>cache:</code> and is <strong>enabled by default</strong>.</p> <pre><code class="yaml">cache: enabled: true # default; false sends no-store so nothing caches the badge maxAge: 300 # max-age + s-maxage in seconds (default 300); ignored when disabled</code></pre> <ul><li><strong><code>enabled: true</code> (default)</strong> — kromgo sends <code>Cache-Control: public, max-age=<maxAge>, s-maxage=<maxAge></code></li></ul> on successful responses and advertises <code>cacheSeconds</code> in the shields.io endpoint JSON. <code>max-age</code> governs browser caches; <code>s-maxage</code> governs shared caches (CDNs, camo) — shields.io sets both. <ul><li><strong><code>enabled: false</code></strong> — kromgo sends <code>Cache-Control: no-cache, no-store, must-revalidate, max-age=0</code>.</li></ul> Sending _no_ header is not the same as disabling caching: it lets camo/CDNs apply their own aggressive default (which is why an unconfigured badge can go stale), so kromgo always sends an explicit header. To turn caching off set <code>enabled: false</code> — not <code>maxAge: 0</code>, which just falls back to the 300s default. <p>Errors are always sent <code>no-store</code>. A <code>Cache-Control</code> header still isn't a hard guarantee against GitHub's camo proxy (<a href="https://github.com/badges/shields/issues/221" target="_blank" rel="noopener">shields#221</a>), but it's the strongest signal kromgo can send.</p> <p>The <strong>other half — actually storing responses — is the edge's job</strong>, and any cache that honors <code>Cache-Control</code> (a CDN, Varnish, nginx <code>proxy_cache</code>) will then cache each endpoint for the advertised <code>maxAge</code>. shields.io already respects <code>cacheSeconds</code>, so badges served through it are cached without any proxy at all.</p> <p>If you want the reverse proxy itself to cache, enable its HTTP cache and let it honor the origin headers — for example, nginx:</p> <pre><code class="nginx">proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=kromgo:10m max_size=100m; <p>server { location / { proxy_cache kromgo; # respects kromgo's Cache-Control add_header X-Cache-Status $upstream_cache_status; proxy_pass http://kromgo:8080; } }</code></pre></p> <p>Caddy (via the <a href="https://github.com/caddyserver/cache-handler" target="_blank" rel="noopener">cache-handler</a> plugin), Traefik, and Envoy can cache too, but generally need a plugin or an external cache/CDN; the simplest setup is to front kromgo with a CDN and let kromgo's <code>Cache-Control</code> header drive it.</p> <h2>Security</h2> <p>kromgo is built to face the public web. Its posture:</p> <ul><li><strong>Prometheus is never exposed.</strong> Only the endpoints you define are reachable; query parameters are</li></ul> parsed as durations/timestamps/enums and never interpolated into PromQL. <ul><li><strong>SVG output is safe.</strong> Badge text and graph labels (which can derive from attacker-influenceable</li></ul> metric label values) are HTML-escaped, and badge/graph/JSON responses carry <code>Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'</code> and <code>X-Content-Type-Options: nosniff</code>, so an SVG can't execute script even when opened directly. <ul><li><strong>The gallery loads nothing external.</strong> Its JS/CSS are embedded and served from <code>/assets/</code>, so the</li></ul> page ships a tightened-but-still-locked-down CSP (<code>script-src 'self'</code>, no <code>unsafe-inline</code>/<code>unsafe-eval</code>, no CDN). The Host header used to build snippet URLs is validated before use. <ul><li><strong>Bounded work.</strong> Each Prometheus query is bounded by <code>KROMGO_QUERY_TIMEOUT</code> (default 30s); graph windows</li></ul> are capped by <code>maxDuration</code> and image dimensions are clamped. A 10s <code>ReadHeaderTimeout</code> guards against Slowloris; tune <code>KROMGO_SERVER_READ_TIMEOUT</code>/<code>KROMGO_SERVER_WRITE_TIMEOUT</code> to your proxy. <ul><li><strong>Minimal image.</strong> A <code>scratch</code> image with just the static binary and a CA bundle (kromgo dials</li></ul> Prometheus over HTTPS) — no shell, package manager, or writable filesystem. It pins no user; set one via your Kubernetes <code>securityContext</code> or <code>docker run --user</code>. Images are cosign-signed (below). <p>Operational guidance:</p> <ul><li><strong>Expose only the main port (<code>8080</code>).</strong> The metrics port (<code>8081</code>) serves <code>/metrics</code> —</li></ul> keep it on the internal network. Health probes ride the main port. <ul><li><strong>Terminate TLS and rate limit at your reverse proxy</strong> (see <a href="#rate-limiting" target="_blank" rel="noopener">Rate limiting</a>).</li> <li>Treat the config as trusted (it's operator-controlled). Fonts are compiled-in (never read from</li></ul> disk), and CEL expressions run sandboxed (no env/file/network access). <h2>Image verification</h2> <p>Images are built and <a href="https://docs.sigstore.dev/cosign/overview/" target="_blank" rel="noopener">Cosign</a>-signed (keyless) by the official <a href="https://github.com/docker/github-builder" target="_blank" rel="noopener"><code>docker/github-builder</code></a> reusable workflow, so the signing identity is that workflow rather than this repo. Verify an image before running it:</p> <pre><code class="bash">cosign verify ghcr.io/home-operations/kromgo:<tag> \ --certificate-identity-regexp="^https://github.com/docker/github-builder/.github/workflows/build.yml@" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com"</code></pre> <p>The exact <code>cosign verify</code> command (with the pinned builder ref) is also printed in each build run's summary.</p> <h2>Building from source</h2> <p>The gallery's <code>marked.js</code> / <code>github-markdown.css</code> and the full Material Design Icons and Simple Icons sets are vendored via npm (<code>package.json</code> + <code>package-lock.json</code>) and baked into the binary with <code>//go:embed</code> rather than committed. <a href="cmd/genassets/main.go" target="_blank" rel="noopener"><code>cmd/genassets</code></a> reads <code>node_modules</code> and writes the embedded files, so a build runs <code>npm ci</code> once (network) and the resulting binary is self-contained (nothing fetched at runtime).</p> <pre><code class="bash">mise run assets # npm ci + go run ./cmd/genassets (re-runs only when the lockfile changes) go build ./cmd/kromgo</code></pre> <p><code>mise run test</code> / <code>lint</code> / <code>test-e2e</code> depend on <code>assets</code>, so they build it automatically; CI and the Docker build (a dedicated <code>node</code> stage) do the same. <a href="https://docs.renovatebot.com" target="_blank" rel="noopener">Renovate</a> keeps <code>marked</code>, <code>github-markdown-css</code>, <code>@mdi/svg</code>, and <code>simple-icons</code> current via PRs against <code>package.json</code>.</p> <h2>Upgrading 0.11 → 0.12</h2> <p>0.12 splits the flat <code>metrics:</code> list into <code>badges:</code> and <code>graphs:</code> sections, with REST-style routes. A pre-0.12 config fails fast at startup with a pointer to this guide.</p> <p>| Change | Action | | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | <strong><code>metrics:</code> split into <code>badges:</code> and <code>graphs:</code>.</strong> Instant-value endpoints go under <code>badges:</code>; time-series endpoints under <code>graphs:</code>. | Move each metric to the section(s) it needs. A metric you served as both a badge and a chart becomes one entry in each (with the same <code>id</code>). | | <strong><code>name</code> → <code>id</code>.</strong> | Rename the key on every endpoint. | | <strong>Routes are namespaced.</strong> <code>GET /{name}</code> + <code>?format=</code> → <code>GET /badges/{id}</code> and <code>GET /graphs/{id}</code>. | Update embed URLs and shields.io endpoint URLs. | | <strong>Badge default is now the SVG image.</strong> <code>?format=badge</code> → default; <code>?format=json</code> (shields schema) → <code>?format=shields</code>; <code>?format=raw</code> removed. | Embed <code>/badges/{id}</code> directly; point shields.io at <code>?format=shields</code>. <code>?format=json</code> now returns kromgo's native JSON (value + result + labels). | | <strong>Graph formats.</strong> <code>?format=chart</code> → <code>/graphs/{id}</code> (SVG default); <code>?format=history</code> → <code>/graphs/{id}?format=json</code>. | Switch to the <code>/graphs/</code> routes. | | <strong><code>defaults.timeseries</code> removed.</strong> The <code>enabled</code> gate is gone — defining a <code>graphs:</code> entry _is_ the opt-in. | Drop <code>timeseries.enabled</code>; move <code>maxDuration</code> to <code>defaults.graph.maxDuration</code> or per-graph <code>maxDuration</code>. | | <strong>Global <code>badge:</code> (font/size) → <code>defaults.badge</code>.</strong> Badge <code>style</code> is now a config field too. | Move <code>badge.font</code>/<code>badge.size</code> under <code>defaults.badge</code>. |</p> <p>Release tags drop the <code>v</code> prefix (e.g. <code>0.12.0</code>, not <code>v0.12.0</code>); pin image tags accordingly.</p> <h2>Upgrading from kashalls/kromgo</h2> <p>This fork began as <a href="https://github.com/kashalls/kromgo" target="_blank" rel="noopener">kashalls/kromgo</a>. Beyond the schema changes above, note: the image moved to <code>ghcr.io/home-operations/kromgo</code>; the badge font is no longer bundled (an embedded font is used, with <code>defaults.badge.font</code> to override); <code>KROMGO_LOG_FORMAT=test</code> was corrected to <code>KROMGO_LOG_FORMAT=text</code>; built-in rate limiting was removed (see <a href="#rate-limiting" target="_blank" rel="noopener">Rate limiting</a>); and a missing <code>KROMGO_PROMETHEUS_URL</code> now fails fast instead of starting degraded.</p> <h2>Community</h2> <p>Thanks to everyone in the <a href="https://discord.gg/home-operations" target="_blank" rel="noopener">Home Operations</a> Discord community. This project began as <a href="https://github.com/kashalls/kromgo" target="_blank" rel="noopener">kashalls/kromgo</a>.</p> </article> <!-- Article Completion Section --> <div class="reader-footer mt-5 pt-5"> <div class="reader-completion-box rounded-3 p-4 text-center"> <div class="reader-completion-icon mb-3"> <i class="fas fa-check-circle"></i> </div> <p class="reader-completion-text fw-semibold mb-1" style="font-size: 1rem;">You finished the article!</p> <p class="reader-completion-subtext small mb-4">Want to go deeper or explore more stories?</p> <div class="d-flex justify-content-center gap-3 flex-wrap"> <a href="https://github.com/home-operations/kromgo" target="_blank" rel="noopener noreferrer" class="btn btn-primary-custom btn-sm rounded-pill px-4"> <i class="fas fa-external-link-alt me-2"></i>Full Article on GitHub Trending </a> <a href="https://neshdevtech.com/news" class="reader-back-btn btn btn-sm rounded-pill px-4"> <i class="fas fa-newspaper me-2"></i>More Tech News </a> </div> </div> </div> <!-- Internal Linking CTA --> </div> </div> </div> <!-- Sidebar (desktop only) --> <div class="col-lg-4 col-xl-3 d-none d-lg-block"> <div class="sticky-top" style="top: 80px;"> <!-- Mini Controller Widget --> <div class="card border-0 shadow-sm mb-4 bg-surface"> <div class="card-body p-4"> <h6 class="fw-bold mb-3"><i class="fas fa-glasses me-2 text-primary-custom"></i>Reading Mode</h6> <p class="text-secondary-custom small mb-4">Adjust typography and theme to your preference.</p> <div class="mb-3"> <label class="text-muted-custom small d-block mb-2">Typography</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-serif">Serif</button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-sans">Sans</button> </div> </div> <div class="mb-4"> <label class="text-muted-custom small d-block mb-2">Text Scale</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-dec"><i class="fas fa-minus small"></i></button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-inc"><i class="fas fa-plus small"></i></button> </div> </div> <div class="border-top border-light-custom pt-3"> <a href="https://github.com/home-operations/kromgo" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary btn-sm w-100 rounded-pill"> View Original <i class="fas fa-external-link-alt ms-1"></i> </a> </div> </div> </div> <!-- Sidebar Related News --> <div class="card border-0 shadow-sm bg-surface"> <div class="card-header bg-transparent border-0 pt-4 pb-0 px-4"> <h5 class="card-title fw-bold mb-0" style="font-size: 1.05rem;"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h5> </div> <div class="card-body p-0"> <div class="list-group list-group-flush rounded-0 bg-transparent"> <a href="https://neshdevtech.com/news/crypto-farm-in-mexican-mountains-puts-spotlight-on-cartel-funding-3BJ6v" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Crypto farm in Mexican mountains puts spotlight on cartel funding </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Hacker News</span> <span>5 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/autoscaling-docker-containers-without-kubernetes-how-gubernator-scales-cpu-gpu-workloads-automatically-dV2Xa" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Autoscaling Docker Containers Without Kubernetes: How Gubernator Scales CPU & GPU Workloads Automatically </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/aws-retired-bedrock-access-gateway-what-moving-to-the-native-apis-actually-costs-AEBzy" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> AWS retired Bedrock Access Gateway: what moving to the native APIs actually costs </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/what-actually-runs-your-code-today-Dx1f7" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> What actually runs your code today </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>2 days ago</span> </div> </a> <a href="https://neshdevtech.com/news/gpt-6-astra-looped-transformers-and-hidden-reasoning-Ol3Xf" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> GPT-6 Astra, Looped Transformers, and Hidden Reasoning </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Hacker News</span> <span>3 days ago</span> </div> </a> </div> </div> </div> </div> </div> </div> <!-- Mobile: Related News horizontal scroll strip --> <div class="d-block d-lg-none mt-4"> <h6 class="fw-bold mb-3 px-1"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h6> <div class="related-scroll-strip"> <a href="https://neshdevtech.com/news/crypto-farm-in-mexican-mountains-puts-spotlight-on-cartel-funding-3BJ6v" class="related-scroll-card"> <p class="related-scroll-title">Crypto farm in Mexican mountains puts spotlight on cartel funding</p> <div class="related-scroll-meta"> <span>Hacker News</span> <span>5 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/autoscaling-docker-containers-without-kubernetes-how-gubernator-scales-cpu-gpu-workloads-automatically-dV2Xa" class="related-scroll-card"> <p class="related-scroll-title">Autoscaling Docker Containers Without Kubernetes: How Gubernator Scale...</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/aws-retired-bedrock-access-gateway-what-moving-to-the-native-apis-actually-costs-AEBzy" class="related-scroll-card"> <p class="related-scroll-title">AWS retired Bedrock Access Gateway: what moving to the native APIs act...</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>1 day ago</span> </div> </a> <a href="https://neshdevtech.com/news/what-actually-runs-your-code-today-Dx1f7" class="related-scroll-card"> <p class="related-scroll-title">What actually runs your code today</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>2 days ago</span> </div> </a> <a href="https://neshdevtech.com/news/gpt-6-astra-looped-transformers-and-hidden-reasoning-Ol3Xf" class="related-scroll-card"> <p class="related-scroll-title">GPT-6 Astra, Looped Transformers, and Hidden Reasoning</p> <div class="related-scroll-meta"> <span>Hacker News</span> <span>3 days ago</span> </div> </a> </div> </div> </div> </section> </main> <!-- Footer --> <!-- Footer --> <footer class="py-5 mt-5 position-relative overflow-hidden" style="background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-lg);"> <!-- Decorative subtle background gradients --> <div class="position-absolute" style="top: -200px; right: -200px; width: 400px; height: 400px; background: rgba(var(--primary-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="position-absolute" style="bottom: -200px; left: -200px; width: 400px; height: 400px; background: rgba(var(--accent-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="container position-relative z-3"> <div class="row g-4"> <!-- Column 1: Brand & About --> <div class="col-lg-4 col-md-6"> <div class="d-flex align-items-center mb-3"> <span class="brand-mark me-2" style="width: 42px; height: 42px;" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> </span> <span class="fs-4 fw-extrabold text-primary-custom" style="letter-spacing: -0.05em; font-family: 'JetBrains Mono', monospace;">NeshDevTech</span> </div> <p class="text-secondary-custom mb-4 pe-lg-4" style="font-size: 0.95rem; line-height: 1.6;">NeshDevTech is a professional technology firm passionate about creating innovative, high-performance web and mobile solutions.</p> <div class="d-flex gap-2"> </div> </div> <!-- Column 2: Navigation Links --> <div class="col-lg-2 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Quick Navigation</h6> <ul class="list-unstyled mb-0"> <li class="mb-2"><a href="https://neshdevtech.com" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Home</a></li> <li class="mb-2"><a href="https://neshdevtech.com/projects" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Projects</a></li> <li class="mb-2"><a href="https://neshdevtech.com/blog" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Tech Blog</a></li> <li class="mb-2"><a href="https://neshdevtech.com/pricing" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Get Quote</a></li> <li class="mb-2"><a href="https://neshdevtech.com/contact" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Contact Me</a></li> </ul> </div> <!-- Column 3: Tech Stack & Services --> <div class="col-lg-3 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Core Services</h6> <ul class="list-unstyled mb-0"> <li class="text-secondary-custom mb-2 small"><i class="bi bi-rocket-takeoff text-primary-custom me-2"></i>Custom Laravel Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-laptop text-primary-custom me-2"></i>SPA (React & Vue.js)</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-phone-vibrate text-primary-custom me-2"></i>Flutter Mobile Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-search text-primary-custom me-2"></i>SEO & Performance Opt</li> </ul> </div> <!-- Column 4: Contact & Availability --> <div class="col-lg-3 col-md-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Get in Touch</h6> <ul class="list-unstyled mb-3"> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-geo-alt text-primary-custom me-2 fs-6"></i>Nairobi, Kenya </li> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-envelope-at text-primary-custom me-2 fs-6"></i> <a href="mailto:info@neshdevtech.com" class="text-secondary-custom text-decoration-none footer-link">info@neshdevtech.com</a> </li> </ul> <div class="bg-primary-custom-light border border-primary-custom border-opacity-10 p-3 rounded-3" style="border-radius: 12px !important;"> <small class="d-block fw-semibold text-primary-custom mb-1"><i class="bi bi-patch-check-fill me-1"></i>Available for Projects</small> <p class="mb-0 text-secondary-custom small" style="font-size: 0.8rem; line-height: 1.4;">Need a high-performing system? Let's discuss your project.</p> </div> </div> </div> <hr style="border-color: var(--border); opacity: 0.8; margin: 2.5rem 0 2rem;"> <div class="row align-items-center"> <div class="col-md-6 text-center text-md-start mb-2 mb-md-0"> <p class="mb-0 text-secondary-custom small">© 2026 NeshDevTech. All rights reserved.</p> </div> <div class="col-md-6 text-center text-md-end"> <p class="mb-0 text-secondary-custom small">Built with <i class="bi bi-heart-fill text-danger"></i></p> </div> </div> </div> </footer> <!-- Bootstrap JS --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <!-- Custom JavaScript --> <script> // Theme Management System class ThemeManager { constructor() { this.themeToggle = document.getElementById('themeToggle'); this.themeIcon = document.getElementById('theme-icon'); this.mobileThemeToggle = document.getElementById('mobileThemeToggle'); this.mobileThemeIcon = document.getElementById('mobile-theme-icon'); this.init(); } init() { // Get current theme (already applied in head) const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; // Update icons to match current theme this.updateIcon(currentTheme); // Listen for desktop theme toggle clicks if (this.themeToggle) { this.themeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for mobile theme toggle clicks if (this.mobileThemeToggle) { this.mobileThemeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { this.setTheme(e.matches ? 'dark' : 'light'); } }); } setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme); if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } localStorage.setItem('theme', theme); this.updateThemeColor(theme); this.updateIcon(theme); } updateThemeColor(theme) { document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => { meta.setAttribute('content', theme === 'dark' ? '#1c2028' : '#e4e8f0'); }); } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; this.setTheme(newTheme); // Close mobile menu if open const navbarCollapse = document.querySelector('.navbar-collapse'); if (navbarCollapse && navbarCollapse.classList.contains('show') && window.innerWidth < 992) { setTimeout(() => { const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); }, 150); } } updateIcon(theme) { const iconClass = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; // Update desktop icon if (this.themeIcon) { this.themeIcon.className = iconClass; } // Update mobile icon if (this.mobileThemeIcon) { this.mobileThemeIcon.className = iconClass; } } } // Initialize theme manager when DOM is loaded document.addEventListener('DOMContentLoaded', () => { new ThemeManager(); // Initialize navbar live clock (function initNavClock(){ const clockEls = Array.from(document.querySelectorAll('[data-clock]')); if (!clockEls.length) return; const locale = navigator.language || 'en-US'; // Detect 12/24h by locale: use resolvedOptions hourCycle when available const dtf = new Intl.DateTimeFormat(locale, { hour: 'numeric' }); const uses12h = (dtf.resolvedOptions().hour12 === true); const fmt = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: uses12h }); const update = () => { const now = new Date(); const text = fmt.format(now); clockEls.forEach(el => { el.textContent = text; }); }; update(); setInterval(update, 1000); })(); }); // Smooth scrolling for anchor links document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); // Navbar background on scroll window.addEventListener('scroll', () => { const navbar = document.querySelector('.navbar'); if (window.scrollY > 50) { navbar.style.backdropFilter = 'blur(20px)'; navbar.style.backgroundColor = 'rgba(var(--background-rgb), 0.8)'; } else { navbar.style.backdropFilter = 'blur(10px)'; navbar.style.backgroundColor = 'var(--background)'; } }); // Add fade-in animation to elements when they come into view const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('animate-fade-in-up'); } }); }, observerOptions); // Observe elements with animation class document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.animate-on-scroll').forEach(el => { observer.observe(el); }); }); // Enhanced mobile menu functionality document.addEventListener('DOMContentLoaded', function() { const navbarToggler = document.querySelector('.navbar-toggler'); const navbarCollapse = document.querySelector('.navbar-collapse'); const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); const socialLinks = document.querySelectorAll('.navbar-nav .d-flex a'); let isToggling = false; // Prevent rapid clicking // Enhanced menu close function function closeMenu() { if (navbarCollapse && navbarCollapse.classList.contains('show')) { // Use Bootstrap's collapse API for smooth transition const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); } // Reset hamburger animation and aria-expanded if (navbarToggler) { navbarToggler.classList.remove('active'); navbarToggler.setAttribute('aria-expanded', 'false'); } } // Enhanced hamburger toggle functionality if (navbarToggler) { navbarToggler.addEventListener('click', function(e) { e.preventDefault(); // Prevent rapid clicking if (isToggling) return; isToggling = true; const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen) { closeMenu(); } else { // Open menu const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.show(); navbarToggler.setAttribute('aria-expanded', 'true'); } // Reset toggle flag after animation setTimeout(() => { isToggling = false; }, 350); }); } // Close menu when clicking outside document.addEventListener('click', function(event) { const isClickInsideNav = navbarCollapse.contains(event.target); const isToggler = navbarToggler.contains(event.target); const isMenuOpen = navbarCollapse.classList.contains('show'); if (!isClickInsideNav && !isToggler && isMenuOpen && window.innerWidth < 992) { closeMenu(); } }); // Close menu when clicking on navigation links (mobile) navLinks.forEach(function(link) { link.addEventListener('click', function(e) { const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen && window.innerWidth < 992 && !this.classList.contains('dropdown-toggle')) { // Add a small delay to let the link navigation start setTimeout(() => { closeMenu(); }, 100); } }); }); // Close menu when clicking on social links (mobile) socialLinks.forEach(function(link) { link.addEventListener('click', function() { if (window.innerWidth < 992) { setTimeout(() => { closeMenu(); }, 100); } }); }); // Escape key closes menu document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && window.innerWidth < 992) { closeMenu(); } }); // Handle window resize to close menu when switching to desktop window.addEventListener('resize', function() { if (window.innerWidth >= 992) { closeMenu(); } }); // Theme toggle functionality is handled by ThemeManager class // No separate event listener needed here // Prevent menu items from being too small on touch devices if ('ontouchstart' in window) { navLinks.forEach(function(link) { link.style.minHeight = '44px'; link.style.display = 'flex'; link.style.alignItems = 'center'; }); } // Add smooth scrolling for anchor links within pages navLinks.forEach(function(link) { const href = link.getAttribute('href'); if (href && href.startsWith('#')) { link.addEventListener('click', function(e) { e.preventDefault(); const target = document.querySelector(href); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } closeMenu(); }); } }); }); </script> <script> // Sticky navbar scroll enhancement (desktop only) (function () { const navbar = document.querySelector('.navbar'); if (!navbar) return; let ticking = false; const SCROLL_THRESHOLD = 20; // px function updateNavbar() { if (window.innerWidth >= 992) { if (window.scrollY > SCROLL_THRESHOLD) { navbar.classList.add('scrolled'); } else { navbar.classList.remove('scrolled'); } } else { // Always remove on mobile so it doesn't affect mobile look navbar.classList.remove('scrolled'); } ticking = false; } window.addEventListener('scroll', function () { if (!ticking) { requestAnimationFrame(updateNavbar); ticking = true; } }, { passive: true }); window.addEventListener('resize', updateNavbar, { passive: true }); // Run once on page load in case page is already scrolled (e.g. back-navigation) updateNavbar(); })(); </script> <script> // Lightweight toast badge notifications (function(){ function ensureContainer(){ let c = document.getElementById('toast-badge-container'); if(!c){ c = document.createElement('div'); c.id = 'toast-badge-container'; c.setAttribute('aria-live','polite'); c.setAttribute('aria-atomic','true'); document.body.appendChild(c); } return c; } function iconFor(type){ switch(type){ case 'success': return '<i class="bi bi-check-circle"></i>'; case 'warning': return '<i class="bi bi-exclamation-triangle"></i>'; case 'error': return '<i class="bi bi-x-circle"></i>'; default: return '<i class="bi bi-info-circle"></i>'; } } window.notifyBadge = function(message, opts={}){ const { type = 'success', timeout = 2500 } = opts; const container = ensureContainer(); const el = document.createElement('div'); el.className = 'toast-badge ' + type; el.setAttribute('role','status'); el.innerHTML = '<span class="icon">'+iconFor(type)+'</span><span>'+message+'</span>'; container.appendChild(el); requestAnimationFrame(()=>{ el.classList.add('show'); }); const remove = () => { el.classList.remove('show'); setTimeout(()=> el.remove(), 200); }; setTimeout(remove, timeout); return { remove }; }; // Global copy helper used by inline buttons window.copyToClipboard = function(url){ try { navigator.clipboard.writeText(url).then(()=>{ // Update the clicked button, if available let btn = document.activeElement && document.activeElement.tagName === 'BUTTON' ? document.activeElement : (window.event && window.event.target && window.event.target.closest ? window.event.target.closest('button') : null); if (btn){ const original = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check me-1"></i> Copied!'; btn.classList.remove('btn-outline-secondary'); btn.classList.add('btn-success'); setTimeout(()=>{ btn.innerHTML = original; btn.classList.remove('btn-success'); btn.classList.add('btn-outline-secondary'); }, 2000); } notifyBadge('Link copied to clipboard', { type: 'success' }); }).catch((err)=>{ console.error('Clipboard error:', err); notifyBadge('Could not copy link', { type: 'error' }); }); } catch (e) { console.error(e); notifyBadge('Copy not supported', { type: 'error' }); } }; })(); </script> <a href="https://wa.me/254768033943?text=Hi%2C+I+visited+your+website+and+would+like+to+inquire+about+your+services." class="whatsapp-btn-float" target="_blank" rel="noopener noreferrer" aria-label="Chat on WhatsApp"> <i class="fab fa-whatsapp"></i> <span class="whatsapp-tooltip">Chat with me</span> </a> <style> .whatsapp-btn-float { position: fixed; bottom: 24px; right: 24px; z-index: 1050; display: flex; align-items: center; justify-content: center; background-color: #25d366; color: #fff !important; width: 60px; height: 60px; border-radius: 50%; box-shadow: 0 4px 16px rgba(37, 211, 102, 0.45); text-decoration: none; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .whatsapp-btn-float i { font-size: 32px; transition: transform 0.3s ease; } .whatsapp-btn-float:hover { background-color: #128c7e; transform: scale(1.1); box-shadow: 0 6px 20px rgba(18, 140, 126, 0.6); } .whatsapp-btn-float:hover i { transform: rotate(10deg); } /* Pulsing ring animation */ .whatsapp-btn-float::before { content: ''; position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: #25d366; opacity: 0.7; z-index: -1; animation: whatsapp-pulse 2s infinite; } @keyframes whatsapp-pulse { 0% { transform: scale(1); opacity: 0.7; } 100% { transform: scale(1.6); opacity: 0; } } /* Tooltip style utilizing site-wide theme colors */ .whatsapp-btn-float .whatsapp-tooltip { position: absolute; right: 76px; background-color: var(--surface); color: var(--text-primary); border: 1px solid var(--border); padding: 8px 14px; border-radius: 20px; font-size: 14px; font-weight: 600; white-space: nowrap; opacity: 0; visibility: hidden; transform: translateX(10px); transition: all 0.3s ease; box-shadow: var(--shadow); pointer-events: none; } .whatsapp-btn-float:hover .whatsapp-tooltip { opacity: 1; visibility: visible; transform: translateX(0); } /* Responsive Design */ @media (max-width: 768px) { .whatsapp-btn-float { bottom: 16px; right: 16px; width: 50px; height: 50px; } .whatsapp-btn-float i { font-size: 26px; } .whatsapp-btn-float .whatsapp-tooltip { display: none; /* Hide tooltip on small screens to prevent overlap issues */ } } </style> <!-- Mobile More Menu (Offcanvas Bottom Sheet) - Auth actions only --> <style> /* ══════════════════════════════════════════════════════════════ NEUMORPHISM REVAMP (front-end only) Soft-extruded UI: same-tone surfaces with dual light/dark shadows. This block sits at the end of the body so it layers over every page's own styles without touching page markup. ══════════════════════════════════════════════════════════════ */ /* ── Buttons: extruded, press-in on click ─────────────────────── */ .btn { box-shadow: var(--shadow) !important; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important; } .btn:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .btn:active:not(.btn-link), .btn.show { transform: translateY(1px) scale(0.99) !important; box-shadow: var(--shadow-inset) !important; } .btn-primary-custom, .btn-primary, .btn-success { background: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow) !important; } .btn-primary-custom:hover, .btn-primary:hover, .btn-success:hover { background: var(--primary-dark) !important; border-color: var(--primary-dark) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-primary-custom, .btn-outline-primary, .btn-outline-success, .btn-outline-secondary, .btn-outline-danger, .btn-outline-dark, .btn-light { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow) !important; } .btn-outline-primary-custom:hover, .btn-outline-primary:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-success:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-secondary:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-danger:hover { background-color: #ef4444 !important; border-color: #ef4444 !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-dark:hover, .btn-light:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: var(--background) !important; box-shadow: var(--shadow-md) !important; } .btn-dark { background-color: var(--text-primary) !important; border-color: var(--text-primary) !important; color: var(--background) !important; box-shadow: var(--shadow) !important; } /* ── Forms: pressed-in surfaces ────────────────────────────────── */ .form-control, .form-select { background-color: var(--surface) !important; border: 1px solid transparent !important; color: var(--text-primary) !important; border-radius: 14px !important; box-shadow: var(--shadow-inset) !important; transition: box-shadow 0.3s ease, background-color 0.25s ease !important; } .form-control:focus, .form-select:focus { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow-inset), 0 0 0 4px rgba(var(--primary-rgb), 0.15) !important; } .form-control::placeholder { color: var(--text-muted) !important; } .input-group-text { background-color: var(--surface-alt) !important; border: 1px solid transparent !important; color: var(--text-secondary) !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Navbar: floating neumorphic pill (tablet & desktop) ──────── */ .navbar { background-color: rgba(var(--background-rgb), 0.9) !important; border-bottom: 1px solid transparent !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; box-shadow: var(--shadow-md) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-md) !important; } @media (min-width: 768px) { .navbar { top: 14px !important; left: 18px !important; right: 18px !important; width: auto !important; border-radius: 20px !important; padding: 0.6rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-lg) !important; } .navbar.scrolled { background-color: rgba(var(--background-rgb), 0.95) !important; border-bottom-color: transparent !important; padding: 0.5rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar.scrolled { background-color: rgba(28, 32, 40, 0.95) !important; box-shadow: var(--shadow-lg) !important; } body { padding-top: 96px !important; } .reading-progress-container { top: 88px !important; } } /* ── Tablet refinements (768px - 991.98px) ────────────────────── */ @media (min-width: 768px) and (max-width: 991.98px) { /* Hamburger dropdown panel blends with the floating pill */ .navbar-collapse { border-radius: 20px !important; border-color: transparent !important; } body { padding-bottom: 0 !important; } .whatsapp-btn-float { bottom: 24px !important; } /* Hero: tighten the inline 80px top padding that assumed a full-height bar, so tablets don't get excessive empty space */ .hero-section-bg { padding-top: 40px !important; } } /* ── Neumorphic brand icon mark ────────────────────────────────── */ .brand-mark { width: 40px; height: 40px; flex-shrink: 0; border-radius: 13px; background-color: var(--surface) !important; color: var(--primary-color) !important; display: inline-flex; align-items: center; justify-content: center; box-shadow: var(--shadow) !important; transition: box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .brand-mark svg { width: 21px; height: 21px; } a:hover .brand-mark { box-shadow: var(--shadow-inset-sm) !important; transform: translateY(1px); } .mobile-logo .brand-mark { width: 34px; height: 34px; border-radius: 11px; } .mobile-logo .brand-mark svg { width: 17px; height: 17px; } /* ── Uploaded brand logo image ─────────────────────────────────── */ .brand-img { height: 38px; width: auto; max-width: 160px; object-fit: contain; flex-shrink: 0; } .brand-img-sm { height: 28px; max-width: 120px; } .brand-img-footer { height: 44px; max-width: 180px; } /* ── Mobile bars: floating neumorphic pills (phones) ──────────── */ @media (max-width: 767.98px) { .mobile-top-bar { top: 8px !important; left: 12px !important; right: 12px !important; height: 52px !important; border-radius: 16px !important; border-bottom: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } .mobile-bottom-bar { left: 12px !important; right: 12px !important; bottom: 12px !important; height: 58px !important; border-radius: 18px !important; border-top: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } body { padding-top: 74px !important; padding-bottom: 86px !important; } .whatsapp-btn-float { bottom: 88px !important; } .reading-progress-container { top: 70px !important; } } /* ── Nav pill widgets: pressed-in ──────────────────────────────── */ .theme-toggle, .mobile-theme-toggle, .nav-clock { background-color: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Icon chips, markers & circles: extruded ───────────────────── */ .icon-wrap, .icon-wrapper, .expertise-card-icon, .timeline-marker, .mobile-more-icon-wrapper, .social-circle, .value-icon, .skill-icon-box { box-shadow: var(--shadow) !important; border-color: transparent !important; } /* ── Panels: extruded ──────────────────────────────────────────── */ .accordion-item { border-color: transparent !important; box-shadow: var(--shadow) !important; } .timeline-content { background: var(--surface) !important; border-radius: 12px !important; padding: 1rem 1.25rem !important; box-shadow: var(--shadow) !important; } .feature-item { background: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow) !important; } .feature-item:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .progress { background-color: var(--surface-alt) !important; box-shadow: var(--shadow-inset-sm) !important; } /* Card hover: consistent soft neu shadow */ .card-hover:hover, .project-card:hover, .blog-card:hover, .post-card:hover, .news-card:hover, .testimonial-card:hover, .hover-lift:hover, .stats-item:hover { box-shadow: var(--shadow-lg) !important; } </style> <script> document.addEventListener('DOMContentLoaded', function() { const readerCard = document.getElementById('reader-card'); if (!readerCard) return; // Settings Key constants const THEME_KEY = 'reader_mode_theme'; const FONT_KEY = 'reader_mode_font'; const SIZE_KEY = 'reader_mode_size'; // Available Options const themes = ['light', 'sepia', 'dark', 'oled']; const fonts = ['serif', 'sans']; const sizes = ['xs', 'sm', 'md', 'lg', 'xl']; // Default Configs const siteTheme = document.documentElement.getAttribute('data-theme') || 'light'; const defaultTheme = siteTheme === 'dark' ? 'dark' : 'light'; let currentTheme = localStorage.getItem(THEME_KEY) || defaultTheme; let currentFont = localStorage.getItem(FONT_KEY) || 'serif'; let currentSize = localStorage.getItem(SIZE_KEY) || 'md'; // Apply initial config applySettings(); // Register Action Listeners (Toolbar) document.getElementById('btn-font-serif').addEventListener('click', () => setFont('serif')); document.getElementById('btn-font-sans').addEventListener('click', () => setFont('sans')); document.getElementById('btn-size-decrease').addEventListener('click', () => adjustSize(-1)); document.getElementById('btn-size-increase').addEventListener('click', () => adjustSize(1)); document.getElementById('theme-light').addEventListener('click', () => setTheme('light')); document.getElementById('theme-sepia').addEventListener('click', () => setTheme('sepia')); document.getElementById('theme-dark').addEventListener('click', () => setTheme('dark')); document.getElementById('theme-oled').addEventListener('click', () => setTheme('oled')); // Register Action Listeners (Sidebar widgets, if present) const sidebarSerif = document.getElementById('sidebar-font-serif'); const sidebarSans = document.getElementById('sidebar-font-sans'); const sidebarSizeDec = document.getElementById('sidebar-size-dec'); const sidebarSizeInc = document.getElementById('sidebar-size-inc'); if (sidebarSerif) sidebarSerif.addEventListener('click', () => setFont('serif')); if (sidebarSans) sidebarSans.addEventListener('click', () => setFont('sans')); if (sidebarSizeDec) sidebarSizeDec.addEventListener('click', () => adjustSize(-1)); if (sidebarSizeInc) sidebarSizeInc.addEventListener('click', () => adjustSize(1)); // Action Core Functions function setTheme(theme) { currentTheme = theme; localStorage.setItem(THEME_KEY, theme); applySettings(); } function setFont(font) { currentFont = font; localStorage.setItem(FONT_KEY, font); applySettings(); } function adjustSize(delta) { let index = sizes.indexOf(currentSize); index = Math.max(0, Math.min(sizes.length - 1, index + delta)); currentSize = sizes[index]; localStorage.setItem(SIZE_KEY, currentSize); applySettings(); } function applySettings() { // 1. Reset theme classes and apply selected themes.forEach(t => readerCard.classList.remove('reader-theme-' + t)); readerCard.classList.add('reader-theme-' + currentTheme); // Update Theme circles highlight themes.forEach(t => { const circle = document.getElementById('theme-' + t); if (circle) { if (t === currentTheme) { circle.classList.add('active'); } else { circle.classList.remove('active'); } } }); // 2. Reset Font classes and apply selected fonts.forEach(f => readerCard.classList.remove('reader-font-' + f)); readerCard.classList.add('reader-font-' + currentFont); // Highlight Font buttons const serifBtns = [document.getElementById('btn-font-serif'), document.getElementById('sidebar-font-serif')]; const sansBtns = [document.getElementById('btn-font-sans'), document.getElementById('sidebar-font-sans')]; serifBtns.forEach(btn => { if (btn) { if (currentFont === 'serif') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); sansBtns.forEach(btn => { if (btn) { if (currentFont === 'sans') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); // 3. Reset Sizing classes and apply selected sizes.forEach(s => readerCard.classList.remove('reader-size-' + s)); readerCard.classList.add('reader-size-' + currentSize); } // Reading Scroll progress calculation const progressBar = document.getElementById('reading-progress-bar'); window.addEventListener('scroll', () => { const documentHeight = document.documentElement.scrollHeight - window.innerHeight; if (documentHeight > 0) { const scrollProgress = (window.pageYOffset / documentHeight) * 100; progressBar.style.width = scrollProgress + '%'; } }); }); </script> <script> // ══════════════════════════════════════════════════════════════ // PWA: Service Worker registration + install prompt // ══════════════════════════════════════════════════════════════ (function () { const PWA_VERSION = '20260805-1'; // ---- Service worker ------------------------------------ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js?v=' + PWA_VERSION).then((reg) => { // Apply updates (new deploy) on the next load without disruption reg.addEventListener('updatefound', () => { const newWorker = reg.installing; if (!newWorker) return; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { newWorker.postMessage({ type: 'SKIP_WAITING' }); } }); }); }).catch(() => {}); }); } // ---- Install prompt -------------------------------------- let deferredPrompt = null; const bannerId = 'pwa-install-banner'; const DISMISS_KEY = 'nesh-install-dismissed'; function showInstallBanner() { if (localStorage.getItem(DISMISS_KEY) || document.getElementById(bannerId)) return; const banner = document.createElement('div'); banner.id = bannerId; banner.setAttribute('role', 'dialog'); banner.setAttribute('aria-label', 'Install app'); banner.innerHTML = '<div class="pwa-install-content">' + '<span class="pwa-install-icon">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' + '</span>' + '<div class="pwa-install-text">' + '<strong>Install NeshDevTech</strong>' + '<span>Get the app on your device</span>' + '</div>' + '<button type="button" class="pwa-install-btn" id="pwa-install-btn">Install</button>' + '<button type="button" class="pwa-install-close" id="pwa-install-close" aria-label="Dismiss">×</button>' + '</div>'; document.body.appendChild(banner); document.getElementById('pwa-install-btn').addEventListener('click', () => { if (!deferredPrompt) return; deferredPrompt.prompt(); deferredPrompt.userChoice.finally(() => { deferredPrompt = null; hideInstallBanner(); }); }); document.getElementById('pwa-install-close').addEventListener('click', () => { localStorage.setItem(DISMISS_KEY, '1'); hideInstallBanner(); }); requestAnimationFrame(() => banner.classList.add('show')); } function hideInstallBanner() { const banner = document.getElementById(bannerId); if (banner) { banner.classList.remove('show'); setTimeout(() => banner.remove(), 250); } } window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; showInstallBanner(); }); window.addEventListener('appinstalled', () => { deferredPrompt = null; hideInstallBanner(); localStorage.setItem(DISMISS_KEY, '1'); }); })(); </script> <style> .pwa-install-content { display: flex; align-items: center; gap: 12px; padding: 14px 18px; } #pwa-install-banner { position: fixed; left: 50%; transform: translate(-50%, 20px); bottom: 84px; z-index: 1070; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); opacity: 0; transition: opacity .25s cubic-bezier(.16, 1, .3, 1), transform .25s cubic-bezier(.16, 1, .3, 1); width: min(420px, calc(100vw - 32px)); } #pwa-install-banner.show { opacity: 1; transform: translate(-50%, 0); } .pwa-install-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: 0 0 40px; border-radius: 12px; background: rgba(var(--primary-rgb), 0.1); border: 1px solid rgba(var(--primary-rgb), 0.2); color: var(--primary-color); } .pwa-install-icon svg { width: 20px; height: 20px; } .pwa-install-text { display: flex; flex-direction: column; flex: 1; min-width: 0; } .pwa-install-text strong { font-size: 0.92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pwa-install-text span { font-size: 0.78rem; color: var(--text-secondary); } .pwa-install-btn { background: var(--primary-color); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 8px 18px; border-radius: 9999px; cursor: pointer; white-space: nowrap; box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25); transition: all .2s ease; } .pwa-install-btn:hover { background: var(--primary-dark); transform: translateY(-1px); } .pwa-install-close { background: transparent; border: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 50%; transition: background-color .2s ease, color .2s ease; } .pwa-install-close:hover { background: var(--surface-alt); color: var(--text-primary); } @media (min-width: 992px) { #pwa-install-banner { bottom: 24px; } } </style> </div><!-- /.page-wrapper --> </body> </html>