From 728157ce86187198bd8ddd7626a14300eb06909f Mon Sep 17 00:00:00 2001 From: ehsan amiri Date: Fri, 4 Sep 2026 01:33:49 +0330 Subject: [PATCH] feat(ui): add default ui component --- .agents/skills/fusion-cli/SKILL.md | 1 + .gitignore | 5 + .../assets/templates/fusion/base.html | 26 +-- .../assets/templates/fusion/components.css | 214 ++++++++++++++++++ .../assets/templates/fusion/macros.html | 47 +++- crates/fusion-core/src/templates.rs | 85 ++++++- .../python/fusion_framework/template.py | 7 +- examples/template_demo.py | 2 + examples/templates/home/index.html | 57 ++++- examples/ui_components.cs | 18 ++ examples/ui_components.mjs | 24 ++ examples/ui_components.py | 28 +++ examples/ui_components_assets/page.html | 16 ++ tests/python/unit/test_templates.py | 19 ++ 14 files changed, 512 insertions(+), 37 deletions(-) create mode 100644 crates/fusion-core/assets/templates/fusion/components.css create mode 100644 examples/ui_components.cs create mode 100644 examples/ui_components.mjs create mode 100644 examples/ui_components.py create mode 100644 examples/ui_components_assets/page.html diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md index 7575ea9..ed40d36 100644 --- a/.agents/skills/fusion-cli/SKILL.md +++ b/.agents/skills/fusion-cli/SKILL.md @@ -118,6 +118,7 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`. ### What the starter demonstrates - `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger). +- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (styles: `{% include "fusion/components.css" %}`). - `FusionBaseApi` at `api/[module]` with `version="v1"` → `/v1/api/product/…`. - Convention verbs (`get` / `post` / …) plus one custom slot (`http_get` / `httpGet` / `[HttpGet]` with `[action]`). - Opt-in middleware list in `main` (e.g. `request_id`, `cors`, `cache_headers`, `security_headers`, `framework_headers`). Framework does **not** auto-enable middleware; the scaffold opts in. diff --git a/.gitignore b/.gitignore index 1560b32..274df90 100644 --- a/.gitignore +++ b/.gitignore @@ -79,3 +79,8 @@ Desktop.ini ## Local secrets / overrides (keep examples/*.json samples in repo) fusion.local.json *.local.json + + +.tmp +.nuget +.sentry-native \ No newline at end of file diff --git a/crates/fusion-core/assets/templates/fusion/base.html b/crates/fusion-core/assets/templates/fusion/base.html index fb4c457..df4f6ab 100644 --- a/crates/fusion-core/assets/templates/fusion/base.html +++ b/crates/fusion-core/assets/templates/fusion/base.html @@ -5,24 +5,14 @@ {% block title %}{{ title | default(value="Fusion") }}{% endblock %} {% block head %}{% endblock %} diff --git a/crates/fusion-core/assets/templates/fusion/components.css b/crates/fusion-core/assets/templates/fusion/components.css new file mode 100644 index 0000000..9c7a9c2 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components.css @@ -0,0 +1,214 @@ +/* Fusion built-in UI component styles (welcome-page look). */ + +:root { + --fusion-primary: #0f172a; + --fusion-accent: #6366f1; + --fusion-muted: #64748b; + --fusion-border: #e2e8f0; + --fusion-surface: #ffffff; + --fusion-bg: #f8fafc; +} + +.fusion-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 17px; + border-radius: 8px; + border: none; + cursor: pointer; + text-decoration: none; + font-size: 14px; + font-weight: 600; + line-height: 1.2; + transition: 0.2s ease; + box-sizing: border-box; +} + +.fusion-btn--primary { + background: var(--fusion-primary); + color: #fff; +} + +.fusion-btn--primary:hover { + background: #1e293b; + transform: translateY(-1px); +} + +.fusion-btn--secondary { + background: var(--fusion-surface); + color: #334155; + border: 1px solid var(--fusion-border); +} + +.fusion-btn--secondary:hover { + background: var(--fusion-bg); + transform: translateY(-1px); +} + +.fusion-btn--danger { + background: #dc2626; + color: #fff; +} + +.fusion-btn--danger:hover { + background: #b91c1c; + transform: translateY(-1px); +} + +.fusion-link { + color: var(--fusion-accent); + text-decoration: none; + font-weight: 600; +} + +.fusion-link:hover { + text-decoration: underline; +} + +.fusion-card { + margin-top: 42px; + padding: 24px; + text-align: left; + background: var(--fusion-surface); + border: 1px solid var(--fusion-border); + border-radius: 14px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); +} + +.fusion-card__title { + font-size: 14px; + font-weight: 700; + color: #334155; + margin: 0 0 14px; +} + +.fusion-card__body { + color: #334155; +} + +.fusion-alert { + padding: 0.75rem 1rem; + border-radius: 0.5rem; + margin: 1rem 0; + font-size: 14px; + font-weight: 600; +} + +.fusion-alert--info { + background: #dbeafe; + color: #1e40af; +} + +.fusion-alert--success { + background: #dcfce7; + color: #166534; +} + +.fusion-alert--warning { + background: #fef9c3; + color: #854d0e; +} + +.fusion-alert--error { + background: #fee2e2; + color: #991b1b; +} + +.fusion-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 13px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + line-height: 1; +} + +.fusion-badge--default { + background: #e2e8f0; + color: #334155; +} + +.fusion-badge--primary { + background: #e0e7ff; + color: #3730a3; +} + +.fusion-badge--success { + background: #ecfdf5; + color: #047857; +} + +.fusion-badge--warning { + background: #fffbeb; + color: #b45309; +} + +.fusion-badge--danger { + background: #fef2f2; + color: #b91c1c; +} + +.fusion-badge__dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +.fusion-badge--success .fusion-badge__dot { + background: #10b981; +} + +.fusion-table-wrap { + width: 100%; + overflow-x: auto; + margin: 1.5rem 0; + border: 1px solid var(--fusion-border); + border-radius: 14px; + background: var(--fusion-surface); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); +} + +.fusion-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; + text-align: left; +} + +.fusion-table caption { + caption-side: top; + padding: 14px 16px 0; + font-size: 14px; + font-weight: 700; + color: #334155; + text-align: left; +} + +.fusion-table th, +.fusion-table td { + padding: 12px 16px; + border-bottom: 1px solid var(--fusion-border); + color: #334155; +} + +.fusion-table th { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--fusion-muted); + background: #f8fafc; +} + +.fusion-table tbody tr:last-child td { + border-bottom: none; +} + +.fusion-table tbody tr:hover td { + background: #f8fafc; +} diff --git a/crates/fusion-core/assets/templates/fusion/macros.html b/crates/fusion-core/assets/templates/fusion/macros.html index 050caac..3ce3cb3 100644 --- a/crates/fusion-core/assets/templates/fusion/macros.html +++ b/crates/fusion-core/assets/templates/fusion/macros.html @@ -1,7 +1,14 @@ -{# Fusion built-in UI components (Tera 2). Use: {{}} #} +{# Fusion built-in UI components (Tera 2). + Use: {{}} + Docs: include styles via {% include "fusion/components.css" %} or extend fusion/base.html +#} {% component fusion.button(label: string, href: string = "", variant: string = "primary", type: string = "button") %} - +{%- if href -%} +{{ label }} +{%- else -%} + +{%- endif -%} {% endcomponent fusion.button %} {% component fusion.link(label: string, href: string = "#", variant: string = "default") %} @@ -10,8 +17,8 @@ {% component fusion.card(title: string = "", content: string = "") %}
- {% if title %}

