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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Jinjalume is an open-source, HTML-first component kit for developers who want re
- A copy-paste [component reference](docs/components.md) with signatures and rendered markup examples
- Tailwind CSS v4 build setup
- A working Flask demo
- Copy-paste UI blocks for authentication and admin dashboards
- Contributor documentation, issue templates, and continuous integration

## Quick start
Expand Down Expand Up @@ -97,6 +98,10 @@ The demo includes a light/dark toggle backed by semantic CSS custom properties.

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.

## UI blocks

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.

## Development

```bash
Expand Down
165 changes: 165 additions & 0 deletions demo/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,171 @@ def index():
return render_template("index.html")


@app.get("/blocks")
def blocks():
return render_template("blocks.html")


@app.route("/login", methods=["GET", "POST"])
def login():
email = request.form.get("email", "").strip()
errors = {}
form_error = None
success_message = None

if request.method == "POST":
if not email:
errors["email"] = "Enter your email address."
if not request.form.get("password"):
errors["password"] = "Enter your password."
if errors:
form_error = "Review the fields below and try again."
else:
success_message = "Demo sign-in accepted. No account was created."

return render_template(
"login.html",
email=email,
remember=bool(request.form.get("remember")),
email_error=errors.get("email"),
password_error=errors.get("password"),
form_error=form_error,
success_message=success_message,
)


@app.route("/signup", methods=["GET", "POST"])
def signup():
name = request.form.get("name", "").strip()
email = request.form.get("email", "").strip()
errors = {}
form_error = None
success_message = None

if request.method == "POST":
password = request.form.get("password", "")
confirmation = request.form.get("password_confirmation", "")
if not name:
errors["name"] = "Enter your full name."
if not email:
errors["email"] = "Enter your email address."
if len(password) < 8:
errors["password"] = "Use at least 8 characters."
if password != confirmation:
errors["confirmation"] = "Passwords must match."
if not request.form.get("terms"):
errors["terms"] = "Accept the terms to continue."
if errors:
form_error = "Review the fields below and try again."
else:
success_message = "Demo account created. No data was saved."

return render_template(
"signup.html",
name=name,
email=email,
name_error=errors.get("name"),
email_error=errors.get("email"),
password_error=errors.get("password"),
confirmation_error=errors.get("confirmation"),
terms_error=errors.get("terms"),
form_error=form_error,
success_message=success_message,
)


@app.get("/admin")
def admin():
return render_template(
"admin.html",
nav_items=[
{"label": "Overview", "href": "/admin"},
{"label": "Customers", "href": "#customers", "badge": "24"},
{"label": "Orders", "href": "#orders"},
{"label": "Analytics", "href": "#analytics"},
{"label": "Settings", "href": "#settings"},
],
metrics=[
{
"label": "Total revenue",
"value": "$48,294",
"change": "+12.8%",
"note": "Compared with last month",
"icon": "$",
},
{
"label": "Active customers",
"value": "2,431",
"change": "+8.2%",
"note": "Across all workspaces",
"icon": "↗",
},
{
"label": "Open tickets",
"value": "18",
"change": "-4.5%",
"trend": "down",
"note": "Compared with last week",
"icon": "!",
},
{
"label": "Conversion rate",
"value": "6.24%",
"change": "+1.4%",
"note": "From 38,910 sessions",
"icon": "%",
},
],
orders=[
{
"customer": "Olivia Martin",
"email": "olivia@example.com",
"amount": "$1,240.00",
"status": "Paid",
"date": "Sep 12, 2026",
},
{
"customer": "Phoenix Baker",
"email": "phoenix@example.com",
"amount": "$860.00",
"status": "Processing",
"date": "Sep 11, 2026",
},
{
"customer": "Lana Byrd",
"email": "lana@example.com",
"amount": "$540.00",
"status": "Paid",
"date": "Sep 11, 2026",
},
{
"customer": "Demi Wilkinson",
"email": "demi@example.com",
"amount": "$320.00",
"status": "Refunded",
"date": "Sep 10, 2026",
},
],
activity=[
{
"label": "New workspace created",
"detail": "Acme Inc. joined the Pro plan",
"time": "12 minutes ago",
},
{
"label": "Invoice paid",
"detail": "Olivia Martin paid invoice #1048",
"time": "48 minutes ago",
},
{
"label": "Support ticket resolved",
"detail": "Ticket #284 was closed by Maya",
"time": "2 hours ago",
},
],
)


@app.route("/htmx", methods=["GET", "POST"])
def htmx_demo():
"""Show an optional HTMX enhancement with a plain form fallback."""
Expand Down
77 changes: 77 additions & 0 deletions demo/templates/admin.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{% extends "base.html" %}
{% from "jinjalume/blocks/dashboard.html" import dashboard_shell, stat_card %}
{% from "jinjalume/components/badge.html" import badge %}
{% from "jinjalume/components/button.html" import button %}
{% from "jinjalume/components/select.html" import select_field %}

{% block title %}Admin dashboard · Jinjalume{% endblock %}
{% block site_header %}{% endblock %}
{% block main_class %}max-w-none p-0{% endblock %}

{% block content %}
{% call dashboard_shell(
title="Overview",
description="Monitor your workspace performance and keep an eye on the latest customer activity.",
nav_items=nav_items,
active_item="Overview",
user_name="Alex Morgan",
user_email="alex@example.com"
) %}
<section aria-labelledby="metrics-heading">
<div class="flex items-end justify-between gap-4">
<div><h2 id="metrics-heading" class="text-lg font-bold text-[var(--jl-text)]">At a glance</h2><p class="mt-1 text-sm text-[var(--jl-text-muted)]">Your key numbers for this month.</p></div>
{{ button("Export report", variant="secondary", size="sm", href="#export") }}
</div>
<div class="mt-5 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
{% for metric in metrics %}
{{ stat_card(metric.label, metric.value, change=metric.change, trend=metric.get("trend", "up"), note=metric.note, icon=metric.icon) }}
{% endfor %}
</div>
</section>

<div class="mt-8 grid gap-6 xl:grid-cols-[minmax(0,1.45fr)_minmax(20rem,0.55fr)]">
<section id="orders" aria-labelledby="orders-heading" class="min-w-0 rounded-2xl bg-[var(--jl-surface)] shadow-sm ring-1 ring-[var(--jl-border)]">
<div class="flex flex-wrap items-start justify-between gap-4 border-b border-[var(--jl-border)] p-5 sm:p-6">
<div><h2 id="orders-heading" class="text-lg font-bold text-[var(--jl-text)]">Recent orders</h2><p class="mt-1 text-sm text-[var(--jl-text-muted)]">The latest transactions across your workspace.</p></div>
<div class="w-40">{{ select_field("order-period", label="Period", options=[{"value": "30", "label": "Last 30 days"}, {"value": "90", "label": "Last 90 days"}], value="30", class_name="text-xs") }}</div>
</div>
<div class="overflow-x-auto">
<table class="w-full min-w-[40rem] text-left text-sm">
<caption class="sr-only">Recent orders</caption>
<thead class="border-b border-[var(--jl-border)] text-xs uppercase tracking-wide text-[var(--jl-text-subtle)]">
<tr><th scope="col" class="px-5 py-3 font-semibold sm:px-6">Customer</th><th scope="col" class="px-5 py-3 font-semibold sm:px-6">Status</th><th scope="col" class="px-5 py-3 text-right font-semibold sm:px-6">Amount</th><th scope="col" class="px-5 py-3 text-right font-semibold sm:px-6">Date</th></tr>
</thead>
<tbody class="divide-y divide-[var(--jl-border)]">
{% for order in orders %}
{% set status_variant = "success" if order.status == "Paid" else "warning" if order.status == "Processing" else "danger" %}
<tr class="hover:bg-[var(--jl-surface-muted)]">
<th scope="row" class="whitespace-nowrap px-5 py-4 font-semibold text-[var(--jl-text)] sm:px-6"><span class="block">{{ order.customer }}</span><span class="mt-1 block text-xs font-normal text-[var(--jl-text-subtle)]">{{ order.email }}</span></th>
<td class="whitespace-nowrap px-5 py-4 sm:px-6">{{ badge(order.status, variant=status_variant) }}</td>
<td class="whitespace-nowrap px-5 py-4 text-right font-semibold text-[var(--jl-text)] sm:px-6">{{ order.amount }}</td>
<td class="whitespace-nowrap px-5 py-4 text-right text-[var(--jl-text-muted)] sm:px-6">{{ order.date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="border-t border-[var(--jl-border)] px-5 py-4 sm:px-6"><a href="#orders" class="text-sm font-semibold text-[var(--jl-accent-text)] hover:underline">View all orders →</a></div>
</section>

<section id="analytics" aria-labelledby="activity-heading" class="rounded-2xl bg-[var(--jl-surface)] p-5 shadow-sm ring-1 ring-[var(--jl-border)] sm:p-6">
<div class="flex items-start justify-between gap-4"><div><h2 id="activity-heading" class="text-lg font-bold text-[var(--jl-text)]">Recent activity</h2><p class="mt-1 text-sm text-[var(--jl-text-muted)]">What is happening today.</p></div><span aria-hidden="true" class="text-xl text-[var(--jl-accent-text)]">✦</span></div>
<ol class="mt-6 space-y-6">
{% for event in activity %}
<li class="relative pl-7">
<span aria-hidden="true" class="absolute left-0 top-1.5 h-2.5 w-2.5 rounded-full bg-[var(--jl-accent)] ring-4 ring-[var(--jl-accent-surface)]"></span>
{% if not loop.last %}<span aria-hidden="true" class="absolute bottom-[-1.5rem] left-[0.29rem] top-4 w-px bg-[var(--jl-border)]"></span>{% endif %}
<p class="text-sm font-semibold text-[var(--jl-text)]">{{ event.label }}</p>
<p class="mt-1 text-sm leading-5 text-[var(--jl-text-muted)]">{{ event.detail }}</p>
<p class="mt-1 text-xs text-[var(--jl-text-subtle)]">{{ event.time }}</p>
</li>
{% endfor %}
</ol>
<a href="#activity" class="mt-8 inline-block text-sm font-semibold text-[var(--jl-accent-text)] hover:underline">View activity log →</a>
</section>
</div>
{% endcall %}
{% endblock %}
5 changes: 4 additions & 1 deletion demo/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,20 @@
<link rel="stylesheet" href="{{ url_for('static', filename='dist/jinjalume.css') }}">
</head>
<body class="min-h-screen bg-[var(--jl-page)] text-[var(--jl-text)] antialiased">
{% block site_header %}
<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('blocks') }}" class="text-[var(--jl-text-muted)] hover:text-[var(--jl-text)]">UI blocks</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">
{% endblock %}
<main class="{% block main_class %}mx-auto max-w-5xl px-6 py-12 lg:px-8{% endblock %}">
{% block content %}{% endblock %}
</main>
<script>
Expand Down
35 changes: 35 additions & 0 deletions demo/templates/blocks.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% from "jinjalume/components/badge.html" import badge %}
{% from "jinjalume/components/button.html" import button %}
{% from "jinjalume/components/card.html" import card %}

{% block title %}Jinjalume UI blocks{% endblock %}

{% block content %}
<div class="max-w-3xl">
{{ badge("Copy-paste recipes", variant="success") }}
<h1 class="mt-6 text-4xl font-bold tracking-tight text-[var(--jl-text)] sm:text-5xl">Realistic UI blocks for your next Flask app.</h1>
<p class="mt-5 text-lg leading-8 text-[var(--jl-text-muted)]">Complete server-rendered screens composed from Jinjalume primitives. Each block keeps native HTML behavior and can be styled through the same semantic tokens.</p>
</div>

<div class="mt-10 grid gap-6 md:grid-cols-3">
{% call card("Sign in", "A focused login flow with validation-ready states.") %}
<div class="flex h-full flex-col justify-between gap-6">
<p class="text-sm text-[var(--jl-text-muted)]">Email, password, remember-me, and forgot-password affordances.</p>
{{ button("Preview login", href=url_for("login"), class_name="w-full") }}
</div>
{% endcall %}
{% call card("Create account", "A complete signup form with password guidance.") %}
<div class="flex h-full flex-col justify-between gap-6">
<p class="text-sm text-[var(--jl-text-muted)]">Profile details, confirmation, terms consent, and errors.</p>
{{ button("Preview signup", href=url_for("signup"), class_name="w-full") }}
</div>
{% endcall %}
{% call card("Admin dashboard", "A responsive workspace overview for operations teams.") %}
<div class="flex h-full flex-col justify-between gap-6">
<p class="text-sm text-[var(--jl-text-muted)]">Sidebar navigation, KPIs, orders, and recent activity.</p>
{{ button("Preview dashboard", href=url_for("admin"), class_name="w-full") }}
</div>
{% endcall %}
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions demo/templates/login.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% from "jinjalume/blocks/auth.html" import auth_shell, login_form %}
{% from "jinjalume/components/alert.html" import alert %}

{% block title %}Sign in · Jinjalume{% endblock %}
{% block site_header %}{% endblock %}
{% block main_class %}p-0{% endblock %}

{% block content %}
{% call auth_shell(
"Welcome back",
description="Sign in to continue to your workspace.",
eyebrow="Sign in",
alternate_label="New to Jinjalume?",
alternate_href=url_for("signup"),
alternate_text="Create an account"
) %}
{% if success_message %}{{ alert(success_message, variant="success", title="Demo complete") }}{% endif %}
{{ login_form(
action=url_for("login"),
email=email,
remember=remember,
email_error=email_error,
password_error=password_error,
form_error=form_error,
forgot_href="#forgot-password"
) }}
{% endcall %}
{% endblock %}
32 changes: 32 additions & 0 deletions demo/templates/signup.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% from "jinjalume/blocks/auth.html" import auth_shell, signup_form %}
{% from "jinjalume/components/alert.html" import alert %}

{% block title %}Create account · Jinjalume{% endblock %}
{% block site_header %}{% endblock %}
{% block main_class %}p-0{% endblock %}

{% block content %}
{% call auth_shell(
"Create your account",
description="Start building server-rendered experiences with Jinjalume.",
eyebrow="Get started",
alternate_label="Already have an account?",
alternate_href=url_for("login"),
alternate_text="Sign in"
) %}
{% if success_message %}{{ alert(success_message, variant="success", title="Demo complete") }}{% endif %}
{{ signup_form(
action=url_for("signup"),
name=name,
email=email,
name_error=name_error,
email_error=email_error,
password_error=password_error,
confirmation_error=confirmation_error,
terms_error=terms_error,
form_error=form_error,
terms_href="#terms"
) }}
{% endcall %}
{% endblock %}
Loading
Loading