From 40e55c82a4065c5752aeb348f40950d625d31af2 Mon Sep 17 00:00:00 2001 From: taha Date: Wed, 9 Sep 2026 23:56:00 +0330 Subject: [PATCH] feat(ui): add runtime home console and component gallery mounts Turn the landing page into a cache/logs console, move the UI gallery to /__fusion/component with button/table/toast/modal, refresh the monitor panel and theme toggle, and wire gallery mounting across Python, Node, and C#. Co-authored-by: Cursor --- .agents/skills/fusion-cli/SKILL.md | 4 +- bindings/csharp/FusionFramework/FusionApp.cs | 1 + bindings/csharp/FusionFramework/FusionUi.cs | 129 +++ .../assets/templates/fusion/base.html | 91 +- .../assets/templates/fusion/components.html | 979 ++++++++++++++++++ .../fusion/components/button/button.css | 105 ++ .../fusion/components/button/button.html | 27 + .../fusion/{ => components}/components.css | 129 ++- .../fusion/components/modal/modal.css | 226 ++++ .../fusion/components/modal/modal.html | 64 ++ .../fusion/components/modal/modal.js | 298 ++++++ .../fusion/components/table/table.css | 210 ++++ .../fusion/components/table/table.html | 185 ++++ .../fusion/components/table/table.js | 120 +++ .../fusion/components/toast/toast.css | 206 ++++ .../fusion/components/toast/toast.html | 32 + .../fusion/components/toast/toast.js | 184 ++++ .../assets/templates/fusion/index.html | 838 +++++++-------- .../assets/templates/fusion/macros.html | 85 +- .../assets/templates/fusion/monitor.html | 154 ++- crates/fusion-core/src/templates.rs | 159 ++- crates/fusion-node/index.js | 86 ++ .../fusion-py/python/fusion_framework/app.py | 3 + .../fusion-py/python/fusion_framework/ui.py | 119 +++ examples/preview_templates.py | 67 +- tests/python/unit/test_cache.py | 12 + 26 files changed, 3889 insertions(+), 624 deletions(-) create mode 100644 bindings/csharp/FusionFramework/FusionUi.cs create mode 100644 crates/fusion-core/assets/templates/fusion/components.html create mode 100644 crates/fusion-core/assets/templates/fusion/components/button/button.css create mode 100644 crates/fusion-core/assets/templates/fusion/components/button/button.html rename crates/fusion-core/assets/templates/fusion/{ => components}/components.css (64%) create mode 100644 crates/fusion-core/assets/templates/fusion/components/modal/modal.css create mode 100644 crates/fusion-core/assets/templates/fusion/components/modal/modal.html create mode 100644 crates/fusion-core/assets/templates/fusion/components/modal/modal.js create mode 100644 crates/fusion-core/assets/templates/fusion/components/table/table.css create mode 100644 crates/fusion-core/assets/templates/fusion/components/table/table.html create mode 100644 crates/fusion-core/assets/templates/fusion/components/table/table.js create mode 100644 crates/fusion-core/assets/templates/fusion/components/toast/toast.css create mode 100644 crates/fusion-core/assets/templates/fusion/components/toast/toast.html create mode 100644 crates/fusion-core/assets/templates/fusion/components/toast/toast.js create mode 100644 crates/fusion-py/python/fusion_framework/ui.py diff --git a/.agents/skills/fusion-cli/SKILL.md b/.agents/skills/fusion-cli/SKILL.md index 97cba71..691bfe3 100644 --- a/.agents/skills/fusion-cli/SKILL.md +++ b/.agents/skills/fusion-cli/SKILL.md @@ -119,7 +119,9 @@ C# (`asp-core`): `main.cs`, `*.csproj` (`net10.0`), `[Route]` / `[HttpGet]`. - `FusionBaseTemplate` at `/` (Tera templates; **not** listed in Swagger). - HTML forms: `form` / `ok` / `fail` + optional `data-fusion-form` (see skill `fusion-template-forms`). -- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (optional `page_size={10}` for client-side row pagination; styles: `{% include "fusion/components.css" %}`). +- Welcome UI via built-in components: `fusion.badge`, `fusion.button`, `fusion.card`, `fusion.table` (optional `page_size={10}` for client-side row pagination; styles: `{% include "fusion/components/components.css" %}` or legacy `fusion/components.css`). +- Component gallery (when assets on disk): `/__fusion/component` +- Monitor (when `monitor.enabled`): `/__fusion/monitor` - `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/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index b47a63b..4383f72 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -116,6 +116,7 @@ public void Mount() Middleware.MountStaticFiles(this, _middleware); SwaggerDocs.Mount(this, SettingsStore.Current); FusionMonitor.Mount(this, SettingsStore.Current); + FusionUi.Mount(this, SettingsStore.Current); } internal void AddRawRoute(string method, string path, Func handler) diff --git a/bindings/csharp/FusionFramework/FusionUi.cs b/bindings/csharp/FusionFramework/FusionUi.cs new file mode 100644 index 0000000..cb4c3d5 --- /dev/null +++ b/bindings/csharp/FusionFramework/FusionUi.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Nodes; + +namespace FusionFramework; + +/// +/// Built-in UI component gallery at /__fusion/component (when assets exist on disk). +/// +public static class FusionUi +{ + public const string DefaultComponentPath = "/__fusion/component"; + + /// Register gallery HTML + static assets when the fusion templates folder is found. + public static bool Mount(FusionApp app, FusionSettings settings) + { + var root = ResolveAssetsRoot(); + if (root is null) + return false; + + var gallery = Path.Combine(root, "components.html"); + if (!File.Exists(gallery)) + return false; + + var path = DefaultComponentPath; + var raw = AsString(settings.Get("ui.component_path", null)); + if (!string.IsNullOrWhiteSpace(raw)) + path = NormalizePath(raw); + + app.AddRawRoute("GET", path, () => FileResponse(gallery, "text/html; charset=utf-8")); + if (path != "/") + app.AddRawRoute("GET", $"{path}/", () => FileResponse(gallery, "text/html; charset=utf-8")); + + var skip = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "components.html", + "index.html", + "monitor.html", + "cache_monitor.html", + }; + + foreach (var file in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(root, file).Replace('\\', '/'); + if (skip.Contains(rel)) + continue; + var url = $"{path}/{rel}"; + var contentType = GuessContentType(file); + var captured = file; + app.AddRawRoute("GET", url, () => FileResponse(captured, contentType)); + } + + return true; + } + + /// Find crates/fusion-core/assets/templates/fusion relative to the process. + static string? ResolveAssetsRoot() + { + var candidates = new List(); + var cwd = Directory.GetCurrentDirectory(); + candidates.Add(Path.Combine(cwd, "crates", "fusion-core", "assets", "templates", "fusion")); + candidates.Add(Path.Combine(cwd, "..", "crates", "fusion-core", "assets", "templates", "fusion")); + + var asm = typeof(FusionUi).Assembly.Location; + if (!string.IsNullOrEmpty(asm)) + { + var dir = Path.GetDirectoryName(asm); + if (!string.IsNullOrEmpty(dir)) + { + candidates.Add(Path.GetFullPath(Path.Combine(dir, "..", "..", "..", "..", "..", "crates", "fusion-core", "assets", "templates", "fusion"))); + } + } + + foreach (var candidate in candidates) + { + try + { + var full = Path.GetFullPath(candidate); + if (File.Exists(Path.Combine(full, "components.html"))) + return full; + } + catch + { + // ignore invalid paths + } + } + return null; + } + + static object FileResponse(string filePath, string contentType) + { + var bytes = File.ReadAllBytes(filePath); + return new Dictionary + { + ["status"] = 200, + ["headers"] = new Dictionary { ["content-type"] = contentType }, + ["body"] = bytes, + }; + } + + static string GuessContentType(string filePath) + { + var ext = Path.GetExtension(filePath).ToLowerInvariant(); + return ext switch + { + ".html" => "text/html; charset=utf-8", + ".css" => "text/css; charset=utf-8", + ".js" => "application/javascript; charset=utf-8", + ".svg" => "image/svg+xml", + ".png" => "image/png", + _ => "application/octet-stream", + }; + } + + static string NormalizePath(string raw) + { + var path = string.IsNullOrWhiteSpace(raw) ? DefaultComponentPath : raw.Trim(); + if (!path.StartsWith('/')) path = "/" + path; + path = path.TrimEnd('/'); + return string.IsNullOrEmpty(path) ? DefaultComponentPath : path; + } + + static string? AsString(object? value) => + value switch + { + null => null, + JsonNode n when n.GetValueKind() == System.Text.Json.JsonValueKind.String => n.GetValue(), + JsonNode n => n.ToJsonString().Trim('"'), + _ => value.ToString(), + }; +} diff --git a/crates/fusion-core/assets/templates/fusion/base.html b/crates/fusion-core/assets/templates/fusion/base.html index df4f6ab..999024d 100644 --- a/crates/fusion-core/assets/templates/fusion/base.html +++ b/crates/fusion-core/assets/templates/fusion/base.html @@ -4,19 +4,100 @@ {% block title %}{{ title | default(value="Fusion") }}{% endblock %} + {% block head %}{% endblock %} {% block content %}{% endblock %} + + + {% block scripts %}{% endblock %} diff --git a/crates/fusion-core/assets/templates/fusion/components.html b/crates/fusion-core/assets/templates/fusion/components.html new file mode 100644 index 0000000..3919b9b --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components.html @@ -0,0 +1,979 @@ + + + + + + + Fusion UI — Components + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+
+
+
+ + + + + + + + diff --git a/crates/fusion-core/assets/templates/fusion/components/button/button.css b/crates/fusion-core/assets/templates/fusion/components/button/button.css new file mode 100644 index 0000000..2b476e2 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/button/button.css @@ -0,0 +1,105 @@ +/* Button — shadcn-aligned modes: primary, secondary, danger, link. */ + +.fusion-btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--spacing-2); + height: var(--control-height); + padding: 0 var(--spacing-4); + margin: 0; + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + line-height: var(--leading-tight); + text-decoration: none; + white-space: nowrap; + border-radius: var(--radius-sm); + border: var(--border-width) solid transparent; + box-shadow: var(--shadow-xs); + cursor: pointer; + outline: none; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast), + box-shadow var(--transition-fast), + opacity var(--transition-fast); +} + +.fusion-btn:focus-visible { + box-shadow: + var(--shadow-xs), + 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} + +.fusion-btn:disabled, +.fusion-btn[aria-disabled="true"] { + opacity: var(--opacity-disabled); + cursor: not-allowed; + pointer-events: none; +} + +.fusion-btn--primary { + color: var(--primary-foreground); + background-color: var(--primary); + border-color: var(--primary); +} + +.fusion-btn--primary:hover:not(:disabled):not([aria-disabled="true"]) { + background-color: color-mix(in oklch, var(--primary) 88%, var(--foreground)); + border-color: color-mix(in oklch, var(--primary) 88%, var(--foreground)); +} + +.fusion-btn--secondary { + color: var(--secondary-foreground); + background-color: var(--secondary); + border-color: var(--border); +} + +.fusion-btn--secondary:hover:not(:disabled):not([aria-disabled="true"]) { + background-color: var(--accent); + color: var(--accent-foreground); + border-color: var(--ring); +} + +.fusion-btn--danger { + color: var(--destructive-foreground); + background-color: var(--destructive); + border-color: var(--destructive); +} + +.fusion-btn--danger:hover:not(:disabled):not([aria-disabled="true"]) { + background-color: color-mix(in oklch, var(--destructive) 88%, black); + border-color: color-mix(in oklch, var(--destructive) 88%, black); +} + +.fusion-btn--danger:focus-visible { + box-shadow: + var(--shadow-xs), + 0 0 0 var(--ring-width) color-mix(in oklch, var(--destructive) 25%, transparent); +} + +/* Link mode — text action, no filled chrome. */ +.fusion-btn--link { + height: auto; + min-height: var(--control-height); + padding: 0 var(--spacing-1); + color: var(--primary); + background-color: transparent; + border-color: transparent; + box-shadow: none; + text-underline-offset: 0.2em; +} + +.fusion-btn--link:hover:not(:disabled):not([aria-disabled="true"]) { + color: var(--foreground); + text-decoration: underline; + background-color: transparent; +} + +.fusion-btn--link:focus-visible { + border-radius: var(--radius-sm); + box-shadow: 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} diff --git a/crates/fusion-core/assets/templates/fusion/components/button/button.html b/crates/fusion-core/assets/templates/fusion/components/button/button.html new file mode 100644 index 0000000..48ce0b9 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/button/button.html @@ -0,0 +1,27 @@ +{# Fusion Button — primary / secondary / danger / link modes. + Usage: + {{}} + {{}} +#} +{% component fusion.button( + label: string, + href: string = "", + variant: string = "primary", + type: string = "button", + disabled: bool = false, + class: string = "" +) %} +{%- if href -%} +{{ label }} +{%- else -%} + +{%- endif -%} +{% endcomponent fusion.button %} diff --git a/crates/fusion-core/assets/templates/fusion/components.css b/crates/fusion-core/assets/templates/fusion/components/components.css similarity index 64% rename from crates/fusion-core/assets/templates/fusion/components.css rename to crates/fusion-core/assets/templates/fusion/components/components.css index 52a6376..a43228e 100644 --- a/crates/fusion-core/assets/templates/fusion/components.css +++ b/crates/fusion-core/assets/templates/fusion/components/components.css @@ -56,6 +56,22 @@ transform: translateY(-1px); } +.fusion-btn--link { + padding: 10px 6px; + color: var(--fusion-accent); + background: transparent; + border: none; + box-shadow: none; + text-underline-offset: 0.2em; +} + +.fusion-btn--link:hover { + color: var(--fusion-primary); + background: transparent; + text-decoration: underline; + transform: none; +} + .fusion-link { color: var(--fusion-accent); text-decoration: none; @@ -137,8 +153,8 @@ } .fusion-badge--success { - background: #ecfdf5; - color: #047857; + background: #dcfce7; + color: #15803d; } .fusion-badge--warning { @@ -160,12 +176,30 @@ } .fusion-badge--success .fusion-badge__dot { - background: #10b981; + background: #22c55e; +} + +.dark .fusion-badge--success { + background: #14532d; + color: #bbf7d0; +} + +.dark .fusion-badge--success .fusion-badge__dot { + background: #4ade80; +} + +.dark .fusion-badge--default { + background: oklch(0.32 0 0); + color: oklch(0.96 0 0); } +/* Table (runtime / monitor) — mirrors components/table/table.css with legacy tokens. */ .fusion-table-wrap { + box-sizing: border-box; + display: flex; + flex-direction: column; width: 100%; - overflow-x: auto; + overflow: hidden; margin: 1.5rem 0; border: 1px solid var(--fusion-border); border-radius: 14px; @@ -173,16 +207,31 @@ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.05); } +.fusion-table-wrap--resizing { + cursor: col-resize; + user-select: none; +} + +.fusion-table-scroll { + width: 100%; + overflow-x: auto; +} + .fusion-table { width: 100%; - border-collapse: collapse; + border-collapse: separate; + border-spacing: 0; font-size: 14px; text-align: left; } +.fusion-table--sized { + table-layout: fixed; +} + .fusion-table caption { caption-side: top; - padding: 14px 16px 0; + padding: 14px 16px 10px; font-size: 14px; font-weight: 700; color: #334155; @@ -191,12 +240,17 @@ .fusion-table th, .fusion-table td { + box-sizing: border-box; padding: 12px 16px; border-bottom: 1px solid var(--fusion-border); color: #334155; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .fusion-table th { + position: relative; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; @@ -205,6 +259,55 @@ background: #f8fafc; } +.fusion-table__head { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.fusion-table__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.fusion-table__resize { + position: absolute; + top: 0; + right: -3px; + z-index: 2; + width: 7px; + height: 100%; + cursor: col-resize; + touch-action: none; +} + +.fusion-table__resize::after { + content: ""; + position: absolute; + top: 25%; + bottom: 25%; + left: 50%; + width: 1px; + transform: translateX(-50%); + background: var(--fusion-border); + opacity: 0; +} + +.fusion-table th:hover .fusion-table__resize::after, +.fusion-table__resize--active::after { + opacity: 1; + background: var(--fusion-accent); +} + +.fusion-table__resize--active::after { + width: 2px; + top: 0; + bottom: 0; +} + .fusion-table tbody tr:last-child td { border-bottom: none; } @@ -236,12 +339,24 @@ } .fusion-table-pager__btn { + display: inline-flex; + align-items: center; + justify-content: center; padding: 8px 14px; font-size: 13px; + font-weight: 600; + color: #334155; + background: var(--fusion-surface); + border: 1px solid var(--fusion-border); + border-radius: 8px; + cursor: pointer; +} + +.fusion-table-pager__btn:hover:not(:disabled) { + background: var(--fusion-bg); } .fusion-table-pager__btn:disabled { opacity: 0.45; cursor: not-allowed; - transform: none; } diff --git a/crates/fusion-core/assets/templates/fusion/components/modal/modal.css b/crates/fusion-core/assets/templates/fusion/components/modal/modal.css new file mode 100644 index 0000000..32bbb2b --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/modal/modal.css @@ -0,0 +1,226 @@ +/* Modal — centered dialog; size + animation are user-tunable via data attrs / CSS vars. */ + +.fusion-modal { + position: fixed; + inset: 0; + z-index: 1100; + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-5); + pointer-events: none; +} + +.fusion-modal[hidden] { + display: none; +} + +.fusion-modal.fusion-modal--open { + pointer-events: auto; +} + +.fusion-modal__backdrop { + position: absolute; + inset: 0; + background-color: color-mix(in oklch, var(--foreground) 45%, transparent); + opacity: 0; + transition: opacity var(--fusion-modal-duration, 220ms) var(--fusion-modal-easing, cubic-bezier(0.4, 0, 0.2, 1)); +} + +.fusion-modal--open .fusion-modal__backdrop { + opacity: 1; +} + +.fusion-modal__dialog { + position: relative; + z-index: 1; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + width: min(var(--fusion-modal-width, 28rem), 100%); + max-height: min(90vh, 40rem); + padding: var(--spacing-5); + overflow: auto; + color: var(--popover-foreground); + background-color: var(--popover); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow); + outline: none; + opacity: 0; + transform: translateY(0.75rem) scale(0.96); + transition: + opacity var(--fusion-modal-duration, 220ms) var(--fusion-modal-easing, cubic-bezier(0.4, 0, 0.2, 1)), + transform var(--fusion-modal-duration, 220ms) var(--fusion-modal-easing, cubic-bezier(0.4, 0, 0.2, 1)); +} + +/* Size presets (override with --fusion-modal-width or data-size custom length). */ +.fusion-modal[data-size="sm"] .fusion-modal__dialog { + --fusion-modal-width: 20rem; +} + +.fusion-modal[data-size="md"] .fusion-modal__dialog { + --fusion-modal-width: 28rem; +} + +.fusion-modal[data-size="lg"] .fusion-modal__dialog { + --fusion-modal-width: 36rem; +} + +.fusion-modal[data-size="xl"] .fusion-modal__dialog { + --fusion-modal-width: 44rem; +} + +.fusion-modal--open .fusion-modal__dialog { + opacity: 1; + transform: translateY(0) scale(1); +} + +/* Animation modes */ +.fusion-modal[data-animation="fade"] .fusion-modal__dialog { + transform: none; +} + +.fusion-modal[data-animation="slide"] .fusion-modal__dialog { + transform: translateY(1.25rem); +} + +.fusion-modal[data-animation="slide"].fusion-modal--open .fusion-modal__dialog { + transform: translateY(0); +} + +.fusion-modal[data-animation="scale"] .fusion-modal__dialog { + transform: scale(0.92); +} + +.fusion-modal[data-animation="scale"].fusion-modal--open .fusion-modal__dialog { + transform: scale(1); +} + +.fusion-modal[data-animation="none"] .fusion-modal__backdrop, +.fusion-modal[data-animation="none"] .fusion-modal__dialog { + transition: none; + transform: none; + opacity: 1; +} + +.fusion-modal[data-animation="none"]:not(.fusion-modal--open) .fusion-modal__backdrop, +.fusion-modal[data-animation="none"]:not(.fusion-modal--open) .fusion-modal__dialog { + opacity: 0; +} + +.fusion-modal__close { + position: absolute; + top: var(--spacing-3); + right: var(--spacing-3); + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + margin: 0; + padding: 0; + font-size: 1.25rem; + line-height: 1; + color: var(--muted-foreground); + background: transparent; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + outline: none; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.fusion-modal__close:hover { + color: var(--foreground); + background-color: var(--accent); +} + +.fusion-modal__close:focus-visible { + box-shadow: 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} + +.fusion-modal__header { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + padding-right: var(--spacing-8); +} + +.fusion-modal__icon { + display: none; + flex-shrink: 0; + width: 1.5rem; + height: 1.5rem; + margin-top: 0.1rem; +} + +.fusion-modal__dialog--success .fusion-modal__icon, +.fusion-modal__dialog--warning .fusion-modal__icon, +.fusion-modal__dialog--error .fusion-modal__icon { + display: block; +} + +.fusion-modal__dialog--success .fusion-modal__icon { + color: oklch(0.55 0.15 145); +} + +.dark .fusion-modal__dialog--success .fusion-modal__icon { + color: oklch(0.75 0.15 145); +} + +.fusion-modal__dialog--warning .fusion-modal__icon { + color: oklch(0.6 0.15 75); +} + +.dark .fusion-modal__dialog--warning .fusion-modal__icon { + color: oklch(0.8 0.14 85); +} + +.fusion-modal__dialog--error .fusion-modal__icon { + color: var(--destructive); +} + +.fusion-modal__dialog--success { + border-color: color-mix(in oklch, oklch(0.65 0.15 145) 40%, var(--border)); +} + +.fusion-modal__dialog--warning { + border-color: color-mix(in oklch, oklch(0.75 0.15 75) 40%, var(--border)); +} + +.fusion-modal__dialog--error { + border-color: color-mix(in oklch, var(--destructive) 40%, var(--border)); +} + +.fusion-modal__title { + margin: 0; + font-family: var(--font-sans); + font-size: var(--text-lg); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-tight); + color: var(--popover-foreground); +} + +.fusion-modal__body { + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--leading-normal); + color: var(--muted-foreground); +} + +.fusion-modal__footer { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--spacing-2); + margin-top: var(--spacing-1); +} + +body.fusion-modal-lock { + overflow: hidden; +} diff --git a/crates/fusion-core/assets/templates/fusion/components/modal/modal.html b/crates/fusion-core/assets/templates/fusion/components/modal/modal.html new file mode 100644 index 0000000..ee92f0e --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/modal/modal.html @@ -0,0 +1,64 @@ +{# Fusion Modal — centered dialog with optional status variants. + Variants: success | warning | error | "" (custom — no status chrome). + Size: sm | md | lg | xl | any CSS length (e.g. "32rem", "480px"). + Animation: scale | fade | slide | none + Duration: milliseconds (applied as --fusion-modal-duration). + + Usage: + {{}} + FusionModal.open({ variant: "success", title: "Saved", body: "All good." }) + +#} +{% component fusion.modal( + id: string = "", + variant: string = "", + title: string = "", + message: string = "", + size: string = "md", + animation: string = "scale", + duration: number = 220, + confirm_label: string = "OK", + cancel_label: string = "Cancel", + show_cancel: bool = true, + class: string = "" +) %} +{%- set modal_id = id -%} +{%- if not modal_id -%}{%- set modal_id = "fusion-modal" -%}{%- endif -%} + +{% endcomponent fusion.modal %} diff --git a/crates/fusion-core/assets/templates/fusion/components/modal/modal.js b/crates/fusion-core/assets/templates/fusion/components/modal/modal.js new file mode 100644 index 0000000..b5e035d --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/modal/modal.js @@ -0,0 +1,298 @@ +/** + * Fusion centered modal — variants success/warning/error, or fully custom. + * Size (sm|md|lg|xl|CSS length) and animation (scale|fade|slide|none + duration ms) + * are configurable per open() / data attributes / CSS variables. + */ +(function () { + var SIZES = { sm: "20rem", md: "28rem", lg: "36rem", xl: "44rem" }; + var ANIMATIONS = ["scale", "fade", "slide", "none"]; + var active = null; + var lastFocus = null; + + /** Return SVG markup for a status icon. */ + function iconSvg(variant) { + if (variant === "success") { + return ''; + } + if (variant === "warning") { + return ''; + } + if (variant === "error") { + return ''; + } + return ""; + } + + /** True when size is a known preset name. */ + function isPresetSize(size) { + return Object.prototype.hasOwnProperty.call(SIZES, size); + } + + /** Resolve width CSS value from size option. */ + function resolveWidth(size) { + if (!size) return SIZES.md; + if (isPresetSize(size)) return SIZES[size]; + return size; + } + + /** Normalize animation name. */ + function normalizeAnimation(value) { + var a = (value || "scale").toLowerCase(); + return ANIMATIONS.indexOf(a) >= 0 ? a : "scale"; + } + + /** Ensure a reusable programmatic modal host exists. */ + function ensureHost() { + var host = document.getElementById("fusion-modal-host"); + if (host) return host; + host = document.createElement("div"); + host.id = "fusion-modal-host"; + host.className = "fusion-modal"; + host.setAttribute("data-fusion-modal", ""); + host.setAttribute("data-size", "md"); + host.setAttribute("data-animation", "scale"); + host.setAttribute("data-duration", "220"); + host.hidden = true; + host.innerHTML = + '
' + + '"; + document.body.appendChild(host); + return host; + } + + /** Apply size / animation / duration knobs onto a modal root. */ + function applyChrome(root, options) { + options = options || {}; + var size = options.size || root.getAttribute("data-size") || "md"; + var animation = normalizeAnimation( + options.animation || root.getAttribute("data-animation") + ); + var duration = + options.duration != null + ? options.duration + : parseInt(root.getAttribute("data-duration") || "220", 10); + if (!(duration >= 0)) duration = 220; + + root.setAttribute("data-size", isPresetSize(size) ? size : "custom"); + root.setAttribute("data-animation", animation); + root.setAttribute("data-duration", String(duration)); + + var dialog = root.querySelector("[data-fusion-modal-dialog]"); + if (!dialog) return; + dialog.style.setProperty("--fusion-modal-width", resolveWidth(size)); + dialog.style.setProperty("--fusion-modal-duration", duration + "ms"); + if (options.easing) { + dialog.style.setProperty("--fusion-modal-easing", options.easing); + } + } + + /** Fill title/body/variant/icons/footer labels. */ + function fillContent(root, options) { + options = options || {}; + var variant = options.variant == null ? root.getAttribute("data-variant") || "" : options.variant; + variant = String(variant || ""); + root.setAttribute("data-variant", variant); + + var dialog = root.querySelector("[data-fusion-modal-dialog]"); + if (dialog) { + dialog.className = "fusion-modal__dialog"; + if (variant) dialog.classList.add("fusion-modal__dialog--" + variant); + } + + var icon = root.querySelector("[data-fusion-modal-icon]"); + if (icon) icon.innerHTML = iconSvg(variant); + + var title = root.querySelector("[data-fusion-modal-title]"); + if (title && options.title != null) title.textContent = options.title; + + var body = root.querySelector("[data-fusion-modal-body]"); + if (body && options.body != null) { + if (options.html) body.innerHTML = options.body; + else body.textContent = options.body; + } + + var cancel = root.querySelector("[data-fusion-modal-cancel]"); + var confirm = root.querySelector("[data-fusion-modal-confirm]"); + if (cancel) { + if (options.cancelLabel != null) cancel.textContent = options.cancelLabel; + cancel.hidden = options.showCancel === false; + } + if (confirm && options.confirmLabel != null) { + confirm.textContent = options.confirmLabel; + } + + root._fusionOnConfirm = typeof options.onConfirm === "function" ? options.onConfirm : null; + root._fusionOnCancel = typeof options.onCancel === "function" ? options.onCancel : null; + root._fusionOnClose = typeof options.onClose === "function" ? options.onClose : null; + } + + /** Open a modal element (or create programmatic host). */ + function open(targetOrOptions, maybeOptions) { + var root; + var options; + if (typeof targetOrOptions === "string" || targetOrOptions instanceof Element) { + root = + typeof targetOrOptions === "string" + ? document.querySelector(targetOrOptions) + : targetOrOptions; + options = maybeOptions || {}; + } else { + options = targetOrOptions || {}; + root = options.el + ? typeof options.el === "string" + ? document.querySelector(options.el) + : options.el + : ensureHost(); + } + if (!root) return null; + + if (active && active !== root) close(active, { silent: true }); + + applyChrome(root, options); + fillContent(root, options); + + lastFocus = document.activeElement; + root.hidden = false; + // Force reflow so enter transition runs. + void root.offsetWidth; + root.classList.add("fusion-modal--open"); + document.body.classList.add("fusion-modal-lock"); + active = root; + + var dialog = root.querySelector("[data-fusion-modal-dialog]"); + if (dialog) dialog.focus(); + return root; + } + + /** Close the active (or given) modal with leave animation. */ + function close(root, opts) { + opts = opts || {}; + root = root || active; + if (!root) return; + var duration = parseInt(root.getAttribute("data-duration") || "220", 10); + if (root.getAttribute("data-animation") === "none") duration = 0; + + root.classList.remove("fusion-modal--open"); + document.body.classList.remove("fusion-modal-lock"); + + function finish() { + root.hidden = true; + if (active === root) active = null; + if (!opts.silent && typeof root._fusionOnClose === "function") root._fusionOnClose(); + if (lastFocus && typeof lastFocus.focus === "function") { + try { + lastFocus.focus(); + } catch (_) {} + } + lastFocus = null; + } + + if (duration > 0) setTimeout(finish, duration); + else finish(); + } + + /** Confirm handler — runs onConfirm then closes. */ + function confirm(root) { + root = root || active; + if (!root) return; + if (typeof root._fusionOnConfirm === "function") root._fusionOnConfirm(); + close(root); + } + + /** Cancel handler — runs onCancel then closes. */ + function cancel(root) { + root = root || active; + if (!root) return; + if (typeof root._fusionOnCancel === "function") root._fusionOnCancel(); + close(root); + } + + /** Read open options from a trigger element's data-* attributes. */ + function optionsFromTrigger(el) { + var opts = { + variant: el.getAttribute("data-modal-variant") || "", + title: el.getAttribute("data-modal-title") || undefined, + body: el.getAttribute("data-modal-body") || undefined, + size: el.getAttribute("data-modal-size") || undefined, + animation: el.getAttribute("data-modal-animation") || undefined, + duration: el.hasAttribute("data-modal-duration") + ? parseInt(el.getAttribute("data-modal-duration"), 10) + : undefined, + confirmLabel: el.getAttribute("data-modal-confirm") || undefined, + cancelLabel: el.getAttribute("data-modal-cancel") || undefined, + showCancel: el.getAttribute("data-modal-show-cancel") !== "false", + }; + if (el.hasAttribute("data-modal-html")) opts.html = true; + return opts; + } + + document.addEventListener("click", function (event) { + var openTrigger = event.target.closest("[data-fusion-modal-open]"); + if (openTrigger) { + event.preventDefault(); + var target = openTrigger.getAttribute("data-fusion-modal-open"); + var opts = optionsFromTrigger(openTrigger); + if (target) open(target, opts); + else open(opts); + return; + } + + var root = event.target.closest("[data-fusion-modal]"); + if (!root || !root.classList.contains("fusion-modal--open")) return; + + if (event.target.closest("[data-fusion-modal-close]")) { + close(root); + return; + } + if (event.target.closest("[data-fusion-modal-cancel]")) { + cancel(root); + return; + } + if (event.target.closest("[data-fusion-modal-confirm]")) { + confirm(root); + return; + } + if (event.target.matches("[data-fusion-modal-backdrop]")) { + cancel(root); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key !== "Escape" || !active) return; + cancel(active); + }); + + window.FusionModal = { + open: open, + close: close, + confirm: confirm, + cancel: cancel, + success: function (options) { + options = options || {}; + options.variant = "success"; + return open(options); + }, + warning: function (options) { + options = options || {}; + options.variant = "warning"; + return open(options); + }, + error: function (options) { + options = options || {}; + options.variant = "error"; + return open(options); + }, + sizes: Object.keys(SIZES), + animations: ANIMATIONS.slice(), + }; +})(); diff --git a/crates/fusion-core/assets/templates/fusion/components/table/table.css b/crates/fusion-core/assets/templates/fusion/components/table/table.css new file mode 100644 index 0000000..453ae96 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/table/table.css @@ -0,0 +1,210 @@ +/* Data table — token-aligned grid with optional column sizing / resize. */ + +.fusion-table-wrap { + box-sizing: border-box; + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + color: var(--card-foreground); + background-color: var(--card); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.fusion-table-wrap--resizing { + cursor: col-resize; + user-select: none; +} + +.fusion-table-wrap--resizing * { + cursor: col-resize !important; + user-select: none !important; +} + +.fusion-table-scroll { + width: 100%; + overflow-x: auto; + scrollbar-width: thin; +} + +.fusion-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--leading-normal); + text-align: left; + color: var(--card-foreground); +} + +.fusion-table--sized { + table-layout: fixed; +} + +.fusion-table caption { + caption-side: top; + padding: var(--spacing-4) var(--spacing-5) var(--spacing-3); + font-size: var(--text-sm); + font-weight: var(--font-weight-semibold); + line-height: var(--leading-tight); + color: var(--card-foreground); + text-align: left; +} + +.fusion-table th, +.fusion-table td { + box-sizing: border-box; + padding: var(--spacing-3) var(--spacing-5); + vertical-align: middle; + border-bottom: var(--border-width) solid var(--border); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fusion-table th { + position: relative; + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted-foreground); + background-color: color-mix(in oklch, var(--muted) 70%, var(--card)); +} + +.fusion-table__head { + display: flex; + align-items: center; + gap: var(--spacing-2); + min-width: 0; +} + +.fusion-table__label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.fusion-table__resize { + position: absolute; + top: 0; + right: -3px; + z-index: 2; + width: 7px; + height: 100%; + margin: 0; + padding: 0; + border: 0; + background: transparent; + cursor: col-resize; + touch-action: none; +} + +.fusion-table__resize::after { + content: ""; + position: absolute; + top: 25%; + bottom: 25%; + left: 50%; + width: 1px; + transform: translateX(-50%); + background-color: var(--border); + opacity: 0; + transition: opacity var(--transition-fast), background-color var(--transition-fast); +} + +.fusion-table th:hover .fusion-table__resize::after, +.fusion-table__resize:focus-visible::after, +.fusion-table__resize--active::after { + opacity: 1; + background-color: var(--ring); +} + +.fusion-table__resize:focus-visible { + outline: none; +} + +.fusion-table__resize--active::after { + width: 2px; + top: 0; + bottom: 0; +} + +.fusion-table tbody tr:last-child td { + border-bottom: none; +} + +.fusion-table tbody tr:hover td { + background-color: color-mix(in oklch, var(--accent) 65%, transparent); +} + +.fusion-table-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-3); + padding: var(--spacing-3) var(--spacing-5); + border-top: var(--border-width) solid var(--border); + background-color: color-mix(in oklch, var(--muted) 55%, var(--card)); +} + +.fusion-table-pager[hidden] { + display: none; +} + +.fusion-table-pager__status { + flex: 1; + min-width: 8rem; + font-family: var(--font-sans); + font-size: var(--text-xs); + font-weight: var(--font-weight-medium); + color: var(--muted-foreground); +} + +.fusion-table-pager__btn { + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--control-height); + padding: 0 var(--spacing-3); + margin: 0; + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + line-height: var(--leading-tight); + color: var(--foreground); + background-color: var(--card); + border: var(--border-width) solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-xs); + cursor: pointer; + transition: + background-color var(--transition-fast), + border-color var(--transition-fast), + box-shadow var(--transition-fast), + opacity var(--transition-fast); +} + +.fusion-table-pager__btn:hover:not(:disabled) { + border-color: var(--ring); + background-color: var(--accent); + color: var(--accent-foreground); +} + +.fusion-table-pager__btn:focus-visible { + outline: none; + border-color: var(--ring); + box-shadow: + var(--shadow-xs), + 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} + +.fusion-table-pager__btn:disabled { + opacity: var(--opacity-disabled); + cursor: not-allowed; +} diff --git a/crates/fusion-core/assets/templates/fusion/components/table/table.html b/crates/fusion-core/assets/templates/fusion/components/table/table.html new file mode 100644 index 0000000..e78630b --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/table/table.html @@ -0,0 +1,185 @@ +{# Fusion Table — data grid with optional column widths, resize, and paging. + Usage: + {{}} + {{}} + `widths` is a parallel array of CSS sizes (e.g. "180px", "40%", "12rem"). Empty = auto. + Drag the header edge handles when resizable={true} (default) to tune column sizes. +#} +{% component fusion.table( + headers: array = [], + rows: array = [], + caption: string = "", + page_size: number = 0, + widths: array = [], + resizable: bool = true, + class: string = "" +) %} +
0 %}data-page-size="{{ page_size }}"{% endif %} + {% if resizable %}data-resizable="true"{% endif %} +> +
+ + {% if caption %}{% endif %} + {% if headers %} + + {% for h in headers %} + {%- set col_width = "" -%} + {%- if widths and widths[loop.index0] -%}{%- set col_width = widths[loop.index0] -%}{%- endif -%} + + {% endfor %} + + + + {% for h in headers %} + {%- set col_width = "" -%} + {%- if widths and widths[loop.index0] -%}{%- set col_width = widths[loop.index0] -%}{%- endif -%} + + {% endfor %} + + + {% endif %} + + {% for row in rows %} + 0 %} data-fusion-row{% endif %}> + {% for cell in row %}{% endfor %} + + {% endfor %} + {{ body | safe }} + +
{{ caption }}
+ + {{ h }} + {% if resizable and not loop.last %} + + {% endif %} + +
{{ cell }}
+
+ {% if page_size > 0 %} + + {% endif %} +
+{% if page_size > 0 or resizable %} + +{% endif %} +{% endcomponent fusion.table %} diff --git a/crates/fusion-core/assets/templates/fusion/components/table/table.js b/crates/fusion-core/assets/templates/fusion/components/table/table.js new file mode 100644 index 0000000..149d085 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/table/table.js @@ -0,0 +1,120 @@ +/** + * Initializes Fusion tables: client-side paging and header column resize. + * Safe to load once; also used as a fallback for gallery demos without inline scripts. + */ +(function () { + /** Wire pagination + column resize for a single `.fusion-table-wrap`. */ + function initTable(root) { + if (!root || root.getAttribute("data-fusion-ready") === "true") return; + root.setAttribute("data-fusion-ready", "true"); + + var size = parseInt(root.getAttribute("data-page-size") || "0", 10); + var rows = root.querySelectorAll("tbody tr[data-fusion-row]"); + var pager = root.querySelector(".fusion-table-pager"); + var status = root.querySelector("[data-fusion-status]"); + var prev = root.querySelector("[data-fusion-prev]"); + var next = root.querySelector("[data-fusion-next]"); + + if (size > 0 && pager && status && prev && next && rows.length > 0) { + var page = 0; + var pages = Math.max(1, Math.ceil(rows.length / size)); + + function renderPage() { + var start = page * size; + var end = start + size; + for (var i = 0; i < rows.length; i++) { + rows[i].hidden = i < start || i >= end; + } + status.textContent = + "Page " + (page + 1) + " / " + pages + " · " + rows.length + " rows"; + prev.disabled = page <= 0; + next.disabled = page >= pages - 1; + pager.hidden = pages <= 1; + } + + prev.addEventListener("click", function () { + if (page > 0) { + page -= 1; + renderPage(); + } + }); + next.addEventListener("click", function () { + if (page < pages - 1) { + page += 1; + renderPage(); + } + }); + renderPage(); + } + + if (root.getAttribute("data-resizable") !== "true") return; + + var table = root.querySelector(".fusion-table"); + var cols = root.querySelectorAll("colgroup col[data-fusion-col]"); + var handles = root.querySelectorAll("[data-fusion-col-resize]"); + if (!table || !cols.length || !handles.length) return; + table.classList.add("fusion-table--sized"); + + function startResize(handle, clientX) { + var th = handle.closest("th"); + if (!th) return; + var index = Array.prototype.indexOf.call(th.parentNode.children, th); + var col = cols[index]; + if (!col) return; + var startX = clientX; + var startW = th.getBoundingClientRect().width; + root.classList.add("fusion-table-wrap--resizing"); + handle.classList.add("fusion-table__resize--active"); + + function onMove(ev) { + var nextW = Math.max(72, startW + (ev.clientX - startX)); + var px = nextW + "px"; + col.style.width = px; + th.style.width = px; + } + + function onUp() { + root.classList.remove("fusion-table-wrap--resizing"); + handle.classList.remove("fusion-table__resize--active"); + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + } + + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + } + + handles.forEach(function (handle) { + handle.addEventListener("mousedown", function (ev) { + ev.preventDefault(); + startResize(handle, ev.clientX); + }); + handle.addEventListener("keydown", function (ev) { + if (ev.key !== "ArrowLeft" && ev.key !== "ArrowRight") return; + ev.preventDefault(); + var th = handle.closest("th"); + if (!th) return; + var index = Array.prototype.indexOf.call(th.parentNode.children, th); + var col = cols[index]; + if (!col) return; + var current = th.getBoundingClientRect().width; + var delta = ev.key === "ArrowRight" ? 16 : -16; + var px = Math.max(72, current + delta) + "px"; + col.style.width = px; + th.style.width = px; + }); + }); + } + + window.__fusionInitTable = initTable; + + function boot() { + document.querySelectorAll("[data-fusion-table]").forEach(initTable); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", boot); + } else { + boot(); + } +})(); diff --git a/crates/fusion-core/assets/templates/fusion/components/toast/toast.css b/crates/fusion-core/assets/templates/fusion/components/toast/toast.css new file mode 100644 index 0000000..01b3516 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/toast/toast.css @@ -0,0 +1,206 @@ +/* Toast — react-hot-toast style notifications with 6 viewport positions. */ + +.fusion-toaster { + pointer-events: none; +} + +.fusion-toaster__region { + position: fixed; + z-index: 1000; + display: flex; + flex-direction: column; + gap: var(--spacing-2); + width: min(22rem, calc(100vw - var(--spacing-8))); + max-width: 100%; + margin: 0; + padding: 0; + pointer-events: none; +} + +.fusion-toaster__region--top-left { + top: var(--spacing-5); + left: var(--spacing-5); + align-items: flex-start; +} + +.fusion-toaster__region--top-center { + top: var(--spacing-5); + left: 50%; + align-items: center; + transform: translateX(-50%); +} + +.fusion-toaster__region--top-right { + top: var(--spacing-5); + right: var(--spacing-5); + align-items: flex-end; +} + +.fusion-toaster__region--bottom-left { + bottom: var(--spacing-5); + left: var(--spacing-5); + align-items: flex-start; + flex-direction: column-reverse; +} + +.fusion-toaster__region--bottom-center { + bottom: var(--spacing-5); + left: 50%; + align-items: center; + flex-direction: column-reverse; + transform: translateX(-50%); +} + +.fusion-toaster__region--bottom-right { + bottom: var(--spacing-5); + right: var(--spacing-5); + align-items: flex-end; + flex-direction: column-reverse; +} + +.fusion-toast { + box-sizing: border-box; + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + width: max-content; + max-width: 100%; + min-width: min(16rem, 100%); + padding: var(--spacing-3) var(--spacing-4); + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: var(--font-weight-medium); + line-height: var(--leading-normal); + color: var(--popover-foreground); + background-color: var(--popover); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + pointer-events: auto; + opacity: 0; + transform: translateY(0.5rem) scale(0.98); + transition: + opacity 180ms cubic-bezier(0.4, 0, 0.2, 1), + transform 180ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.fusion-toaster__region--top-left .fusion-toast, +.fusion-toaster__region--top-center .fusion-toast, +.fusion-toaster__region--top-right .fusion-toast { + transform: translateY(-0.5rem) scale(0.98); +} + +.fusion-toast.fusion-toast--visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +.fusion-toast.fusion-toast--leaving { + opacity: 0; + transform: translateY(0.35rem) scale(0.98); +} + +.fusion-toaster__region--top-left .fusion-toast.fusion-toast--leaving, +.fusion-toaster__region--top-center .fusion-toast.fusion-toast--leaving, +.fusion-toaster__region--top-right .fusion-toast.fusion-toast--leaving { + transform: translateY(-0.35rem) scale(0.98); +} + +.fusion-toast__icon { + flex-shrink: 0; + width: 1.15rem; + height: 1.15rem; + margin-top: 0.1rem; +} + +.fusion-toast__body { + flex: 1; + min-width: 0; + word-break: break-word; +} + +.fusion-toast__close { + box-sizing: border-box; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + margin: -0.15rem -0.35rem -0.15rem 0; + padding: 0; + color: var(--muted-foreground); + background: transparent; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; + outline: none; + transition: + background-color var(--transition-fast), + color var(--transition-fast); +} + +.fusion-toast__close:hover { + color: var(--foreground); + background-color: var(--accent); +} + +.fusion-toast__close:focus-visible { + box-shadow: 0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent); +} + +.fusion-toast--success { + border-color: color-mix(in oklch, oklch(0.65 0.15 145) 45%, var(--border)); +} + +.fusion-toast--success .fusion-toast__icon { + color: oklch(0.55 0.15 145); +} + +.dark .fusion-toast--success .fusion-toast__icon { + color: oklch(0.75 0.15 145); +} + +.fusion-toast--error { + border-color: color-mix(in oklch, var(--destructive) 45%, var(--border)); +} + +.fusion-toast--error .fusion-toast__icon { + color: var(--destructive); +} + +.fusion-toast--warning { + border-color: color-mix(in oklch, oklch(0.75 0.15 75) 45%, var(--border)); +} + +.fusion-toast--warning .fusion-toast__icon { + color: oklch(0.6 0.15 75); +} + +.dark .fusion-toast--warning .fusion-toast__icon { + color: oklch(0.8 0.14 85); +} + +.fusion-toast--info { + border-color: color-mix(in oklch, oklch(0.6 0.12 250) 45%, var(--border)); +} + +.fusion-toast--info .fusion-toast__icon { + color: oklch(0.5 0.12 250); +} + +.dark .fusion-toast--info .fusion-toast__icon { + color: oklch(0.75 0.1 250); +} + +/* Gallery: position picker grid */ +.fusion-toast-demo-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: var(--spacing-3); + max-width: 28rem; +} + +.fusion-toast-demo-grid .fusion-btn { + width: 100%; +} diff --git a/crates/fusion-core/assets/templates/fusion/components/toast/toast.html b/crates/fusion-core/assets/templates/fusion/components/toast/toast.html new file mode 100644 index 0000000..a96db57 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/toast/toast.html @@ -0,0 +1,32 @@ +{# Fusion Toast — hot-toast style notifications with 6 screen positions. + Mount once (usually in the base layout), then call FusionToast.show from JS + or use data-fusion-toast on a fusion.button / any clickable. + + Usage: + {{}} + + + Positions: top-left | top-center | top-right | bottom-left | bottom-center | bottom-right + Variants: default | success | error | warning | info +#} +{% component fusion.toast( + position: string = "top-center", + duration: number = 3500, + class: string = "" +) %} +
+
+
+
+
+
+
+
+{% endcomponent fusion.toast %} diff --git a/crates/fusion-core/assets/templates/fusion/components/toast/toast.js b/crates/fusion-core/assets/templates/fusion/components/toast/toast.js new file mode 100644 index 0000000..d290fd7 --- /dev/null +++ b/crates/fusion-core/assets/templates/fusion/components/toast/toast.js @@ -0,0 +1,184 @@ +/** + * Fusion toast notifications (react-hot-toast style). + * Mount {{}} (or a .fusion-toaster host), then call FusionToast.show + * or click any [data-fusion-toast] control (works with fusion.button markup). + */ +(function () { + var POSITIONS = [ + "top-left", + "top-center", + "top-right", + "bottom-left", + "bottom-center", + "bottom-right", + ]; + var DEFAULT_DURATION = 3500; + var seq = 0; + var timers = {}; + + /** Ensure a toaster host exists and return it. */ + function getToaster() { + var host = document.querySelector("[data-fusion-toaster]"); + if (host) return host; + host = document.createElement("div"); + host.className = "fusion-toaster"; + host.setAttribute("data-fusion-toaster", ""); + host.setAttribute("data-default-position", "top-center"); + host.setAttribute("data-default-duration", String(DEFAULT_DURATION)); + host.setAttribute("aria-live", "polite"); + host.setAttribute("aria-relevant", "additions"); + POSITIONS.forEach(function (pos) { + var region = document.createElement("div"); + region.className = "fusion-toaster__region fusion-toaster__region--" + pos; + region.setAttribute("data-position", pos); + host.appendChild(region); + }); + document.body.appendChild(host); + return host; + } + + /** Normalize a position string to one of the six supported slots. */ + function normalizePosition(value, fallback) { + var pos = (value || fallback || "top-center").toLowerCase(); + return POSITIONS.indexOf(pos) >= 0 ? pos : "top-center"; + } + + /** Build the small status icon for a toast variant. */ + function iconSvg(variant) { + if (variant === "success") { + return ''; + } + if (variant === "error") { + return ''; + } + if (variant === "warning") { + return ''; + } + if (variant === "info") { + return ''; + } + return ''; + } + + /** Remove a toast by id with leave animation. */ + function dismiss(id) { + var el = document.querySelector('.fusion-toast[data-toast-id="' + id + '"]'); + if (!el) return; + if (timers[id]) { + clearTimeout(timers[id]); + delete timers[id]; + } + el.classList.remove("fusion-toast--visible"); + el.classList.add("fusion-toast--leaving"); + setTimeout(function () { + if (el.parentNode) el.parentNode.removeChild(el); + }, 180); + } + + /** Dismiss every visible toast. */ + function dismissAll() { + document.querySelectorAll(".fusion-toast[data-toast-id]").forEach(function (el) { + dismiss(el.getAttribute("data-toast-id")); + }); + } + + /** + * Show a toast notification. + * @param {string} message + * @param {{variant?: string, position?: string, duration?: number}} [options] + * @returns {string} toast id + */ + function show(message, options) { + options = options || {}; + var host = getToaster(); + var variant = options.variant || "default"; + var position = normalizePosition( + options.position, + host.getAttribute("data-default-position") + ); + var duration = options.duration; + if (duration == null) { + duration = parseInt(host.getAttribute("data-default-duration") || "", 10); + if (!(duration > 0)) duration = DEFAULT_DURATION; + } + var region = host.querySelector('[data-position="' + position + '"]'); + if (!region) region = host.querySelector('[data-position="top-center"]'); + + seq += 1; + var id = "toast-" + seq; + var el = document.createElement("div"); + el.className = "fusion-toast fusion-toast--" + variant; + el.setAttribute("role", "status"); + el.setAttribute("data-toast-id", id); + el.innerHTML = + iconSvg(variant) + + '
' + + ''; + el.querySelector(".fusion-toast__body").textContent = String(message == null ? "" : message); + + region.appendChild(el); + requestAnimationFrame(function () { + el.classList.add("fusion-toast--visible"); + }); + + el.querySelector("[data-fusion-toast-close]").addEventListener("click", function () { + dismiss(id); + }); + + el.addEventListener("mouseenter", function () { + if (timers[id]) { + clearTimeout(timers[id]); + delete timers[id]; + } + }); + el.addEventListener("mouseleave", function () { + if (duration > 0) { + timers[id] = setTimeout(function () { + dismiss(id); + }, duration); + } + }); + + if (duration > 0) { + timers[id] = setTimeout(function () { + dismiss(id); + }, duration); + } + return id; + } + + function withVariant(variant) { + return function (message, options) { + options = options || {}; + options.variant = variant; + return show(message, options); + }; + } + + /** Handle clicks on [data-fusion-toast] triggers (including fusion.button). */ + function onClick(event) { + var trigger = event.target.closest("[data-fusion-toast]"); + if (!trigger) return; + event.preventDefault(); + show(trigger.getAttribute("data-fusion-toast") || trigger.textContent.trim(), { + variant: trigger.getAttribute("data-toast-variant") || "default", + position: trigger.getAttribute("data-toast-position") || undefined, + duration: trigger.hasAttribute("data-toast-duration") + ? parseInt(trigger.getAttribute("data-toast-duration"), 10) + : undefined, + }); + } + + document.addEventListener("click", onClick); + + window.FusionToast = { + show: show, + success: withVariant("success"), + error: withVariant("error"), + warning: withVariant("warning"), + info: withVariant("info"), + dismiss: dismiss, + dismissAll: dismissAll, + positions: POSITIONS.slice(), + }; +})(); diff --git a/crates/fusion-core/assets/templates/fusion/index.html b/crates/fusion-core/assets/templates/fusion/index.html index 01178ab..e444137 100644 --- a/crates/fusion-core/assets/templates/fusion/index.html +++ b/crates/fusion-core/assets/templates/fusion/index.html @@ -1,490 +1,356 @@ - - - - - - - Fusion UI — Input, Text, Card, Dropdown - - - - - - - - - -
-
+{% endblock %} diff --git a/crates/fusion-core/assets/templates/fusion/macros.html b/crates/fusion-core/assets/templates/fusion/macros.html index bba0154..d1e50fa 100644 --- a/crates/fusion-core/assets/templates/fusion/macros.html +++ b/crates/fusion-core/assets/templates/fusion/macros.html @@ -1,13 +1,15 @@ {# Fusion built-in UI components (Tera 2). Use: {{}} - Docs: include styles via {% include "fusion/components.css" %} or extend fusion/base.html + Button variants: primary | secondary | danger | link + Docs: include styles via {% include "fusion/components/components.css" %} + (legacy alias: fusion/components.css) or extend fusion/base.html #} -{% component fusion.button(label: string, href: string = "", variant: string = "primary", type: string = "button") %} +{% component fusion.button(label: string, href: string = "", variant: string = "primary", type: string = "button", disabled: bool = false) %} {%- if href -%} -{{ label }} +{{ label }} {%- else -%} - + {%- endif -%} {% endcomponent fusion.button %} @@ -34,77 +36,4 @@ {% endcomponent fusion.badge %} -{# Data table. Pass headers/rows as arrays, and/or nest custom markup as the body. - Set page_size={10} to paginate the rows array client-side (0 = off). #} -{% component fusion.table(headers: array = [], rows: array = [], caption: string = "", page_size: number = 0) %} -
0 %} data-page-size="{{ page_size }}"{% endif %}> - - {% if caption %}{% endif %} - {% if headers %} - - - {% for h in headers %}{% endfor %} - - - {% endif %} - - {% for row in rows %} - 0 %} data-fusion-row{% endif %}> - {% for cell in row %}{% endfor %} - - {% endfor %} - {{ body | safe }} - -
{{ caption }}
{{ h }}
{{ cell }}
- {% if page_size > 0 %} - - {% endif %} -
-{% if page_size > 0 %} - -{% endif %} -{% endcomponent fusion.table %} +{# Data table lives in fusion/components/table/table.html (loaded as a builtin). #} diff --git a/crates/fusion-core/assets/templates/fusion/monitor.html b/crates/fusion-core/assets/templates/fusion/monitor.html index f313215..fa5a189 100644 --- a/crates/fusion-core/assets/templates/fusion/monitor.html +++ b/crates/fusion-core/assets/templates/fusion/monitor.html @@ -3,37 +3,136 @@ {% block head %} {% endblock %} @@ -46,40 +145,43 @@

{{ title | default(value="Fusion Monitor") }}

{{}} {{}} {{}} + - {% %} +
+

Live entries

{% if empty_entries %}

No keys in the process-wide cache.

{% else %} - {{}} + {{}} {% endif %} - {% %} - -
+
- {% %} +
+

Recent activity

{% if empty_events %}

No set / delete / clear events yet.

{% else %} - {{}} + {{}} {% endif %} - {% %} - -
+
- {% %} +
+

Background tasks

{% if empty_tasks %}

No process-wide Tokio background tasks tracked yet.

{% else %} - {{}} + {{}} {% endif %} - {% %} +
{{}} {{}} + {{}}
{% endblock %} diff --git a/crates/fusion-core/src/templates.rs b/crates/fusion-core/src/templates.rs index bcde81f..269e961 100644 --- a/crates/fusion-core/src/templates.rs +++ b/crates/fusion-core/src/templates.rs @@ -10,9 +10,23 @@ 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"); + include_str!("../assets/templates/fusion/components/components.css"); +const BUILTIN_GLOBAL_CSS: &str = include_str!("../assets/templates/fusion/global.css"); +const BUILTIN_BUTTON_CSS: &str = + include_str!("../assets/templates/fusion/components/button/button.css"); +const BUILTIN_TABLE_CSS: &str = + include_str!("../assets/templates/fusion/components/table/table.css"); +const BUILTIN_TABLE_JS: &str = + include_str!("../assets/templates/fusion/components/table/table.js"); const BUILTIN_MONITOR: &str = include_str!("../assets/templates/fusion/monitor.html"); const BUILTIN_FORM_JS: &str = include_str!("../assets/templates/fusion/form.js"); +const BUILTIN_TABLE: &str = + include_str!("../assets/templates/fusion/components/table/table.html"); +const BUILTIN_TOAST: &str = + include_str!("../assets/templates/fusion/components/toast/toast.html"); +const BUILTIN_MODAL: &str = + include_str!("../assets/templates/fusion/components/modal/modal.html"); +const BUILTIN_HOME: &str = include_str!("../assets/templates/fusion/index.html"); static ENGINE_CACHE: Mutex> = Mutex::new(None); @@ -62,10 +76,31 @@ 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/global.css".to_string(), + BUILTIN_GLOBAL_CSS.to_string(), + ), + // Canonical location + legacy alias for older templates. + ( + "fusion/components/components.css".to_string(), + BUILTIN_COMPONENTS_CSS.to_string(), + ), ( "fusion/components.css".to_string(), BUILTIN_COMPONENTS_CSS.to_string(), ), + ( + "fusion/components/button/button.css".to_string(), + BUILTIN_BUTTON_CSS.to_string(), + ), + ( + "fusion/components/table/table.css".to_string(), + BUILTIN_TABLE_CSS.to_string(), + ), + ( + "fusion/components/table/table.js".to_string(), + BUILTIN_TABLE_JS.to_string(), + ), ( "fusion/monitor.html".to_string(), BUILTIN_MONITOR.to_string(), @@ -79,6 +114,26 @@ fn build_engine(root: &Path) -> Result { "fusion/form.js".to_string(), BUILTIN_FORM_JS.to_string(), ), + ( + "fusion/components/table/table.html".to_string(), + BUILTIN_TABLE.to_string(), + ), + ( + "fusion/components/toast/toast.html".to_string(), + BUILTIN_TOAST.to_string(), + ), + ( + "fusion/components/modal/modal.html".to_string(), + BUILTIN_MODAL.to_string(), + ), + ( + "fusion/index.html".to_string(), + BUILTIN_HOME.to_string(), + ), + ( + "fusion/home.html".to_string(), + BUILTIN_HOME.to_string(), + ), ]; if root.is_dir() { @@ -132,7 +187,7 @@ pub fn builtin_components() -> HashMap<&'static str, &'static str> { HashMap::from([ ( "button", - "{{}}", + "{{}}", ), ("link", "{{}}"), ("card", "{{}}"), @@ -146,7 +201,15 @@ pub fn builtin_components() -> HashMap<&'static str, &'static str> { ), ( "table", - "{{}}", + "{{}}", + ), + ( + "toast", + "{{}} /* then FusionToast.show(msg, { position, variant }) */", + ), + ( + "modal", + "{{}}", ), ]) } @@ -205,10 +268,12 @@ mod tests { ) .unwrap(); assert!(html.contains("fusion-table")); - assert!(html.contains("Name")); + assert!(html.contains("fusion-table__label")); + assert!(html.contains("Name")); assert!(html.contains("Widget")); assert!(html.contains("Products")); - assert!(!html.contains("fusion-table-pager")); + assert!(!html.contains("class=\"fusion-table-pager\"")); + assert!(html.contains("data-fusion-table")); let _ = std::fs::remove_dir_all(&dir); } @@ -238,6 +303,74 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn renders_table_with_column_widths() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_table_widths_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": ["Route", "Method"], + "rows": [["/health", "GET"]], + "widths": ["40%", "120px"], + }), + &dir, + ) + .unwrap(); + assert!(html.contains("fusion-table--sized")); + assert!(html.contains("data-resizable=\"true\"")); + assert!(html.contains("width: 40%")); + assert!(html.contains("width: 120px")); + assert!(html.contains("data-fusion-col-resize")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_toast_host_with_six_positions() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_toast_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("data-fusion-toaster")); + assert!(html.contains("data-default-position=\"bottom-right\"")); + assert!(html.contains("data-default-duration=\"2000\"")); + assert!(html.contains("data-position=\"top-left\"")); + assert!(html.contains("data-position=\"top-center\"")); + assert!(html.contains("data-position=\"top-right\"")); + assert!(html.contains("data-position=\"bottom-left\"")); + assert!(html.contains("data-position=\"bottom-center\"")); + assert!(html.contains("data-position=\"bottom-right\"")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn renders_modal_with_variant_size_animation() { + clear_template_cache(); + let tpl = r#"{{}}"#; + let dir = std::env::temp_dir().join("fusion_tpl_modal_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("data-fusion-modal")); + assert!(html.contains("id=\"confirm\"")); + assert!(html.contains("data-variant=\"warning\"")); + assert!(html.contains("data-size=\"lg\"")); + assert!(html.contains("data-animation=\"slide\"")); + assert!(html.contains("data-duration=\"300\"")); + assert!(html.contains("Delete?")); + assert!(html.contains("Sure?")); + assert!(html.contains("fusion-modal__dialog--warning")); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn includes_css_partial() { clear_template_cache(); @@ -324,4 +457,20 @@ mod tests { assert!(html.contains(".fusion-table")); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn includes_components_css_from_components_folder() { + clear_template_cache(); + let dir = std::env::temp_dir().join("fusion_tpl_components_folder_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-badge")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 4134794..8aa42ba 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -1197,6 +1197,90 @@ function mountMonitor(engine, settingsInstance) { /** @deprecated Use mountMonitor */ const mountCacheMonitor = mountMonitor +const DEFAULT_COMPONENT_PATH = '/__fusion/component' + +/** Locate fusion-core UI assets (components.html + CSS/JS) on disk. */ +function resolveFusionUiAssets() { + const candidates = [ + path.join(__dirname, '..', 'fusion-core', 'assets', 'templates', 'fusion'), + path.join(process.cwd(), 'crates', 'fusion-core', 'assets', 'templates', 'fusion'), + path.join(process.cwd(), '..', 'crates', 'fusion-core', 'assets', 'templates', 'fusion'), + ] + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, 'components.html'))) { + return candidate + } + } + return null +} + +/** Guess Content-Type for a gallery asset. */ +function fusionUiContentType(filePath) { + const ext = path.extname(filePath).toLowerCase() + if (ext === '.html') return 'text/html; charset=utf-8' + if (ext === '.css') return 'text/css; charset=utf-8' + if (ext === '.js') return 'application/javascript; charset=utf-8' + if (ext === '.svg') return 'image/svg+xml' + if (ext === '.png') return 'image/png' + return 'application/octet-stream' +} + +/** + * Mount the component gallery at /__fusion/component when assets exist on disk. + * Returns whether routes were registered. + */ +function mountComponentGallery(engine, settingsInstance) { + const root = resolveFusionUiAssets() + if (!root) return false + + const s = settingsInstance || settings + let mountPath = DEFAULT_COMPONENT_PATH + const raw = s.get('ui.component_path', null) + if (raw != null && String(raw).trim() !== '') { + mountPath = normalizeMonitorPath(raw) + } + + const gallery = path.join(root, 'components.html') + if (!fs.existsSync(gallery)) return false + + const htmlHandler = () => ({ + status: 200, + headers: { 'content-type': 'text/html; charset=utf-8' }, + body: fs.readFileSync(gallery), + }) + engine.route('GET', mountPath, htmlHandler) + if (mountPath !== '/') { + engine.route('GET', `${mountPath}/`, htmlHandler) + } + + const skip = new Set([ + 'components.html', + 'index.html', + 'monitor.html', + 'cache_monitor.html', + ]) + + function walk(dir, base) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(full, base) + continue + } + const rel = path.relative(base, full).split(path.sep).join('/') + if (skip.has(rel)) continue + const url = `${mountPath}/${rel}` + engine.route('GET', url, () => ({ + status: 200, + headers: { 'content-type': fusionUiContentType(full) }, + body: fs.readFileSync(full), + })) + } + } + walk(root, root) + return true +} + function mountSwaggerAssets(engine, prefix) { const assetsPrefix = `${prefix}/assets` for (const [name, { contentType, body }] of Object.entries(SWAGGER_ASSETS)) { @@ -1547,6 +1631,7 @@ class FusionApp { } mountMonitor(this.engine, settings) + mountComponentGallery(this.engine, settings) this.mounted = true } @@ -1993,6 +2078,7 @@ module.exports = { tasks, mountMonitor, mountCacheMonitor, + mountComponentGallery, renderTemplate, clearRouteRegistry, openapiSpec, diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py index f08e785..ca80263 100644 --- a/crates/fusion-py/python/fusion_framework/app.py +++ b/crates/fusion-py/python/fusion_framework/app.py @@ -412,6 +412,9 @@ def listen( from fusion_framework.monitor import mount_monitor mount_monitor(self._engine, self.settings) + from fusion_framework.ui import mount_component_gallery + + mount_component_gallery(self._engine, self.settings) self._mounted = True host = host if host is not None else self.settings.host port = port if port is not None else self.settings.port diff --git a/crates/fusion-py/python/fusion_framework/ui.py b/crates/fusion-py/python/fusion_framework/ui.py new file mode 100644 index 0000000..9598a5a --- /dev/null +++ b/crates/fusion-py/python/fusion_framework/ui.py @@ -0,0 +1,119 @@ +"""Built-in Fusion UI surfaces: component gallery at ``/__fusion/component``. + +Serves ``components.html`` (gallery) and static assets from the fusion-core +templates folder when that directory is available (editable / repo installs). +""" + +from __future__ import annotations + +import mimetypes +from pathlib import Path +from typing import Any + +DEFAULT_COMPONENT_PATH = "/__fusion/component" + + +def _normalize_path(raw: Any, default: str = DEFAULT_COMPONENT_PATH) -> str: + """Normalize a URL path (leading slash, no trailing slash).""" + path = str(raw or default).strip() or default + if not path.startswith("/"): + path = f"/{path}" + return path.rstrip("/") or default + + +def resolve_fusion_ui_assets() -> Path | None: + """Locate ``crates/fusion-core/assets/templates/fusion`` when present on disk.""" + here = Path(__file__).resolve() + candidates: list[Path] = [] + # Editable install: .../crates/fusion-py/python/fusion_framework/ui.py + if len(here.parents) >= 4: + candidates.append( + here.parents[3] / "fusion-core" / "assets" / "templates" / "fusion" + ) + # Repo checkout from examples/ or CWD + cwd = Path.cwd() + candidates.append(cwd / "crates" / "fusion-core" / "assets" / "templates" / "fusion") + candidates.append( + cwd.parent / "crates" / "fusion-core" / "assets" / "templates" / "fusion" + ) + for path in candidates: + if (path / "components.html").is_file(): + return path + return None + + +def _content_type(path: Path) -> str: + """Guess a Content-Type for a static UI asset.""" + guessed, _ = mimetypes.guess_type(str(path)) + if guessed: + return guessed + if path.suffix == ".js": + return "application/javascript; charset=utf-8" + if path.suffix == ".css": + return "text/css; charset=utf-8" + if path.suffix == ".html": + return "text/html; charset=utf-8" + return "application/octet-stream" + + +def mount_component_gallery(engine, settings=None) -> bool: + """Register ``/__fusion/component`` HTML + static assets when files exist. + + Returns whether routes were mounted. + """ + root = resolve_fusion_ui_assets() + if root is None: + return False + + path = DEFAULT_COMPONENT_PATH + if settings is not None: + raw = settings.get("ui.component_path", default=None) + if raw is not None and str(raw).strip(): + path = _normalize_path(raw) + + gallery = root / "components.html" + if not gallery.is_file(): + return False + + def html_handler(_req: dict) -> Any: + """Serve the component gallery HTML.""" + return { + "status": 200, + "headers": {"content-type": "text/html; charset=utf-8"}, + "body": gallery.read_bytes(), + } + + engine.route("GET", path, html_handler) + if path != "/": + engine.route("GET", f"{path}/", html_handler) + + # Register each asset under /__fusion/component/... + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel = file_path.relative_to(root).as_posix() + if rel in ("components.html", "index.html", "monitor.html", "cache_monitor.html"): + continue + url = f"{path}/{rel}" + + def _make_handler(target: Path): + def asset_handler(_req: dict, p: Path = target) -> Any: + """Serve one gallery static file.""" + return { + "status": 200, + "headers": {"content-type": _content_type(p)}, + "body": p.read_bytes(), + } + + return asset_handler + + engine.route("GET", url, _make_handler(file_path)) + + return True + + +__all__ = [ + "DEFAULT_COMPONENT_PATH", + "resolve_fusion_ui_assets", + "mount_component_gallery", +] diff --git a/examples/preview_templates.py b/examples/preview_templates.py index 78d3789..837f30e 100644 --- a/examples/preview_templates.py +++ b/examples/preview_templates.py @@ -1,54 +1,89 @@ -"""Preview the Fusion UI component gallery (index.html) at /ui. +"""Preview Fusion home + component gallery + monitor. .venv/bin/python examples/preview_templates.py Then open: - http://127.0.0.1:3456/ui + http://127.0.0.1:3456/ home (live cache + logs) + http://127.0.0.1:3456/__fusion/component + http://127.0.0.1:3456/__fusion/monitor + http://127.0.0.1:3456/swagger """ from __future__ import annotations +import tempfile from pathlib import Path -from fusion_framework import static_files +from fusion_framework import cache, static_files, tasks from fusion_framework.api import FusionBaseApi from fusion_framework.app import FusionApp from fusion_framework.config import settings from fusion_framework.route import route +from fusion_framework.template import render_template -# Built-in gallery: crates/fusion-core/assets/templates/fusion/index.html REPO_ROOT = Path(__file__).resolve().parents[1] FUSION_UI = REPO_ROOT / "crates" / "fusion-core" / "assets" / "templates" / "fusion" -INDEX_HTML = FUSION_UI / "index.html" +# Empty root → only built-in fusion/* templates (avoids parsing static components.html as Tera). +TEMPLATES_ROOT = Path(tempfile.gettempdir()) / "fusion_preview_templates_empty" +TEMPLATES_ROOT.mkdir(parents=True, exist_ok=True) +COMPONENT_PATH = "/__fusion/component" settings.configure( - monitor={"enabled": False}, + swagger={"enabled": True, "path": "/swagger"}, + monitor={"enabled": True, "path": "/__fusion/monitor"}, + cache={"driver": "moka", "max_events": 50}, + templates={"dir": str(TEMPLATES_ROOT)}, ) -@route("/ui") -class UiGallery(FusionBaseApi): - """Serve the component gallery HTML at /ui (assets under /ui/…).""" +@route("/") +class HomePage(FusionBaseApi): + """Serve the Fusion home page with live cache database + event logs.""" def get(self): - """Return index.html as a text/html response.""" + """Render fusion/index.html with the real monitor panel context.""" + # mount_monitor re-configures cache on listen and wipes pre-listen seeds; + # populate a small demo dataset on first empty view so tables stay real. + ctx = dict(cache.panel_context()) + if ctx.get("empty_entries"): + cache.set("demo:user", {"name": "Ada"}, ttl=60) + cache.set("session:1", {"ok": True}, ttl=300) + cache.set("feature:flag", "on") + ctx = dict(cache.panel_context()) + ctx["title"] = "Fusion" + html = render_template( + "fusion/index.html", + ctx, + templates_root=TEMPLATES_ROOT, + ) return { "status": 200, "headers": {"content-type": "text/html; charset=utf-8"}, - "body": INDEX_HTML.read_bytes(), + "body": html, } def main() -> None: - if not INDEX_HTML.is_file(): - raise SystemExit(f"Gallery not found: {INDEX_HTML}") + if not (FUSION_UI / "index.html").is_file(): + raise SystemExit(f"Home template not found: {FUSION_UI / 'index.html'}") + + cache.configure(settings) + cache.set("demo:user", {"name": "Ada"}, ttl=60) + cache.set("session:1", {"ok": True}, ttl=300) + cache.set("feature:flag", "on") + tasks.reset() + tasks.spawn(lambda: cache.set("from:task", True)) + tasks.spawn_after(5_000, lambda: None) print("Preview:", flush=True) - print(" http://127.0.0.1:3456/ui", flush=True) + print(" http://127.0.0.1:3456/", flush=True) + print(f" http://127.0.0.1:3456{COMPONENT_PATH}", flush=True) + print(" http://127.0.0.1:3456/__fusion/monitor", flush=True) + print(" http://127.0.0.1:3456/swagger", flush=True) app = FusionApp(settings) - # CSS/JS/components resolve via in index.html - app.use(static_files(root=FUSION_UI, prefix="/ui", max_age=0)) + # Gallery CSS/JS resolve via in components.html + app.use(static_files(root=FUSION_UI, prefix=COMPONENT_PATH, max_age=0)) app.listen(host="127.0.0.1", port=3456) diff --git a/tests/python/unit/test_cache.py b/tests/python/unit/test_cache.py index a6c3bb0..7398ceb 100644 --- a/tests/python/unit/test_cache.py +++ b/tests/python/unit/test_cache.py @@ -144,3 +144,15 @@ def test_mount_monitor_respects_enabled_flag(): ) engine_on = App() assert mount_monitor(engine_on, settings_on) is True + + +def test_mount_component_gallery_when_assets_exist(): + from fusion_framework._fusion import App + from fusion_framework.ui import mount_component_gallery, resolve_fusion_ui_assets + + root = resolve_fusion_ui_assets() + assert root is not None + assert (root / "components.html").is_file() + + engine = App() + assert mount_component_gallery(engine) is True