Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,32 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build:css

visual:
name: Playwright visual regression
runs-on: ubuntu-latest
needs: tailwind
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: python -m pip install --upgrade pip
- run: python -m pip install -e ".[dev]"
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium chromium-headless-shell
- run: npm run test:visual
- name: Upload Playwright diagnostics
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-diagnostics
path: |
playwright-report/
test-results/
if-no-files-found: ignore
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ build/
node_modules/
demo/static/dist/*.css
!demo/static/dist/.gitkeep
playwright-report/
test-results/
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ make lint
npm run build:css
```

### Visual regression checks

The component gallery has Playwright snapshots for desktop, mobile, and the dark theme. Install Chromium once with `npx playwright install chromium`, then run `npm run test:visual`. When a UI change is intentional, inspect the screenshots locally and update them explicitly with `npx playwright test --update-snapshots`. Do not update snapshots to hide an unintended layout change. CI uploads failed reports and screenshots for review without committing generated artifacts.

## Adding a component

1. Add the component under `jinjalume/templates/jinjalume/components/`.
Expand Down
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: install test lint css demo
.PHONY: install test lint css visual demo

install:
python -m pip install -e ".[dev]"
Expand All @@ -13,5 +13,8 @@ lint:
css:
npm run build:css

visual:
npm run test:visual

demo: css
flask --app demo.app run --debug
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Jinjalume is an open-source, HTML-first component kit for developers who want re

- A small Flask extension that makes Jinjalume templates available to your app
- Reusable Jinja macros for buttons, badges, alerts, cards, inputs, textareas, avatars, spinners, and dialogs
- A copy-paste [component reference](docs/components.md) with signatures and rendered markup examples
- Tailwind CSS v4 build setup
- A working Flask demo
- Contributor documentation, issue templates, and continuous integration
Expand Down Expand Up @@ -81,12 +82,21 @@ Import the macros you need from `jinjalume/components/`:
- `alert.html` — informational, success, warning, and danger messages
- `card.html` — content containers with a caller block
- `input.html` and `textarea.html` — labeled fields with help and error states
- `select.html` — native select fields with selected options and help/error states
- `avatar.html` — image or initials avatar
- `spinner.html` — accessible loading indicator
- `modal.html` — native HTML dialog markup for progressive enhancement

All components are plain Jinja macros. They do not require a JavaScript framework, and interactive behavior can be progressively enhanced with native browser APIs, HTMX, or Alpine.js.

See [docs/components.md](docs/components.md) for copy-paste examples and the full argument reference. The demo gallery renders every component at <http://127.0.0.1:5000/> after the development server starts.

## Themes and optional HTMX example

The demo includes a light/dark toggle backed by semantic CSS custom properties. Light mode is the default; applications opt in by setting `data-theme="dark"` on the document root. Read the [theming proposal](docs/theming.md) for the token contract and Tailwind integration notes.

The `/htmx` demo shows an optional HTMX enhancement around a normal Flask form. The form remains usable without JavaScript or HTMX, and HTMX is loaded only by that demo page. The core package has no HTMX dependency.

## Development

```bash
Expand All @@ -109,9 +119,8 @@ Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.

- WTForms helpers and validation states
- More accessible interactive components using progressive enhancement
- Optional HTMX and Alpine.js integrations
- Theme tokens, dark mode, and RTL examples
- Component documentation site and visual regression tests
- Optional Alpine.js integrations
- RTL examples and expanded theme customization

## License

Expand Down
39 changes: 38 additions & 1 deletion demo/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import Flask, render_template
from flask import Flask, render_template, request

from jinjalume import Jinjalume

Expand All @@ -9,3 +9,40 @@
@app.get("/")
def index():
return render_template("index.html")


@app.route("/htmx", methods=["GET", "POST"])
def htmx_demo():
"""Show an optional HTMX enhancement with a plain form fallback."""

submitted = request.method == "POST"
message = request.form.get("message", "").strip()

if submitted and message:
result_variant = "success"
result_title = "Server response"
result_message = f"Received: {message}"
elif submitted:
result_variant = "danger"
result_title = "Message required"
result_message = "Enter a message before submitting the form."
else:
result_variant = None
result_title = None
result_message = None

if request.headers.get("HX-Request") == "true":
return render_template(
"partials/htmx_result.html",
result_variant=result_variant,
result_title=result_title,
result_message=result_message,
)

return render_template(
"htmx.html",
submitted=submitted,
result_variant=result_variant,
result_title=result_title,
result_message=result_message,
)
46 changes: 43 additions & 3 deletions demo/templates/base.html
Original file line number Diff line number Diff line change
@@ -1,14 +1,54 @@
<!doctype html>
<html lang="en">
<html lang="en" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Jinjalume{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='dist/jinjalume.css') }}">
</head>
<body class="min-h-screen bg-slate-50 text-slate-900 antialiased">
<main class="mx-auto max-w-5xl px-6 py-16 lg:px-8">
<body class="min-h-screen bg-[var(--jl-page)] text-[var(--jl-text)] antialiased">
<header class="border-b border-[var(--jl-border)] bg-[var(--jl-surface)]">
<div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-6 py-4 lg:px-8">
<a href="{{ url_for('index') }}" class="font-semibold text-[var(--jl-text)]">Jinjalume</a>
<nav class="flex items-center gap-4 text-sm">
<a href="{{ url_for('index') }}" class="text-[var(--jl-text-muted)] hover:text-[var(--jl-text)]">Components</a>
<a href="{{ url_for('htmx_demo') }}" class="text-[var(--jl-text-muted)] hover:text-[var(--jl-text)]">HTMX demo</a>
<button id="theme-toggle" type="button" aria-pressed="false" class="rounded-lg bg-[var(--jl-surface)] px-3 py-2 font-semibold text-[var(--jl-text)] ring-1 ring-inset ring-[var(--jl-border)] hover:bg-[var(--jl-surface-muted)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--jl-focus)]">Use dark mode</button>
</nav>
</div>
</header>
<main class="mx-auto max-w-5xl px-6 py-12 lg:px-8">
{% block content %}{% endblock %}
</main>
<script>
(() => {
const root = document.documentElement;
const toggle = document.getElementById("theme-toggle");
let savedTheme = null;
try {
savedTheme = window.localStorage.getItem("jinjalume-theme");
} catch (error) {
// Storage may be unavailable in privacy-restricted browsers.
}

const setTheme = (theme) => {
const isDark = theme === "dark";
root.dataset.theme = isDark ? "dark" : "light";
toggle.setAttribute("aria-pressed", String(isDark));
toggle.textContent = isDark ? "Use light mode" : "Use dark mode";
};

setTheme(savedTheme === "dark" ? "dark" : "light");
toggle.addEventListener("click", () => {
const nextTheme = root.dataset.theme === "dark" ? "light" : "dark";
setTheme(nextTheme);
try {
window.localStorage.setItem("jinjalume-theme", nextTheme);
} catch (error) {
// The toggle still works for the current page without storage.
}
});
})();
</script>
</body>
</html>
37 changes: 37 additions & 0 deletions demo/templates/htmx.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% from "jinjalume/components/button.html" import button %}
{% from "jinjalume/components/card.html" import card %}
{% from "jinjalume/components/input.html" import input_field %}
{% from "jinjalume/components/spinner.html" import spinner %}

{% block title %}Optional HTMX example · Jinjalume{% endblock %}

{% block content %}
<div class="max-w-2xl">
<p class="text-sm font-semibold text-[var(--jl-accent-text)]">Optional integration</p>
<h1 class="mt-3 text-4xl font-bold tracking-tight text-[var(--jl-text)]">Progressive enhancement with HTMX</h1>
<p class="mt-5 text-lg leading-8 text-[var(--jl-text-muted)]">The form is a normal POST first. When HTMX is available, only the result region is replaced.</p>
</div>

<div class="mt-10 max-w-xl">
{% call card("Send a message", "Try it with JavaScript disabled to see the full-page fallback.") %}
<form method="post" action="{{ url_for('htmx_demo') }}" hx-post="{{ url_for('htmx_demo') }}" hx-target="#htmx-result" hx-swap="innerHTML" hx-indicator="#htmx-spinner" class="space-y-5">
{{ input_field("message", label="Message", placeholder="Hello from the server…", help_text="This value is submitted to the Flask route.", required=True) }}
<div class="flex items-center gap-3">
{{ button("Submit", type="submit") }}
<span id="htmx-spinner">{{ spinner("Submitting") }}</span>
</div>
</form>
<div id="htmx-result" aria-live="polite" class="mt-5">
{% if submitted %}
{% include "partials/htmx_result.html" %}
{% else %}
<p class="text-sm text-[var(--jl-text-subtle)]">The server response will appear here.</p>
{% endif %}
</div>
{% endcall %}
</div>

<p class="mt-8 text-sm text-[var(--jl-text-subtle)]">The demo loads HTMX from a CDN only on this page. The core Jinjalume package does not depend on HTMX.</p>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
{% endblock %}
61 changes: 50 additions & 11 deletions demo/templates/index.html
Original file line number Diff line number Diff line change
@@ -1,37 +1,76 @@
{% extends "base.html" %}
{% from "jinjalume/components/alert.html" import alert %}
{% from "jinjalume/components/avatar.html" import avatar %}
{% from "jinjalume/components/badge.html" import badge %}
{% from "jinjalume/components/button.html" import button %}
{% from "jinjalume/components/card.html" import card %}
{% from "jinjalume/components/input.html" import input_field %}
{% from "jinjalume/components/modal.html" import modal %}
{% from "jinjalume/components/select.html" import select_field %}
{% from "jinjalume/components/spinner.html" import spinner %}
{% from "jinjalume/components/textarea.html" import textarea_field %}

{% block title %}Jinjalume MVP{% endblock %}
{% block title %}Jinjalume component gallery{% endblock %}

{% block content %}
<div class="max-w-2xl">
<div class="flex items-center gap-3">
<div class="max-w-3xl">
<div class="flex flex-wrap items-center gap-3">
{{ badge("MVP", variant="success") }}
<span class="text-sm text-slate-500">Jinja + Tailwind + Flask</span>
<span class="text-sm text-[var(--jl-text-subtle)]">Jinja + Tailwind + Flask</span>
</div>
<h1 class="mt-6 text-4xl font-bold tracking-tight text-slate-950 sm:text-5xl">Jinjalume</h1>
<p class="mt-5 text-lg leading-8 text-slate-600">Reusable, server-rendered UI components for Python web apps.</p>
<h1 class="mt-6 text-4xl font-bold tracking-tight text-[var(--jl-text)] sm:text-5xl">Jinjalume component gallery</h1>
<p class="mt-5 text-lg leading-8 text-[var(--jl-text-muted)]">Reusable, server-rendered UI components for Python web apps, with native browser behavior and an opt-in token-based dark theme.</p>
</div>

<div class="mt-10">
{{ alert("The first Jinjalume components are ready for feedback and contribution.", title="Welcome to the MVP", variant="info") }}
{{ alert("The component library is ready for feedback and contribution.", title="Welcome to Jinjalume", variant="info") }}
</div>

<div class="mt-10 grid gap-6 md:grid-cols-2">
{% call card("Buttons", "Small, composable actions with sensible defaults.") %}
<div class="flex flex-wrap gap-3">
{% call card("Buttons and badges", "Actions, links, and compact status labels.") %}
<div class="flex flex-wrap items-center gap-3">
{{ button("Primary") }}
{{ button("Secondary", variant="secondary") }}
{{ button("Delete", variant="danger") }}
{{ button("Read docs", href="https://github.com/phcodesage/jinjalume") }}
</div>
<div class="mt-5 flex flex-wrap gap-2">
{{ badge("Neutral") }}
{{ badge("Success", variant="success") }}
{{ badge("Warning", variant="warning") }}
{{ badge("Danger", variant="danger") }}
</div>
{% endcall %}

{% call card("Form fields", "Labels and descriptions stay connected to their controls.") %}
<div class="space-y-5">
{{ input_field("email", label="Email address", placeholder="you@example.com", help_text="We will never share your email.", required=True) }}
{{ select_field("status", label="Status", options=[{"value": "draft", "label": "Draft"}, {"value": "published", "label": "Published"}], value="draft", help_text="Choose the publication state.") }}
{{ textarea_field("message", label="Message", rows=3, placeholder="Write a short message…", error="A message is required.", help_text="Keep it concise.") }}
</div>
{% endcall %}

{% call card("Feedback", "Alerts communicate status without a frontend framework.") %}
<div class="space-y-3">
{{ alert("Your changes were saved.", variant="success") }}
{{ alert("Check the highlighted fields.", variant="warning") }}
{{ alert("Something went wrong.", variant="danger") }}
</div>
{% endcall %}

{% call card("Identity and loading", "Accessible defaults for avatars and progress indicators.") %}
<div class="flex items-center gap-4">
{{ avatar("Jinjalume", size="lg") }}
{{ avatar("Taylor Example", size="md") }}
{{ spinner("Saving changes") }}
</div>
{% endcall %}

{% call card("Form input", "A baseline input with help and error states.") %}
{{ input_field("email", label="Email address", placeholder="you@example.com", help_text="We will never share your email.", required=True) }}
{% call card("Native dialog", "The modal uses the browser's dialog element and needs no library JavaScript.") %}
<button type="button" onclick="document.getElementById('demo-modal').showModal()" class="rounded-lg bg-[var(--jl-primary)] px-4 py-2.5 text-sm font-semibold text-[var(--jl-primary-contrast)] hover:bg-[var(--jl-primary-hover)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--jl-focus)]">Open modal</button>
{% call modal("demo-modal", "Example dialog", description="Close it with the button or the Escape key.") %}
<p>This content is rendered on the server inside a native HTML dialog.</p>
{% endcall %}
{% endcall %}
</div>
{% endblock %}
5 changes: 5 additions & 0 deletions demo/templates/partials/htmx_result.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{% from "jinjalume/components/alert.html" import alert %}

{% if result_message %}
{{ alert(result_message, variant=result_variant, title=result_title) }}
{% endif %}
Loading
Loading