From b9ce5f3bd737908d12fb724e42ee0b6dcaf83f17 Mon Sep 17 00:00:00 2001 From: Vittorio Distefano Date: Fri, 20 Mar 2026 22:32:55 +0100 Subject: [PATCH] Emit versioned asset bundles for immutable caching --- .github/workflows/ci.yml | 2 +- .versionrc.json | 5 +- Makefile | 16 +- README.md | 9 + src/lib.rs | 44 +- static/sf/sf.0.1.0.css | 1973 ++++++++++++++++++++++++++++++++++++++ static/sf/sf.0.1.0.js | 1875 ++++++++++++++++++++++++++++++++++++ 7 files changed, 3915 insertions(+), 9 deletions(-) create mode 100644 static/sf/sf.0.1.0.css create mode 100644 static/sf/sf.0.1.0.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26de168..42f057e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: run: | make clean make assets - git diff --exit-code static/sf/sf.css static/sf/sf.js + git diff --exit-code static/sf/sf.css static/sf/sf.js static/sf/sf.*.css static/sf/sf.*.js - name: Check formatting run: cargo fmt --all -- --check diff --git a/.versionrc.json b/.versionrc.json index efebdef..9add22d 100644 --- a/.versionrc.json +++ b/.versionrc.json @@ -10,5 +10,8 @@ "filename": "Cargo.toml", "updater": "scripts/cargo-version.js" } - ] + ], + "scripts": { + "postbump": "make clean assets && git add -A static/sf" + } } diff --git a/Makefile b/Makefile index cd9d69d..3062993 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,8 @@ RUST_VERSION := 1.75+ # ============== Asset Sources ============== CSS_SRC := $(sort $(wildcard css-src/*.css)) JS_SRC := $(sort $(wildcard js-src/*.js)) +VERSIONED_CSS := static/sf/sf.$(VERSION).css +VERSIONED_JS := static/sf/sf.$(VERSION).js # ============== Phony Targets ============== .PHONY: banner help assets build build-release test test-quick test-doc test-unit test-one \ @@ -40,16 +42,18 @@ banner: # ============== Asset Targets ============== -assets: static/sf/sf.css static/sf/sf.js +assets: static/sf/sf.css static/sf/sf.js $(VERSIONED_CSS) $(VERSIONED_JS) -static/sf/sf.css: $(CSS_SRC) +static/sf/sf.css $(VERSIONED_CSS): $(CSS_SRC) @printf "$(PROGRESS) CSS sf.css ($(words $(CSS_SRC)) files)\n" - @cat $(CSS_SRC) > $@ + @cat $(CSS_SRC) > static/sf/sf.css + @cp static/sf/sf.css $(VERSIONED_CSS) @printf "$(GREEN)$(CHECK) CSS bundled$(RESET)\n" -static/sf/sf.js: $(JS_SRC) +static/sf/sf.js $(VERSIONED_JS): $(JS_SRC) @printf "$(PROGRESS) JS sf.js ($(words $(JS_SRC)) files)\n" - @cat $(JS_SRC) > $@ + @cat $(JS_SRC) > static/sf/sf.js + @cp static/sf/sf.js $(VERSIONED_JS) @printf "$(GREEN)$(CHECK) JS bundled$(RESET)\n" # ============== Build Targets ============== @@ -238,7 +242,7 @@ publish: banner clean: @printf "$(ARROW) Cleaning build artifacts...\n" @cargo clean - @rm -f static/sf/sf.css static/sf/sf.js + @rm -f static/sf/sf.css static/sf/sf.js static/sf/sf.*.css static/sf/sf.*.js @printf "$(GREEN)$(CHECK) Clean complete$(RESET)\n" # ============== Development ============== diff --git a/README.md b/README.md index 428cf9b..6ddcdcf 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,11 @@ This repository keeps both shipped UI code and design exploration in the same tr - Planned or exploratory ideas may appear in CSS or wireframes before the public API is finished. Those should not be treated as supported integration surface until they are wired into a shipped asset and described in the README API reference. - When adding new surface area, update the JavaScript API, README, and runnable examples in the same change so the public contract stays explicit. +For production caching, versioned bundle filenames are also emitted as +`/sf/sf..css` and `/sf/sf..js`. Those versioned +files are served with immutable caching, while the stable `sf.css` and `sf.js` +paths remain available for compatibility. + ## Screenshots **Planner123** — Gantt chart with split panes, project-colored bars, and constraint scoring: @@ -506,6 +511,10 @@ Use `make package-verify` to inspect the exact crate contents that would be publ The verification step checks that required bundled assets and crate metadata are present, and that development-only sources such as `css-src/`, `js-src/`, `scripts/`, and screenshots are not shipped in the published crate. +Bundling writes both stable compatibility assets (`static/sf/sf.css`, +`static/sf/sf.js`) and versioned assets (`static/sf/sf..css`, +`static/sf/sf..js`). + ## Acknowledgments solverforge-ui builds on these excellent open-source projects: diff --git a/src/lib.rs b/src/lib.rs index 1181504..eff2884 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,7 +53,27 @@ fn mime_from_path(path: &str) -> &'static str { } fn is_immutable(path: &str) -> bool { - path.starts_with("fonts/") || path.starts_with("vendor/") || path.starts_with("img/") + path.starts_with("fonts/") + || path.starts_with("vendor/") + || path.starts_with("img/") + || is_versioned_bundle(path) +} + +fn is_versioned_bundle(path: &str) -> bool { + path.strip_prefix("sf.") + .and_then(|rest| rest.rsplit_once('.')) + .map(|(version, ext)| { + !version.is_empty() + && version.chars().all(|ch| { + ch.is_ascii_digit() + || ch == '.' + || ch == '-' + || ch == '+' + || ch.is_ascii_alphabetic() + }) + && matches!(ext, "css" | "js") + }) + .unwrap_or(false) } #[cfg(test)] @@ -65,6 +85,17 @@ mod tests { }; use tower::util::ServiceExt; + #[test] + fn versioned_bundles_are_detected() { + assert!(is_versioned_bundle("sf.0.1.0.css")); + assert!(is_versioned_bundle("sf.0.1.0.js")); + assert!(is_versioned_bundle("sf.0.2.0-beta.1.js")); + assert!(is_versioned_bundle("sf.0.1.0+build.7.css")); + assert!(!is_versioned_bundle("sf.css")); + assert!(!is_versioned_bundle("sf.js")); + assert!(!is_versioned_bundle("vendor/sf.0.1.0.js")); + } + #[test] fn caches_paths_are_predicted_correctly() { assert_eq!(mime_from_path("styles/sf.css"), "text/css; charset=utf-8"); @@ -78,9 +109,20 @@ mod tests { assert!(is_immutable("fonts/jetbrains-mono.woff2")); assert!(is_immutable("vendor/leaflet/leaflet.js")); assert!(is_immutable("img/solverforge-logo.svg")); + assert!(is_immutable("sf.0.1.0.css")); + assert!(is_immutable("sf.0.1.0+build.7.js")); assert!(!is_immutable("sf.css")); } + #[test] + fn mime_detection_still_works_for_versioned_assets() { + assert_eq!(mime_from_path("sf.0.1.0.css"), "text/css; charset=utf-8"); + assert_eq!( + mime_from_path("sf.0.1.0+build.7.js"), + "application/javascript; charset=utf-8" + ); + } + #[tokio::test] async fn serves_assets_with_expected_headers() { let app = routes(); diff --git a/static/sf/sf.0.1.0.css b/static/sf/sf.0.1.0.css new file mode 100644 index 0000000..d87d351 --- /dev/null +++ b/static/sf/sf.0.1.0.css @@ -0,0 +1,1973 @@ +/* ============================================================================ + SolverForge UI — Design System Tokens + Single source of truth for the emerald theme. + ============================================================================ */ + +:root { + /* ======================================================================== + Core Color Palette + ======================================================================== */ + + /* Emerald (Primary Brand Colors) */ + --sf-emerald-50: #ecfdf5; + --sf-emerald-100: #d1fae5; + --sf-emerald-200: #a7f3d0; + --sf-emerald-300: #6ee7b7; + --sf-emerald-400: #34d399; + --sf-emerald-500: #10b981; + --sf-emerald-600: #059669; + --sf-emerald-700: #047857; + --sf-emerald-800: #065f46; + --sf-emerald-900: #064e3b; + + /* Grays */ + --sf-gray-50: #f9fafb; + --sf-gray-100: #f3f4f6; + --sf-gray-200: #e5e7eb; + --sf-gray-300: #d1d5db; + --sf-gray-400: #9ca3af; + --sf-gray-500: #6b7280; + --sf-gray-600: #4b5563; + --sf-gray-700: #374151; + --sf-gray-800: #1f2937; + --sf-gray-900: #111827; + + /* Red (Error/Danger) */ + --sf-red-50: #fef2f2; + --sf-red-100: #fee2e2; + --sf-red-200: #fecaca; + --sf-red-300: #fca5a5; + --sf-red-400: #f87171; + --sf-red-500: #ef4444; + --sf-red-600: #dc2626; + --sf-red-700: #b91c1c; + --sf-red-800: #991b1b; + --sf-red-900: #7f1d1d; + + /* Amber (Warning) */ + --sf-amber-50: #fffbeb; + --sf-amber-100: #fef3c7; + --sf-amber-200: #fde68a; + --sf-amber-300: #fcd34d; + --sf-amber-400: #fbbf24; + --sf-amber-500: #f59e0b; + --sf-amber-600: #d97706; + --sf-amber-700: #b45309; + --sf-amber-800: #92400e; + --sf-amber-900: #78350f; + + /* Blue (Info) */ + --sf-blue-50: #eff6ff; + --sf-blue-100: #dbeafe; + --sf-blue-200: #bfdbfe; + --sf-blue-300: #93c5fd; + --sf-blue-400: #60a5fa; + --sf-blue-500: #3b82f6; + --sf-blue-600: #2563eb; + --sf-blue-700: #1d4ed8; + --sf-blue-800: #1e40af; + --sf-blue-900: #1e3a8a; + + /* ── Project Colors (8 distinct) ── */ + --sf-project-0: var(--sf-emerald-500); + --sf-project-0-dark: var(--sf-emerald-700); + --sf-project-0-light: rgba(16, 185, 129, 0.15); + + --sf-project-1: #3b82f6; + --sf-project-1-dark: #1d4ed8; + --sf-project-1-light: rgba(59, 130, 246, 0.15); + + --sf-project-2: #8b5cf6; + --sf-project-2-dark: #6d28d9; + --sf-project-2-light: rgba(139, 92, 246, 0.15); + + --sf-project-3: var(--sf-amber-500); + --sf-project-3-dark: var(--sf-amber-700); + --sf-project-3-light: rgba(245, 158, 11, 0.15); + + --sf-project-4: #ec4899; + --sf-project-4-dark: #be185d; + --sf-project-4-light: rgba(236, 72, 153, 0.15); + + --sf-project-5: #06b6d4; + --sf-project-5-dark: #0e7490; + --sf-project-5-light: rgba(6, 182, 212, 0.15); + + --sf-project-6: #f43f5e; + --sf-project-6-dark: #be123c; + --sf-project-6-light: rgba(244, 63, 94, 0.15); + + --sf-project-7: #84cc16; + --sf-project-7-dark: #4d7c0f; + --sf-project-7-light: rgba(132, 204, 22, 0.15); + + /* ── Priority Colors ── */ + --sf-priority-high: var(--sf-red-500); + --sf-priority-high-bg: var(--sf-red-100); + --sf-priority-medium: var(--sf-amber-500); + --sf-priority-medium-bg: var(--sf-amber-100); + --sf-priority-low: #22c55e; + --sf-priority-low-bg: #dcfce7; + + /* ── Semantic Colors ── */ + --sf-color-primary: var(--sf-emerald-500); + --sf-color-primary-dark: var(--sf-emerald-600); + --sf-color-primary-darker: var(--sf-emerald-700); + --sf-color-primary-light: var(--sf-emerald-400); + --sf-color-primary-lighter: var(--sf-emerald-300); + + --sf-color-background: #f8f9fa; + --sf-color-surface: white; + --sf-color-text: var(--sf-gray-900); + --sf-color-text-secondary: var(--sf-gray-600); + --sf-color-text-tertiary: var(--sf-gray-500); + --sf-color-text-inverse: white; + + --sf-color-border: var(--sf-gray-200); + --sf-color-border-light: var(--sf-gray-100); + --sf-color-border-dark: var(--sf-gray-300); + + /* ======================================================================== + Spacing & Sizing + ======================================================================== */ + --sf-space-0: 0; + --sf-space-1: 0.25rem; + --sf-space-2: 0.5rem; + --sf-space-3: 0.75rem; + --sf-space-4: 1rem; + --sf-space-5: 1.25rem; + --sf-space-6: 1.5rem; + --sf-space-8: 2rem; + --sf-space-10: 2.5rem; + --sf-space-12: 3rem; + --sf-space-16: 4rem; + + /* ── Fonts ── */ + --sf-font-body: 'Space Grotesk', sans-serif; + --sf-font-mono: 'JetBrains Mono', monospace; + + --sf-text-xs: 0.75rem; + --sf-text-sm: 0.875rem; + --sf-text-base: 1rem; + --sf-text-lg: 1.125rem; + --sf-text-xl: 1.25rem; + --sf-text-2xl: 1.5rem; + --sf-text-3xl: 1.875rem; + --sf-text-4xl: 2.25rem; + + --sf-font-normal: 400; + --sf-font-medium: 500; + --sf-font-semibold: 600; + --sf-font-bold: 700; + + --sf-line-none: 1; + --sf-line-tight: 1.25; + --sf-line-snug: 1.375; + --sf-line-normal: 1.5; + --sf-line-relaxed: 1.625; + + /* ======================================================================== + Borders & Radii + ======================================================================== */ + --sf-border-1: 1px; + --sf-border-2: 2px; + + --sf-radius-none: 0; + --sf-radius-sm: 0.125rem; + --sf-radius-base: 0.25rem; + --sf-radius-md: 0.375rem; + --sf-radius-lg: 0.5rem; + --sf-radius-xl: 0.75rem; + --sf-radius-2xl: 1rem; + --sf-radius-full: 9999px; + + /* ======================================================================== + Shadows + ======================================================================== */ + --sf-shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --sf-shadow-base: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); + --sf-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --sf-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --sf-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + --sf-shadow-2xl: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + --sf-shadow-inner: inset 0 2px 4px 0 rgba(0, 0, 0, 0.06); + --sf-shadow-emerald: 0 4px 12px rgba(16, 185, 129, 0.3); + --sf-shadow-red: 0 4px 12px rgba(239, 68, 68, 0.3); + + /* ======================================================================== + Transitions + ======================================================================== */ + --sf-duration-fast: 150ms; + --sf-duration-base: 200ms; + --sf-duration-slow: 300ms; + --sf-duration-slower: 500ms; + + --sf-ease-linear: linear; + --sf-ease-in: cubic-bezier(0.4, 0, 1, 1); + --sf-ease-out: cubic-bezier(0, 0, 0.2, 1); + --sf-ease-in-out: cubic-bezier(0.4, 0, 0.2, 1); + --sf-ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + + --sf-transition-all: all var(--sf-duration-base) var(--sf-ease-in-out); + --sf-transition-colors: color var(--sf-duration-base) var(--sf-ease-in-out), + background-color var(--sf-duration-base) var(--sf-ease-in-out), + border-color var(--sf-duration-base) var(--sf-ease-in-out); + --sf-transition-transform: transform var(--sf-duration-base) var(--sf-ease-in-out); + --sf-transition-shadow: box-shadow var(--sf-duration-base) var(--sf-ease-in-out); + + /* ======================================================================== + Z-Index Scale + ======================================================================== */ + --sf-z-0: 0; + --sf-z-10: 10; + --sf-z-20: 20; + --sf-z-30: 30; + --sf-z-40: 40; + --sf-z-50: 50; + --sf-z-dropdown: 1000; + --sf-z-sticky: 1020; + --sf-z-fixed: 1030; + --sf-z-modal-backdrop: 1040; + --sf-z-modal: 1050; + --sf-z-popover: 1060; + --sf-z-tooltip: 1070; + + /* ======================================================================== + Gradients + ======================================================================== */ + --sf-gradient-primary: linear-gradient(135deg, var(--sf-emerald-500) 0%, var(--sf-emerald-600) 100%); + --sf-gradient-primary-reverse: linear-gradient(135deg, var(--sf-emerald-600) 0%, var(--sf-emerald-700) 100%); + --sf-gradient-header: var(--sf-gradient-primary); + --sf-gradient-danger: linear-gradient(135deg, var(--sf-red-500) 0%, var(--sf-red-600) 100%); + --sf-gradient-warning: linear-gradient(135deg, var(--sf-amber-500) 0%, var(--sf-amber-600) 100%); + --sf-gradient-surface: linear-gradient(180deg, white 0%, var(--sf-gray-50) 100%); + + /* ======================================================================== + Component Variables + ======================================================================== */ + + /* Header */ + --sf-header-bg: var(--sf-gradient-header); + --sf-header-height: 60px; + --sf-header-padding-x: var(--sf-space-8); + + /* Buttons */ + --sf-btn-padding-x: var(--sf-space-4); + --sf-btn-padding-y: var(--sf-space-2); + --sf-btn-radius: var(--sf-radius-md); + --sf-btn-font-weight: var(--sf-font-medium); + + /* Forms */ + --sf-input-border-color: var(--sf-emerald-500); + --sf-input-radius: var(--sf-radius-base); + + /* Cards */ + --sf-card-radius: var(--sf-radius-lg); + --sf-card-padding: var(--sf-space-4); + --sf-card-shadow: var(--sf-shadow-base); + + /* Modals */ + --sf-modal-radius: var(--sf-radius-xl); + --sf-modal-shadow: var(--sf-shadow-2xl); + --sf-modal-overlay-bg: rgba(0, 0, 0, 0.6); + --sf-modal-header-bg: var(--sf-gradient-primary); + + /* Tables */ + --sf-table-header-bg: var(--sf-gray-100); + --sf-table-row-hover: var(--sf-gray-50); + + /* Rows */ + --sf-row-height: 36px; + --sf-row-padding-x: var(--sf-space-3); + --sf-row-padding-y: var(--sf-space-2); + + /* Badges */ + --sf-badge-font-size: var(--sf-text-xs); + --sf-badge-font-weight: var(--sf-font-semibold); + --sf-badge-padding-x: var(--sf-space-2); + --sf-badge-padding-y: 2px; + --sf-badge-radius: var(--sf-radius-md); +} +/* ============================================================================ + SolverForge UI — Reset + ============================================================================ */ + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} +/* ============================================================================ + SolverForge UI — Typography + Self-hosted variable-weight fonts: Space Grotesk + JetBrains Mono + ============================================================================ */ + +@font-face { + font-family: 'Space Grotesk'; + src: url('/sf/fonts/space-grotesk.woff2') format('woff2'); + font-weight: 300 700; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'JetBrains Mono'; + src: url('/sf/fonts/jetbrains-mono.woff2') format('woff2'); + font-weight: 100 800; + font-style: normal; + font-display: swap; +} + +body { + font-family: var(--sf-font-body); + color: var(--sf-color-text); + line-height: var(--sf-line-normal); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +/* ============================================================================ + SolverForge UI — Layout + ============================================================================ */ + +.sf-app { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--sf-color-background); +} + +.sf-main { + flex: 1; + padding: var(--sf-space-4) var(--sf-space-6); + overflow-y: auto; +} + +.sf-tab-panel { + display: none; +} + +.sf-tab-panel.active { + display: block; +} +/* ============================================================================ + SolverForge UI — Header + ============================================================================ */ + +.sf-header { + background: var(--sf-header-bg); + height: var(--sf-header-height); + display: flex; + align-items: center; + padding: 0 var(--sf-header-padding-x); + gap: var(--sf-space-6); + box-shadow: 0 2px 8px rgba(5, 150, 105, 0.4); + position: sticky; + top: 0; + z-index: var(--sf-z-sticky); + flex-shrink: 0; +} + +.sf-header-logo { + height: 44px; + width: 44px; + filter: brightness(0) invert(1); +} + +.sf-header-brand { + display: flex; + flex-direction: column; +} + +.sf-header-title { + color: white; + font-size: var(--sf-text-lg); + font-weight: var(--sf-font-semibold); + letter-spacing: -0.01em; + line-height: var(--sf-line-tight); +} + +.sf-header-subtitle { + color: rgba(255, 255, 255, 0.7); + font-size: var(--sf-text-xs); + font-family: var(--sf-font-mono); +} + +.sf-header-nav { + display: flex; + gap: 2px; + margin-left: auto; +} + +.sf-nav-btn { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 5px 14px; + border-radius: var(--sf-radius-base); + font-size: var(--sf-text-sm); + font-family: var(--sf-font-body); + font-weight: var(--sf-font-medium); + cursor: pointer; + transition: var(--sf-transition-colors); + display: inline-flex; + align-items: center; + gap: 6px; +} + +.sf-nav-btn:hover, +.sf-nav-btn.active { + background: rgba(255, 255, 255, 0.25); + border-color: rgba(255, 255, 255, 0.4); +} + +.sf-header-actions { + display: flex; + align-items: center; + gap: var(--sf-space-2); + margin-left: var(--sf-space-4); +} + +/* Solving spinner */ +.sf-solving-spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: sf-spin 0.7s linear infinite; + display: none; +} + +.sf-solving-spinner.active { + display: inline-block; +} +/* ============================================================================ + SolverForge UI — Status Bar + ============================================================================ */ + +.sf-statusbar { + background: var(--sf-gray-50); + border-bottom: 1px solid var(--sf-gray-200); + padding: 6px var(--sf-header-padding-x); + display: flex; + align-items: center; + gap: var(--sf-space-6); + font-family: var(--sf-font-mono); + font-size: 12px; + color: var(--sf-gray-400); + flex-shrink: 0; +} + +.sf-statusbar-score { + font-weight: var(--sf-font-semibold); +} + +.sf-statusbar-score.score-green { color: var(--sf-emerald-600); } +.sf-statusbar-score.score-red { color: #e53e3e; } +.sf-statusbar-score.score-yellow { color: var(--sf-amber-400); } + +.sf-statusbar-score.improved { + animation: sf-score-flash 0.4s ease-out; +} + +.sf-statusbar-constraints { + display: flex; + gap: 4px; + align-items: center; +} + +.sf-constraint-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--sf-emerald-500); + transition: background 0.3s; + cursor: pointer; +} + +.sf-constraint-dot.violated { + background: var(--sf-red-500); + animation: sf-dot-pulse 1s ease-in-out infinite; +} + +.sf-constraint-dot.violated-soft { + background: var(--sf-amber-400); + animation: sf-dot-pulse 1s ease-in-out infinite; +} + +.sf-statusbar-sep { + color: var(--sf-gray-300); +} +/* ============================================================================ + SolverForge UI — Buttons + Unified system: variants, sizes, shapes, states. + ============================================================================ */ + +.sf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 16px; + border: none; + border-radius: var(--sf-radius-base); + font-family: var(--sf-font-body); + font-size: var(--sf-text-sm); + font-weight: var(--sf-font-semibold); + cursor: pointer; + transition: var(--sf-transition-all); + white-space: nowrap; + line-height: 1.4; +} + +.sf-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* ── Variants ── */ + +.sf-btn--primary { + background: var(--sf-emerald-700); + color: white; +} + +.sf-btn--primary:hover { + background: var(--sf-emerald-800); +} + +.sf-btn--success { + background: white; + color: var(--sf-emerald-700); +} + +.sf-btn--success:hover { + background: var(--sf-emerald-50); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +.sf-btn--danger { + background: var(--sf-red-600); + color: white; +} + +.sf-btn--danger:hover { + background: var(--sf-red-700); +} + +.sf-btn--default { + background: var(--sf-gray-100); + color: var(--sf-gray-700); + border: 1px solid var(--sf-gray-300); +} + +.sf-btn--default:hover { + background: var(--sf-gray-200); +} + +.sf-btn--ghost { + background: rgba(255, 255, 255, 0.15); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); +} + +.sf-btn--ghost:hover { + background: rgba(255, 255, 255, 0.25); +} + +/* ── Outline Modifier ── */ + +.sf-btn--outline { + background: transparent; +} + +.sf-btn--outline.sf-btn--primary { + color: var(--sf-emerald-700); + border: 1px solid var(--sf-emerald-700); +} + +.sf-btn--outline.sf-btn--primary:hover { + background: var(--sf-emerald-50); +} + +.sf-btn--outline.sf-btn--danger { + color: var(--sf-red-600); + border: 1px solid var(--sf-red-600); + background: transparent; +} + +.sf-btn--outline.sf-btn--danger:hover { + background: var(--sf-red-50); +} + +/* ── Sizes ── */ + +.sf-btn--sm { + padding: 3px 10px; + font-size: var(--sf-text-xs); + gap: 4px; +} + +.sf-btn--lg { + padding: 10px 24px; + font-size: var(--sf-text-base); + gap: 8px; +} + +/* ── Shapes ── */ + +.sf-btn--pill { + border-radius: var(--sf-radius-full); +} + +.sf-btn--circle { + width: 30px; + height: 30px; + padding: 0; + border-radius: var(--sf-radius-base); +} + +.sf-btn--icon { + padding: 4px 8px; +} +/* ============================================================================ + SolverForge UI — Modal + ============================================================================ */ + +.sf-modal-overlay { + display: none; + position: fixed; + inset: 0; + background: var(--sf-modal-overlay-bg); + backdrop-filter: blur(2px); + z-index: var(--sf-z-modal-backdrop); + align-items: center; + justify-content: center; +} + +.sf-modal-overlay.open { + display: flex; +} + +.sf-modal { + background: white; + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-modal-radius); + max-width: 680px; + width: 95%; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: var(--sf-modal-shadow); + animation: sf-dialog-slide-in 0.18s var(--sf-ease-out); + position: relative; + z-index: var(--sf-z-modal); +} + +.sf-modal-header { + background: var(--sf-modal-header-bg); + padding: 12px 20px; + border-radius: var(--sf-modal-radius) var(--sf-modal-radius) 0 0; + display: flex; + justify-content: space-between; + align-items: center; +} + +.sf-modal-title { + color: white; + font-size: var(--sf-text-base); + font-weight: var(--sf-font-semibold); +} + +.sf-modal-close { + background: rgba(255, 255, 255, 0.15); + border: none; + color: white; + width: 26px; + height: 26px; + border-radius: var(--sf-radius-base); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.sf-modal-close:hover { + background: rgba(255, 255, 255, 0.25); +} + +.sf-modal-body { + padding: 20px 24px; + overflow-y: auto; + flex: 1; +} + +.sf-modal-footer { + padding: 12px 24px; + border-top: 1px solid var(--sf-gray-200); + display: flex; + justify-content: flex-end; + gap: var(--sf-space-2); +} +/* ============================================================================ + SolverForge UI — Data Tables + ============================================================================ */ + +.sf-table-container { + overflow-x: auto; + border-radius: var(--sf-radius-lg); + border: 1px solid var(--sf-gray-200); +} + +.sf-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.sf-table thead tr { + background: var(--sf-table-header-bg); +} + +.sf-table th { + padding: 9px 12px; + text-align: left; + font-size: 11px; + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-600); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + border-bottom: 1px solid var(--sf-gray-200); +} + +.sf-table td { + padding: 9px 12px; + border-bottom: 1px solid var(--sf-gray-100); + color: var(--sf-gray-800); + vertical-align: middle; +} + +.sf-table tbody tr:last-child td { + border-bottom: none; +} + +.sf-table tbody tr:hover { + background: var(--sf-table-row-hover); +} + +.sf-table-mono { + font-family: var(--sf-font-mono); + font-size: 12px; +} + +.sf-table-center { + text-align: center; +} + +.sf-table-name { + font-weight: var(--sf-font-semibold); + white-space: nowrap; +} + +/* ── Overview table (constraint analysis) ── */ +.sf-ov-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.sf-ov-th { padding: 7px 10px; text-align: left; font-family: var(--sf-font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: .06em; color: var(--sf-gray-500); border-bottom: 2px solid var(--sf-gray-200); } +.sf-ov-row { cursor: pointer; transition: background 0.12s; } +.sf-ov-row:hover td { background: var(--sf-gray-50); } +.sf-ov-row-violated td { background: #fef9f9; } +.sf-ov-row-violated:hover td { background: #fef2f2; } +.sf-ov-td { padding: 8px 10px; border-bottom: 1px solid var(--sf-gray-100); vertical-align: middle; } +.sf-ov-td-label { color: var(--sf-gray-800); font-weight: 500; font-family: var(--sf-font-body); } +.sf-ov-td-num { font-family: var(--sf-font-mono); font-size: 12px; color: var(--sf-gray-600); text-align: right; } +.sf-ov-td-score { font-family: var(--sf-font-mono); font-size: 12px; text-align: right; white-space: nowrap; } +.sf-ov-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; } +.sf-ov-dot-hard { background: var(--sf-red-500); } +.sf-ov-dot-soft { background: var(--sf-amber-400); } +.sf-ov-dot-ok { background: var(--sf-emerald-400); } +.sf-ov-type { font-family: var(--sf-font-mono); font-size: 9px; padding: 2px 6px; border-radius: 3px; font-weight: 700; letter-spacing: .05em; } +.sf-ov-type-hard { background: #fef2f2; color: #b91c1c; border: 1px solid #fca5a5; } +.sf-ov-type-soft { background: #fffbeb; color: #b45309; border: 1px solid #fcd34d; } +.sf-ov-score-hard { color: #b91c1c; } +.sf-ov-score-soft { color: #b45309; } +.sf-ov-score-ok { color: var(--sf-gray-400); } +/* ============================================================================ + SolverForge UI — Badges + ============================================================================ */ + +.sf-badge { + display: inline-block; + padding: var(--sf-badge-padding-y) var(--sf-badge-padding-x); + border-radius: var(--sf-badge-radius); + font-size: var(--sf-badge-font-size); + font-weight: var(--sf-badge-font-weight); + white-space: nowrap; +} + +/* Skill badge (emerald) */ +.sf-badge--skill { + background: var(--sf-emerald-50); + color: var(--sf-emerald-700); + border: 1px solid var(--sf-emerald-200); +} + +/* Priority badges */ +.sf-badge--high { + background: #fef2f2; + color: #dc2626; + border: 1px solid #fecaca; +} + +.sf-badge--medium { + background: #fffbeb; + color: #d97706; + border: 1px solid #fde68a; +} + +.sf-badge--low { + background: #f0fdf4; + color: #16a34a; + border: 1px solid #bbf7d0; +} + +/* Type badges (hard/soft constraint) */ +.sf-badge--hard { + font-family: var(--sf-font-mono); + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 700; + letter-spacing: .05em; + background: #fef2f2; + color: #b91c1c; + border: 1px solid #fca5a5; +} + +.sf-badge--soft { + font-family: var(--sf-font-mono); + font-size: 9px; + padding: 2px 6px; + border-radius: 3px; + font-weight: 700; + letter-spacing: .05em; + background: #fffbeb; + color: #b45309; + border: 1px solid #fcd34d; +} + +/* Process badge (colored by process) */ +.sf-badge--process { + padding: 2px 8px; + border-radius: 4px; + border: 1px solid; + font-size: 11px; + font-weight: var(--sf-font-semibold); +} +/* ============================================================================ + SolverForge UI — Cards & Panels + ============================================================================ */ + +.sf-card { + background: var(--sf-color-surface); + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-card-radius); + padding: var(--sf-card-padding); + box-shadow: var(--sf-card-shadow); +} + +/* Summary panel */ +.sf-summary-panel { + background: white; + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-base); + padding: var(--sf-space-4); +} + +.sf-summary-title { + font-size: 10px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--sf-gray-400); + margin-bottom: var(--sf-space-3); +} + +/* KPI cards */ +.sf-kpi-row { + display: flex; + gap: var(--sf-space-4); + flex-wrap: wrap; + margin-bottom: var(--sf-space-3); +} + +.sf-kpi-card { + background: var(--sf-gray-50); + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-base); + padding: 8px 16px; + min-width: 100px; + text-align: center; +} + +.sf-kpi-value { + font-size: var(--sf-text-2xl); + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-bold); + color: var(--sf-gray-900); + line-height: 1; +} + +.sf-kpi-value.danger { color: var(--sf-red-400); } +.sf-kpi-value.warn { color: var(--sf-amber-400); } +.sf-kpi-value.ok { color: var(--sf-emerald-400); } + +.sf-kpi-label { + font-size: 9px; + font-family: var(--sf-font-mono); + color: var(--sf-gray-500); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 3px; +} + +/* Chip */ +.sf-chip { + background: var(--sf-gray-100); + color: var(--sf-gray-700); + font-size: 10px; + font-family: var(--sf-font-mono); + padding: 2px 8px; + border-radius: 2px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.sf-chip-count { + background: var(--sf-gray-300); + color: var(--sf-gray-800); + font-size: 9px; + padding: 0 4px; + border-radius: 2px; + font-weight: var(--sf-font-semibold); +} +/* ============================================================================ + SolverForge UI — Tooltip + ============================================================================ */ + +.sf-tooltip { + position: fixed; + background: white; + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-lg); + padding: 10px 14px; + font-size: var(--sf-text-xs); + font-family: var(--sf-font-mono); + color: var(--sf-gray-700); + z-index: var(--sf-z-tooltip); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + max-width: 240px; + box-shadow: var(--sf-shadow-xl); +} + +.sf-tooltip.visible { + opacity: 1; +} + +.sf-tooltip-title { + font-size: 11px; + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-900); + margin-bottom: 6px; +} + +.sf-tooltip-row { + display: flex; + justify-content: space-between; + gap: 12px; + margin-bottom: 2px; + line-height: 1.4; +} + +.sf-tooltip-key { + color: var(--sf-gray-400); + font-size: 10px; +} + +.sf-tooltip-val { + color: var(--sf-gray-700); + font-size: 10px; + font-weight: var(--sf-font-medium); + text-align: right; +} +/* ============================================================================ + SolverForge UI — Footer + ============================================================================ */ + +.sf-footer { + background: var(--sf-gray-50); + border-top: 1px solid var(--sf-gray-200); + padding: 10px var(--sf-header-padding-x); + display: flex; + align-items: center; + gap: var(--sf-space-4); + font-size: 11px; + color: var(--sf-gray-400); + flex-shrink: 0; +} + +.sf-footer a { + color: var(--sf-gray-500); + text-decoration: none; +} + +.sf-footer a:hover { + color: var(--sf-emerald-600); +} + +.sf-footer .sf-vr { + width: 1px; + height: 12px; + background: var(--sf-gray-200); +} +/* ============================================================================ + SolverForge UI — Custom Scrollbars + ============================================================================ */ + +.sf-app ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.sf-app ::-webkit-scrollbar-track { + background: transparent; +} + +.sf-app ::-webkit-scrollbar-thumb { + background: var(--sf-gray-300); + border-radius: 4px; +} + +.sf-app ::-webkit-scrollbar-thumb:hover { + background: var(--sf-emerald-400); +} +/* ============================================================================ + SolverForge UI — Animations + ============================================================================ */ + +@keyframes sf-spin { + to { transform: rotate(360deg); } +} + +@keyframes sf-dot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +@keyframes sf-score-flash { + 0% { opacity: 0.5; } + 100% { opacity: 1; } +} + +@keyframes sf-dialog-slide-in { + from { opacity: 0; transform: scale(0.95) translateY(-10px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes sf-breathe { + 0%, 100% { border-color: var(--sf-gray-300); } + 50% { border-color: var(--sf-emerald-500); box-shadow: 0 0 12px rgba(16, 185, 129, 0.25); } +} + +@keyframes sf-slide-in { + from { opacity: 0; transform: translateY(4px) scaleY(0.8); } + to { opacity: 1; transform: translateY(0) scaleY(1); } +} + +@keyframes sf-fade-in { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes sf-late-glow { + 0%, 100% { box-shadow: 0 0 4px rgba(239, 68, 68, 0.3); } + 50% { box-shadow: 0 0 12px rgba(239, 68, 68, 0.7); } +} + +/* Toast animations */ +.sf-toast-enter { + animation: sf-fade-in 0.2s var(--sf-ease-out); +} + +.sf-toast-exit { + animation: sf-fade-in 0.15s var(--sf-ease-in) reverse forwards; +} + +/* API guide */ +.sf-api-guide { + max-width: 800px; + padding: var(--sf-space-4) 0; +} + +.sf-api-section { + background: white; + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-base); + padding: var(--sf-space-4); + margin-bottom: var(--sf-space-3); +} + +.sf-api-section h3 { + font-size: var(--sf-text-sm); + font-weight: var(--sf-font-semibold); + color: var(--sf-emerald-700); + margin-bottom: var(--sf-space-2); +} + +.sf-api-code-block { + position: relative; + background: var(--sf-gray-50); + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-base); + padding: 10px 40px 10px 12px; +} + +.sf-api-code-block code { + font-family: var(--sf-font-mono); + font-size: var(--sf-text-xs); + color: var(--sf-gray-700); + white-space: pre-wrap; + word-break: break-all; +} + +.sf-copy-btn { + position: absolute; + top: 6px; + right: 6px; + background: var(--sf-gray-200); + color: var(--sf-gray-600); + border: none; + padding: 2px 8px; + border-radius: 2px; + font-size: 10px; + font-family: var(--sf-font-mono); + cursor: pointer; + transition: var(--sf-transition-colors); +} + +.sf-copy-btn:hover { + background: var(--sf-gray-300); + color: var(--sf-gray-800); +} + +/* Toast container */ +.sf-toast-container { + position: fixed; + top: var(--sf-space-4); + right: var(--sf-space-4); + z-index: var(--sf-z-tooltip); + display: flex; + flex-direction: column; + gap: var(--sf-space-2); +} + +.sf-toast { + background: white; + border: 1px solid var(--sf-gray-200); + border-radius: var(--sf-radius-lg); + padding: 12px 16px; + box-shadow: var(--sf-shadow-lg); + max-width: 400px; + font-size: var(--sf-text-sm); + display: flex; + gap: var(--sf-space-3); + align-items: flex-start; +} + +.sf-toast--danger { + border-left: 3px solid var(--sf-red-500); +} + +.sf-toast--success { + border-left: 3px solid var(--sf-emerald-500); +} + +.sf-toast--warning { + border-left: 3px solid var(--sf-amber-500); +} + +.sf-toast-message { + flex: 1; +} + +.sf-toast-title { + font-weight: var(--sf-font-semibold); + margin-bottom: 2px; +} + +.sf-toast-close { + background: none; + border: none; + color: var(--sf-gray-400); + cursor: pointer; + padding: 0; + font-size: 16px; + line-height: 1; +} + +.sf-toast-close:hover { + color: var(--sf-gray-700); +} + +@media (prefers-reduced-motion: reduce) { + [class^="sf-"], + [class*=" sf-"], + [class^="sf-"]::before, + [class*=" sf-"]::before, + [class^="sf-"]::after, + [class*=" sf-"]::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +/* ============================================================================ + SolverForge UI — Timeline Rail: Resources + Header, resource cards, gauges, stats. + ============================================================================ */ + +/* ── Timeline Header (day columns) ── */ +.sf-timeline-header { + background: white; + border: 1px solid var(--sf-gray-300); + border-radius: var(--sf-radius-base) var(--sf-radius-base) 0 0; + display: grid; + height: 28px; + overflow: hidden; + margin-bottom: -1px; +} + +.sf-timeline-label-spacer { + border-right: 1px solid var(--sf-gray-300); + display: flex; + align-items: center; + padding: 0 12px; + font-size: 11px; + color: var(--sf-gray-600); + font-family: var(--sf-font-mono); + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.sf-timeline-days { + display: grid; +} + +.sf-timeline-day-col { + border-right: 1px solid var(--sf-gray-300); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-700); + text-transform: uppercase; + letter-spacing: 0.06em; + gap: 6px; +} + +.sf-timeline-day-col:last-child { + border-right: none; +} + +/* ── Resource Card ── */ +.sf-resource-card { + background: white; + border: 1px solid var(--sf-gray-300); + border-radius: var(--sf-radius-base); + overflow: hidden; + transition: box-shadow 0.4s ease; + position: relative; + box-shadow: var(--sf-shadow-sm); +} + +.sf-resource-card.solving { + animation: sf-breathe 2s ease-in-out infinite; +} + +/* ── Resource Header ── */ +.sf-resource-header { + display: grid; + border-bottom: 1px solid var(--sf-gray-300); + background: var(--sf-gray-100); +} + +.sf-resource-identity { + padding: 8px 12px; + border-right: 1px solid var(--sf-gray-300); + display: flex; + flex-direction: column; + gap: 2px; +} + +.sf-resource-name { + font-size: 11px; + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-900); + text-transform: uppercase; + letter-spacing: 0.06em; + line-height: 1; +} + +.sf-resource-meta { + display: flex; + gap: 6px; + align-items: center; + margin-top: 2px; +} + +.sf-resource-type-badge { + font-size: 9px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + letter-spacing: 0.04em; + padding: 1px 5px; + border-radius: 2px; + text-transform: uppercase; +} + +/* ── Gauge bars (temp, load, capacity) ── */ +.sf-gauges { + padding: 6px 12px; + display: flex; + flex-direction: column; + gap: 4px; + justify-content: center; +} + +.sf-gauge-row { + display: flex; + align-items: center; + gap: 6px; +} + +.sf-gauge-label { + font-size: 9px; + font-family: var(--sf-font-mono); + color: var(--sf-gray-600); + text-transform: uppercase; + letter-spacing: 0.05em; + width: 28px; + flex-shrink: 0; +} + +.sf-gauge-track { + flex: 1; + height: 6px; + background: var(--sf-gray-300); + border-radius: 3px; + overflow: hidden; + max-width: 120px; +} + +.sf-gauge-fill { + height: 100%; + border-radius: 3px; + transition: width 0.6s var(--sf-ease-out); +} + +.sf-gauge-fill--heat { + background: linear-gradient(90deg, #3b82f6 0%, #fbbf24 50%, #f97316 80%, #ef4444 100%); +} + +.sf-gauge-fill--load { + background: linear-gradient(90deg, var(--sf-emerald-500) 0%, #fbbf24 70%, var(--sf-red-500) 100%); +} + +.sf-gauge-fill--emerald { + background: var(--sf-emerald-500); +} + +.sf-gauge-value { + font-size: 10px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-700); + min-width: 52px; +} + +/* ── Resource Body (stats) ── */ +.sf-resource-body { + display: grid; + min-height: 56px; +} + +.sf-resource-stats { + padding: 6px 12px; + border-right: 1px solid var(--sf-gray-300); + display: flex; + flex-direction: column; + gap: 3px; + justify-content: center; +} + +.sf-stat-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sf-stat-label { + font-size: 9px; + font-family: var(--sf-font-mono); + color: var(--sf-gray-600); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sf-stat-value { + font-size: 11px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-700); +} +/* ============================================================================ + SolverForge UI — Timeline Rail: Blocks + Rail container, day grid, task blocks, changeover, heatmap, unassigned. + ============================================================================ */ + +/* ── The Rail (where blocks live) ── */ +.sf-rail-container { + position: relative; + overflow: hidden; +} + +.sf-rail { + position: relative; + height: 100%; + min-height: 56px; + background: var(--sf-gray-50); +} + +/* Day column dividers */ +.sf-day-grid { + position: absolute; + inset: 0; + display: grid; + pointer-events: none; +} + +.sf-day-col { + border-right: 1px solid var(--sf-gray-200); +} + +.sf-day-col:nth-child(odd) { + background: rgba(0, 0, 0, 0.01); +} + +.sf-day-col:last-child { + border-right: none; +} + +/* Hour marks */ +.sf-hour-mark { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(0, 0, 0, 0.04); + pointer-events: none; +} + +/* ── Work Order / Task Blocks ── */ +.sf-block { + position: absolute; + top: 6px; + bottom: 6px; + border-radius: 3px; + border-left: 3px solid transparent; + cursor: pointer; + transition: filter 0.2s, transform 0.15s; + display: flex; + flex-direction: column; + justify-content: center; + padding: 2px 5px; + overflow: hidden; + min-width: 20px; + animation: sf-slide-in 0.35s var(--sf-ease-out) both; +} + +.sf-block:hover { + filter: brightness(1.25); + transform: translateY(-1px) scaleY(1.05); + z-index: 10; +} + +.sf-block.moved { + animation: sf-block-moved 0.6s ease-out; +} + +@keyframes sf-block-moved { + 0% { filter: brightness(2) saturate(2); transform: scaleY(1.15); } + 100% { filter: brightness(1) saturate(1); transform: scaleY(1); } +} + +.sf-block.late { + animation: sf-slide-in 0.35s var(--sf-ease-out) both, sf-late-glow 1.5s ease-in-out infinite; +} + +.sf-block-label { + font-size: 9px; + font-family: var(--sf-font-mono); + font-weight: var(--sf-font-semibold); + color: rgba(0, 0, 0, 0.9); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1; +} + +.sf-block-meta { + font-size: 8px; + font-family: var(--sf-font-mono); + color: rgba(0, 0, 0, 0.7); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1; + margin-top: 1px; +} + +/* Changeover / gap region */ +.sf-changeover { + position: absolute; + top: 4px; + bottom: 4px; + background-image: repeating-linear-gradient( + 45deg, + rgba(251, 191, 36, 0.08) 0px, + rgba(251, 191, 36, 0.08) 3px, + transparent 3px, + transparent 9px + ); + border-left: 1px dashed rgba(251, 191, 36, 0.3); + border-right: 1px dashed rgba(251, 191, 36, 0.3); + pointer-events: none; +} + +/* ── Heatmap strip ── */ +.sf-heatmap { + display: grid; + height: 10px; + border-top: 1px solid var(--sf-gray-300); +} + +.sf-heatmap-label { + background: var(--sf-gray-100); + border-right: 1px solid var(--sf-gray-300); +} + +.sf-heatmap-track { + position: relative; + background: var(--sf-gray-100); + overflow: hidden; +} + +.sf-heatmap-segment { + position: absolute; + top: 0; + bottom: 0; + transition: background 0.5s ease; +} + +/* ── Unassigned row ── */ +.sf-unassigned-rail { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 4px 8px; + border-top: 1px solid var(--sf-gray-300); + background: var(--sf-gray-50); + min-height: 28px; +} + +.sf-unassigned-pill { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--sf-red-400); + font-size: 9px; + font-family: var(--sf-font-mono); + padding: 2px 6px; + border-radius: 2px; + white-space: nowrap; +} +/* ============================================================================ + SolverForge UI — Gantt: Layout + Split panes, gutter, container, grid table, grid rows. + ============================================================================ */ + +/* ── Split Layout ── */ +.sf-gantt-split { + height: 100%; + display: flex; + flex-direction: column; + position: relative; +} + +.sf-gantt-pane { + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--sf-color-surface); + border: 1px solid var(--sf-gray-200); +} + +.sf-gantt-pane-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--sf-row-padding-y) var(--sf-row-padding-x); + background: var(--sf-gray-50); + border-bottom: 1px solid var(--sf-gray-200); + min-height: var(--sf-row-height); + flex-shrink: 0; +} + +.sf-gantt-pane-header h3 { + margin: 0; + font-size: var(--sf-text-sm); + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-700); +} + +.sf-gantt-pane-controls { + display: flex; + gap: var(--sf-space-1); +} + +.sf-gantt-pane-content { + flex: 1; + overflow: auto; + position: relative; +} + +.sf-gantt-container { + height: 100%; + overflow: hidden; +} + +.sf-gantt-container .gantt-container { + cursor: grab; + overflow: auto; + height: 100%; +} + +.sf-gantt-container .gantt-container:active { + cursor: grabbing; +} + +/* ── Split.js Gutter ── */ +.sf-gantt-split > .gutter { + background: var(--sf-gray-200); + position: relative; + transition: background var(--sf-duration-base); +} + +.sf-gantt-split > .gutter:hover { + background: var(--sf-emerald-400); +} + +.sf-gantt-split > .gutter.gutter-horizontal { + cursor: ns-resize; + height: 8px; + margin: -1px 0; + z-index: 10; +} + +.sf-gantt-split > .gutter.gutter-horizontal::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 30px; + height: 3px; + background: currentColor; + opacity: 0.3; + border-radius: var(--sf-radius-sm); +} + +/* ── Grid Table ── */ +.sf-gantt-grid { + height: 100%; + overflow: auto; + background: linear-gradient(180deg, var(--sf-gray-50) 0%, var(--sf-gray-100) 100%); + position: relative; +} + +.sf-gantt-table { + width: 100%; + border-collapse: collapse; + font-size: var(--sf-text-sm); +} + +.sf-gantt-table th { + position: sticky; + top: 0; + background: var(--sf-gray-50); + color: var(--sf-gray-700); + font-size: var(--sf-text-sm); + font-weight: var(--sf-font-semibold); + text-align: left; + padding: var(--sf-row-padding-y) var(--sf-row-padding-x); + border-bottom: 2px solid var(--sf-gray-200); + white-space: nowrap; + z-index: 10; +} + +.sf-gantt-table th.sortable { + cursor: pointer; + user-select: none; + transition: background var(--sf-duration-base), color var(--sf-duration-base); +} + +.sf-gantt-table th.sortable:hover { + background: var(--sf-gray-100); + color: var(--sf-emerald-700); +} + +.sf-gantt-table th.sortable.active { + color: var(--sf-emerald-700); + font-weight: var(--sf-font-bold); +} + +.sf-gantt-table th .sort-icon { + margin-left: 4px; + font-size: 10px; + opacity: 0.45; + transition: opacity var(--sf-duration-base); +} + +.sf-gantt-table th.sortable:hover .sort-icon, +.sf-gantt-table th.sortable.active .sort-icon { + opacity: 1; +} + +.sf-gantt-table td { + padding: var(--sf-row-padding-y) var(--sf-row-padding-x); + border-bottom: 1px solid var(--sf-gray-200); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: var(--sf-text-sm); + color: var(--sf-gray-700); +} + +/* ── Grid Rows ── */ +.sf-gantt-row { + transition: background var(--sf-duration-fast), transform var(--sf-duration-fast), + box-shadow var(--sf-duration-fast); + cursor: pointer; + position: relative; + border-left: 4px solid transparent; +} + +.sf-gantt-row:hover { + background: var(--sf-gray-50); + transform: translateX(2px); + box-shadow: var(--sf-shadow-sm); +} + +.sf-gantt-row.selected { + background: var(--sf-emerald-50); + box-shadow: var(--sf-shadow-base); +} + +/* Project color accents on left border */ +.sf-gantt-row.sf-project-0 { border-left-color: var(--sf-project-0); } +.sf-gantt-row.sf-project-1 { border-left-color: var(--sf-project-1); } +.sf-gantt-row.sf-project-2 { border-left-color: var(--sf-project-2); } +.sf-gantt-row.sf-project-3 { border-left-color: var(--sf-project-3); } +.sf-gantt-row.sf-project-4 { border-left-color: var(--sf-project-4); } +.sf-gantt-row.sf-project-5 { border-left-color: var(--sf-project-5); } +.sf-gantt-row.sf-project-6 { border-left-color: var(--sf-project-6); } +.sf-gantt-row.sf-project-7 { border-left-color: var(--sf-project-7); } + +/* Task name sweep underline */ +.sf-gantt-table .sf-task-name { + font-weight: var(--sf-font-medium); + color: var(--sf-gray-900); + max-width: 300px; + position: relative; +} + +.sf-gantt-table .sf-task-name::before { + content: ''; + position: absolute; + left: 0; + bottom: 0; + width: 0; + height: 2px; + background: var(--sf-emerald-500); + transition: width var(--sf-duration-slow) var(--sf-ease-out); +} + +.sf-gantt-row:hover .sf-task-name::before { + width: 100%; +} + +/* ── View Mode Selector ── */ +.sf-gantt-view-controls { + display: flex; + align-items: center; + gap: var(--sf-space-2); +} + +.sf-gantt-view-select { + font-family: var(--sf-font-body); + font-size: var(--sf-text-xs); + padding: 3px 8px; + border: 1px solid var(--sf-gray-300); + border-radius: var(--sf-radius-base); + background: var(--sf-color-surface); + color: var(--sf-gray-700); + cursor: pointer; +} + +.sf-gantt-view-select:focus { + outline: none; + border-color: var(--sf-emerald-500); + box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2); +} +/* ============================================================================ + SolverForge UI — Gantt: Bars + Frappe Gantt bar overrides, drag UX, pinned, badges, popup. + ============================================================================ */ + +/* ── Frappe Gantt Bar Overrides ── */ + +/* Project colors on bars */ +.gantt .bar-wrapper.project-color-0 .bar { fill: var(--sf-project-0); } +.gantt .bar-wrapper.project-color-1 .bar { fill: var(--sf-project-1); } +.gantt .bar-wrapper.project-color-2 .bar { fill: var(--sf-project-2); } +.gantt .bar-wrapper.project-color-3 .bar { fill: var(--sf-project-3); } +.gantt .bar-wrapper.project-color-4 .bar { fill: var(--sf-project-4); } +.gantt .bar-wrapper.project-color-5 .bar { fill: var(--sf-project-5); } +.gantt .bar-wrapper.project-color-6 .bar { fill: var(--sf-project-6); } +.gantt .bar-wrapper.project-color-7 .bar { fill: var(--sf-project-7); } + +/* Priority classes */ +.gantt .bar-wrapper.priority-1 .bar { opacity: 1; } +.gantt .bar-wrapper.priority-2 .bar { opacity: 0.85; } +.gantt .bar-wrapper.priority-3 .bar { opacity: 0.7; } + +/* Bar hover glow */ +.gantt .bar-wrapper:hover .bar { + filter: brightness(1.12) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.22)); +} + +/* Grab cursor on bars */ +.gantt .bar-wrapper .bar, +.gantt .bar-wrapper .bar-label, +.gantt .bar-wrapper .bar-progress { + cursor: grab; +} + +/* Resize handles */ +.gantt .bar-wrapper .handle { + cursor: ew-resize; + fill: rgba(255, 255, 255, 0.3); + opacity: 0; + transition: opacity 0.15s; +} + +.gantt .bar-wrapper:hover .handle { + opacity: 1; +} + +/* Bars at rest — subtle snap transition */ +.gantt .bar-wrapper:not(.dragging) .bar, +.gantt .bar-wrapper:not(.dragging) .bar-progress { + transition: x 0.12s var(--sf-ease-out), + width 0.12s var(--sf-ease-out); +} + +/* ── Pinned Task Indicators ── */ +.gantt .bar-wrapper.pinned .bar { + stroke: var(--sf-amber-500); + stroke-width: 2; + stroke-dasharray: 6 3; +} + +/* ── Highlighted task (pulse from grid click) ── */ +.gantt .bar-wrapper.highlighted .bar { + animation: sf-gantt-pulse 0.5s ease-in-out 3; +} + +@keyframes sf-gantt-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* ── Priority Badges (grid table) ── */ +.sf-priority-badge { + display: inline-block; + padding: var(--sf-badge-padding-y) var(--sf-badge-padding-x); + border-radius: var(--sf-badge-radius); + font-size: var(--sf-badge-font-size); + font-weight: var(--sf-badge-font-weight); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.sf-priority-badge.priority-1 { + background: var(--sf-red-100); + color: var(--sf-red-600); +} + +.sf-priority-badge.priority-2 { + background: var(--sf-amber-100); + color: var(--sf-amber-600); +} + +.sf-priority-badge.priority-3 { + background: var(--sf-gray-100); + color: var(--sf-gray-500); +} + +/* ── Gantt Popup ── */ +.sf-gantt-popup { + padding: 8px 12px; + font-family: var(--sf-font-body); + font-size: var(--sf-text-sm); +} + +.sf-gantt-popup h4 { + margin: 0 0 6px; + font-size: var(--sf-text-sm); + font-weight: var(--sf-font-semibold); + color: var(--sf-gray-900); +} + +.sf-gantt-popup p { + margin: 2px 0; + font-size: var(--sf-text-xs); + color: var(--sf-gray-600); + font-family: var(--sf-font-mono); +} + +.sf-gantt-popup-pinned { + color: var(--sf-amber-600); + font-weight: var(--sf-font-semibold); + font-size: var(--sf-text-xs); + margin-top: 4px; +} diff --git a/static/sf/sf.0.1.0.js b/static/sf/sf.0.1.0.js new file mode 100644 index 0000000..fbbe110 --- /dev/null +++ b/static/sf/sf.0.1.0.js @@ -0,0 +1,1875 @@ +/* ============================================================================ + SolverForge UI — Core + ============================================================================ */ + +const SF = (function () { + 'use strict'; + + const sf = { version: '0.1.0' }; + var uidCounter = 0; + + /* ── Utilities ── */ + + sf.escHtml = function (str) { + if (!str) return ''; + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + }; + + sf.assert = function (cond, message) { + if (!cond) throw new Error('[SolverForge] ' + message); + }; + + sf.el = function (tag, attrs) { + var children = Array.prototype.slice.call(arguments, 2); + var el = document.createElement(tag); + if (attrs) { + Object.keys(attrs).forEach(function (key) { + if (key === 'className') el.className = attrs[key]; + else if (key === 'style' && typeof attrs[key] === 'object') { + Object.assign(el.style, attrs[key]); + } + else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); + else if (key === 'dataset') Object.assign(el.dataset, attrs[key]); + else if (key === 'html') el.textContent = attrs[key]; + else if (key === 'unsafeHtml') el.innerHTML = attrs[key]; + else el.setAttribute(key, attrs[key]); + }); + } + children.forEach(function (child) { + if (child == null) return; + if (typeof child === 'string') el.appendChild(document.createTextNode(child)); + else if (child instanceof Node) el.appendChild(child); + }); + return el; + }; + + sf.uid = function (prefix) { + uidCounter += 1; + return (prefix || 'sf') + '-' + uidCounter; + }; + + sf.bindActivation = function (el, onActivate) { + if (!el || typeof onActivate !== 'function') return; + + function handleActivate(e) { + if (!e || e.type === 'keydown' && e.key !== 'Enter' && e.key !== ' ') return; + if (e.type === 'keydown') e.preventDefault(); + onActivate(e); + } + + el.addEventListener('click', handleActivate); + el.addEventListener('keydown', handleActivate); + }; + + if (typeof window !== 'undefined') window.SF = sf; + return sf; +})(); +/* ============================================================================ + SolverForge UI — Score Parsing + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.score = {}; + + sf.score.parseHard = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)hard/); + return m ? parseInt(m[1], 10) : 0; + }; + + sf.score.parseSoft = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)soft/); + return m ? parseInt(m[1], 10) : 0; + }; + + sf.score.parseMedium = function (scoreStr) { + if (!scoreStr) return 0; + var m = scoreStr.match(/(-?\d+)medium/); + return m ? parseInt(m[1], 10) : 0; + }; + + sf.score.getComponents = function (scoreStr) { + return { + hard: sf.score.parseHard(scoreStr), + medium: sf.score.parseMedium(scoreStr), + soft: sf.score.parseSoft(scoreStr), + }; + }; + + sf.score.colorClass = function (scoreStr) { + var hard = sf.score.parseHard(scoreStr); + var soft = sf.score.parseSoft(scoreStr); + return hard < 0 ? 'score-red' : soft < 0 ? 'score-yellow' : 'score-green'; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Color Factory + Tango palette + project color assignment. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + var SEQUENCE_1 = [0x8AE234, 0xFCE94F, 0x729FCF, 0xE9B96E, 0xAD7FA8]; + var SEQUENCE_2 = [0x73D216, 0xEDD400, 0x3465A4, 0xC17D11, 0x75507B]; + + var colorMap = {}; + var nextColorCount = 0; + + function buildPercentageColor(floor, ceil, pct) { + var red = (floor & 0xFF0000) + Math.floor(pct * ((ceil & 0xFF0000) - (floor & 0xFF0000))) & 0xFF0000; + var green = (floor & 0x00FF00) + Math.floor(pct * ((ceil & 0x00FF00) - (floor & 0x00FF00))) & 0x00FF00; + var blue = (floor & 0x0000FF) + Math.floor(pct * ((ceil & 0x0000FF) - (floor & 0x0000FF))) & 0x0000FF; + return red | green | blue; + } + + function nextColor() { + var colorIndex = nextColorCount % SEQUENCE_1.length; + var shadeIndex = Math.floor(nextColorCount / SEQUENCE_1.length); + var color; + if (shadeIndex === 0) { + color = SEQUENCE_1[colorIndex]; + } else if (shadeIndex === 1) { + color = SEQUENCE_2[colorIndex]; + } else { + shadeIndex -= 3; + var base = Math.floor((shadeIndex / 2) + 1); + var divisor = 2; + while (base >= divisor) divisor *= 2; + base = (base * 2) - divisor + 1; + color = buildPercentageColor(SEQUENCE_2[colorIndex], SEQUENCE_1[colorIndex], base / divisor); + } + nextColorCount++; + return '#' + color.toString(16).padStart(6, '0'); + } + + sf.colors = {}; + + sf.colors.pick = function (key) { + if (colorMap[key] !== undefined) return colorMap[key]; + var c = nextColor(); + colorMap[key] = c; + return c; + }; + + sf.colors.reset = function () { + colorMap = {}; + nextColorCount = 0; + }; + + var PROJECT_COLORS = [ + { main: '#10b981', dark: '#047857', light: 'rgba(16,185,129,0.15)' }, + { main: '#3b82f6', dark: '#1d4ed8', light: 'rgba(59,130,246,0.15)' }, + { main: '#8b5cf6', dark: '#6d28d9', light: 'rgba(139,92,246,0.15)' }, + { main: '#f59e0b', dark: '#b45309', light: 'rgba(245,158,11,0.15)' }, + { main: '#ec4899', dark: '#be185d', light: 'rgba(236,72,153,0.15)' }, + { main: '#06b6d4', dark: '#0e7490', light: 'rgba(6,182,212,0.15)' }, + { main: '#f43f5e', dark: '#be123c', light: 'rgba(244,63,94,0.15)' }, + { main: '#84cc16', dark: '#4d7c0f', light: 'rgba(132,204,22,0.15)' }, + ]; + + sf.colors.project = function (index) { + return PROJECT_COLORS[index % PROJECT_COLORS.length]; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Button Factory + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createButton = function (config) { + sf.assert(config, 'createButton(config) requires a configuration object'); + + var classes = ['sf-btn']; + + if (config.variant) classes.push('sf-btn--' + config.variant); + if (config.size === 'small') classes.push('sf-btn--sm'); + if (config.size === 'large') classes.push('sf-btn--lg'); + if (config.pill) classes.push('sf-btn--pill'); + if (config.circle) classes.push('sf-btn--circle'); + if (config.outline) classes.push('sf-btn--outline'); + if (config.iconOnly) classes.push('sf-btn--icon'); + + var btn = sf.el('button', { + className: classes.join(' '), + type: 'button', + }); + + if (config.disabled) btn.disabled = true; + + sf.assert(!config.onClick || typeof config.onClick === 'function', 'createButton(onClick) must be a function'); + + if (config.icon) { + var icon = sf.el('i', { className: 'fa-solid ' + config.icon }); + btn.appendChild(icon); + } + + if (config.text && !config.circle && !config.iconOnly) { + btn.appendChild(document.createTextNode(config.text)); + } + + if (config.onClick) { + btn.addEventListener('click', config.onClick); + } + + if (config.tooltip) { + btn.title = config.tooltip; + } + + if (config.ariaLabel) { + btn.setAttribute('aria-label', config.ariaLabel); + } else if (config.iconOnly && config.text) { + btn.setAttribute('aria-label', config.text); + } else if (config.icon && !config.text) { + btn.setAttribute('aria-label', config.icon.replace(/fa-/, '').replace(/-/g, ' ')); + } + + if (config.id) { + btn.id = config.id; + } + + if (config.dataset) { + Object.assign(btn.dataset, config.dataset); + } + + return btn; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Header Factory + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + + var header = sf.el('header', { className: 'sf-header' }); + var controls = { + actions: null, + spinner: null, + solveBtn: null, + stopBtn: null, + analyzeBtn: null, + nav: null, + }; + + // Logo + if (config.logo) { + var logo = sf.el('img', { + className: 'sf-header-logo', + src: config.logo, + alt: 'Logo', + }); + header.appendChild(logo); + } + + // Brand text + var brand = sf.el('div', { className: 'sf-header-brand' }); + if (config.title) { + brand.appendChild(sf.el('div', { className: 'sf-header-title' }, config.title)); + } + if (config.subtitle) { + brand.appendChild(sf.el('div', { className: 'sf-header-subtitle' }, config.subtitle)); + } + header.appendChild(brand); + + // Nav tabs + if (config.tabs && config.tabs.length > 0) { + sf.assert(Array.isArray(config.tabs), 'createHeader(config.tabs) expects an array'); + var nav = sf.el('nav', { className: 'sf-header-nav' }); + controls.nav = nav; + config.tabs.forEach(function (tab) { + sf.assert(tab && tab.id, 'createHeader tab entries require an id'); + sf.assert(typeof tab.label === 'string', 'createHeader tab entries require a label'); + var btn = sf.el('button', { + className: 'sf-nav-btn' + (tab.active ? ' active' : ''), + role: 'tab', + 'aria-selected': !!tab.active, + tabIndex: 0, + dataset: { tab: tab.id }, + onKeyDown: function (e) { + if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; + var buttons = nav.querySelectorAll('.sf-nav-btn'); + var list = Array.prototype.slice.call(buttons); + var nextIndex = e.key === 'ArrowRight' + ? (list.indexOf(btn) + 1) % list.length + : (list.length + list.indexOf(btn) - 1) % list.length; + var next = list[nextIndex]; + if (next && next.focus) next.focus(); + }, + onClick: function () { + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { b.classList.remove('active'); }); + btn.classList.add('active'); + nav.querySelectorAll('.sf-nav-btn').forEach(function (b) { + b.setAttribute('aria-selected', b === btn ? 'true' : 'false'); + }); + if (config.onTabChange) config.onTabChange(tab.id); + }, + }); + if (tab.icon) { + btn.appendChild(sf.el('i', { className: 'fa-solid ' + tab.icon })); + } + btn.appendChild(document.createTextNode(tab.label)); + nav.appendChild(btn); + }); + header.appendChild(nav); + } + + // Action buttons + if (config.actions) { + sf.assert(typeof config.actions === 'object', 'createHeader(config.actions) expects an object'); + sf.assert(!config.actions.onSolve || typeof config.actions.onSolve === 'function', 'createHeader(config.actions.onSolve) must be a function'); + sf.assert(!config.actions.onStop || typeof config.actions.onStop === 'function', 'createHeader(config.actions.onStop) must be a function'); + sf.assert(!config.actions.onAnalyze || typeof config.actions.onAnalyze === 'function', 'createHeader(config.actions.onAnalyze) must be a function'); + sf.assert(!config.onTabChange || typeof config.onTabChange === 'function', 'createHeader(config.onTabChange) must be a function'); + + var actions = sf.el('div', { className: 'sf-header-actions' }); + controls.actions = actions; + + // Spinner + var spinner = sf.el('div', { className: 'sf-solving-spinner' }); + controls.spinner = spinner; + actions.appendChild(spinner); + + if (config.actions.onSolve) { + var solveBtn = sf.createButton({ + text: 'Solve', + variant: 'success', + icon: 'fa-play', + onClick: config.actions.onSolve, + }); + controls.solveBtn = solveBtn; + actions.appendChild(solveBtn); + } + + if (config.actions.onStop) { + var stopBtn = sf.createButton({ + text: 'Stop', + variant: 'danger', + icon: 'fa-stop', + onClick: config.actions.onStop, + }); + stopBtn.style.display = 'none'; + controls.stopBtn = stopBtn; + actions.appendChild(stopBtn); + } + + if (config.actions.onAnalyze) { + var analyzeBtn = sf.createButton({ + variant: 'ghost', + icon: 'fa-chart-bar', + circle: true, + tooltip: 'Score Analysis', + onClick: config.actions.onAnalyze, + }); + controls.analyzeBtn = analyzeBtn; + actions.appendChild(analyzeBtn); + } + + header.appendChild(actions); + } + + header.sfControls = controls; + return header; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Status Bar Factory + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createStatusBar = function (config) { + var bar = sf.el('div', { className: 'sf-statusbar' }); + var lastScore = null; + var controls = null; + + // Score display + var scoreEl = sf.el('span', { className: 'sf-statusbar-score' }, '\u2014'); + var scoreEl = sf.el('span', { className: 'sf-statusbar-score', id: 'sfScoreDisplay', 'aria-live': 'polite' }, '\u2014'); + bar.appendChild(scoreEl); + + // Separator + bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); + + // Constraint dots container + var dotsContainer = sf.el('div', { className: 'sf-statusbar-constraints' }); + bar.appendChild(dotsContainer); + + // Separator + moves display + var movesSep = sf.el('span', { className: 'sf-statusbar-sep' }, '|'); + movesSep.style.display = 'none'; + bar.appendChild(movesSep); + + var movesEl = sf.el('span'); + movesEl.style.display = 'none'; + bar.appendChild(movesEl); + + // Separator + status text + bar.appendChild(sf.el('span', { className: 'sf-statusbar-sep' }, '|')); + var statusEl = sf.el('span'); + var statusEl = sf.el('span', { id: 'sfStatusText', role: 'status', 'aria-live': 'polite' }); + bar.appendChild(statusEl); + + // Build initial constraint dots + if (config && config.constraints) { + buildDots(dotsContainer, config.constraints, config.onConstraintClick); + } + + var api = { el: bar }; + + api.bindHeader = function (header) { + controls = header && header.sfControls ? header.sfControls : null; + return api; + }; + + api.updateScore = function (scoreStr) { + if (scoreStr && scoreStr !== lastScore) { + scoreEl.textContent = scoreStr; + var colorClass = sf.score.colorClass(scoreStr); + scoreEl.classList.remove('improved', 'score-green', 'score-red', 'score-yellow'); + scoreEl.classList.add(colorClass); + void scoreEl.offsetWidth; + scoreEl.classList.add('improved'); + lastScore = scoreStr; + } else if (!scoreStr) { + scoreEl.textContent = '\u2014'; + scoreEl.classList.remove('score-green', 'score-red', 'score-yellow', 'improved'); + } + }; + + api.setSolving = function (solving) { + var solveBtn = controls && controls.solveBtn; + var stopBtn = controls && controls.stopBtn; + var spinner = controls && controls.spinner; + + if (solveBtn) solveBtn.style.display = solving ? 'none' : ''; + if (stopBtn) stopBtn.style.display = solving ? '' : 'none'; + if (spinner) spinner.classList.toggle('active', solving); + + statusEl.textContent = solving ? 'Solving\u2026' : 'Ready'; + statusEl.style.color = solving + ? 'var(--sf-emerald-600)' + : 'var(--sf-gray-500)'; + }; + + api.updateMoves = function (mps) { + if (mps != null && mps > 0) { + movesEl.textContent = mps.toLocaleString() + ' moves/s'; + movesEl.style.display = ''; + movesSep.style.display = ''; + } else { + movesEl.style.display = 'none'; + movesSep.style.display = 'none'; + } + }; + + api.updateConstraintDots = function (constraints) { + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + }; + + api.colorDotsByScore = function (scoreStr) { + var hard = sf.score.parseHard(scoreStr); + var soft = sf.score.parseSoft(scoreStr); + dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot) { + var isHard = dot.dataset.type === 'hard'; + dot.classList.toggle('violated', isHard && hard < 0); + dot.classList.toggle('violated-soft', !isHard && soft < 0); + }); + }; + + api.colorDotsFromAnalysis = function (constraints) { + if (!constraints || constraints.length === 0) return; + buildDots(dotsContainer, constraints, config && config.onConstraintClick); + dotsContainer.querySelectorAll('.sf-constraint-dot').forEach(function (dot, i) { + var c = constraints[i]; + if (!dot) return; + var isHard = c.type === 'hard'; + var scoreVal = isHard ? sf.score.parseHard(c.score) : sf.score.parseSoft(c.score); + var violated = scoreVal < 0; + dot.classList.toggle('violated', isHard && violated); + dot.classList.toggle('violated-soft', !isHard && violated); + }); + }; + + if (config && config.header) { + api.bindHeader(config.header); + } + + return api; + }; + + function buildDots(container, constraints, onClick) { + container.innerHTML = ''; + if (!constraints) return; + constraints.forEach(function (c, i) { + var dot = sf.el('div', { + className: 'sf-constraint-dot', + id: 'sf-cdot-' + i, + title: c.name || ('Constraint ' + i), + role: onClick ? 'button' : null, + tabIndex: onClick ? '0' : null, + 'aria-label': onClick ? ('Open constraint ' + (c.name || ('Constraint ' + i))) : null, + dataset: { type: c.type || 'hard', index: String(i) }, + }); + if (onClick) { + dot.style.cursor = 'pointer'; + sf.bindActivation(dot, function () { onClick(i); }); + } + container.appendChild(dot); + }); + } + +})(SF); +/* ============================================================================ + SolverForge UI — Modal Factory + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createModal = function (config) { + sf.assert(config, 'createModal(config) requires a configuration object'); + sf.assert(!config.footer || Array.isArray(config.footer), 'createModal(config.footer) must be an array'); + + var overlay = sf.el('div', { className: 'sf-modal-overlay' }); + var dialogId = sf.uid('sf-modal'); + var dialog = sf.el('div', { + className: 'sf-modal', + id: dialogId, + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': dialogId + '-title', + }); + var body = sf.el('div', { className: 'sf-modal-body' }); + + // Header + var header = sf.el('div', { className: 'sf-modal-header' }); + var titleEl = sf.el('div', { className: 'sf-modal-title', id: dialogId + '-title' }, config.title || ''); + header.appendChild(titleEl); + + var closeBtn = sf.el('button', { + className: 'sf-modal-close', + html: '×', + 'aria-label': 'Close modal', + onClick: function () { api.close(); }, + }, '×'); + header.appendChild(closeBtn); + + dialog.appendChild(header); + + // Body + setBodyContent(body, config.body, config.unsafeBody); + dialog.appendChild(body); + + // Footer + if (config.footer) { + var footer = sf.el('div', { className: 'sf-modal-footer' }); + config.footer.forEach(function (child) { + footer.appendChild(child); + }); + dialog.appendChild(footer); + } + + overlay.appendChild(dialog); + + var previousFocus = null; + + // Close on backdrop click + overlay.addEventListener('click', function (e) { + if (e.target === overlay) api.close(); + }); + + // Close on Escape + function onKeyDown(e) { + if (e.key === 'Escape') api.close(); + } + + var api = { el: overlay, body: body }; + + api.open = function () { + previousFocus = document.activeElement; + document.body.appendChild(overlay); + if (closeBtn.focus) closeBtn.focus(); + overlay.classList.add('open'); + document.addEventListener('keydown', onKeyDown); + }; + + api.close = function () { + overlay.classList.remove('open'); + document.removeEventListener('keydown', onKeyDown); + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (previousFocus && previousFocus.focus) previousFocus.focus(); + if (config.onClose) config.onClose(); + }; + + api.setBody = function (content) { + setBodyContent(body, content); + }; + + if (config.width) { + dialog.style.maxWidth = config.width; + } + + return api; + }; + + function setBodyContent(target, content, explicitUnsafeHtml) { + target.textContent = ''; + if (explicitUnsafeHtml != null) { + target.innerHTML = explicitUnsafeHtml; + } else if (typeof content === 'string') { + target.textContent = content; + } else if (content && content.unsafeBody) { + target.innerHTML = content.unsafeBody; + } else if (content && content.unsafeHtml) { + target.innerHTML = content.unsafeHtml; + } else if (content instanceof Node) { + target.appendChild(content); + } + } + +})(SF); +/* ============================================================================ + SolverForge UI — Tab Switching + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.showTab = function (tabId, root) { + if (root) { + activateTabInScope(root, tabId); + return; + } + + document.querySelectorAll('.sf-tabs-container').forEach(function (container) { + activateTabInScope(container, tabId); + }); + }; + + sf.createTabs = function (config) { + sf.assert(config, 'createTabs(config) requires a configuration object'); + sf.assert(Array.isArray(config.tabs), 'createTabs(config.tabs) must be an array'); + + var container = sf.el('div', { className: 'sf-tabs-container' }); + var tabsId = sf.uid('sf-tabs'); + + config.tabs.forEach(function (tab) { + var panel = sf.el('div', { + className: 'sf-tab-panel' + (tab.active ? ' active' : ''), + id: tabsId + '-' + tab.id, + dataset: { tabId: tab.id }, + }); + if (tab.content) { + if (typeof tab.content === 'string') panel.textContent = tab.content; + else if (tab.content && tab.content.unsafeHtml) panel.innerHTML = tab.content.unsafeHtml; + else if (tab.content instanceof Node) panel.appendChild(tab.content); + } + container.appendChild(panel); + }); + + return { + el: container, + show: function (tabId) { + sf.showTab(tabId, container); + }, + }; + }; + + function activateTabInScope(scope, tabId) { + scope.querySelectorAll('.sf-tab-panel').forEach(function (p) { + p.classList.remove('active'); + }); + + var panel = scope.querySelector('[data-tab-id="' + tabId + '"]'); + if (panel) panel.classList.add('active'); + } + +})(SF); +/* ============================================================================ + SolverForge UI — Table Factory + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createTable = function (config) { + sf.assert(config, 'createTable(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createTable(config.columns) must be an array'); + sf.assert(!config.rows || Array.isArray(config.rows), 'createTable(config.rows) must be an array'); + + var wrapper = sf.el('div', { className: 'sf-table-container' }); + var table = sf.el('table', { className: 'sf-table' }); + + // Header + if (config.columns) { + var thead = sf.el('thead'); + var tr = sf.el('tr'); + config.columns.forEach(function (col) { + var th = sf.el('th', null, typeof col === 'string' ? col : col.label); + if (col.align) th.style.textAlign = col.align; + if (col.width) th.style.width = col.width; + tr.appendChild(th); + }); + thead.appendChild(tr); + table.appendChild(thead); + } + + // Body + var tbody = sf.el('tbody'); + if (config.rows) { + config.rows.forEach(function (row, rowIdx) { + var tr = sf.el('tr'); + row.forEach(function (cell, colIdx) { + var td = sf.el('td'); + if (typeof cell === 'string' || typeof cell === 'number') { + td.textContent = cell; + } else if (cell instanceof Node) { + td.appendChild(cell); + } else if (cell && cell.unsafeHtml) { + td.innerHTML = cell.unsafeHtml; + } + var col = config.columns && config.columns[colIdx]; + if (col && col.align) td.style.textAlign = col.align; + if (col && col.className) td.classList.add(col.className); + tr.appendChild(td); + }); + if (config.onRowClick) { + tr.style.cursor = 'pointer'; + tr.setAttribute('role', 'button'); + tr.tabIndex = 0; + sf.bindActivation(tr, function () { config.onRowClick(rowIdx, row); }); + } + tbody.appendChild(tr); + }); + } + table.appendChild(tbody); + wrapper.appendChild(table); + + return wrapper; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Toast Notifications + jQuery-free replacement for showError/showSimpleError. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + var container = null; + + function ensureContainer() { + if (container && document.body.contains(container)) return; + container = sf.el('div', { className: 'sf-toast-container' }); + document.body.appendChild(container); + } + + sf.showToast = function (config) { + sf.assert(config, 'showToast(config) requires a configuration object'); + + ensureContainer(); + + var variant = config.variant || 'danger'; + var toast = sf.el('div', { + className: 'sf-toast sf-toast--' + variant + ' sf-toast-enter', + role: 'status', + 'aria-live': 'polite', + }); + + var msg = sf.el('div', { className: 'sf-toast-message' }); + if (config.title) { + msg.appendChild(sf.el('div', { className: 'sf-toast-title' }, config.title)); + } + if (config.message) { + msg.appendChild(sf.el('div', null, config.message)); + } + if (config.detail) { + var pre = sf.el('pre', { style: { margin: '4px 0 0', fontSize: '11px', whiteSpace: 'pre-wrap' } }); + pre.appendChild(sf.el('code', null, config.detail)); + msg.appendChild(pre); + } + toast.appendChild(msg); + + var closeBtn = sf.el('button', { + className: 'sf-toast-close', + html: '×', + 'aria-label': 'Dismiss toast', + onClick: function () { dismiss(); }, + }, '×'); + toast.appendChild(closeBtn); + + container.appendChild(toast); + + var delay = config.delay || 10000; + var timer = setTimeout(dismiss, delay); + + function dismiss() { + clearTimeout(timer); + toast.classList.remove('sf-toast-enter'); + toast.classList.add('sf-toast-exit'); + setTimeout(function () { + if (toast.parentNode) toast.parentNode.removeChild(toast); + }, 200); + } + }; + + sf.showError = function (title, detail) { + sf.showToast({ title: 'Error', message: title, detail: detail, variant: 'danger', delay: 30000 }); + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Backend Adapters + Pluggable transport: Axum, Tauri IPC, generic fetch. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createBackend = function (config) { + config = config || {}; + var type = config.type || 'axum'; + if (type === 'tauri') return createTauriBackend(config); + return createHttpBackend(config); + }; + + function resolveJobId(raw) { + if (raw == null) return ''; + if (typeof raw === 'string' || typeof raw === 'number') return String(raw).trim(); + if (typeof raw !== 'object') return ''; + + if (raw.id != null) return String(raw.id).trim(); + if (raw.jobId != null) return String(raw.jobId).trim(); + if (raw.job_id != null) return String(raw.job_id).trim(); + if (raw.scheduleId != null) return String(raw.scheduleId).trim(); + if (raw.schedule_id != null) return String(raw.schedule_id).trim(); + + if (raw.data && typeof raw.data === 'object' && raw.data.id != null) { + return String(raw.data.id).trim(); + } + return ''; + } + + function resolveEventJobId(payload) { + if (!payload || typeof payload !== 'object') return ''; + if (payload.jobId != null) return String(payload.jobId).trim(); + if (payload.job_id != null) return String(payload.job_id).trim(); + if (payload.scheduleId != null) return String(payload.scheduleId).trim(); + if (payload.schedule_id != null) return String(payload.schedule_id).trim(); + if (payload.id != null) return String(payload.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.id != null) return String(payload.data.id).trim(); + if (payload.data && typeof payload.data === 'object' && payload.data.jobId != null) return String(payload.data.jobId).trim(); + return ''; + } + + /* ── HTTP backend (Axum, Rails, anything) ── */ + + function createHttpBackend(config) { + var baseUrl = config.baseUrl || ''; + var schedulesPath = config.schedulesPath || '/schedules'; + var demoDataPath = config.demoDataPath || '/demo-data'; + var extraHeaders = config.headers || {}; + + function headers(extra) { + var h = Object.assign({ 'Content-Type': 'application/json' }, extraHeaders, extra || {}); + return h; + } + + function request(method, path, body) { + var opts = { method: method, headers: headers() }; + if (body !== undefined) opts.body = JSON.stringify(body); + return fetch(baseUrl + path, opts).then(function (res) { + if (!res.ok) throw new Error(res.status + ' ' + res.statusText); + var ct = res.headers.get('content-type') || ''; + return ct.indexOf('json') !== -1 ? res.json() : res.text(); + }); + } + + return { + createSchedule: function (data) { + return request('POST', schedulesPath, data).then(resolveJobId); + }, + getSchedule: function (id) { + return request('GET', schedulesPath + '/' + id); + }, + deleteSchedule: function (id) { + return request('DELETE', schedulesPath + '/' + id); + }, + analyze: function (id) { + return request('GET', schedulesPath + '/' + id + '/analyze'); + }, + getDemoData: function (name) { + return request('GET', demoDataPath + '/' + (name || 'STANDARD')); + }, + listDemoData: function () { + return request('GET', demoDataPath); + }, + streamEvents: function (id, onMessage) { + var url = baseUrl + schedulesPath + '/' + id + '/events'; + var es = new EventSource(url); + es.onmessage = function (e) { + try { onMessage(JSON.parse(e.data)); } catch (_) {} + }; + return function close() { es.close(); }; + }, + }; + } + + /* ── Tauri IPC backend ── */ + + function createTauriBackend(config) { + sf.assert(typeof config === 'object', 'createBackend({}) is required for Tauri adapter'); + sf.assert(typeof config.invoke === 'function', 'Tauri backend requires config.invoke'); + sf.assert(typeof config.listen === 'function', 'Tauri backend requires config.listen'); + + var invoke = config.invoke; + var listen = config.listen; + var commands = config.commands || {}; + var eventName = config.eventName || 'solver-update'; + + return { + createSchedule: function (data) { + return invoke(commands.startSolve || 'create_schedule', { request: data }).then(resolveJobId); + }, + getSchedule: function (id) { + return invoke(commands.getSchedule || 'get_schedule', { id: id }); + }, + deleteSchedule: function (id) { + return invoke(commands.stopSolve || 'delete_schedule', { id: id }); + }, + analyze: function (id) { + return invoke(commands.analyze || 'score_schedule', { id: id }); + }, + getDemoData: function (name) { + return invoke(commands.demoData || 'demo_seed', { name: name }); + }, + listDemoData: function () { + return Promise.resolve([]); + }, + streamEvents: function (id, onMessage) { + var targetId = String(id); + var unlisten = null; + listen(eventName, function (event) { + var payload = event && event.payload ? event.payload : {}; + var payloadId = resolveEventJobId(payload); + if (payloadId && payloadId !== targetId) return; + onMessage(payload); + }).then(function (fn) { unlisten = fn; }); + return function close() { if (unlisten) unlisten(); }; + }, + }; + } + +})(SF); +/* ============================================================================ + SolverForge UI — Solver Lifecycle + SSE state machine: start → streaming → stop/complete. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createSolver = function (config) { + sf.assert(config, 'createSolver(config) requires a configuration object'); + sf.assert(config.backend, 'createSolver(config.backend) is required'); + sf.assert(config.backend.createSchedule && typeof config.backend.createSchedule === 'function', 'createSolver(config.backend.createSchedule) must be a function'); + sf.assert(config.backend.streamEvents && typeof config.backend.streamEvents === 'function', 'createSolver(config.backend.streamEvents) must be a function'); + sf.assert(config.backend.getSchedule && typeof config.backend.getSchedule === 'function', 'createSolver(config.backend.getSchedule) must be a function'); + sf.assert(!config.onUpdate || typeof config.onUpdate === 'function', 'createSolver(config.onUpdate) must be a function'); + sf.assert(!config.onComplete || typeof config.onComplete === 'function', 'createSolver(config.onComplete) must be a function'); + sf.assert(!config.onAnalysis || typeof config.onAnalysis === 'function', 'createSolver(config.onAnalysis) must be a function'); + sf.assert(!config.onError || typeof config.onError === 'function', 'createSolver(config.onError) must be a function'); + + var backend = config.backend; + var statusBar = config.statusBar; + var closeStream = null; + var jobId = null; + var running = false; + + var api = {}; + + api.start = function (data) { + if (running) return; + running = true; + + if (statusBar) { + statusBar.setSolving(true); + statusBar.updateMoves(null); + } + + backend.createSchedule(data).then(function (id) { + if (typeof id !== 'string' || !id.trim()) { + throw new Error('Invalid solver backend createSchedule response'); + } + jobId = id; + closeStream = backend.streamEvents(jobId, function (msg) { + if (!isEventForCurrentJob(msg, jobId)) return; + + // Solver finished + if (msg.solverStatus === 'NOT_SOLVING') { + backend.getSchedule(jobId).then(function (final) { + if (config.onComplete) config.onComplete(final); + if (statusBar) { + statusBar.updateScore(final.score); + statusBar.updateMoves(null); + } + }); + api._cleanup(false); + return; + } + + // Live update + if (statusBar) { + statusBar.updateScore(msg.score); + statusBar.updateMoves(msg.movesPerSecond); + } + if (config.onUpdate) config.onUpdate(msg); + }); + }).catch(function (err) { + running = false; + if (statusBar) statusBar.setSolving(false); + if (config.onError) config.onError(err.message || String(err)); + }); + }; + + api.stop = function () { + if (!running || !jobId) return; + var stoppedId = jobId; + + // Fetch analysis before deleting + backend.analyze(stoppedId).then(function (analysis) { + if (statusBar && analysis && analysis.constraints) { + statusBar.colorDotsFromAnalysis(analysis.constraints); + } + if (config.onAnalysis) config.onAnalysis(analysis); + }).catch(function () {}).then(function () { + backend.deleteSchedule(stoppedId).catch(function () {}); + }); + + api._cleanup(true); + }; + + api._cleanup = function (stopped) { + if (closeStream) { closeStream(); closeStream = null; } + running = false; + jobId = null; + if (statusBar) { + statusBar.setSolving(false); + statusBar.updateMoves(null); + } + }; + + api.isRunning = function () { return running; }; + + api.getJobId = function () { return jobId; }; + + return api; + + function isEventForCurrentJob(msg, expectedId) { + if (!msg || typeof msg !== 'object') return false; + var candidate = msg.jobId || msg.job_id || msg.scheduleId || msg.schedule_id || msg.id || (msg.data && msg.data.id); + if (candidate == null) return true; + return String(candidate) === String(expectedId); + } + }; + +})(SF); +/* ============================================================================ + SolverForge UI — API Guide Panel + Generates REST API documentation from endpoint definitions. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.createApiGuide = function (config) { + sf.assert(config, 'createApiGuide(config) requires a configuration object'); + sf.assert(Array.isArray(config.endpoints), 'createApiGuide(config.endpoints) must be an array'); + + var guide = sf.el('div', { className: 'sf-api-guide' }); + var endpoints = config.endpoints; + + endpoints.forEach(function (ep) { + var section = sf.el('div', { className: 'sf-api-section' }); + section.appendChild(sf.el('h3', null, (ep.method || 'GET') + ' ' + ep.path)); + if (ep.description) { + section.appendChild(sf.el('p', { style: { fontSize: '13px', color: 'var(--sf-gray-600)', marginBottom: '8px' } }, ep.description)); + } + + if (ep.curl) { + var block = sf.el('div', { className: 'sf-api-code-block' }); + block.appendChild(sf.el('code', null, ep.curl)); + var copyBtn = sf.el('button', { + className: 'sf-copy-btn', + 'aria-label': 'Copy command', + onClick: function () { + navigator.clipboard.writeText(ep.curl).then(function () { + copyBtn.textContent = 'Copied!'; + setTimeout(function () { copyBtn.textContent = 'Copy'; }, 1500); + }); + }, + }, 'Copy'); + block.appendChild(copyBtn); + section.appendChild(block); + } + + guide.appendChild(section); + }); + + return guide; + }; + + sf.createFooter = function (config) { + sf.assert(config, 'createFooter(config) requires a configuration object'); + + var footer = sf.el('footer', { className: 'sf-footer' }); + if (config.links) { + config.links.forEach(function (link, i) { + if (i > 0) footer.appendChild(sf.el('span', { className: 'sf-vr' })); + footer.appendChild(sf.el('a', { href: link.url, target: '_blank' }, link.label)); + }); + } + if (config.version) { + footer.appendChild(sf.el('span', { style: { marginLeft: 'auto' } }, config.version)); + } + return footer; + }; + +})(SF); +/* ============================================================================ + SolverForge UI — Timeline Rail + Resource-lane timeline: header + cards with positioned blocks. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.rail = {}; + + sf.rail.createHeader = function (config) { + sf.assert(config, 'createHeader(config) requires a configuration object'); + sf.assert(!config.columns || Array.isArray(config.columns), 'createHeader(config.columns) expects an array'); + + var labelWidth = config.labelWidth || 200; + var columns = config.columns || []; + + var header = sf.el('div', { className: 'sf-timeline-header' }); + header.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + var spacer = sf.el('div', { className: 'sf-timeline-label-spacer' }, config.label || ''); + header.appendChild(spacer); + + var days = sf.el('div', { className: 'sf-timeline-days' }); + days.style.gridTemplateColumns = 'repeat(' + columns.length + ', 1fr)'; + + columns.forEach(function (col) { + var colEl = sf.el('div', { className: 'sf-timeline-day-col' }); + colEl.appendChild(sf.el('span', null, typeof col === 'string' ? col : col.label)); + days.appendChild(colEl); + }); + + header.appendChild(days); + return header; + }; + + sf.rail.createCard = function (config) { + sf.assert(config, 'createCard(config) requires a configuration object'); + + var labelWidth = config.labelWidth || 200; + var card = sf.el('div', { className: 'sf-resource-card' }); + var state = { + unassigned: [], + railConfig: config, + }; + + if (config.id) card.dataset.resourceId = config.id; + + // Header row (identity + gauges) + var resHeader = sf.el('div', { className: 'sf-resource-header' }); + resHeader.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + var identity = sf.el('div', { className: 'sf-resource-identity' }); + if (config.name) { + identity.appendChild(sf.el('div', { className: 'sf-resource-name' }, config.name)); + } + if (config.badges || config.type) { + var meta = sf.el('div', { className: 'sf-resource-meta' }); + if (config.type) { + var badge = sf.el('span', { className: 'sf-resource-type-badge' }, config.type); + if (config.typeStyle) { + badge.style.background = config.typeStyle.bg || ''; + badge.style.color = config.typeStyle.color || ''; + badge.style.border = config.typeStyle.border || ''; + } + meta.appendChild(badge); + } + var badges = Array.isArray(config.badges) + ? config.badges + : config.badges + ? [config.badges] + : []; + if (badges.length) { + badges.forEach(function (entry) { + if (!entry) return; + if (typeof entry === 'string') { + meta.appendChild(sf.el('span', { className: 'sf-resource-type-badge' }, entry)); + return; + } + var extraBadge = sf.el('span', { className: 'sf-resource-type-badge' }, entry.label || ''); + if (entry.style) { + extraBadge.style.background = entry.style.bg || ''; + extraBadge.style.color = entry.style.color || ''; + extraBadge.style.border = entry.style.border || ''; + } + meta.appendChild(extraBadge); + }); + } + identity.appendChild(meta); + } + resHeader.appendChild(identity); + + // Gauges + if (config.gauges && config.gauges.length > 0) { + var gauges = sf.el('div', { className: 'sf-gauges' }); + config.gauges.forEach(function (g) { + var row = sf.el('div', { className: 'sf-gauge-row' }); + row.appendChild(sf.el('span', { className: 'sf-gauge-label' }, g.label)); + var track = sf.el('div', { className: 'sf-gauge-track' }); + var fill = sf.el('div', { + className: 'sf-gauge-fill' + (g.style ? ' sf-gauge-fill--' + g.style : ''), + }); + fill.style.width = Math.min(g.pct || 0, 100) + '%'; + track.appendChild(fill); + row.appendChild(track); + if (g.text) row.appendChild(sf.el('span', { className: 'sf-gauge-value' }, g.text)); + gauges.appendChild(row); + }); + resHeader.appendChild(gauges); + } + + card.appendChild(resHeader); + + // Body (stats + rail) + var body = sf.el('div', { className: 'sf-resource-body' }); + body.style.gridTemplateColumns = labelWidth + 'px 1fr'; + + // Stats panel + var stats = sf.el('div', { className: 'sf-resource-stats' }); + if (config.stats) { + config.stats.forEach(function (s) { + var row = sf.el('div', { className: 'sf-stat-row' }); + row.appendChild(sf.el('span', { className: 'sf-stat-label' }, s.label)); + row.appendChild(sf.el('span', { className: 'sf-stat-value' }, String(s.value))); + stats.appendChild(row); + }); + } + body.appendChild(stats); + + // Rail + var railContainer = sf.el('div', { className: 'sf-rail-container' }); + var rail = sf.el('div', { className: 'sf-rail' }); + if (config.id) rail.id = 'sf-rail-' + config.id; + + // Day grid + var numCols = config.columns || 5; + var dayGrid = sf.el('div', { className: 'sf-day-grid' }); + dayGrid.style.gridTemplateColumns = 'repeat(' + numCols + ', 1fr)'; + for (var i = 0; i < numCols; i++) { + dayGrid.appendChild(sf.el('div', { className: 'sf-day-col' })); + } + rail.appendChild(dayGrid); + + railContainer.appendChild(rail); + body.appendChild(railContainer); + card.appendChild(body); + + // Optional heatmap strip + if (config.heatmap) { + var heatmapCfg = { + horizon: config.heatmap.horizon || 1, + label: config.heatmap.label, + segments: config.heatmap.segments, + labelWidth: labelWidth, + }; + heatmapCfg.railConfig = config; + var heatmap = sf.rail.createHeatmap(heatmapCfg); + if (heatmap) card.appendChild(heatmap); + } + + // Optional unassigned list + var unassignedRail = sf.el('div', { className: 'sf-unassigned-rail' }); + if (config.unassigned) { + state.unassigned = config.unassigned; + renderUnassigned(unassignedRail, config.unassigned, config.onUnassignedClick); + } + if (unassignedRail.children.length > 0) card.appendChild(unassignedRail); + + // API + var cardApi = { el: card, rail: rail }; + + cardApi.addBlock = function (blockConfig) { + return sf.rail.addBlock(rail, blockConfig); + }; + + cardApi.setUnassigned = function (items) { + state.unassigned = Array.isArray(items) ? items : []; + if (state.unassigned.length === 0 && unassignedRail.parentNode) { + unassignedRail.innerHTML = ''; + unassignedRail.parentNode && unassignedRail.parentNode.removeChild(unassignedRail); + return; + } + if (state.unassigned.length > 0) { + renderUnassigned(unassignedRail, state.unassigned, config.onUnassignedClick); + } else { + unassignedRail.innerHTML = ''; + } + if (state.unassigned.length > 0 && !unassignedRail.parentNode) { + card.appendChild(unassignedRail); + } + }; + + cardApi.clearBlocks = function () { + rail.querySelectorAll('.sf-block, .sf-changeover').forEach(function (el) { + el.remove(); + }); + }; + + cardApi.setSolving = function (solving) { + card.classList.toggle('solving', solving); + }; + + return cardApi; + }; + + sf.rail.createHeatmap = function (config) { + if (!config || !config.segments || !Array.isArray(config.segments) || config.segments.length === 0) return null; + + var heatmap = sf.el('div', { className: 'sf-heatmap' }); + heatmap.style.gridTemplateColumns = (config.labelWidth || 200) + 'px 1fr'; + var label = sf.el('div', { className: 'sf-heatmap-label' }, config.label || ''); + heatmap.appendChild(label); + + var track = sf.el('div', { className: 'sf-heatmap-track' }); + var columns = config.railConfig && config.railConfig.columns || 1; + track.style.gridTemplateColumns = 'repeat(' + columns + ', 1fr)'; + heatmap.appendChild(track); + + var horizon = config.horizon || 1; + config.segments.forEach(function (segment) { + if (!segment || segment.end <= segment.start) return; + var band = sf.el('div', { className: 'sf-heatmap-segment' }); + var start = Math.max(0, segment.start); + var width = Math.max(0, segment.end - start); + band.style.left = (start / horizon * 100) + '%'; + band.style.width = Math.max(width / horizon * 100, 0.25) + '%'; + if (segment.color) band.style.background = segment.color; + if (segment.opacity != null) band.style.opacity = segment.opacity; + if (segment.tooltip) band.title = segment.tooltip; + track.appendChild(band); + }); + + return heatmap; + }; + + sf.rail.createUnassignedRail = function (tasks, onTaskClick) { + var rail = sf.el('div', { className: 'sf-unassigned-rail' }); + renderUnassigned(rail, tasks, onTaskClick); + return rail; + }; + + sf.rail.addBlock = function (rail, config) { + sf.assert(rail, 'addBlock(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addBlock(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addBlock(config.start/config.end) are required'); + + var horizon = config.horizon || 1; + var startPct = (config.start / horizon) * 100; + var widthPct = ((config.end - config.start) / horizon) * 100; + + var block = sf.el('div', { className: 'sf-block' }); + block.style.left = startPct + '%'; + block.style.width = Math.max(widthPct, 0.5) + '%'; + + if (config.color) { + block.style.background = config.color; + block.style.borderLeftColor = config.borderColor || config.color; + } + if (config.className) block.classList.add(config.className); + if (config.late) block.classList.add('late'); + if (config.id) block.dataset.blockId = config.id; + if (config.delay) block.style.animationDelay = config.delay; + + if (config.label) { + block.appendChild(sf.el('div', { className: 'sf-block-label' }, config.label)); + } + if (config.meta) { + block.appendChild(sf.el('div', { className: 'sf-block-meta' }, config.meta)); + } + + if (config.onHover) { + block.addEventListener('mouseenter', function (e) { config.onHover(e, config); }); + } + if (config.onLeave) { + block.addEventListener('mouseleave', function () { config.onLeave(); }); + } + if (config.onClick) { + block.setAttribute('role', 'button'); + block.tabIndex = 0; + sf.bindActivation(block, function (e) { config.onClick(e, config); }); + } + + rail.appendChild(block); + return block; + }; + + sf.rail.addChangeover = function (rail, config) { + sf.assert(rail, 'addChangeover(rail) requires a rail element'); + sf.assert(config && config.horizon != null, 'addChangeover(config.horizon) is required'); + sf.assert(config.start != null && config.end != null, 'addChangeover(config.start/config.end) are required'); + + var horizon = config.horizon || 1; + var startPct = (config.start / horizon) * 100; + var widthPct = ((config.end - config.start) / horizon) * 100; + + var co = sf.el('div', { className: 'sf-changeover' }); + co.style.left = startPct + '%'; + co.style.width = widthPct + '%'; + rail.appendChild(co); + return co; + }; + + function renderUnassigned(unassignedRail, items, onTaskClick) { + unassignedRail.innerHTML = ''; + (items || []).forEach(function (item) { + var label = typeof item === 'string' ? item : item.label || item.id || ''; + if (!label) return; + var pill = sf.el('button', { + className: 'sf-unassigned-pill', + onClick: function () { + if (onTaskClick) onTaskClick(item); + }, + }, label); + unassignedRail.appendChild(pill); + }); + } + +})(SF); +/* ============================================================================ + SolverForge UI — Gantt (Frappe Gantt + Split.js wrapper) + Requires: Frappe Gantt (Gantt) and Split (Split) loaded globally. + ============================================================================ */ + +(function (sf) { + 'use strict'; + + sf.gantt = {}; + + sf.gantt.create = function (config) { + config = config || {}; + var instanceId = sf.uid('sf-gantt'); + var chartPaneId = config.chartPane || (instanceId + '-chart-pane'); + var gridPaneId = config.gridPane || (instanceId + '-grid-pane'); + var chartContainerId = config.chartContainer || (instanceId + '-container'); + var svgId = config.svgId || (instanceId + '-svg'); + var ganttChart = null; + var splitInstance = null; + var mounted = false; + var mountTarget = null; + var resizeObserver = null; + var tasks = []; + var sortState = { key: null, direction: 'asc' }; + + // ── Build DOM ── + var wrapper = sf.el('div', { className: 'sf-gantt-split' }); + + // Grid pane + var gridPane = sf.el('div', { className: 'sf-gantt-pane', id: gridPaneId }); + var gridHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); + gridHeader.appendChild(sf.el('h3', null, config.gridTitle || 'Tasks')); + var gridControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); + gridHeader.appendChild(gridControls); + gridPane.appendChild(gridHeader); + + var gridContent = sf.el('div', { className: 'sf-gantt-pane-content' }); + var grid = sf.el('div', { className: 'sf-gantt-grid' }); + gridContent.appendChild(grid); + gridPane.appendChild(gridContent); + + // Chart pane + var chartPane = sf.el('div', { className: 'sf-gantt-pane', id: chartPaneId }); + var chartHeader = sf.el('div', { className: 'sf-gantt-pane-header' }); + chartHeader.appendChild(sf.el('h3', null, config.chartTitle || 'Timeline')); + + var viewControls = sf.el('div', { className: 'sf-gantt-view-controls' }); + var viewSelect = sf.el('select', { className: 'sf-gantt-view-select' }); + var modes = [ + { value: 'Quarter Day', label: 'Quarter Day' }, + { value: 'Half Day', label: 'Half Day' }, + { value: 'Day', label: 'Day' }, + { value: 'Week', label: 'Week' }, + { value: 'Month', label: 'Month' }, + ]; + modes.forEach(function (m) { + var opt = sf.el('option', { value: m.value }, m.label); + if (m.value === (config.viewMode || 'Quarter Day')) opt.selected = true; + viewSelect.appendChild(opt); + }); + viewSelect.addEventListener('change', function () { + if (ganttChart) ganttChart.change_view_mode(viewSelect.value); + }); + viewControls.appendChild(viewSelect); + + var chartControls = sf.el('div', { className: 'sf-gantt-pane-controls' }); + chartHeader.appendChild(viewControls); + chartHeader.appendChild(chartControls); + chartPane.appendChild(chartHeader); + + var chartContent = sf.el('div', { className: 'sf-gantt-pane-content' }); + var chartContainer = sf.el('div', { className: 'sf-gantt-container', id: chartContainerId }); + chartContent.appendChild(chartContainer); + chartPane.appendChild(chartContent); + + wrapper.appendChild(gridPane); + wrapper.appendChild(chartPane); + + // ── API ── + var ctrl = { el: wrapper }; + + ctrl.mount = function (parent) { + sf.assert(parent, 'gantt.mount(parent) requires a mount target'); + var target = typeof parent === 'string' ? document.getElementById(parent) : parent; + sf.assert(target, 'gantt.mount(parent) target not found: ' + parent); + validateMountTarget(target); + + if (mounted && mountTarget === target && wrapper.parentNode === target) { + return; + } + if (mounted) ctrl.destroy(); + target.appendChild(wrapper); + mounted = true; + mountTarget = target; + if (tasks.length > 0 || grid.firstChild || chartContainer.firstChild) { + renderGrid(tasks); + renderChart(tasks); + } + initSplit(); + bindResizeObserver(); + }; + + ctrl.setTasks = function (newTasks) { + sf.assert(Array.isArray(newTasks), 'gantt.setTasks(tasks) expects an array'); + tasks = newTasks; + renderGrid(newTasks); + renderChart(newTasks); + }; + + ctrl.refresh = function () { + if (ganttChart && tasks.length > 0) { + ganttChart.refresh(tasksToFrappe(tasks)); + } + }; + + ctrl.getChart = function () { return ganttChart; }; + + ctrl.changeViewMode = function (mode) { + viewSelect.value = mode; + if (ganttChart) ganttChart.change_view_mode(mode); + }; + + ctrl.highlightTask = function (taskId) { + grid.querySelectorAll('.sf-gantt-row').forEach(function (row) { + row.classList.toggle('selected', row.dataset.taskId === taskId); + }); + var svg = chartContainer.querySelector('svg'); + if (svg) { + svg.querySelectorAll('.bar-wrapper').forEach(function (bw) { + bw.classList.remove('highlighted'); + }); + var bar = svg.querySelector('.bar-wrapper[data-id="' + taskId + '"]'); + if (bar) bar.classList.add('highlighted'); + } + }; + + ctrl.destroy = function () { + if (resizeObserver) { + resizeObserver.disconnect(); + resizeObserver = null; + } + if (splitInstance) { splitInstance.destroy(); splitInstance = null; } + ganttChart = null; + mounted = false; + mountTarget = null; + if (wrapper.parentNode) wrapper.parentNode.removeChild(wrapper); + }; + + return ctrl; + + function initSplit() { + if (typeof Split !== 'function') return; + if (splitInstance) { + splitInstance.destroy(); + splitInstance = null; + } + + var splitSizes = normalizePair(config.splitSizes, [40, 60]); + var splitMinSize = normalizePair(config.splitMinSize, [200, 300]); + + splitInstance = Split(['#' + gridPaneId, '#' + chartPaneId], { + direction: 'vertical', + sizes: splitSizes, + minSize: splitMinSize, + snapOffset: 30, + gutterSize: 4, + cursor: 'col-resize', + onDragEnd: function () { + if (ganttChart) { + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 100); + } + }, + }); + } + + function bindResizeObserver() { + if (typeof ResizeObserver !== 'function') return; + if (resizeObserver) { + resizeObserver.disconnect(); + } + resizeObserver = new ResizeObserver(function () { + if (!ganttChart) return; + setTimeout(function () { ganttChart.refresh(tasksToFrappe(tasks)); }, 0); + }); + if (wrapper.parentNode) resizeObserver.observe(wrapper.parentNode); + } + + function normalizePair(value, fallback) { + if (typeof value === 'number' && isFinite(value)) return [value, value]; + if (!Array.isArray(value) || value.length !== 2) return fallback.slice(); + var n0 = Number(value[0]); + var n1 = Number(value[1]); + if (!isFinite(n0) || !isFinite(n1)) return fallback.slice(); + return [n0, n1]; + } + + function validateMountTarget(target) { + sf.assert(target && typeof target.appendChild === 'function', 'gantt.mount(parent) requires a valid DOM container'); + sf.assert(getElementSize(target, 'Width') > 0 && getElementSize(target, 'Height') > 0, 'gantt.mount(parent) target is not laid out yet'); + } + + function getElementSize(target, axis) { + var clientKey = 'client' + axis; + var offsetKey = 'offset' + axis; + var rectKey = axis === 'Width' ? 'width' : 'height'; + + if (typeof target[clientKey] === 'number') return target[clientKey]; + if (typeof target[offsetKey] === 'number') return target[offsetKey]; + if (typeof target.getBoundingClientRect === 'function') { + var rect = target.getBoundingClientRect(); + if (rect && typeof rect[rectKey] === 'number') return rect[rectKey]; + } + return 0; + } + + function tasksToFrappe(taskList) { + return taskList + .filter(function (t) { return t.start && t.end; }) + .map(function (t) { + var customClass = t.custom_class || ''; + if (t.pinned) { + customClass = customClass ? customClass + ' pinned' : 'pinned'; + } + return { + id: t.id, + name: t.name || t.label || t.id, + start: t.start, + end: t.end, + custom_class: customClass, + dependencies: t.dependencies || '', + }; + }); + } + + function renderChart(taskList) { + var frappeTasks = tasksToFrappe(taskList); + + if (frappeTasks.length === 0) { + chartContainer.textContent = ''; + chartContainer.appendChild(sf.el('div', { + className: 'sf-gantt-empty-state', + style: { + padding: '24px', + color: 'var(--sf-gray-400)', + fontFamily: 'var(--sf-font-mono)', + fontSize: '13px', + }, + }, 'No scheduled tasks to display.')); + ganttChart = null; + return; + } + + chartContainer.textContent = ''; + chartContainer.appendChild(createSvgRoot(svgId)); + + ganttChart = new Gantt('#' + svgId, frappeTasks, { + view_mode: viewSelect.value || 'Quarter Day', + date_format: 'YYYY-MM-DD HH:mm', + custom_popup_html: config.unsafePopupHtml || config.popupHtml || defaultPopup, + on_click: function (task) { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + on_date_change: function (task, start, end) { + if (config.onDateChange) config.onDateChange(task, start, end); + }, + }); + } + + function renderGrid(taskList) { + while (grid.firstChild) grid.removeChild(grid.firstChild); + var table = sf.el('table', { className: 'sf-gantt-table' }); + var columns = config.columns || [ + { key: 'name', label: 'Task' }, + { key: 'start', label: 'Start' }, + { key: 'end', label: 'End' }, + ]; + var sortedTasks = sortTasks(taskList); + + var thead = sf.el('thead'); + var headerRow = sf.el('tr'); + columns.forEach(function (col) { + headerRow.appendChild(buildHeaderCell(col)); + }); + thead.appendChild(headerRow); + table.appendChild(thead); + + var tbody = sf.el('tbody'); + sortedTasks.forEach(function (task) { + var rowClasses = ['sf-gantt-row']; + if (task.custom_class) rowClasses.push(task.custom_class); + if (task.projectIndex != null) rowClasses.push('sf-project-' + task.projectIndex); + + var tr = sf.el('tr', { + className: rowClasses.join(' '), + dataset: { taskId: task.id }, + onClick: function () { + ctrl.highlightTask(task.id); + if (config.onTaskClick) config.onTaskClick(task); + }, + }); + + columns.forEach(function (col) { + var td = sf.el('td'); + if (col.key === 'name') { + td.className = 'sf-task-name'; + td.textContent = task.name || task.label || task.id; + } else if (col.render) { + var content = col.render(task); + if (typeof content === 'string') td.textContent = content; + else if (content && content.unsafeHtml) td.innerHTML = content.unsafeHtml; + else if (content instanceof Node) td.appendChild(content); + } else { + td.textContent = task[col.key] || ''; + td.style.fontFamily = 'var(--sf-font-mono)'; + td.style.fontSize = '12px'; + } + tr.appendChild(td); + }); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + grid.appendChild(table); + } + + function buildHeaderCell(col) { + if (!col.sortable) { + return sf.el('th', null, col.label); + } + + var isCurrent = sortState.key === col.key; + var th = sf.el('th', { + className: 'sortable' + (isCurrent ? ' active' : ''), + role: 'button', + tabIndex: 0, + 'aria-sort': isCurrent ? (sortState.direction === 'asc' ? 'ascending' : 'descending') : 'none', + }); + th.appendChild(document.createTextNode(col.label)); + th.appendChild(sf.el('span', { className: 'sort-icon' }, isCurrent ? (sortState.direction === 'asc' ? '▲' : '▼') : '')); + + sf.bindActivation(th, function () { + if (sortState.key === col.key) { + sortState.direction = sortState.direction === 'asc' ? 'desc' : 'asc'; + } else { + sortState.key = col.key; + sortState.direction = 'asc'; + } + renderGrid(tasks); + }); + + return th; + } + + function sortTasks(taskList) { + if (!sortState.key) return taskList.slice(); + var sorted = taskList.slice(); + sorted.sort(function (a, b) { + var aVal = sortValue(a[sortState.key], sortState.key); + var bVal = sortValue(b[sortState.key], sortState.key); + if (aVal === bVal) return 0; + if (sortState.direction === 'asc') return aVal < bVal ? -1 : 1; + return aVal > bVal ? -1 : 1; + }); + return sorted; + } + + function sortValue(value, key) { + if (value == null) return ''; + if (key === 'start' || key === 'end') { + var parsed = Date.parse(value); + return isNaN(parsed) ? String(value).toLowerCase() : parsed; + } + if (typeof value === 'number') return value; + return String(value).toLowerCase(); + } + + function defaultPopup(task) { + var t = tasks.find(function (x) { return x.id === task.id; }); + if (!t) return ''; + return '
' + + '

' + sf.escHtml(t.name || t.id) + '

' + + '

Start: ' + sf.escHtml(t.start) + '

' + + '

End: ' + sf.escHtml(t.end) + '

' + + (t.duration_minutes ? '

Duration: ' + t.duration_minutes + ' min

' : '') + + (t.pinned ? '

Pinned

' : '') + + '
'; + } + + function createSvgRoot(id) { + if (document.createElementNS) { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.id = id; + return svg; + } + return sf.el('svg', { id: id }); + } + }; + +})(SF);