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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .agents/skills/fusion-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions bindings/csharp/FusionFramework/FusionApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<object?> handler)
Expand Down
129 changes: 129 additions & 0 deletions bindings/csharp/FusionFramework/FusionUi.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System.Text.Json.Nodes;

namespace FusionFramework;

/// <summary>
/// Built-in UI component gallery at <c>/__fusion/component</c> (when assets exist on disk).
/// </summary>
public static class FusionUi
{
public const string DefaultComponentPath = "/__fusion/component";

/// <summary>Register gallery HTML + static assets when the fusion templates folder is found.</summary>
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<string>(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;
}

/// <summary>Find crates/fusion-core/assets/templates/fusion relative to the process.</summary>
static string? ResolveAssetsRoot()
{
var candidates = new List<string>();
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<string, object?>
{
["status"] = 200,
["headers"] = new Dictionary<string, string> { ["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<string>(),
JsonNode n => n.ToJsonString().Trim('"'),
_ => value.ToString(),
};
}
91 changes: 86 additions & 5 deletions crates/fusion-core/assets/templates/fusion/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,100 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}{{ title | default(value="Fusion") }}{% endblock %}</title>
<script>
(function () {
try {
var stored = localStorage.getItem("fusion-theme");
var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (stored === "dark" || (!stored && prefersDark)) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
} catch (_) {}
})();
</script>
<style>
{% include "fusion/global.css" %}
{% include "fusion/components/components.css" %}
{% include "fusion/components/button/button.css" %}
{% include "fusion/components/table/table.css" %}

body {
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 2rem;
color: #0f172a;
background: #f8fafc;
min-height: 100vh;
padding: 0;
font-family: var(--font-sans);
color: var(--foreground);
background: var(--background);
transition:
background-color var(--transition-fast),
color var(--transition-fast);
}

.fusion-theme-toggle {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
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: var(--card);
border: var(--border-width) solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-xs);
cursor: pointer;
outline: none;
transition:
background-color var(--transition-fast),
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}

.fusion-theme-toggle:hover {
border-color: var(--ring);
background: var(--accent);
color: var(--accent-foreground);
}

.fusion-theme-toggle:focus-visible {
box-shadow:
var(--shadow-xs),
0 0 0 var(--ring-width) color-mix(in oklch, var(--ring) 25%, transparent);
}
{% include "fusion/components.css" %}
</style>
{% block head %}{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
<script>{% include "fusion/components/table/table.js" %}</script>
<script>
(function () {
/** Sync theme toggle label/pressed state with <html class="dark">. */
function syncFusionThemeToggle(btn) {
var dark = document.documentElement.classList.contains("dark");
btn.setAttribute("aria-pressed", dark ? "true" : "false");
var label = btn.querySelector("[data-theme-label]");
if (label) label.textContent = dark ? "Light" : "Dark";
}

document.querySelectorAll("[data-fusion-theme-toggle]").forEach(function (btn) {
syncFusionThemeToggle(btn);
btn.addEventListener("click", function () {
var next = document.documentElement.classList.toggle("dark") ? "dark" : "light";
try {
localStorage.setItem("fusion-theme", next);
} catch (_) {}
syncFusionThemeToggle(btn);
});
});
})();
</script>
{% block scripts %}{% endblock %}
</body>
</html>
Loading