From 1c19addb0e2b90ec83e35ffe00ca75a125c25515 Mon Sep 17 00:00:00 2001 From: Rechcel Toledo Date: Sat, 12 Sep 2026 19:39:52 +0800 Subject: [PATCH] feat: resolve open component issues --- .github/workflows/ci.yml | 29 ++ .gitignore | 2 + CONTRIBUTING.md | 4 + Makefile | 5 +- README.md | 15 +- demo/app.py | 39 ++- demo/templates/base.html | 46 ++- demo/templates/htmx.html | 37 ++ demo/templates/index.html | 61 +++- demo/templates/partials/htmx_result.html | 5 + docs/components.md | 253 ++++++++++++++ docs/theming.md | 47 +++ .../templates/jinjalume/components/alert.html | 8 +- .../jinjalume/components/avatar.html | 2 +- .../templates/jinjalume/components/badge.html | 8 +- .../jinjalume/components/button.html | 6 +- .../templates/jinjalume/components/card.html | 8 +- .../templates/jinjalume/components/input.html | 17 +- .../templates/jinjalume/components/modal.html | 8 +- .../jinjalume/components/select.html | 37 ++ .../jinjalume/components/spinner.html | 2 +- .../jinjalume/components/textarea.html | 17 +- notebooks/jinjalume_reverse_engineering.ipynb | 319 ++++++++++++++++++ package-lock.json | 46 +++ package.json | 4 +- playwright.config.js | 37 ++ static/src/input.css | 79 +++++ tests/test_demo.py | 44 +++ tests/test_extension.py | 65 +++- tests/visual/README.md | 25 ++ tests/visual/gallery.spec.js | 34 ++ .../gallery-dark-desktop.png | Bin 0 -> 141014 bytes .../gallery-dark-mobile.png | Bin 0 -> 141361 bytes .../gallery-desktop.png | Bin 0 -> 136212 bytes .../gallery-mobile.png | Bin 0 -> 135346 bytes 35 files changed, 1255 insertions(+), 54 deletions(-) create mode 100644 demo/templates/htmx.html create mode 100644 demo/templates/partials/htmx_result.html create mode 100644 docs/components.md create mode 100644 docs/theming.md create mode 100644 jinjalume/templates/jinjalume/components/select.html create mode 100644 notebooks/jinjalume_reverse_engineering.ipynb create mode 100644 playwright.config.js create mode 100644 tests/test_demo.py create mode 100644 tests/visual/README.md create mode 100644 tests/visual/gallery.spec.js create mode 100644 tests/visual/gallery.spec.js-snapshots/gallery-dark-desktop.png create mode 100644 tests/visual/gallery.spec.js-snapshots/gallery-dark-mobile.png create mode 100644 tests/visual/gallery.spec.js-snapshots/gallery-desktop.png create mode 100644 tests/visual/gallery.spec.js-snapshots/gallery-mobile.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0dcef4a..d3da1da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index d79dd11..c93cd1d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ build/ node_modules/ demo/static/dist/*.css !demo/static/dist/.gitkeep +playwright-report/ +test-results/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33dcc98..db2f636 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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/`. diff --git a/Makefile b/Makefile index 3fd6fc8..656f2d2 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install test lint css demo +.PHONY: install test lint css visual demo install: python -m pip install -e ".[dev]" @@ -13,5 +13,8 @@ lint: css: npm run build:css +visual: + npm run test:visual + demo: css flask --app demo.app run --debug diff --git a/README.md b/README.md index bed3eb5..835034e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 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 @@ -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 diff --git a/demo/app.py b/demo/app.py index c5363f0..0b296cf 100644 --- a/demo/app.py +++ b/demo/app.py @@ -1,4 +1,4 @@ -from flask import Flask, render_template +from flask import Flask, render_template, request from jinjalume import Jinjalume @@ -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, + ) diff --git a/demo/templates/base.html b/demo/templates/base.html index c636c62..0a6d077 100644 --- a/demo/templates/base.html +++ b/demo/templates/base.html @@ -1,14 +1,54 @@ - + {% block title %}Jinjalume{% endblock %} - -
+ +
+
+ Jinjalume + +
+
+
{% block content %}{% endblock %}
+ diff --git a/demo/templates/htmx.html b/demo/templates/htmx.html new file mode 100644 index 0000000..b44b98a --- /dev/null +++ b/demo/templates/htmx.html @@ -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 %} +
+

Optional integration

+

Progressive enhancement with HTMX

+

The form is a normal POST first. When HTMX is available, only the result region is replaced.

+
+ +
+ {% call card("Send a message", "Try it with JavaScript disabled to see the full-page fallback.") %} +
+ {{ input_field("message", label="Message", placeholder="Hello from the server…", help_text="This value is submitted to the Flask route.", required=True) }} +
+ {{ button("Submit", type="submit") }} + {{ spinner("Submitting") }} +
+
+
+ {% if submitted %} + {% include "partials/htmx_result.html" %} + {% else %} +

The server response will appear here.

+ {% endif %} +
+ {% endcall %} +
+ +

The demo loads HTMX from a CDN only on this page. The core Jinjalume package does not depend on HTMX.

+ +{% endblock %} diff --git a/demo/templates/index.html b/demo/templates/index.html index d02605e..88a3401 100644 --- a/demo/templates/index.html +++ b/demo/templates/index.html @@ -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 %} -
-
+
+
{{ badge("MVP", variant="success") }} - Jinja + Tailwind + Flask + Jinja + Tailwind + Flask
-

Jinjalume

-

Reusable, server-rendered UI components for Python web apps.

+

Jinjalume component gallery

+

Reusable, server-rendered UI components for Python web apps, with native browser behavior and an opt-in token-based dark theme.

- {{ 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") }}
- {% call card("Buttons", "Small, composable actions with sensible defaults.") %} -
+ {% call card("Buttons and badges", "Actions, links, and compact status labels.") %} +
{{ button("Primary") }} {{ button("Secondary", variant="secondary") }} {{ button("Delete", variant="danger") }} + {{ button("Read docs", href="https://github.com/phcodesage/jinjalume") }} +
+
+ {{ badge("Neutral") }} + {{ badge("Success", variant="success") }} + {{ badge("Warning", variant="warning") }} + {{ badge("Danger", variant="danger") }} +
+ {% endcall %} + + {% call card("Form fields", "Labels and descriptions stay connected to their controls.") %} +
+ {{ 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.") }} +
+ {% endcall %} + + {% call card("Feedback", "Alerts communicate status without a frontend framework.") %} +
+ {{ alert("Your changes were saved.", variant="success") }} + {{ alert("Check the highlighted fields.", variant="warning") }} + {{ alert("Something went wrong.", variant="danger") }} +
+ {% endcall %} + + {% call card("Identity and loading", "Accessible defaults for avatars and progress indicators.") %} +
+ {{ avatar("Jinjalume", size="lg") }} + {{ avatar("Taylor Example", size="md") }} + {{ spinner("Saving changes") }}
{% 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.") %} + + {% call modal("demo-modal", "Example dialog", description="Close it with the button or the Escape key.") %} +

This content is rendered on the server inside a native HTML dialog.

+ {% endcall %} {% endcall %}
{% endblock %} diff --git a/demo/templates/partials/htmx_result.html b/demo/templates/partials/htmx_result.html new file mode 100644 index 0000000..a12c037 --- /dev/null +++ b/demo/templates/partials/htmx_result.html @@ -0,0 +1,5 @@ +{% from "jinjalume/components/alert.html" import alert %} + +{% if result_message %} + {{ alert(result_message, variant=result_variant, title=result_title) }} +{% endif %} diff --git a/docs/components.md b/docs/components.md new file mode 100644 index 0000000..a3813bf --- /dev/null +++ b/docs/components.md @@ -0,0 +1,253 @@ +# Jinjalume component reference + +The demo gallery at `/` renders every component in this reference. Start it with: + +```bash +make css +flask --app demo.app run --debug +``` + +Every example assumes `Jinjalume(app)` has been initialized and imports the macro from its package template. + +## Button + +```jinja +{% from "jinjalume/components/button.html" import button %} + +{{ button("Save changes", variant="primary", type="submit") }} +{{ button("Cancel", variant="secondary", href="/cancel") }} +{{ button("Delete", variant="danger", size="sm") }} +``` + +Signature: `button(label, variant="primary", size="md", type="button", href=None, class_name="", disabled=False)` + +`variant` supports `primary`, `secondary`, and `danger`. `size` supports `sm`, `md`, and `lg`. With `href`, the macro emits an anchor; otherwise it emits a button. `disabled` is native for buttons and expressed with `aria-disabled` plus `tabindex=-1` for links. + +Rendered shape: + +```html + +``` + +## Badge + +```jinja +{% from "jinjalume/components/badge.html" import badge %} + +{{ badge("Draft") }} +{{ badge("Published", variant="success") }} +{{ badge("Needs review", variant="warning") }} +{{ badge("Failed", variant="danger") }} +``` + +Signature: `badge(label, variant="neutral", class_name="")` + +`variant` supports `neutral`, `success`, `warning`, and `danger`. + +Rendered shape: + +```html +Published +``` + +## Alert + +```jinja +{% from "jinjalume/components/alert.html" import alert %} + +{{ alert("Your profile was saved.", variant="success", title="Success") }} +{{ alert("Check the highlighted fields.", variant="warning") }} +``` + +Signature: `alert(message, variant="info", title=None, class_name="")` + +`variant` supports `info`, `success`, `warning`, and `danger`. Alerts use `role="alert"` and accept plain text or already-rendered Jinja content according to the consuming application's autoescape policy. + +Rendered shape: + +```html + +``` + +## Card + +Cards use a caller block for body content: + +```jinja +{% from "jinjalume/components/card.html" import card %} + +{% call card("Account", "Update your contact details.") %} +

Your account is active.

+{% endcall %} +``` + +Signature: `card(title=None, description=None, class_name="")` + +`title` and `description` are optional. The caller block is required and is rendered inside the card body. + +Rendered shape: + +```html +
+

Account

+

Update your contact details.

+
...
+
+``` + +## Input field + +```jinja +{% from "jinjalume/components/input.html" import input_field %} + +{{ input_field( + "email", + label="Email address", + type="email", + value="person@example.com", + placeholder="you@example.com", + help_text="We will never share your email.", + required=True +) }} +``` + +Signature: `input_field(name, label=None, value="", type="text", placeholder="", help_text=None, error=None, required=False, class_name="")` + +`type` is passed to the native input. When `error` is present, the control gets `aria-invalid="true"` and a linked error message. Help and error messages get deterministic IDs and are combined in `aria-describedby`. + +Rendered shape: + +```html + + +

We will never share your email.

+``` + +## Select field + +```jinja +{% from "jinjalume/components/select.html" import select_field %} + +{{ select_field( + "status", + label="Status", + options=[ + {"value": "draft", "label": "Draft"}, + {"value": "published", "label": "Published"}, + {"value": "archived", "label": "Archived", "disabled": True} + ], + value="published", + help_text="Choose the publication state.", + required=True +) }} +``` + +Signature: `select_field(name, label=None, options=[], value="", help_text=None, error=None, required=False, class_name="")` + +Each option may be a mapping with `value`, `label`, and optional `disabled` keys, or a two-item `(value, label)` pair. The option whose value matches `value` is selected. The macro emits a native ` + + +``` + +## Textarea field + +```jinja +{% from "jinjalume/components/textarea.html" import textarea_field %} + +{{ textarea_field( + "message", + label="Message", + value="Existing text", + rows=5, + placeholder="Write a message…", + error="A message is required." +) }} +``` + +Signature: `textarea_field(name, label=None, value="", rows=4, placeholder="", help_text=None, error=None, required=False, class_name="")` + +`rows` controls the native textarea height. Error and help messages use the same accessible relationship contract as `input_field` and `select_field`. + +Rendered shape: + +```html + + +

A message is required.

+``` + +## Avatar + +```jinja +{% from "jinjalume/components/avatar.html" import avatar %} + +{{ avatar("Jinjalume") }} +{{ avatar("Taylor Example", src="/static/taylor.jpg", size="lg") }} +``` + +Signature: `avatar(name, src=None, size="md", class_name="")` + +`size` supports `sm`, `md`, and `lg`. Without `src`, the macro renders the first character of `name` with an accessible label. With `src`, it renders an image whose `alt` text is `name`. + +Rendered shape without an image: + +```html +J +``` + +## Spinner + +```jinja +{% from "jinjalume/components/spinner.html" import spinner %} + +{{ spinner("Saving changes") }} +{{ spinner("Loading results", size="sm") }} +``` + +Signature: `spinner(label="Loading", size="md", class_name="")` + +`size` supports `sm`, `md`, and `lg`. The wrapper has `role="status"` and an accessible label; the visible SVG is hidden from assistive technology. + +Rendered shape: + +```html +... +``` + +## Modal + +Modals use a caller block and the browser's native `` element: + +```jinja +{% from "jinjalume/components/modal.html" import modal %} + + + +{% call modal("help", "Help", description="About this form") %} +

Use the close button or press Escape.

+{% endcall %} +``` + +Signature: `modal(id, title, description=None, class_name="")` + +`id` must be unique in the document. The title is linked through `aria-labelledby`; when `description` is present it is also linked through `aria-describedby`. The close control submits a `method="dialog"` form, and no JavaScript framework is required. + +Rendered shape: + +```html +... +``` + +## Custom classes and CSS + +All macros accept `class_name` for local composition. Jinjalume does not ship a compiled stylesheet: the consuming application owns its Tailwind build and must scan the Jinjalume template directory. The demo uses `static/src/input.css` and includes the token definitions described in [theming.md](theming.md). diff --git a/docs/theming.md b/docs/theming.md new file mode 100644 index 0000000..cac5478 --- /dev/null +++ b/docs/theming.md @@ -0,0 +1,47 @@ +# Jinjalume theming proposal + +## Decision + +Jinjalume uses semantic CSS custom properties for component colors and opts into dark mode with a root `data-theme` attribute: + +```html + +``` + +Set `data-theme="dark"` on the document root to opt in. Light mode remains the default. This avoids forcing an application to follow the user's system preference and works with server-rendered HTML before any JavaScript runs. + +The reference token definitions live in `static/src/input.css`: + +```css +:root { + --jl-surface: #ffffff; + --jl-text: #0f172a; + --jl-border: #e2e8f0; +} + +[data-theme="dark"] { + --jl-surface: #1e293b; + --jl-text: #f8fafc; + --jl-border: #475569; +} +``` + +Components consume these semantic tokens through Tailwind arbitrary-value utilities such as `bg-[var(--jl-surface)]` and `text-[var(--jl-text)]`. The token names are stable; their values are application-owned. + +## Why this shape + +- It preserves the current light appearance while allowing a complete theme swap at one root element. +- It keeps component templates independent of a JavaScript framework. +- It lets applications choose server-side, user-preference, or client-side theme selection without changing macros. +- It avoids publishing a second CSS framework or requiring consumers to adopt a global class naming convention. +- Semantic tokens make future brand themes, high-contrast themes, and RTL examples additive rather than a rewrite of every component. + +## Application integration + +The Python package intentionally does not ship compiled CSS. An application using Tailwind should scan its installed Jinjalume templates and copy the token layer into its CSS entry point. The demo already does this through `static/src/input.css`. + +The demo's theme toggle is a few lines of vanilla JavaScript and stores the choice in `localStorage`. Applications may replace it with a server-side preference, a cookie, or their own control. No toggle is required for the theme contract: setting `data-theme="dark"` is enough. + +## Initial token groups + +The first implementation covers page/surface/text/border, primary and danger actions, focus color, info/success/warning/danger feedback, neutral badges, and the avatar/spinner accent. All four required component groups—buttons, alerts, cards, and form fields—consume these tokens. diff --git a/jinjalume/templates/jinjalume/components/alert.html b/jinjalume/templates/jinjalume/components/alert.html index a17b3ff..52ac377 100644 --- a/jinjalume/templates/jinjalume/components/alert.html +++ b/jinjalume/templates/jinjalume/components/alert.html @@ -1,9 +1,9 @@ {% macro alert(message, variant="info", title=None, class_name="") -%} {% set variants = { - "info": "border-sky-200 bg-sky-50 text-sky-900", - "success": "border-emerald-200 bg-emerald-50 text-emerald-900", - "warning": "border-amber-200 bg-amber-50 text-amber-900", - "danger": "border-red-200 bg-red-50 text-red-900" + "info": "border-[var(--jl-info-border)] bg-[var(--jl-info-surface)] text-[var(--jl-info-text)]", + "success": "border-[var(--jl-success-border)] bg-[var(--jl-success-surface)] text-[var(--jl-success-text)]", + "warning": "border-[var(--jl-warning-border)] bg-[var(--jl-warning-surface)] text-[var(--jl-warning-text)]", + "danger": "border-[var(--jl-danger-border)] bg-[var(--jl-danger-surface)] text-[var(--jl-danger-text)]" } %}