perf: self-host fonts and add /admin route

Self-hosted fonts:
- Download all 10 typefaces (35 font files, 728KB) from Google Fonts
- Create @font-face declarations in assets/css/fonts.css
- Remove Google Fonts external dependency from layouts
- Privacy improvement (no Google tracking)
- Performance improvement (no DNS lookup to fonts.googleapis.com)
- GDPR compliant (no third-party requests)

Admin access:
- Add /admin route that redirects to /admin/theme (requires auth)
- Remove Admin link from footer (too visible for visitors)
- Shop owners can bookmark or type /admin directly

Layout improvements:
- Create shop_root.html.heex as minimal root for shop pages
- Shop pages no longer show admin nav bar

Other:
- Update .gitignore to exclude digested static files
- Add PageSpeed 100% task to ROADMAP.md
- Fix test to check /users/settings instead of shop homepage

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jamey Greenwood 2026-01-20 22:54:07 +00:00
parent 8ab7169c1a
commit 5faa6c4c09
45 changed files with 364 additions and 16 deletions

19
.gitignore vendored
View File

@ -31,6 +31,25 @@ simpleshop_theme-*.tar
# Ignore digested assets cache. # Ignore digested assets cache.
/priv/static/cache_manifest.json /priv/static/cache_manifest.json
# Ignore digested versions of static files (with hash in filename)
# Keep source files, ignore build outputs
/priv/static/*-*.css
/priv/static/*-*.css.gz
/priv/static/*-*.js
/priv/static/*-*.js.gz
/priv/static/*-*.ico
/priv/static/*-*.txt
/priv/static/*-*.txt.gz
/priv/static/*-*.html
/priv/static/*-*.html.gz
/priv/static/*.gz
# Digested fonts have 32-char hash before extension
/priv/static/fonts/*-????????????????????????????????.woff2
/priv/static/mockups/*-*.jpg
/priv/static/images/*-*.svg
/priv/static/images/*-*.svg.gz
/priv/static/images/*.gz
# In case you use Node.js/npm, you want to ignore these. # In case you use Node.js/npm, you want to ignore these.
npm-debug.log npm-debug.log
/assets/node_modules/ /assets/node_modules/

View File

@ -339,7 +339,30 @@ This ensures sellers never unknowingly sell at a loss due to Printify price chan
## Quick Wins (Low Effort) ## Quick Wins (Low Effort)
*(None pending)* ### PageSpeed 100% Score
**Status:** In progress
**Effort:** 0.5-1 day
Achieve 100% Google PageSpeed score on all core metrics.
**Completed:**
- [x] Self-hosted fonts (removed Google Fonts external dependency)
- [x] Production asset pipeline (minified, gzipped CSS/JS)
**Remaining:**
- [ ] Add proper image dimensions to all `<img>` tags (CLS)
- [ ] Preload critical fonts (LCP)
- [ ] Lazy load below-fold images
- [ ] Add `fetchpriority="high"` to hero images
- [ ] Optimise mockup images (resize to actual display sizes, WebP format)
- [ ] Review and reduce unused CSS (currently 22KB gzipped)
- [ ] Add cache headers for static assets
**Metrics to hit:**
- Performance: 100
- Accessibility: 100
- Best Practices: 100
- SEO: 100
--- ---
@ -480,3 +503,14 @@ The project is currently named `simpleshop_theme` (reflecting its origins as a t
- Shadow above nav for visual separation - Shadow above nav for visual separation
- Hidden on desktop (≥768px), replaces header nav on mobile - Hidden on desktop (≥768px), replaces header nav on mobile
- Works in both live shop and theme preview modes - Works in both live shop and theme preview modes
### Self-Hosted Fonts ✅
- Removed Google Fonts external dependency
- All 10 typefaces (35 font files, 728KB) served from `/fonts/`
- Privacy improvement (no Google tracking)
- Performance improvement (no DNS lookup to fonts.googleapis.com)
- GDPR compliant (no third-party requests)
### Admin Access Route ✅
- `/admin` redirects to `/admin/theme` (requires auth)
- Shop owners can bookmark or type `/admin` to access

View File

@ -102,6 +102,9 @@
/* Make LiveView wrapper divs transparent for layout */ /* Make LiveView wrapper divs transparent for layout */
[data-phx-session], [data-phx-teleported-src] { display: contents } [data-phx-session], [data-phx-teleported-src] { display: contents }
/* Self-hosted fonts - all font-face declarations */
@import "./fonts.css";
/* Theme CSS - Layer 1: Primitives (fixed CSS variables) */ /* Theme CSS - Layer 1: Primitives (fixed CSS variables) */
@import "./theme-primitives.css"; @import "./theme-primitives.css";

269
assets/css/fonts.css Normal file
View File

