Spaces:
Running
Running
Space: shared app-shell sidebar (nav.json IR + sidebar.css/js) wired into Gradio
Browse filesThe same nav.json + sidebar.css + sidebar.js the React app uses (auto-battler
src/shell/*) now drive a slide-in sidebar on the Gradio Space: Python templates
the IR into markup, css_paths injects the shared CSS, head injects the shared
behaviour JS. Proves UI chrome is shareable across React and Gradio from one
source (Pixi excluded — it's canvas-only and already shared).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- .gitignore +2 -0
- __pycache__/app.cpython-313.pyc +0 -0
- app.py +32 -2
- web/shell/nav.json +23 -0
- web/shell/sidebar.css +76 -0
- web/shell/sidebar.js +53 -0
.gitignore
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
__pycache__/app.cpython-313.pyc
DELETED
|
Binary file (5.72 kB)
|
|
|
app.py
CHANGED
|
@@ -23,10 +23,38 @@ _manifest = json.load(open(os.path.join(WEB, "assets", "characters.json")))
|
|
| 23 |
CHARACTERS = [(c["name"], c["slug"]) for p in _manifest["packs"] for c in p["characters"]]
|
| 24 |
ANIMS = ["idle", "walk", "attack", "dmg", "die"]
|
| 25 |
|
| 26 |
-
HEAD = '<script type="module" src="/web/tiny.js"></script>'
|
|
|
|
|
|
|
| 27 |
STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def diary(unit: str, traits: str) -> str:
|
| 31 |
"""Stub — replaced during the hack by a local llama.cpp small model."""
|
| 32 |
t = (traits or "").strip() or "untested"
|
|
@@ -37,6 +65,7 @@ def diary(unit: str, traits: str) -> str:
|
|
| 37 |
|
| 38 |
|
| 39 |
with gr.Blocks(title="Tiny Army") as demo:
|
|
|
|
| 40 |
gr.Markdown("# ⚔️ Tiny Army\n*Every fighter writes its own legend — and the "
|
| 41 |
"legend is true.* Built on Gradio; sprites + engine reused from "
|
| 42 |
"auto-battler, rendered with Pixi.")
|
|
@@ -68,7 +97,8 @@ fastapi_app.mount("/web", StaticFiles(directory=WEB), name="web")
|
|
| 68 |
# NOTE: serve sprite assets at /sprites, NOT /assets — Gradio serves its own UI
|
| 69 |
# bundle from /assets, and mounting there shadows it (breaks the whole UI).
|
| 70 |
fastapi_app.mount("/sprites", StaticFiles(directory=os.path.join(WEB, "assets")), name="sprites")
|
| 71 |
-
app = gr.mount_gradio_app(fastapi_app, demo, path="/", head=HEAD,
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
if __name__ == "__main__":
|
|
|
|
| 23 |
CHARACTERS = [(c["name"], c["slug"]) for p in _manifest["packs"] for c in p["characters"]]
|
| 24 |
ANIMS = ["idle", "walk", "attack", "dmg", "die"]
|
| 25 |
|
| 26 |
+
HEAD = ('<script type="module" src="/web/tiny.js"></script>'
|
| 27 |
+
'<script src="/web/shell/sidebar.js"></script>')
|
| 28 |
+
SIDEBAR_CSS = os.path.join(WEB, "shell", "sidebar.css")
|
| 29 |
STAGE = "height:56vh;border:1px solid #20262e;border-radius:12px;overflow:hidden;background:#0b0e12"
|
| 30 |
|
| 31 |
|
| 32 |
+
# Shared app-shell sidebar: rendered from the SAME nav.json + sidebar.css +
|
| 33 |
+
# sidebar.js the React app uses (src/shell/*). Here we just template the IR into
|
| 34 |
+
# the markup; the CSS styles it and the JS slides/collapses it — proving the
|
| 35 |
+
# chrome is shareable across React and Gradio from one source.
|
| 36 |
+
def build_sidebar(nav):
|
| 37 |
+
b = nav.get("brand", {})
|
| 38 |
+
out = ['<aside class="tac-sidebar">',
|
| 39 |
+
f'<div class="tac-brand"><span class="tac-brand-icon">{b.get("icon","")}</span>'
|
| 40 |
+
f'<span>{b.get("title","")}</span>'
|
| 41 |
+
f'<button class="tac-collapse tac-toggle" title="Collapse">‹</button></div>']
|
| 42 |
+
for sec in nav.get("sections", []):
|
| 43 |
+
out.append('<div class="tac-section">')
|
| 44 |
+
if sec.get("title"):
|
| 45 |
+
out.append(f'<div class="tac-section-title">{sec["title"]}</div>')
|
| 46 |
+
for it in sec.get("items", []):
|
| 47 |
+
out.append(f'<a class="tac-nav-item" data-target="{it["target"]}" href="#">'
|
| 48 |
+
f'<span class="tac-ico">{it.get("icon","")}</span><span>{it["label"]}</span></a>')
|
| 49 |
+
out.append('</div>')
|
| 50 |
+
out.append('</aside>')
|
| 51 |
+
out.append('<button class="tac-toggle tac-reopen" title="Open menu">›</button>')
|
| 52 |
+
return "".join(out)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
SIDEBAR_HTML = build_sidebar(json.load(open(os.path.join(WEB, "shell", "nav.json"))))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def diary(unit: str, traits: str) -> str:
|
| 59 |
"""Stub — replaced during the hack by a local llama.cpp small model."""
|
| 60 |
t = (traits or "").strip() or "untested"
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
with gr.Blocks(title="Tiny Army") as demo:
|
| 68 |
+
gr.HTML(SIDEBAR_HTML)
|
| 69 |
gr.Markdown("# ⚔️ Tiny Army\n*Every fighter writes its own legend — and the "
|
| 70 |
"legend is true.* Built on Gradio; sprites + engine reused from "
|
| 71 |
"auto-battler, rendered with Pixi.")
|
|
|
|
| 97 |
# NOTE: serve sprite assets at /sprites, NOT /assets — Gradio serves its own UI
|
| 98 |
# bundle from /assets, and mounting there shadows it (breaks the whole UI).
|
| 99 |
fastapi_app.mount("/sprites", StaticFiles(directory=os.path.join(WEB, "assets")), name="sprites")
|
| 100 |
+
app = gr.mount_gradio_app(fastapi_app, demo, path="/", head=HEAD,
|
| 101 |
+
css_paths=[SIDEBAR_CSS], theme=gr.themes.Soft())
|
| 102 |
|
| 103 |
|
| 104 |
if __name__ == "__main__":
|
web/shell/nav.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"brand": { "title": "Tiny Army", "icon": "⚔️" },
|
| 3 |
+
"sections": [
|
| 4 |
+
{
|
| 5 |
+
"title": "Play",
|
| 6 |
+
"items": [
|
| 7 |
+
{ "label": "Battle", "target": "Battle", "icon": "◆" }
|
| 8 |
+
]
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"title": "Sandbox",
|
| 12 |
+
"items": [
|
| 13 |
+
{ "label": "Sprite Animations", "target": "Sprite Animations", "icon": "🎞" }
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"title": "Barracks",
|
| 18 |
+
"items": [
|
| 19 |
+
{ "label": "War Diaries", "target": "Barracks", "icon": "📓" }
|
| 20 |
+
]
|
| 21 |
+
}
|
| 22 |
+
]
|
| 23 |
+
}
|
web/shell/sidebar.css
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Shared app-shell sidebar — ONE source for both the React app and the Gradio
|
| 2 |
+
* Space. Pure CSS (framework-neutral); behaviour is in sidebar.js, content in
|
| 3 |
+
* nav.json. Everything is namespaced `.tac-*` so it never collides with Gradio's
|
| 4 |
+
* own classes. The collapse/slide is driven by a `tac-collapsed` class that
|
| 5 |
+
* sidebar.js toggles on <body>.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
:root {
|
| 9 |
+
--tac-w: 248px;
|
| 10 |
+
--tac-bg: #12161c;
|
| 11 |
+
--tac-bg-2: #0d1116;
|
| 12 |
+
--tac-ink: #e7e0cf;
|
| 13 |
+
--tac-muted: #8b95a3;
|
| 14 |
+
--tac-accent: #6b5bff;
|
| 15 |
+
--tac-border: #232a33;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/* The drawer — a fixed slide-in panel (works inside Gradio's flow). */
|
| 19 |
+
.tac-sidebar {
|
| 20 |
+
position: fixed; top: 0; left: 0; bottom: 0; z-index: 1000;
|
| 21 |
+
width: var(--tac-w); box-sizing: border-box;
|
| 22 |
+
background: var(--tac-bg); border-right: 1px solid var(--tac-border);
|
| 23 |
+
color: var(--tac-ink); font-family: ui-monospace, Menlo, monospace;
|
| 24 |
+
display: flex; flex-direction: column;
|
| 25 |
+
transform: translateX(0); transition: transform .22s ease;
|
| 26 |
+
overflow-y: auto;
|
| 27 |
+
}
|
| 28 |
+
body.tac-collapsed .tac-sidebar { transform: translateX(-100%); }
|
| 29 |
+
|
| 30 |
+
/* Push the page content over on desktop when the drawer is open; on mobile it
|
| 31 |
+
* just overlays (see media query). */
|
| 32 |
+
.gradio-container { transition: margin-left .22s ease; }
|
| 33 |
+
body:not(.tac-collapsed) .gradio-container { margin-left: var(--tac-w); }
|
| 34 |
+
|
| 35 |
+
.tac-brand {
|
| 36 |
+
display: flex; align-items: center; gap: 8px;
|
| 37 |
+
padding: 14px 14px 10px; font-weight: 700; font-size: 15px; letter-spacing: .02em;
|
| 38 |
+
border-bottom: 1px solid var(--tac-border);
|
| 39 |
+
}
|
| 40 |
+
.tac-brand .tac-brand-icon { font-size: 18px; }
|
| 41 |
+
.tac-brand .tac-collapse {
|
| 42 |
+
margin-left: auto; background: none; border: 0; color: var(--tac-muted);
|
| 43 |
+
cursor: pointer; font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px;
|
| 44 |
+
}
|
| 45 |
+
.tac-brand .tac-collapse:hover { background: var(--tac-bg-2); color: var(--tac-ink); }
|
| 46 |
+
|
| 47 |
+
.tac-section { padding: 10px 8px 0; }
|
| 48 |
+
.tac-section-title {
|
| 49 |
+
font-size: 10px; letter-spacing: .18em; text-transform: uppercase;
|
| 50 |
+
color: var(--tac-muted); padding: 4px 8px;
|
| 51 |
+
}
|
| 52 |
+
.tac-nav-item {
|
| 53 |
+
display: flex; align-items: center; gap: 8px; width: 100%;
|
| 54 |
+
padding: 8px 10px; margin: 1px 0; border: 0; border-radius: 8px;
|
| 55 |
+
background: none; color: var(--tac-ink); font: inherit; text-align: left;
|
| 56 |
+
cursor: pointer; text-decoration: none;
|
| 57 |
+
}
|
| 58 |
+
.tac-nav-item:hover { background: var(--tac-bg-2); }
|
| 59 |
+
.tac-nav-item.active { background: color-mix(in srgb, var(--tac-accent) 22%, transparent); }
|
| 60 |
+
.tac-nav-item .tac-ico { width: 18px; text-align: center; opacity: .9; }
|
| 61 |
+
|
| 62 |
+
/* Edge tab to reopen when collapsed. */
|
| 63 |
+
.tac-reopen {
|
| 64 |
+
position: fixed; top: 12px; left: 0; z-index: 1001;
|
| 65 |
+
display: none; background: var(--tac-bg); color: var(--tac-ink);
|
| 66 |
+
border: 1px solid var(--tac-border); border-left: 0;
|
| 67 |
+
border-radius: 0 8px 8px 0; padding: 8px 10px; cursor: pointer; font-size: 14px;
|
| 68 |
+
}
|
| 69 |
+
body.tac-collapsed .tac-reopen { display: block; }
|
| 70 |
+
|
| 71 |
+
/* Mobile: the drawer overlays full-bleed-ish and never pushes content. JS
|
| 72 |
+
* auto-collapses below this width on load + resize. */
|
| 73 |
+
@media (max-width: 768px) {
|
| 74 |
+
.tac-sidebar { width: min(280px, 86vw); box-shadow: 4px 0 24px rgba(0,0,0,.5); }
|
| 75 |
+
body:not(.tac-collapsed) .gradio-container { margin-left: 0; }
|
| 76 |
+
}
|
web/shell/sidebar.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Shared app-shell sidebar behaviour — ONE source for React and Gradio. Pure
|
| 2 |
+
* vanilla DOM (no framework): it toggles a `tac-collapsed` class on <body> (CSS
|
| 3 |
+
* does the slide), auto-collapses below 768px on load + resize, and routes nav
|
| 4 |
+
* clicks to the host (a Gradio tab, or a hash route in React). Because it's all
|
| 5 |
+
* event-delegation + class toggling, the SAME file runs in both frameworks.
|
| 6 |
+
*/
|
| 7 |
+
(function () {
|
| 8 |
+
var MOBILE = '(max-width: 768px)'
|
| 9 |
+
var userToggled = false
|
| 10 |
+
|
| 11 |
+
function isMobile() { return window.matchMedia(MOBILE).matches }
|
| 12 |
+
function collapsed() { return document.body.classList.contains('tac-collapsed') }
|
| 13 |
+
function setCollapsed(c) { document.body.classList.toggle('tac-collapsed', !!c) }
|
| 14 |
+
|
| 15 |
+
// Auto-collapse on mobile unless the user has explicitly toggled this session.
|
| 16 |
+
function applyResponsive() { if (!userToggled) setCollapsed(isMobile()) }
|
| 17 |
+
|
| 18 |
+
// Highlight the active nav item by its target label.
|
| 19 |
+
function setActive(target) {
|
| 20 |
+
document.querySelectorAll('.tac-nav-item').forEach(function (el) {
|
| 21 |
+
el.classList.toggle('active', el.getAttribute('data-target') === target)
|
| 22 |
+
})
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
// Host hook: how a nav item activates a view. Override window.tacNavigate to
|
| 26 |
+
// customise; default switches the active Gradio tab by matching its label.
|
| 27 |
+
window.tacNavigate = window.tacNavigate || function (target) {
|
| 28 |
+
var tab = Array.prototype.find.call(
|
| 29 |
+
document.querySelectorAll('button[role="tab"]'),
|
| 30 |
+
function (b) { return b.textContent.trim() === target })
|
| 31 |
+
if (tab) tab.click()
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
document.addEventListener('click', function (e) {
|
| 35 |
+
if (e.target.closest('.tac-toggle')) {
|
| 36 |
+
userToggled = true; setCollapsed(!collapsed()); return
|
| 37 |
+
}
|
| 38 |
+
var item = e.target.closest('.tac-nav-item')
|
| 39 |
+
if (item) {
|
| 40 |
+
e.preventDefault()
|
| 41 |
+
var target = item.getAttribute('data-target')
|
| 42 |
+
setActive(target)
|
| 43 |
+
try { window.tacNavigate(target) } catch (_) {}
|
| 44 |
+
if (isMobile()) setCollapsed(true) // close the drawer after navigating on phones
|
| 45 |
+
}
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
window.addEventListener('resize', applyResponsive)
|
| 49 |
+
// Run once the DOM is ready (the sidebar markup may mount after this script).
|
| 50 |
+
if (document.readyState === 'loading') {
|
| 51 |
+
document.addEventListener('DOMContentLoaded', applyResponsive)
|
| 52 |
+
} else { applyResponsive() }
|
| 53 |
+
})()
|