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 + +
+
+
+ +
+
+
+
login.html
+ +
server-rendered by defaultauth block preview
+
+
+
+
+ +
+
+

12

visual test cases

+

10

starter components

+

3

application blocks

+

0

frontend frameworks

+
+
+ +
+
+

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.

+
+
+
Authenticationauth.html
+

Sign in

Welcome back

Continue to your workspace.

Email address
Password
Sign in

Forgot password?

+
login form · validation-readyInspect ↗
+
+ +
+
Authenticationauth.html
+

Get started

Create your account

Full name
Email address
Password
I agree to the terms and conditions.
Create account
+
signup form · consent stateInspect ↗
+
+ +
+
Operationsdashboard.html
+
OverviewCustomersOrdersAnalytics

Workspace

Overview

A
Revenue$48k
Customers2,431

Recent orders

Olivia Martin$1,240
Phoenix Baker$860
Lana Byrd$540
+
sidebar · KPI cards · tableInspect ↗
+
+
+
+
+ +
+
+

The building blocks

Small pieces.
Serious range.

Inspect every macro ↗
+
+
button.html

Actions with intent.

PrimaryCancel
+
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.

Confirm changes
+
+
+
+ +

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.

+
+ + + + 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"); + }); +});