diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..263d1c3
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,32 @@
+name: Deploy landing page
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ name: Deploy GitHub Pages site
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/README.md b/README.md
index 4c26516..d35cf37 100644
--- a/README.md
+++ b/README.md
@@ -102,6 +102,8 @@ The `/htmx` demo shows an optional HTMX enhancement around a normal Flask form.
The demo also includes realistic application screens at `/blocks`, `/login`, `/signup`, and `/admin`. The reusable block macros live under `jinjalume/blocks/` and compose the core components into auth flows and a responsive admin console. See [docs/blocks.md](docs/blocks.md) for signatures and copy-paste examples.
+The public landing page source lives in [`site/`](site/). The Pages workflow publishes it from the `main` branch; the repository's Pages setting should use **GitHub Actions** as its build source.
+
## Development
```bash
diff --git a/playwright.config.js b/playwright.config.js
index 8d41796..cedc745 100644
--- a/playwright.config.js
+++ b/playwright.config.js
@@ -40,10 +40,18 @@ module.exports = defineConfig({
use: { ...devices["Desktop Chrome"], viewport: { width: 390, height: 844 } },
},
],
- webServer: {
- command: `npm run build:css && ${python} -m flask --app demo.app run --no-debugger --no-reload --port 5000`,
- url: "http://127.0.0.1:5000",
- reuseExistingServer: !process.env.CI,
- timeout: 120_000,
- },
+ webServer: [
+ {
+ command: `npm run build:css && ${python} -m flask --app demo.app run --no-debugger --no-reload --port 5000`,
+ url: "http://127.0.0.1:5000",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ {
+ command: `${python} -m http.server 8000 --directory site`,
+ url: "http://127.0.0.1:8000",
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+ ],
});
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..18ef7b8
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+ Jinjalume — UI for Python apps
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Open source · MIT
+ Flask + Jinja
+ Tailwind CSS
+ Auth + dashboard blocks
+
+
// the missing UI layer for Python’s server-rendered web
+
+ UI that speaks
+ Jinja.
+
+
Reusable Tailwind components and complete application blocks for Flask and Python apps that render HTML on purpose. No SPA migration. No component compiler. Just clean macros you can drop into a template.
+
+
+
+ $ pip install jinjalume
+ Copy
+
+
+
+
+
+
+
+
login.html
+
+
+
Sign in
+
Welcome back
+
Sign in to continue to your workspace.
+
+
Sign in
+
New here? Create an account
+
+
+
server-rendered by default auth block preview
+
+
+
+
+
+
+
+
+
+
Composition blocks
From primitives to product screens. Start with a form, ship with a flow. These ready-made blocks show how Jinjalume components become useful application surfaces without adding a JavaScript framework.
+
+
+ Authentication auth.html
+ Sign in
Welcome back Continue to your workspace.
Sign in
Forgot password?
+
+
+
+
+ Authentication auth.html
+ Get started
Create your account Full name
Email address
Password
I agree to the terms and conditions.
Create account
+
+
+
+
+ Operations dashboard.html
+ Revenue $48k
Customers 2,431
Recent orders
Olivia Martin $1,240
Phoenix Baker $860
Lana Byrd $540
+
+
+
+
+
+
+
+
+
+
+
button.html Actions with intent. Primary Cancel
+
input.html Fields that explain themselves. Email address
Help text and errors stay linked.
+
alert.html Feedback that earns attention. Your changes were saved.
+
modal.html Native interaction.
+
+
+
+
+ Why Jinjalume
HTML is still a superpower. 01
Server-first. Forms submit, dialogs close, and pages work before JavaScript enters the room.
02
Composable. Use one macro in a template or compose a complete login flow and admin console.
03
Your tokens. Semantic CSS variables keep themes, brands, and accessibility decisions in your hands.
04
Python-shaped. Stay in Flask, Jinja, and the tools you already know. Ship the useful thing.
+
+
Open source by design
Bring your best server-rendered ideas. Jinjalume is early, opinionated, and ready for useful contributions. Add a component, improve an accessibility state, or bring the next application block.
+
+
+ Jinjalume. Small components for serious Python apps.
MIT licensed · open source
© 2026 Jinjalume contributors
+
+
diff --git a/site/site.css b/site/site.css
new file mode 100644
index 0000000..1edd739
--- /dev/null
+++ b/site/site.css
@@ -0,0 +1,50 @@
+:root { color-scheme: light; }
+
+html { scroll-behavior: smooth; }
+
+body { text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; }
+
+::selection { background: #7759ee; color: #fff; }
+
+.noise { background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.8'/%3E%3C/svg%3E"); }
+
+.logo-mark { display: inline-flex; height: 2.5rem; width: 2.5rem; align-items: center; justify-content: center; border-radius: .75rem; background: #7759ee; color: #fff; font-size: 1.35rem; font-weight: 700; letter-spacing: -.1em; line-height: 1; transition: transform 300ms cubic-bezier(.22, 1, .36, 1), background 300ms ease; }
+.group:hover .logo-mark { transform: rotate(-8deg) scale(1.05); background: #111116; }
+
+[data-reveal] { opacity: 0; transform: translateY(26px); transition: opacity 800ms cubic-bezier(.22, 1, .36, 1), transform 800ms cubic-bezier(.22, 1, .36, 1); }
+[data-reveal].is-visible { opacity: 1; transform: translateY(0); }
+[data-reveal][data-delay="1"] { transition-delay: 100ms; }
+[data-reveal][data-delay="2"] { transition-delay: 200ms; }
+
+.component-card { position: relative; overflow: hidden; border: 1px solid rgba(17, 17, 22, .13); border-radius: 1rem; background: #fff; box-shadow: 0 3px 5px rgba(17, 17, 22, .12), 0 12px 25px rgba(17, 17, 22, .07); transition: box-shadow 240ms ease, transform 240ms cubic-bezier(.22, 1, .36, 1); }
+.component-card::before { position: absolute; inset: 0 0 auto; height: 4px; background: #d7ff63; content: ""; }
+.component-card:nth-child(3n + 2)::before { background: #7759ee; }
+.component-card:nth-child(3n)::before { background: #111116; }
+.component-card:hover { box-shadow: 0 5px 8px rgba(17, 17, 22, .14), 0 18px 32px rgba(17, 17, 22, .1); transform: translateY(-3px); }
+.component-card-head, .component-card-foot { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.25rem; font-size: .72rem; }
+.component-card-head { border-bottom: 1px solid rgba(17, 17, 22, .1); color: rgba(17, 17, 22, .55); }
+.component-card-head code { color: #7759ee; }
+.component-card-foot { min-height: 3.5rem; border-top: 1px solid rgba(17, 17, 22, .1); color: rgba(17, 17, 22, .55); }
+.component-card-foot a { white-space: nowrap; }
+
+.hero-login-grid { background-image: radial-gradient(circle at 100% 0, rgba(119, 89, 238, .14), transparent 38%), radial-gradient(circle at 0 100%, rgba(215, 255, 99, .35), transparent 35%); }
+.ui-preview { min-height: 23rem; }
+.mini-field { height: 2rem; display: flex; align-items: center; border: 1px solid rgba(17, 17, 22, .12); border-radius: .5rem; background: #fff; color: rgba(17, 17, 22, .42); font-size: .62rem; padding: 0 .65rem; }
+.mini-dashboard { display: flex; min-height: 22rem; overflow: hidden; border: 1px solid rgba(17, 17, 22, .1); border-radius: .75rem; background: #f7f4ed; }
+.mini-sidebar { display: flex; width: 4.25rem; flex-shrink: 0; flex-direction: column; gap: .65rem; border-right: 1px solid rgba(17, 17, 22, .1); background: #fff; padding: .75rem .45rem; }
+.mini-logo, .mini-avatar { display: inline-flex; height: 1.5rem; width: 1.5rem; align-items: center; justify-content: center; border-radius: .5rem; background: #111116; color: #fff; font-size: .65rem; font-weight: 700; }
+.mini-nav { overflow: hidden; border-radius: .35rem; color: rgba(17, 17, 22, .55); font-size: .5rem; padding: .4rem .25rem; text-overflow: ellipsis; white-space: nowrap; }
+.mini-nav.active { background: #111116; color: #fff; }
+.mini-avatar { border-radius: 999px; background: #d9d0ff; color: #7759ee; }
+.mini-stat { display: flex; min-height: 3.25rem; flex-direction: column; justify-content: space-between; border-radius: .65rem; background: #fff; padding: .55rem; }
+.mini-stat span { color: rgba(17, 17, 22, .5); font-size: .5rem; }
+.mini-stat b { font-size: .9rem; letter-spacing: -.05em; }
+.mini-row { display: flex; justify-content: space-between; gap: .5rem; border-bottom: 1px solid rgba(17, 17, 22, .08); padding: .45rem 0; color: rgba(17, 17, 22, .62); font-size: .52rem; }
+.mini-row:last-child { border-bottom: 0; }
+.mini-row b { color: #111116; }
+
+@media (prefers-reduced-motion: reduce) {
+ html { scroll-behavior: auto; }
+ [data-reveal] { opacity: 1; transform: none; transition: none; }
+ .component-card, .logo-mark { transition: none; }
+}
diff --git a/site/site.js b/site/site.js
new file mode 100644
index 0000000..774cb20
--- /dev/null
+++ b/site/site.js
@@ -0,0 +1,52 @@
+const menuButton = document.querySelector("[data-menu-toggle]");
+const mobileMenu = document.querySelector("[data-mobile-menu]");
+
+if (menuButton && mobileMenu) {
+ menuButton.addEventListener("click", () => {
+ const isOpen = menuButton.getAttribute("aria-expanded") === "true";
+ menuButton.setAttribute("aria-expanded", String(!isOpen));
+ menuButton.setAttribute("aria-label", isOpen ? "Open navigation" : "Close navigation");
+ mobileMenu.classList.toggle("hidden", isOpen);
+ });
+
+ mobileMenu.querySelectorAll("a").forEach((link) => {
+ link.addEventListener("click", () => {
+ menuButton.setAttribute("aria-expanded", "false");
+ menuButton.setAttribute("aria-label", "Open navigation");
+ mobileMenu.classList.add("hidden");
+ });
+ });
+}
+
+document.querySelectorAll("[data-copy]").forEach((copyButton) => {
+ copyButton.addEventListener("click", async () => {
+ const source = document.getElementById(copyButton.dataset.copy);
+ if (!source) return;
+
+ try {
+ await navigator.clipboard.writeText(source.innerText);
+ const originalLabel = copyButton.textContent;
+ copyButton.textContent = "Copied ✓";
+ window.setTimeout(() => { copyButton.textContent = originalLabel; }, 1600);
+ } catch {
+ copyButton.textContent = "Select to copy";
+ }
+ });
+});
+
+const revealItems = document.querySelectorAll("[data-reveal]");
+if ("IntersectionObserver" in window) {
+ const observer = new IntersectionObserver((entries, currentObserver) => {
+ entries.forEach((entry) => {
+ if (!entry.isIntersecting) return;
+ entry.target.classList.add("is-visible");
+ currentObserver.unobserve(entry.target);
+ });
+ }, { threshold: 0.12 });
+ revealItems.forEach((item) => observer.observe(item));
+} else {
+ revealItems.forEach((item) => item.classList.add("is-visible"));
+}
+
+const year = document.getElementById("year");
+if (year) year.textContent = new Date().getFullYear();
diff --git a/tests/visual/README.md b/tests/visual/README.md
index 4ff5969..b8c4785 100644
--- a/tests/visual/README.md
+++ b/tests/visual/README.md
@@ -1,6 +1,6 @@
# Visual regression tests
-The component gallery and the login, signup, and admin dashboard blocks are checked with Playwright at desktop (`1280x800`) and mobile (`390x844`) Chromium viewports. The gallery also has a dark-theme snapshot. Reference PNGs live beside each spec in its snapshot directory.
+The static landing page, component gallery, and the login, signup, and admin dashboard blocks are checked with Playwright at desktop (`1280x800`) and mobile (`390x844`) Chromium viewports. The gallery also has a dark-theme snapshot. Reference PNGs live beside each visual spec in its snapshot directory.
Install the Python development environment and browser once:
diff --git a/tests/visual/landing.spec.js b/tests/visual/landing.spec.js
new file mode 100644
index 0000000..d7cbf27
--- /dev/null
+++ b/tests/visual/landing.spec.js
@@ -0,0 +1,30 @@
+const { test, expect } = require("@playwright/test");
+
+test.use({ baseURL: "http://127.0.0.1:8000" });
+
+test.describe("landing page", () => {
+ test("shows the UI block catalog", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(page).toHaveTitle(/UI for Python apps/);
+ await expect(page.locator("h1")).toContainText("Jinja.");
+ await expect(page.locator("#blocks")).toContainText("Composition blocks");
+ await expect(page.locator("#login-block")).toContainText("Welcome back");
+ await expect(page.locator("#signup-block")).toContainText("Create your account");
+ await expect(page.locator("#admin-block")).toContainText("Recent orders");
+ });
+
+ test("mobile navigation exposes the block links", async ({ page }) => {
+ test.skip(test.info().project.name !== "mobile", "Mobile navigation is covered by the mobile project.");
+ await page.goto("/");
+
+ const menuButton = page.locator("[data-menu-toggle]");
+ const mobileMenu = page.locator("[data-mobile-menu]");
+
+ await expect(mobileMenu).toBeHidden();
+ await menuButton.click();
+ await expect(menuButton).toHaveAttribute("aria-expanded", "true");
+ await expect(mobileMenu).toBeVisible();
+ await expect(mobileMenu).toContainText("UI blocks");
+ });
+});