@ -0,0 +1,269 @@
/* Self-hosted Google Fonts
* All fonts loaded locally for privacy and performance.
* Browsers only download fonts actually used on the page.
*/
/* Inter - Clean, Modern, Impulse presets (body) */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/inter-v20-latin-300.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/inter-v20-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/inter-v20-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/inter-v20-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/inter-v20-latin-700.woff2') format('woff2');
}
/* Manrope - Clean preset (heading) */
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/manrope-v20-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/manrope-v20-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/manrope-v20-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Manrope';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/manrope-v20-latin-700.woff2') format('woff2');
}
/* Work Sans - Friendly preset (body) */
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/work-sans-v24-latin-300.woff2') format('woff2');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/work-sans-v24-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/work-sans-v24-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Work Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/work-sans-v24-latin-600.woff2') format('woff2');
}
/* DM Sans - Minimal preset (heading) */
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/dm-sans-v17-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/dm-sans-v17-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/dm-sans-v17-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'DM Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/dm-sans-v17-latin-700.woff2') format('woff2');
}
/* Raleway - Editorial, Impulse presets (body/heading) */
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/raleway-v37-latin-300.woff2') format('woff2');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/raleway-v37-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Raleway';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/raleway-v37-latin-500.woff2') format('woff2');
}
/* Space Grotesk - Modern preset (heading) */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/space-grotesk-v22-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/space-grotesk-v22-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/space-grotesk-v22-latin-600.woff2') format('woff2');
}
/* Playfair Display - Editorial preset (heading) */
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/playfair-display-v40-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/playfair-display-v40-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Playfair Display';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/playfair-display-v40-latin-700.woff2') format('woff2');
}
/* Cormorant Garamond - Classic preset (heading) */
@font-face {
font-family: 'Cormorant Garamond';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/cormorant-garamond-v21-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Cormorant Garamond';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/cormorant-garamond-v21-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Cormorant Garamond';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/cormorant-garamond-v21-latin-600.woff2') format('woff2');
}
/* Source Serif 4 - Classic, Minimal presets (body) */
@font-face {
font-family: 'Source Serif 4';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/source-serif-4-v14-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Source Serif 4';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/source-serif-4-v14-latin-600.woff2') format('woff2');
}
/* Fraunces - Friendly preset (heading) */
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/fraunces-v38-latin-regular.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/fraunces-v38-latin-500.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/fraunces-v38-latin-600.woff2') format('woff2');
}
@font-face {
font-family: 'Fraunces';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/fraunces-v38-latin-700.woff2') format('woff2');
}

View File

@ -8,9 +8,6 @@
{assigns[:page_title]} {assigns[:page_title]}
</.live_title> </.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=DM+Sans:opsz,wght@9..40,400..700&family=Fraunces:opsz,wght@9..144,400..700&family=Inter:wght@300..700&family=Manrope:wght@400..700&family=Outfit:wght@300..600&family=Playfair+Display:wght@400;500;700&family=Raleway:wght@300;400;500&family=Source+Serif+4:wght@400;600&family=Space+Grotesk:wght@400..600&family=Work+Sans:wght@300..600&display=swap" rel="stylesheet">
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script> </script>
<script> <script>

View File

@ -6,12 +6,6 @@
<meta name="csrf-token" content={get_csrf_token()} /> <meta name="csrf-token" content={get_csrf_token()} />
<.live_title><%= assigns[:page_title] || @theme_settings.site_name %></.live_title> <.live_title><%= assigns[:page_title] || @theme_settings.site_name %></.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} /> <link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=DM+Sans:opsz,wght@9..40,400..700&family=Fraunces:opsz,wght@9..144,400..700&family=Inter:wght@300..700&family=Manrope:wght@400..700&family=Outfit:wght@300..600&family=Playfair+Display:wght@400;500;700&family=Raleway:wght@300;400;500&family=Source+Serif+4:wght@400;600&family=Space+Grotesk:wght@400..600&family=Work+Sans:wght@300..600&display=swap"
rel="stylesheet"
/>
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script> </script>
<!-- Generated theme CSS (only active values, not all variants) --> <!-- Generated theme CSS (only active values, not all variants) -->

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<.live_title default="SimpleshopTheme">
{assigns[:page_title]}
</.live_title>
<link phx-track-static rel="stylesheet" href={~p"/assets/css/app.css"} />
<script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
</script>
</head>
<body class="h-full">
{@inner_content}
</body>
</html>

View File

@ -0,0 +1,7 @@
defmodule SimpleshopThemeWeb.AdminController do
use SimpleshopThemeWeb, :controller
def index(conn, _params) do
redirect(conn, to: ~p"/admin/theme")
end
end

View File

@ -18,6 +18,7 @@ defmodule SimpleshopThemeWeb.Router do
end end
pipeline :shop do pipeline :shop do
plug :put_root_layout, html: {SimpleshopThemeWeb.Layouts, :shop_root}
plug SimpleshopThemeWeb.Plugs.LoadTheme plug SimpleshopThemeWeb.Plugs.LoadTheme
end end
@ -72,6 +73,13 @@ defmodule SimpleshopThemeWeb.Router do
## Authentication routes ## Authentication routes
# /admin redirects to theme editor (requires auth, will redirect to login if needed)
scope "/admin", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", AdminController, :index
end
scope "/", SimpleshopThemeWeb do scope "/", SimpleshopThemeWeb do
pipe_through [:browser, :require_authenticated_user] pipe_through [:browser, :require_authenticated_user]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -20,8 +20,8 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu # Now do a logged in request to an admin page and assert on the menu
conn = get(conn, ~p"/") conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
assert response =~ ~p"/users/settings" assert response =~ ~p"/users/settings"
@ -84,8 +84,8 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert get_session(conn, :user_token) assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
# Now do a logged in request and assert on the menu # Now do a logged in request to an admin page and assert on the menu
conn = get(conn, ~p"/") conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
assert response =~ ~p"/users/settings" assert response =~ ~p"/users/settings"
@ -108,8 +108,8 @@ defmodule SimpleshopThemeWeb.UserSessionControllerTest do
assert Accounts.get_user!(user.id).confirmed_at assert Accounts.get_user!(user.id).confirmed_at
# Now do a logged in request and assert on the menu # Now do a logged in request to an admin page and assert on the menu
conn = get(conn, ~p"/") conn = get(conn, ~p"/users/settings")
response = html_response(conn, 200) response = html_response(conn, 200)
assert response =~ user.email assert response =~ user.email
assert response =~ ~p"/users/settings" assert response =~ ~p"/users/settings"