Visual reference for every component in the library. Each section shows the
DOM structure, CSS classes, and how the JS factory wires them together.
This document describes the 0.9.0 shipped surface unless a section is marked
Planned. The README is the normative API reference; this file is a visual
and composition reference, not a promise that every CSS pattern has a factory.
Sections in this document follow a simple staging rule:
- Shipped: backed by the current JavaScript API in
js-src/and safe to document as supported behavior. - Planned: useful design or styling direction, but not part of the supported public API yet.
solverforge-ui owns the generated /sf/* asset tree. The framework-neutral
surface is solverforge_ui::assets:
assets::get(path)accepts strict/sf-relative paths such assf.js,sf.css,modules/sf-map.js, andvendor/leaflet/leaflet.js.assets::get(path)returnsResult<UiAsset, AssetError>.UiAssetexposes the canonical path, content type, cache-control value, and embedded bytes through accessors.- Invalid paths such as empty strings,
./paths, absolute paths, backslash paths, duplicate slashes, and..traversal are rejected distinctly from valid-but-missing assets. AssetErrordistinguishesInvalidPathfromNotFound.- The asset API remains available with
default-features = false. solverforge_ui::routes()is the optional Axum adapter over this same asset API and serves/sf/{*path}when the defaultaxumfeature is enabled.- Stable
sf.js/sf.csscompatibility paths use a short cache lifetime; versioned bundles, vendor files, fonts, and images use immutable caching. SF.versionandassets::version()identify the crate version that produced the embedded asset set.
This keeps Axum applications and non-Axum hosts, including Python/FastAPI bindings, on the same asset source of truth.
+------------------------------------------------------------------------+
| .sf-header (sticky, 60px, emerald gradient) |
| [logo] [title / subtitle] [nav tabs...] [Solve][Pause][Ana] |
+------------------------------------------------------------------------+
| .sf-statusbar (score | constraint dots | moves/s | status) |
+------------------------------------------------------------------------+
| |
| .sf-main |
| |
| .sf-tab-panel.active |
| +------------------------------------------------------------------+|
| | ||
| | (application content) ||
| | ||
| +------------------------------------------------------------------+|
| |
+------------------------------------------------------------------------+
| .sf-footer (links | version) |
+------------------------------------------------------------------------+
Wrap the page in <body class="sf-app"> to get the flex column layout,
sticky header, and scrollable main area.
+------------------------------------------------------------------------+
| ┌──┐ |
| │🐍│ Furnace Scheduler [Forni] [Ordini] [Gantt] [▶ Solve][❚❚][◉] |
| └──┘ by SolverForge ←─ nav tabs ──→ ←─ actions ──→ |
+------------------------------------------------------------------------+
↑ ↑ ↑
.sf-header-logo .sf-header-brand .sf-header-actions
44×44, white filter .sf-header-title (18px, white) .sf-btn--success (Solve)
.sf-header-subtitle (12px, mono) .sf-btn--default (Pause, hidden)
.sf-btn--danger (Stop, lifecycle visible)
.sf-btn--ghost (Analyze, circle)
JS: SF.createHeader({ logo, title, subtitle, tabs[], onTabChange, actions: { onSolve, onPause, onResume, onCancel, onAnalyze } })
Nav buttons (.sf-nav-btn): semi-transparent white, .active state toggles on click.
+------------------------------------------------------------------------+
| 0hard/-42soft | ●●●●○○●●● | 12,400 moves/s | Solving... |
| ↑ ↑ ↑ ↑ |
| .sf-statusbar- .sf-constraint- moves display status text |
| score dot |
| (green/yellow/ |
| red by score) green = OK |
| red = hard violated (pulses) |
| amber = soft violated (pulses) |
+------------------------------------------------------------------------+
JS: SF.createStatusBar({ header?, constraints[], onConstraintClick })
Returns: { el, bindHeader(header), updateScore(str), setLifecycleState(state), setSolving(bool), updateMoves(n), updateConstraintDots(arr), colorDotsByScore(str), colorDotsFromAnalysis(arr) }
Pass header or call bindHeader(header) when the status bar should control a
specific header's lifecycle controls and spinner state. Without a bound header,
setLifecycleState() only updates the status text and moves display.
Variants:
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ ▶ Solve │ │ ■ Stop │ │ Primary │ │ Default │ │ Ghost │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
--success --danger --primary --default --ghost
white bg red-600 bg emerald-700 gray border transparent
emerald text white text white text gray text white text
Sizes: Shapes:
┌────┐ ┌────────┐ ┌──────────┐ ┌────────────────┐ ┌──┐
│ sm │ │ medium │ │ large │ │ pill (rounded) │ │◉ │ circle
└────┘ └────────┘ └──────────┘ └────────────────┘ └──┘
Modifiers:
┌ ─ ─ ─ ─ ─ ─┐ --outline: border only, transparent bg
│ Outlined │ combinable with any variant
└ ─ ─ ─ ─ ─ ─┘
JS: SF.createButton({ text, variant, size, icon, pill, circle, outline, disabled, onClick })
┌────────────────────────────────────────────┐
│ ░░░░░░░░░░ emerald gradient ░░░░░░░░░░ ×│ .sf-modal-header
│ Score Analysis │ .sf-modal-title
├────────────────────────────────────────────┤
│ │
│ .sf-modal-body (scrollable) │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ constraint table / analysis / form │ │
│ └──────────────────────────────────────┘ │
│ │
├────────────────────────────────────────────┤
│ [Close] │ .sf-modal-footer
└────────────────────────────────────────────┘
Backdrop: .sf-modal-overlay (dark, blur)
Animation: scale(0.95) → scale(1), 180ms
JS: SF.createModal({ title, body, footer[], width?, onClose? })
Returns: { el, body, open(), close(), setBody(content) }
┌────────────────────────────────────────────────────────┐
│ NAME │ PROCESS │ PRIORITY │ DUE │ SCORE│ .sf-table th
├──────────────┼────────────┼──────────┼──────────┼──────┤
│ Order #142 │ ┌────────┐ │ ┌──────┐ │ Mon 14:00│ -200 │
│ │ │Tempra │ │ │ HIGH │ │ │ │
│ │ └────────┘ │ └──────┘ │ │ │
├──────────────┼────────────┼──────────┼──────────┼──────┤
│ Order #143 │ ┌────────┐ │ ┌──────┐ │ Tue 09:00│ 0 │
│ │ │Ricott. │ │ │ LOW │ │ │ │ hover: gray-50
│ │ └────────┘ │ └──────┘ │ │ │
└──────────────┴────────────┴──────────┴──────────┴──────┘
.sf-badge .sf-badge
--process --high / --medium / --low
JS: SF.createTable({ columns[], rows[], onRowClick? })
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Tempra │ │ HIGH │ │ HARD │
│ (process) │ │ (priority)│ │ (constraint)│
└────────────┘ └────────────┘ └────────────┘
colored bg/ red bg/text red border
border per amber bg/text amber border
process type green bg/text
┌─────────────┐
│ Forklift │ .sf-badge--skill
│ (skill) │ emerald-50 bg, emerald-700 text
└─────────────┘
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ 154 │ │ 3 │ │ 96% │ │ 1,240 │
│ ORDERS │ │ LATE │ │ ON-TIME │ │ WEIGHT │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
.sf-kpi-card .sf-kpi-value.danger
gray-50 bg red for danger
amber for warn
emerald for ok
Wrapped in .sf-kpi-row (flex, gap).
┌──────────────────────────┐
│ ┃ Error × │
│ ┃ Connection refused │
│ ┃ 404: Not Found │
└──────────────────────────┘
↑
.sf-toast--danger
red left border
top-right fixed position
auto-dismiss 10s
Variants: --danger (red), --success (emerald), --warning (amber)
JS: SF.showToast({ title, message, detail?, variant, delay? })
Shorthand: SF.showError(title, detail)
┌─────────────────────┐
│ ODL-2847 │ .sf-tooltip-title
│ Process Tempra │ .sf-tooltip-row
│ Temp 850°C │ .sf-tooltip-key / .sf-tooltip-val
│ Weight 120 kg │
│ Due Mon 14h │
│ ┌───────────────┐ │
│ │ LATE: +45 min │ │ late badge
│ └───────────────┘ │
└─────────────────────┘
fixed position, shadow-xl
opacity transition 150ms
+------------------------------------------------------------------------+
| SolverForge │ Documentation │ GitHub v0.9.0 |
| ↑ links with hover emerald ↑ right-aligned |
+------------------------------------------------------------------------+
JS: SF.createFooter({ links: [{label, url}], version })
┌─────────────────────────────────────────────┐
│ GET /jobs/{id}/snapshot │ .sf-api-section h3
│ Get the latest or requested retained │ (emerald-700 text)
│ snapshot for a job. │
│ ┌───────────────────────────────────┬────┐ │
│ │ curl localhost:7860/jobs/abc/ │Copy│ │ .sf-api-code-block
│ │ snapshot?snapshot_revision=4 │ │ │
│ └───────────────────────────────────┴────┘ │ .sf-copy-btn
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ POST /jobs/{id}/pause │
│ Request an exact runtime-managed pause. │
│ ┌───────────────────────────────────┬────┐ │
│ │ curl -X POST localhost:7860/jobs/ │Copy│ │
│ │ abc/pause │ │ │
│ └───────────────────────────────────┴────┘ │
└─────────────────────────────────────────────┘
JS: SF.createApiGuide({ endpoints: [{ method, path, description, curl }] })
start()
│
├── "progress" → scored metadata update only
│
└── "best_solution" → scored live snapshot update
Startup rule:
the first lifecycle event may be either branch above, but it must already
carry score data.
Retained bootstrap rule:
if startup state is restored from a retained snapshot, do not follow that
bootstrap with an identical duplicate startup "best_solution".
Job id rule:
createJob() must resolve to a non-empty string id, a finite numeric id
including 0, or an object with scalar id/jobId/job_id. Invalid non-scalar
ids are rejected before stream attachment.
Shipped runtime expectations:
createJob()ids are normalized once at startup, and every downstream stream, snapshot, pause, resume, cancel, analysis, and delete call uses that normalized string id.progressupdates score, telemetry, and status only.best_solutionupdates score, telemetry, and the live solution view.best_solutionalways includessolutionplussnapshotRevision.paused,completed,cancelled, andfailedremain authoritative and trigger retained snapshot synchronization before downstream callbacks fire.- HTTP
EventSource.onerroris transport state. Reconnecting errors stay local to the browser transport; a closed stream surfaces throughonErrorand preserves the last authoritative lifecycle, retained job id, score, metadata, and snapshot revision. start()never replaces a retained job. Terminal retained jobs require successfuldelete()cleanup before another solve can start.delete()waits for terminal synchronization, calls backenddeleteJob(), and clears local retained state only after required terminal synchronization and backend cleanup both succeed.COMPLETEDandTERMINATED_BY_CONFIGrequire successful terminal snapshot sync before backend deletion.- Stop remains visible during
CANCELLING, but that state blocks duplicate cancel commands. Activating Stop duringCANCELLINGmay reattach a detached stream listen-only so the UI can observe the terminal event.
Callback rendering rule:
function renderAndSync(snapshot, meta) {
try {
if (snapshot && snapshot.solution) render(snapshot.solution);
} finally {
syncMarkers(meta);
}
}
onProgress, onPauseRequested, and onResumed receive metadata only.
onSolution, onPaused, onCancelled, and onComplete receive a snapshot
when one is available. onFailure may receive null snapshot and analysis
values. A missing snapshot must not suppress lifecycle marker synchronization.
.sf-rail-timeline
┌─────────────────────────────────────────────────────────────────────────────┐
│ [1W] [2W] [4W] [Reset] drag to pan · read only │ optional zoom controls
├───────────────┬─────────────────────────────────────────────────────────────┤
│ Staffing lane │ Mon 20 Tue 21 Wed 22 Thu 23 Fri 24 │ sticky top time header
│ │ 00 06 12 18 00 06 12 18 00 06 12 18 00 06 12 18 ... │ 6-hour tick marks
├───────────────┼─────────────────────────────────────────────────────────────┤
│ Ward East │ ███ summary ███ █ cardio block █ │ overview lane
│ Coverage 92% │ count/open chips + tone mix bar keep dense windows legible │
├───────────────┼─────────────────────────────────────────────────────────────┤
│ Ward West │ ███ summary ███ █ handoff █ │ overview lane
│ Coverage 88% │ overlays stay behind the schedule; focus reveals tooltip │
├───────────────┼─────────────────────────────────────────────────────────────┤
│ Ada │ █ primary █ │
│ Hours 38h │ █ overlap █ │ detailed lane
│ │ track 0 track 1 │ interval packed
├───────────────┼─────────────────────────────────────────────────────────────┤
│ Marco │ █ primary █ │ detailed lane
│ Hours 42h │ █ late coverage █ │
└───────────────┴─────────────────────────────────────────────────────────────┘
sticky left lane labels
header scrollbar visually hidden; body viewport owns vertical scrolling
synchronized horizontal body/header viewport
weekend shading behind the axis
JS:
var timeline = SF.rail.createTimeline({
title: 'Scheduling timeline',
label: 'Staffing lane',
labelWidth: 280,
zoomPresets: ['1w', '2w', '4w', 'reset'],
model: {
axis: {
startMinute: 0,
endMinute: 28 * 1440,
days: buildDays(28),
ticks: buildSixHourTicks(28),
initialViewport: { startMinute: 0, endMinute: 14 * 1440 },
},
lanes: [
{
id: 'ward-east',
label: 'By location · Ward East',
mode: 'overview',
overlays: [{ dayIndex: 5, label: 'Unavailable', tone: 'red' }],
items: [
{
id: 'east-rush',
clusterId: 'east-rush',
startMinute: 360,
endMinute: 1080,
label: 'Monday intake surge',
tone: 'blue',
summary: {
primaryLabel: 'Monday intake surge',
secondaryLabel: 'ER intake · trauma hold · overflow beds',
count: 24,
openCount: 3,
toneSegments: [
{ tone: 'blue', count: 15 },
{ tone: 'amber', count: 6 },
{ tone: 'rose', count: 3 },
],
},
detailItems: [
{ id: 'east-1', startMinute: 360, endMinute: 840, label: 'ER intake', tone: 'blue' },
{ id: 'east-2', startMinute: 420, endMinute: 960, label: 'Trauma hold', tone: 'amber' },
{ id: 'east-3', startMinute: 480, endMinute: 1080, label: 'Overflow beds', tone: 'rose' },
],
},
],
},
{
id: 'employee-ada',
label: 'By employee · Ada',
mode: 'detailed',
items: [
{ id: 'ada-1', startMinute: 2 * 1440 + 360, endMinute: 2 * 1440 + 840, label: 'Primary shift', tone: 'amber' },
{ id: 'ada-2', startMinute: 2 * 1440 + 660, endMinute: 2 * 1440 + 1020, label: 'Handoff overlap', tone: 'amber' },
],
},
],
},
});
timeline.setViewport({ startMinute: 7 * 1440, endMinute: 21 * 1440 });
timeline.expandCluster('ward-east', 'east-rush');
The original createHeader/createCard/addBlock/addChangeover APIs remain
shipped as low-level primitives, but they are no longer the recommended
integration path for dense scheduling UIs.
Shipped dense overview rules:
- overview lanes are for scanability, not raw-label dumps
- additive
summarymetadata can provide explicit headline, count, open state, and tone mix - mixed summarized/raw groups combine explicit summary fields with derived count/open/tone data from the other grouped items
- summary items that override aggregate count must also provide open/tone aggregate data if those signals should be shown
- if
summaryis omitted, the timeline computes a default aggregate summary from grouped detail items - expanded overview clusters keep the aggregate block visible as the collapse affordance
- focus and hover expose the same tooltip content for overview and detailed blocks
Shipped detailed/viewport rules:
zoomPresetsdefaults to['1w', '2w', '4w', 'reset']; pass[]to omit the controls on fixed-horizon app surfaces- detailed blocks render exact interval geometry with no timeline-specific minimum width inflation
- adjacent detailed intervals remain disjoint on one track, while true overlaps are packed onto separate track rows
- the body viewport owns vertical scrolling for dense solved schedules, while horizontal scroll and drag-pan remain synchronized with the sticky header
- timelines created or updated before DOM attachment resynchronize layout after mount so label compaction and content width use real viewport dimensions
- minute fields, day indexes, day counts, ticks, and viewport bounds are finite integer values; consumers normalize timestamps and timezones before rendering
- each
clusterIdidentifies at most one overview group per lane; expansion usesexpandCluster(laneId, clusterId)rather than DOM mutation
.sf-gantt-split
┌──────────────────────────────────────────────────────────────────┐
│ .sf-gantt-pane (grid) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Tasks │ │ .sf-gantt-pane-header
│ ├────────────────────────────────────────────────────────────┤ │
│ │ TASK │ START │ P │ │ │ .sf-gantt-table th
│ ├───────────────────────────────────────────────────────────│ │
│ │▌ Design review │ 09:00 │ P1 │ │ │ .sf-gantt-row
│ │▌ Implementation │ 10:30 │ P2 │ │ │ left border = custom_class
│ │▌ Testing │ 14:00 │ P3 │ │ │ sweep underline on hover
│ └────────────────────────────────────────────────────────────┘ │
├══════════════════════════════════════════════════════════════════┤ ← Split.js gutter (4px, emerald on hover)
│ .sf-gantt-pane (chart) │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Schedule [Quarter Day ▾] │ │ .sf-gantt-pane-header
│ ├────────────────────────────────────────────────────────────┤ │
│ │ Frappe Gantt SVG │ │
│ │ ┌───────────────┬──────────────────────────┬─────────┐ │ │
│ │ │ 09:00 10:00 │ 11:00 12:00 13:00 │ 14:00 │ │ │
│ │ ├───────────────┼──────────────────────────┼─────────┤ │ │
│ │ │ ████████████ ┼──→ ██████████████████████│ │ │ │ bars colored by custom_class
│ │ │ Design │ Implementation │ │ │ │ arrows = dependencies
│ │ │ │ │ ████████│ │ │
│ │ │ │ │ Testing │ │ │
│ │ └───────────────┴──────────────────────────┴─────────┘ │ │
│ │ │ │
│ │ hover: brightness(1.12) + drop-shadow │ │
│ │ drag: grab cursor │ │
│ │ resize: ew-resize handles appear on hover │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
JS:
var gantt = SF.gantt.create({
gridTitle: 'Tasks', chartTitle: 'Schedule',
viewMode: 'Quarter Day',
columns: [
{ key: 'name', label: 'Task' },
{ key: 'start', label: 'Start' },
{ key: 'priority', label: 'P', render: fn },
],
onTaskClick: function (task) { ... },
onDateChange: function (task, start, end) { ... },
});
gantt.mount('container');
gantt.setTasks(taskArray);
gantt.changeViewMode('Day');
gantt.highlightTask('task-1');
Requires: /sf/vendor/frappe-gantt/ + /sf/vendor/split/
The mount target must already have non-zero width and height. The wrapper's
default popup escapes task strings; custom popup HTML and column renderer HTML
are explicit unsafe surfaces and must contain trusted or escaped content.
These helpers remain shipped for custom primitive rail compositions:
.sf-heatmap/.sf-heatmap-segmentviaSF.rail.createHeatmap(config).sf-unassigned-pillviaSF.rail.createUnassignedRail(tasks, onTaskClick)orcard.setUnassigned(items)
Treat them as low-level add-ons, not the canonical dense scheduling entrypoint.
┌─────────────────────────────────────────────────┐
│ │
│ 🏠 depot (sf-marker-vehicle) │
│ ╲ │
│ ╲── route polyline (color per vehicle)│
│ ╲ │
│ ①───②───③ (sf-marker-stop, numbered) │
│ │ │
│ 🍴 visit (sf-marker-visit, icon per type) │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Click to add new visit │ │ .sf-map-hint
│ └──────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
JS:
var map = SF.map.create({ container, center, zoom });
map.addVehicleMarker({ lat, lng, color });
map.addVisitMarker({ lat, lng, color, icon, assigned });
map.addStopNumber({ lat, lng, number, color });
map.drawRoute({ points, color });
map.drawEncodedRoute({ encoded, color });
map.highlight(vehicleColor);
map.clearRoutes();
map.clearStops();
map.clearMarkers();
map.clearAll();
┌──────────────────────────────────────────────────┐
│ ░░░ Score Analysis ░░░░░░░░░░░░░░░░░░░░░░░░ ×│
├──────────────────────────────────────────────────┤
│ │
│ ● Constraint Type # Score │ .sf-ov-table
│ ───────────────────────────────────────── │
│ 🔴 Unassigned HARD 3 -600 │ .sf-ov-row-violated
│ 🔴 Temp Exceeded HARD 1 -150 │
│ 🟢 Process Compat HARD 0 0 │
│ 🟡 Late Delivery SOFT 12 -840 │
│ 🟡 Changeover Cost SOFT 8 -240 │
│ 🟢 Shift Balance SOFT 0 0 │
│ │
│ Click a row to see individual violations → │
│ │
├──────────────────────────────────────────────────┤
│ [Close] │
└──────────────────────────────────────────────────┘
Emerald (Primary) Semantic
┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐ ┌──────────────────┐
│50││100│200│300│400│500│600│ │ success = 500 │
└──┘└──┘└──┘└──┘└──┘└──┘└──┘ │ primary = 700 │
light ──────────────────→ dark │ danger = red-600│
│ warning = amb-500│
Project Colors (8) └──────────────────┘
┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐┌──┐
│Em││Bl││Pu││Am││Pk││Cy││Ro││Li│
└──┘└──┘└──┘└──┘└──┘└──┘└──┘└──┘
#10b981 #3b82f6 #8b5cf6 #f59e0b ...
Fonts
────────────────────────────────
Body: Space Grotesk (400–700)
Mono: JetBrains Mono (400–700)