| name | go-embedded-frontend |
| description | A single-page frontend embedded into a Go binary from internal/server/static/ - HTML boilerplate, Tailwind v4 browser build, the Catppuccin Mocha palette, self-hosted fonts, icons, and PWA files. Use when building or changing the web UI of a Go Web Only or CLI + Web project, laying out static assets, theming a page, choosing which surface a panel or control sits on, or adding PWA support. Triggers on internal/server/static/, index.html, tailwind, @theme, Catppuccin, crust, favicon, manifest.json, sw.js, Lucide, and Font Awesome. Not for templ or htmx server-rendered HTML, and not for a framework SPA built outside the repository. |
| user-invocable | false |
Go Embedded Frontend
One index.html under internal/server/static/, styled with the Tailwind browser build and the Catppuccin Mocha palette, with every asset self-hosted.
The Go server owns how these bytes are served. This covers what goes inside static/.
Layout
internal/server/static/
├── index.html # the single page
├── app.js # application logic, when it outgrows an inline block
├── manifest.json # PWA, when enabled
├── sw.js # PWA, when enabled
├── css/
│ ├── inter.css # @font-face for Inter
│ ├── google-sans.css # @font-face for Google Sans
│ ├── jetbrains-mono.css # @font-face for JetBrains Mono
│ ├── github-dark.min.css # highlight.js theme, when rendering markdown
│ └── devicon.min.css # when using tech logos
├── fonts/ # woff2 only
├── fontawesome/
│ ├── css/
│ └── webfonts/
├── icons/
│ ├── favicon.ico # 32x32, legacy browser tabs
│ ├── favicon.png # 32x32
│ ├── apple-touch-icon.png # 180x180, iOS home screen
│ ├── icon-192.png # PWA, Android
│ ├── icon-512.png # PWA, splash screen
│ └── logo.png # in-app branding
└── js/ # tailwind.js, lucide.min.js, and anything else vendored
Nothing under css/, js/, fonts/, or fontawesome/ is committed to the repository. A Makefile target downloads them on demand and the release workflow calls that same target, so the tree in git holds only what a person wrote. A downloaded asset in a diff is a binary nobody reviews and a version nobody can trace.
Assets
Every asset is pinned to an exact version. A floating @latest makes two builds of the same commit produce different bytes, and a new major arriving overnight breaks rendering with no diff to point at.
| Asset | Version | Lands at |
|---|
| Tailwind browser build | @tailwindcss/browser@4.3.3 | js/tailwind.js |
| Lucide | lucide@1.34.0 | js/lucide.min.js |
| Font Awesome | @fortawesome/fontawesome-free@7.3.1 | fontawesome/css/, fontawesome/webfonts/ |
| Dev Icons | devicon@2.17.0 | css/devicon.min.css |
| Inter | Google Fonts | css/inter.css, fonts/*.woff2 |
| Google Sans | Google Fonts | css/google-sans.css, fonts/*.woff2 |
| JetBrains Mono | Google Fonts | css/jetbrains-mono.css, fonts/*.woff2 |
| JetBrains Mono Nerd Font Mono | nerd-fonts v3.5.1, opt-in | css/jetbrains-mono.css, fonts/*.woff2 |
| Marked | marked@18.0.11 | js/marked.umd.js |
| Highlight.js | highlight.js@11.12.0 | js/highlight.min.js, css/github-dark.min.css |
| Mermaid | mermaid@11.17.2 | js/mermaid.min.js |
| Chart.js | chart.js@4.5.1 | js/chart.umd.js |
The three fonts are always downloaded, whether or not a given page uses all three, so the asset step is the same everywhere and a later design change needs no build change. Everything below them in the table is downloaded when the project actually uses it.
Web fonts are woff2, never ttf. woff2 is roughly half the bytes of the same ttf and every browser targeted here supports it. All three families come from Google Fonts, which already serves woff2, so nothing is converted.
Only the latin and latin-ext blocks of each Google Fonts stylesheet are kept. The endpoint declares every subset the family has, which for Google Sans is twenty-five files covering scripts the page never renders, and //go:embed compiles all of them into the binary regardless.
Font Awesome's stylesheet references its webfonts by a relative path that does not survive being served from /static/, so the asset step rewrites it:
sed -i.bak 's|../webfonts/|/static/fontawesome/webfonts/|g' fontawesome/css/all.min.css
rm -f fontawesome/css/all.min.css.bak
sed -i.bak rather than sed -i '', because the empty-argument form is BSD-only and the release workflow runs this on Linux.
Fonts
| Font | Role | font-family |
|---|
| Inter | body and UI text | 'Inter' |
| Google Sans | display headings and branding | 'Google Sans' |
| JetBrains Mono | code and monospace | 'JetBrains Mono' |
Each has its own @font-face stylesheet under css/, linked from <head>, pointing at local woff2 files. Nothing loads from fonts.googleapis.com at run time, because a page that fetches a font from a third party leaks every visitor's IP and stops rendering correctly offline.
The mono slot takes the plain family by default. The Nerd Font variant is a patched build Google Fonts does not carry, so it is downloaded from the nerd-fonts release as ttf and compressed to woff2, which costs two megabytes and roughly ten seconds against sixty kilobytes and half a second. A page earns that only by actually rendering Nerd Font glyphs, which means a Powerline separator, a file-type icon from the private use area, or terminal output that carries them. An icon that Lucide or Font Awesome already has is not a reason.
Switching is a Makefile variable, not a page change. The nerd target writes the same css/jetbrains-mono.css under the same JetBrains Mono family name, so the HTML is identical either way.
The Page
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>APP_NAME</title>
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon.png">
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/apple-touch-icon.png">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#11111b">
APP_NAME
Page Title
lucide.createIcons();
Theme values are declared in a <style type="text/tailwindcss"> block containing an @theme at-rule. The Tailwind v4 browser build reads that block and rejects a JavaScript config entirely, so the tailwind.config = {...} global from v3 produces no CSS and no error.
A --color-mauve entry generates bg-mauve, text-mauve, border-mauve, and a real var(--color-mauve) for hand-written CSS. Naming the palette entries after Catppuccin's own names keeps the class you write and the swatch you picked identical.
@theme maps each name onto a --ctp-* variable rather than a literal hex. The utility resolves the variable at paint time, so redefining the --ctp-* set under a selector re-themes the page without a second @theme and without touching a single class. Opacity modifiers survive it, since v4 emits color-mix(in oklab, var(--color-mauve) 50%, transparent) rather than substituting a value.
@import "tailwindcss"; is omitted, because the browser build injects it when no @import appears anywhere in the block. Writing one takes over the whole import graph and drops the base styles unless you add it back yourself.
Layering
The page is grounded on crust, the darkest step, and every surface is raised from there. Grounding on base instead pushes the whole page one rung up the ramp, leaves the chrome darker than the page it sits on, and costs contrast against every text color.
| Role | Token |
|---|
| Page ground, and any chrome flush with it such as a sidebar, header, or rail | crust |
| A surface that reads as lifted off the page, such as a card, modal, popover, or dropdown | mantle |
| A control sitting on either of those, such as an input, chip, button, or hovered row | surface0 |
Borders, dividers, and the hover state of a surface0 control | surface1 |
A well recessed inside a mantle panel, such as a code block or an embedded preview | base |
Adjacent layers step one rung and no more. crust to mantle is a contrast ratio of 1.07 and crust to surface0 is 1.49, which separates them without drawing a seam. Skipping a rung reads as two unrelated panels rather than one raised off the other.
Text runs the same way. Body copy takes subtext0, headings and the one value a row exists to show take text, and metadata takes overlay1. Defaulting body copy to text makes every word shout and leaves nothing louder for a heading to be.
Structure never borrows from the accent ramp. Borders, dividers, and backgrounds come from the neutral steps, and an accent marks one thing per view: the active item, the primary action, or a state. Two accents competing in one view means neither is signal.
Nothing here names a component. The tokens are assigned by what a surface does, so a layout this skill has never seen still lands on the right step.
Theme
Catppuccin Mocha is the default for a new frontend in this style. A project that already has a palette keeps it rather than being re-themed, since re-theming an existing app is a design decision rather than a convention.
Light and dark switching is added only when it is asked for. The --ctp-* indirection is what makes it cheap: redefine that set for Latte and every utility follows.
<style>
:root:not(.dark) {
--ctp-crust: #eff1f5; --ctp-mantle: #e6e9ef; --ctp-base: #dce0e8;
--ctp-surface0: #ccd0da; --ctp-surface1: #bcc0cc; --ctp-surface2: #acb0be;
--ctp-text: #4c4f69; --ctp-subtext0: #5c5f77; --ctp-overlay1: #6c6f85;
--ctp-blue: #1e66f5; --ctp-mauve: #8839ef;
}
</style>
Latte's ramp runs light to dark, the opposite of Mocha's, so re-slotting is what keeps crust the ground and mantle the surface above it in both. The text tokens shift by the same one step, because Latte's own subtext0 on its own base is 4.37 and body copy has to clear 4.5.
A script in <head> sets the class from localStorage or prefers-color-scheme before the body renders, which is what prevents a flash of the wrong theme.
Styling Rules
Styling is Tailwind utility classes on the element. Hand-written CSS is the exception and earns its place only in a case that has no utility form:
@font-face declarations, which live in the downloaded stylesheets under css/
- scrollbar pseudo-elements,
::selection, and other pseudo-elements Tailwind does not reach
- a subtree a third party generates, where the classes are not yours to write, such as rendered Markdown, a Mermaid diagram, or an editor widget
@media print rules
@keyframes
Everything else is a class. A hand-written rule that duplicates a utility is a second place a color or a spacing value can drift, and it is invisible to anyone reading the element.
The custom CSS that remains lives in inline <style> blocks. Downloaded stylesheets live in css/. Keeping the two apart means the asset step can wipe and re-download css/ without touching anything a person wrote.
An existing project's working CSS is not ripped out to impose this. The rule governs what you write, not what is already there.
The browser build compiles utility classes in the page at run time. Tailwind documents it as development-only. It is fine for an embedded tool or a dashboard, and an app that needs a small payload and no runtime compile moves to a build step emitting a static tailwind.css.
One index.html is the default. Views are shown and hidden client-side, and shared logic moves into app.js only once more than one place needs it.
Icons
| Library | Use for | Vendored to |
|---|
| Lucide | general UI icons, the default | js/lucide.min.js |
| Font Awesome | brand icons, and gaps in Lucide | fontawesome/ |
| Dev Icons | technology and language logos | css/devicon.min.css |
<i data-lucide="settings"></i>
<script>lucide.createIcons();</script>
<i class="fab fa-github"></i>
<i class="devicon-go-original-wordmark"></i>
lucide.createIcons() runs after any DOM update that inserts an <i data-lucide>, since it replaces those elements with inline SVG once and does not observe later insertions.
App icons are PNG with transparency, recognizable at 16 pixels, and drawn from the Catppuccin palette so they read on both light and dark browser chrome. PWA icons keep their content inside the centre 80%, because the installer rounds the corners off.
PWA
Add PWA support when the app is used often on a phone, when offline behavior is useful, or when installability was requested. Skip it for an admin dashboard, a developer tool, or anything opened twice a year, where an install prompt is noise.
static/manifest.json:
{
"name": "APP_NAME",
"short_name": "APP_SHORT",
"description": "APP_DESCRIPTION",
"start_url": "/",
"display": "standalone",
"background_color": "#11111b",
"theme_color": "#11111b",
"icons": [
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png"
static/sw.js is a no-op worker that exists only to make the app installable:
self.addEventListener('fetch', () => {});
It caches nothing and every request goes to the network, so the app behaves exactly like a normal tab. A caching worker serves stale assets after a deploy and gives users a version they cannot refresh away.
Drop the manifest, the worker, the registration script, and the PWA meta tags together when not building a PWA. A manifest with no worker is an install prompt that leads nowhere.