{{ title }}

{% endif %} -
{{ content | safe }}
+ {% if title %}
{{ title }}
{% endif %} +
{% if content %}{{ content | safe }}{% else %}{{ body | safe }}{% endif %}
{% endcomponent fusion.card %} @@ -19,6 +26,34 @@ {% endcomponent fusion.alert %} -{% component fusion.badge(label: string, variant: string = "default") %} -{{ label }} +{# Status/pill badge. Set dot={true} for the success-style indicator used on the welcome page. #} +{% component fusion.badge(label: string, variant: string = "default", dot: bool = false) %} + + {%- if dot -%}{%- endif -%} + {{ label }} + {% endcomponent fusion.badge %} + +{# Data table. Pass headers/rows as arrays, and/or nest custom markup as the body. #} +{% component fusion.table(headers: array = [], rows: array = [], caption: string = "") %} +
+ + {% if caption %}{% endif %} + {% if headers %} + + + {% for h in headers %}{% endfor %} + + + {% endif %} + + {% for row in rows %} + + {% for cell in row %}{% endfor %} + + {% endfor %} + {{ body | safe }} + +
{{ caption }}
{{ h }}
{{ cell }}
+
+{% endcomponent fusion.table %} diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs index 2485223..7229ded 100644 --- a/crates/fusion-core/src/templates.rs +++ b/crates/fusion-core/src/templates.rs @@ -9,6 +9,8 @@ use tera::{Context, Tera}; const BUILTIN_MACROS: &str = include_str!("../assets/templates/fusion/macros.html"); const BUILTIN_BASE: &str = include_str!("../assets/templates/fusion/base.html"); +const BUILTIN_COMPONENTS_CSS: &str = + include_str!("../assets/templates/fusion/components.css"); static ENGINE_CACHE: Mutex> = Mutex::new(None); @@ -58,6 +60,10 @@ fn build_engine(root: &Path) -> Result { let mut raw: Vec<(String, String)> = vec![ ("fusion/macros.html".to_string(), BUILTIN_MACROS.to_string()), ("fusion/base.html".to_string(), BUILTIN_BASE.to_string()), + ( + "fusion/components.css".to_string(), + BUILTIN_COMPONENTS_CSS.to_string(), + ), ]; if root.is_dir() { @@ -121,7 +127,11 @@ pub fn builtin_components() -> HashMap<&'static str, &'static str> { ), ( "badge", - "{{}}", + "{{}}", + ), + ( + "table", + "{{}}", ), ]) } @@ -141,10 +151,51 @@ mod tests { std::fs::write(dir.join("test.html"), tpl).unwrap(); let html = render_template("test.html", &json!({}), &dir).unwrap(); assert!(html.contains("fusion-btn")); + assert!(html.contains("href=\"/\"")); assert!(html.contains("Go")); let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn renders_badge_with_dot() { + clear_template_cache(); + let tpl = + r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_badge_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains("fusion-badge--success")); + assert!(html.contains("fusion-badge__dot")); + assert!(html.contains("Installation successful")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_table_from_arrays() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_table_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template( + "test.html", + &json!({ + "headers": ["Name", "Status"], + "rows": [["Widget", "ok"], ["Gadget", "draft"]], + }), + &dir, + ) + .unwrap(); + assert!(html.contains("fusion-table")); + assert!(html.contains("Name")); + assert!(html.contains("Widget")); + assert!(html.contains("Products")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn includes_css_partial() { clear_template_cache(); @@ -161,4 +212,36 @@ mod tests { assert!(html.contains("color: red")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn renders_card_with_body_slot() { + clear_template_cache(); + let tpl = r#"{% %}
hello
{%
%}"#; + let dir = std::env::temp_dir().join("fusion_tpl_card_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("test.html"), tpl).unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains("fusion-card")); + assert!(html.contains("Get started")); + assert!(html.contains("hello")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn includes_builtin_components_css() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_components_css_test"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("test.html"), + r#""#, + ) + .unwrap(); + let html = render_template("test.html", &json!({}), &dir).unwrap(); + assert!(html.contains(".fusion-btn")); + assert!(html.contains(".fusion-table")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/fusion-py/python/fusion_framework/template.py b/crates/fusion-py/python/fusion_framework/template.py index 8ae5b89..cd07cad 100644 --- a/crates/fusion-py/python/fusion_framework/template.py +++ b/crates/fusion-py/python/fusion_framework/template.py @@ -34,7 +34,12 @@ class FusionBaseTemplate(FusionBaseApi): Built-in UI components are defined in ``fusion/macros.html`` (Tera 2 components):: - {{}} + {{}} + {{}} + {{}} + + Include styles with ``{% include "fusion/components.css" %}`` or extend + ``fusion/base.html``. """ __fusion_template__ = True diff --git a/examples/template_demo.py b/examples/template_demo.py index cf51287..2a4913f 100644 --- a/examples/template_demo.py +++ b/examples/template_demo.py @@ -43,6 +43,8 @@ def context(self): return { "title": "Fusion Templates", "message": "Hello from Tera!", + "headers": ["Name", "Status"], + "rows": [["Widget", "ok"], ["Gadget", "draft"]], } diff --git a/examples/templates/home/index.html b/examples/templates/home/index.html index e68b3f4..600e7ae 100644 --- a/examples/templates/home/index.html +++ b/examples/templates/home/index.html @@ -1,11 +1,46 @@ -{% extends "fusion/base.html" %} - -{% block title %}{{ title }}{% endblock %} - -{% block content %} - {{}} -

{{ title }}

-

{{ message }}

- {{}} - {{}} -{% endblock %} + + + + + + {{ title }} + + + +
+ {{}} +

{{ title }}

+

{{ message }}

+ + {{}} +
+ + diff --git a/examples/ui_components.cs b/examples/ui_components.cs new file mode 100644 index 0000000..aad5879 --- /dev/null +++ b/examples/ui_components.cs @@ -0,0 +1,18 @@ +// Render Fusion Tera UI components (button, badge, table). +// Run from repo root after building the C# binding. + +using System.Text.Json.Nodes; +using FusionFramework; + +var root = Path.Combine(Directory.GetCurrentDirectory(), "examples", "ui_components_assets"); + +var ctx = new Dictionary +{ + ["headers"] = new JsonArray("Route", "Method"), + ["rows"] = new JsonArray( + new JsonArray("/v1/api/product", "GET"), + new JsonArray("/swagger", "GET")), +}; + +var html = Templates.Render("page.html", ctx, root); +Console.WriteLine(html); diff --git a/examples/ui_components.mjs b/examples/ui_components.mjs new file mode 100644 index 0000000..0484b36 --- /dev/null +++ b/examples/ui_components.mjs @@ -0,0 +1,24 @@ +/** + * Render Fusion Tera UI components (button, badge, table). + * + * node examples/ui_components.mjs + */ +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { renderTemplate } from 'fusion-framework' + +const root = join(dirname(fileURLToPath(import.meta.url)), 'ui_components_assets') + +const html = renderTemplate( + 'page.html', + { + headers: ['Route', 'Method'], + rows: [ + ['/v1/api/product', 'GET'], + ['/swagger', 'GET'], + ], + }, + root, +) + +console.log(html) diff --git a/examples/ui_components.py b/examples/ui_components.py new file mode 100644 index 0000000..577b7ce --- /dev/null +++ b/examples/ui_components.py @@ -0,0 +1,28 @@ +"""Render Fusion Tera UI components (button, badge, table). + + python examples/ui_components.py +""" + +from __future__ import annotations + +from pathlib import Path + +from fusion_framework.template import render_template + +ROOT = Path(__file__).resolve().parent / "ui_components_assets" + + +def main() -> None: + html = render_template( + "page.html", + { + "headers": ["Route", "Method"], + "rows": [["/v1/api/product", "GET"], ["/swagger", "GET"]], + }, + templates_root=ROOT, + ) + print(html) + + +if __name__ == "__main__": + main() diff --git a/examples/ui_components_assets/page.html b/examples/ui_components_assets/page.html new file mode 100644 index 0000000..90042d4 --- /dev/null +++ b/examples/ui_components_assets/page.html @@ -0,0 +1,16 @@ + + + + + UI components + + + + {{}} +

+ {{}} + {{}} +

+ {{}} + + diff --git a/tests/python/unit/test_templates.py b/tests/python/unit/test_templates.py index 418066e..f97cce2 100644 --- a/tests/python/unit/test_templates.py +++ b/tests/python/unit/test_templates.py @@ -18,6 +18,25 @@ def test_render_builtin_button_macro(): assert "Go" in html +def test_render_badge_and_table(tmp_path: Path): + """Badge + table components render with the welcome-page styles.""" + (tmp_path / "page.html").write_text( + """ + {{}} + {{}} + """, + encoding="utf-8", + ) + html = render_template( + "page.html", + {"headers": ["A"], "rows": [["1"]]}, + templates_root=tmp_path, + ) + assert "fusion-badge__dot" in html + assert "fusion-table" in html + assert "1" in html + + def test_fusion_base_template_context(tmp_path: Path): class Page(FusionBaseTemplate): template = "hello.html"