Skip to main content
Casca
Theme

Integration Guide (server-rendered, no-JS first)Link to section

A guide for rendering Casca from any server-side stack. Casca's default and flagship use case is a static, no-JavaScript render: the server emits plain HTML once, you serve it as a static file, and the charts work with no runtime, no network round-trip, and no client JS. That path is covered first.

The Go-template + htmx patterns further down are an optional interactive enhancement (live refresh, fragment swaps), useful for an interactive app, but not required for a no-JS dashboard. Everything here is framework-agnostic; the examples happen to use Go templates, but the markup and the rules apply to Django, Phoenix, Rails, Laravel, PHP, or a raw handler in any language.

Read the server-render security contract before interpolating any dynamic value into Casca markup.

Quick StartLink to section

1. InstallationLink to section

Copy Casca CSS to your static assets:

# Option A: Copy built CSS
cp dist/casca-core.css static/css/

# Option B: Include in your CSS build pipeline
# Add src/casca-core.css to your build process

2. Include in TemplatesLink to section

// base.tmpl
<!DOCTYPE html>
<html lang="en">
<head>
  <link rel="stylesheet" href="/static/css/casca-core.css">
</head>
<body>
  {{template "content" .}}
</body>
</html>

3. Server-Side RenderingLink to section

Use the provided Go templates:

import "html/template"

func main() {
    tmpl := template.Must(template.New("").Funcs(template.FuncMap{
        "inc": func(i int) int { return i + 1 },
    }).ParseGlob("templates/*.tmpl"))

    // Include Casca templates
    tmpl = template.Must(tmpl.ParseGlob("casca/go-templates/*.tmpl"))

    http.HandleFunc("/dashboard", func(w http.ResponseWriter, r *http.Request) {
        data := getDashboardData()
        tmpl.ExecuteTemplate(w, "dashboard", data)
    })
}

Static, no-JS render (the default path)Link to section

The simplest and most robust way to ship Casca: render the HTML once and serve it as a static file. No JavaScript, no network round-trip, no template runtime at request time. This is the right recipe for public, auditable, print-and-screenshot dashboards.

Each chart is a <figure class="casca casca-figure"> containing two things:

  1. A .casca-data table, the accessible source of truth. Screen readers and a stylesheet-less reader get the real numbers from it.
  2. The visual chart, marked aria-hidden="true" (decorative; the table already carries the data). Use data-labels="always" on a bar chart so the magnitudes are visible on the chart itself in print and on touch, with no hover.
<figure class="casca casca-figure">
  <figcaption class="casca-title">Sessions by source</figcaption>

  <!-- 1) accessible source of truth -->
  <table class="casca-data">
    <caption>Sessions by source</caption>
    <tbody>
      <tr><th scope="row">Organic search</th><td>4,200</td></tr>
      <tr><th scope="row">Direct</th><td>3,100</td></tr>
    </tbody>
  </table>

  <!-- 2) decorative visual; values shown without hover -->
  <div class="casca-bar" data-orientation="horizontal" data-labels="always"
       style="--casca-height: auto" aria-hidden="true">
    <div class="casca-bar-group">
      <div class="casca-bar-item">
        <span class="casca-bar-label">Organic search</span>
        <div class="casca-bar-value" style="--value: 100%"></div>
        <span class="casca-bar-rank-value">4,200</span>
      </div>
      <!-- …one row per item… -->
    </div>
  </div>
</figure>

Render that to a file and serve it. No server is needed at view time:

# framework-agnostic pseudocode (the same shape in Go, Python, PHP, JS, …):
rows      = query_or_load_data()            # your data
max_value = max(r.value for r in rows)
html      = render_template("dashboard.html", rows, max_value)
write_file("public/dashboard.html", html)   # build step / cron / CI
# then: any static file server (nginx, CDN, `file://`) serves public/dashboard.html

The percentage each bar needs (--value) is computed by the server: --value: {{ 100 * row.value / max_value }}%. The chart never does math in the browser, so there is nothing to hydrate.

Pair this with ADOPTION.md ("when not to use Casca") if your data changes per-request and you actually want live updates. That is the optional htmx path below, not the static default.

Server-render security contractLink to section

Casca puts numbers into inline style custom properties (style="--value: 60%", an angle, a count). A server that interpolates dynamic or user-controlled values into that markup must follow these rules, or it risks CSS / markup injection. This contract is the same one a strict static consumer enforces and tests; follow it for any server-rendered output, JS or not.

1. Inline style carries server-COMPUTED NUMBERS only. Only a value the server itself computes and formats as a number (the --value percent, an --angle, a --qx, an OHLC level) may go into an inline style custom property. Format it yourself (e.g. clamp to 0100, append %); do not pass a raw caller/user/stored string through.

# OK: server computed a number:
style="--value: {{ clampPercent(row.value, max) }}%"

# NEVER: a string from data/users in style (CSS/markup injection risk):
style="--value: {{ row.rawField }}"        # ✗
style="--color: {{ user.themeString }}"    # ✗  (could break out of the attribute)

2. Durable / untrusted STRINGS go in text or a quoted attribute, HTML-escaped. Labels, categories, captions, titles (any string) belong in element text content or a double-quoted attribute, and MUST be HTML-escaped (& < > " ') at render time. Never place a string in style, href, src, or url().

# OK: escaped string in text content:
<span class="casca-bar-label">{{ escapeHTML(row.label) }}</span>
<span class="casca-bar-rank-value">{{ escapeHTML(row.formatted) }}</span>

3. attr(data-label) is fine, but still escape the value. The hover/always value label uses data-label="…" rendered via CSS content: attr(data-label) (CSS renders the attribute as text, so there is no CSS injection through it). The attribute value is still a string in HTML, so escape it like any other attribute:

data-label="{{ escapeHTML(row.formatted) }}"

Most server templating escapes HTML by default (Go html/template, Django autoescape, etc.); the rule that needs care is #1. Those engines do not make a raw string safe inside an inline style, so keep style numeric.


Dashboard PatternsLink to section

Cash Flow VisualizationLink to section

Requirement: Diverging baseline bars for income vs expenses

Implementation:

// models/cashflow.go
type CashFlowData struct {
    ID             string
    Title          string
    Subtitle       string
    LabelColumn    string
    ShowGrid       bool
    PositiveLabel  string
    NegativeLabel  string
    DataPoints     []CashFlowPoint
}

type CashFlowPoint struct {
    Label          string
    Value          float64
    FormattedValue string
    Percentage     float64
}

// Helper: Calculate percentages relative to max absolute value
func PrepareCashFlowData(data []CashFlowPoint) []CashFlowPoint {
    maxAbs := 0.0
    for _, point := range data {
        if abs := math.Abs(point.Value); abs > maxAbs {
            maxAbs = abs
        }
    }

    for i := range data {
        data[i].Percentage = (math.Abs(data[i].Value) / maxAbs) * 100
    }

    return data
}

Template:

// templates/dashboard.tmpl
{{define "dashboard"}}
<section>
  <h1>Cash Flow Dashboard</h1>

  {{/* Use Casca's cash-flow template */}}
  {{template "cash-flow" .CashFlowData}}
</section>
{{end}}

Controller:

func DashboardHandler(w http.ResponseWriter, r *http.Request) {
    // Fetch from database
    rawData := fetchMonthlyCashFlow()

    // Prepare for charting
    points := make([]CashFlowPoint, len(rawData))
    for i, d := range rawData {
        points[i] = CashFlowPoint{
            Label:          d.Month,
            Value:          d.NetFlow,
            FormattedValue: formatCurrency(d.NetFlow),
        }
    }

    data := CashFlowData{
        ID:            "monthly-cashflow",
        Title:         "Monthly Cash Flow",
        Subtitle:      "Income vs Expenses",
        LabelColumn:   "Month",
        ShowGrid:      true,
        PositiveLabel: "Income",
        NegativeLabel: "Expenses",
        DataPoints:    PrepareCashFlowData(points),
    }

    tmpl.ExecuteTemplate(w, "dashboard", map[string]interface{}{
        "CashFlowData": data,
    })
}

Multi-Series ComparisonLink to section

Requirement: Grouped bars for comparing metrics across time periods

Implementation:

// models/comparison.go
type GroupedBarData struct {
    Title          string
    Subtitle       string
    CategoryColumn string
    Categories     []string
    Series         []SeriesInfo
    DataByCategory [][]DataPoint
}

type SeriesInfo struct {
    Name  string
    Color string
}

type DataPoint struct {
    Value          float64
    FormattedValue string
    Percentage     float64
}

// Helper: Normalize percentages to 0-100 scale
func NormalizeToPercentage(values [][]float64) [][]DataPoint {
    maxValue := 0.0
    for _, series := range values {
        for _, v := range series {
            if v > maxValue {
                maxValue = v
            }
        }
    }

    result := make([][]DataPoint, len(values))
    for i, series := range values {
        result[i] = make([]DataPoint, len(series))
        for j, v := range series {
            result[i][j] = DataPoint{
                Value:          v,
                FormattedValue: formatCurrency(v),
                Percentage:     (v / maxValue) * 100,
            }
        }
    }

    return result
}

Controller:

func SalesComparisonHandler(w http.ResponseWriter, r *http.Request) {
    rangeParam := r.URL.Query().Get("range") // "month", "quarter", "year"

    // Fetch data for each year
    data2023 := fetchSalesData(2023, rangeParam)
    data2024 := fetchSalesData(2024, rangeParam)
    data2025 := fetchSalesData(2025, rangeParam)

    chartData := GroupedBarData{
        Title:          "Sales Comparison",
        Subtitle:       fmt.Sprintf("By %s", rangeParam),
        CategoryColumn: strings.Title(rangeParam),
        Categories:     getCategories(rangeParam), // ["Q1", "Q2", "Q3", "Q4"]
        Series: []SeriesInfo{
            {Name: "2023", Color: "var(--casca-color-1)"},
            {Name: "2024", Color: "var(--casca-color-2)"},
            {Name: "2025", Color: "var(--casca-color-3)"},
        },
        DataByCategory: NormalizeToPercentage([][]float64{
            data2023,
            data2024,
            data2025,
        }),
    }

    tmpl.ExecuteTemplate(w, "grouped-bars", chartData)
}

Optional: live updates with htmxLink to section

This is the interactive opt-in, not the baseline. A no-JS / static dashboard (see Static, no-JS render) needs none of this. Reach for htmx only when the data must update without a full page load. The security contract applies to every fragment you render here too.

Requirement: Fragment swaps for dynamic chart updates without page reload

Pattern 1: Auto-Refreshing ChartsLink to section

<!-- Dashboard template -->
<section id="live-metrics"
         hx-get="/api/charts/metrics"
         hx-trigger="load, every 30s"
         hx-swap="innerHTML">
  {{/* Initial chart loaded server-side */}}
  {{template "cash-flow" .InitialData}}
</section>

Endpoint:

func MetricsChartHandler(w http.ResponseWriter, r *http.Request) {
    // Fetch latest data
    data := getCurrentMetrics()

    // Return only the chart fragment (not full page)
    tmpl.ExecuteTemplate(w, "cash-flow", data)
}

Pattern 2: User-Triggered UpdatesLink to section

<!-- Date range selector -->
<select name="range"
        hx-get="/api/charts/sales"
        hx-target="#sales-chart"
        hx-swap="innerHTML">
  <option value="month">Last Month</option>
  <option value="quarter">Last Quarter</option>
  <option value="year" selected>Last Year</option>
</select>

<div id="sales-chart">
  {{template "grouped-bars" .SalesData}}
</div>

Endpoint:

func SalesChartHandler(w http.ResponseWriter, r *http.Request) {
    rangeParam := r.URL.Query().Get("range")

    data := getSalesData(rangeParam)

    // Return chart fragment
    tmpl.ExecuteTemplate(w, "grouped-bars", data)
}

Pattern 3: Interactive Chart ButtonsLink to section

<!-- Chart as submit button -->
<form hx-post="/api/actions/generate-report"
      hx-target="#report-status"
      hx-swap="innerHTML">

  <button type="submit" class="casca casca-figure casca-interactive">
    <div class="casca-title">Generate Report</div>
    <div class="casca-gauge" style="--value: {{.Progress}}; --color: var(--casca-color-1)">
      <div class="casca-gauge-value">
        {{.Progress}}%
        <span class="casca-gauge-label">Ready</span>
      </div>
    </div>
  </button>
</form>

<div id="report-status"></div>

Handler:

func GenerateReportHandler(w http.ResponseWriter, r *http.Request) {
    // Process report generation
    reportID := generateReport()

    // Return status chip
    w.Write([]byte(fmt.Sprintf(`
        <div class="casca-chip" data-variant="success">
            ✓ Report #%s generated
        </div>
    `, reportID)))
}

Paginated Legends for Large DatasetsLink to section

Requirement: Handle 10+ categories without overwhelming UI

Implementation:

// Utility function
func PaginateLegend(items []LegendItem, perPage int) []LegendPage {
    var pages []LegendPage

    for i := 0; i < len(items); i += perPage {
        end := i + perPage
        if end > len(items) {
            end = len(items)
        }

        pages = append(pages, LegendPage{
            Items: items[i:end],
        })
    }

    return pages
}

Usage:

func CategoryBreakdownHandler(w http.ResponseWriter, r *http.Request) {
    // Fetch all categories
    categories := fetchAllCategories() // 20+ items

    legendItems := make([]LegendItem, len(categories))
    for i, cat := range categories {
        legendItems[i] = LegendItem{
            Label: cat.Name,
            Value: fmt.Sprintf("%.1f%%", cat.Percentage),
            Color: getColor(i),
        }
    }

    data := PaginatedLegendData{
        ID:    "category-legend",
        Pages: PaginateLegend(legendItems, 5), // 5 per page
    }

    tmpl.ExecuteTemplate(w, "paginated-legend", data)
}

Best PracticesLink to section

1. Data PreparationLink to section

Always normalize values to percentages:

// Good: Consistent 0-100% scale
func NormalizeValues(values []float64) []float64 {
    max := math.Max(values...)
    normalized := make([]float64, len(values))
    for i, v := range values {
        normalized[i] = (v / max) * 100
    }
    return normalized
}

// Bad: Raw values may exceed 100%
func UseRawValues(values []float64) []float64 {
    return values // ❌ May break visual scale
}

2. AccessibilityLink to section

Always include data tables:

{{define "accessible-chart"}}
<figure class="casca casca-figure" role="img" aria-labelledby="chart-{{.ID}}-title">
  <figcaption>
    <div id="chart-{{.ID}}-title" class="casca-title">{{.Title}}</div>
  </figcaption>

  {{/* Data table for screen readers */}}
  <table class="casca-data">
    <caption>{{.Title}}</caption>
    <thead>
      <tr>
        <th>{{.LabelColumn}}</th>
        <th>Value</th>
      </tr>
    </thead>
    <tbody>
      {{range .DataPoints}}
      <tr>
        <td>{{.Label}}</td>
        <td>{{.FormattedValue}}</td>
      </tr>
      {{end}}
    </tbody>
  </table>

  {{/* Visual chart */}}
  <div class="casca-bar" aria-hidden="true">
    <!-- Chart visualization -->
  </div>
</figure>
{{end}}

3. PerformanceLink to section

Cache chart data:

type ChartCache struct {
    mu    sync.RWMutex
    data  map[string]interface{}
    expiry map[string]time.Time
}

func (c *ChartCache) Get(key string) (interface{}, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()

    if exp, ok := c.expiry[key]; ok && time.Now().Before(exp) {
        return c.data[key], true
    }

    return nil, false
}

func (c *ChartCache) Set(key string, data interface{}, ttl time.Duration) {
    c.mu.Lock()
    defer c.mu.Unlock()

    c.data[key] = data
    c.expiry[key] = time.Now().Add(ttl)
}

// Usage
var chartCache = &ChartCache{
    data:   make(map[string]interface{}),
    expiry: make(map[string]time.Time),
}

func CashFlowHandler(w http.ResponseWriter, r *http.Request) {
    cacheKey := "cashflow:monthly"

    if cached, ok := chartCache.Get(cacheKey); ok {
        tmpl.ExecuteTemplate(w, "cash-flow", cached)
        return
    }

    data := fetchAndPrepareCashFlow()
    chartCache.Set(cacheKey, data, 5*time.Minute)

    tmpl.ExecuteTemplate(w, "cash-flow", data)
}

4. Error HandlingLink to section

Graceful degradation:

func SafeChartRender(w http.ResponseWriter, data interface{}) {
    if err := tmpl.ExecuteTemplate(w, "chart", data); err != nil {
        // Log error
        log.Printf("Chart render error: %v", err)

        // Return accessible fallback
        fmt.Fprintf(w, `
            <div class="casca casca-figure" role="alert">
                <div class="casca-chip" data-variant="error">
                    ⚠ Chart data temporarily unavailable
                </div>
                <table class="casca-data">
                    <!-- Raw data table as fallback -->
                </table>
            </div>
        `)
    }
}

TestingLink to section

Unit TestsLink to section

func TestCashFlowDataPreparation(t *testing.T) {
    input := []CashFlowPoint{
        {Value: 15000},
        {Value: -8000},
        {Value: 22000},
    }

    result := PrepareCashFlowData(input)

    // Max absolute value is 22000
    // 15000 / 22000 * 100 ≈ 68.18%
    assert.InDelta(t, 68.18, result[0].Percentage, 0.01)

    // -8000 / 22000 * 100 ≈ 36.36%
    assert.InDelta(t, 36.36, result[1].Percentage, 0.01)

    // 22000 / 22000 * 100 = 100%
    assert.Equal(t, 100.0, result[2].Percentage)
}

Integration TestsLink to section

func TestHTMXChartSwap(t *testing.T) {
    // Create test server
    ts := httptest.NewServer(http.HandlerFunc(MetricsChartHandler))
    defer ts.Close()

    // Make htmx request
    req, _ := http.NewRequest("GET", ts.URL+"/api/charts/metrics", nil)
    req.Header.Set("HX-Request", "true")

    resp, err := http.DefaultClient.Do(req)
    assert.NoError(t, err)
    assert.Equal(t, 200, resp.StatusCode)

    // Verify response contains chart HTML
    body, _ := ioutil.ReadAll(resp.Body)
    assert.Contains(t, string(body), `class="casca-bar"`)
    assert.Contains(t, string(body), `data-diverging`)
}

Deployment ChecklistLink to section


Example Project StructureLink to section

myapp/
├── static/
│   └── css/
│       └── casca-core.css
├── templates/
│   ├── base.tmpl
│   ├── dashboard.tmpl
│   └── casca/
│       ├── cash-flow.tmpl
│       ├── grouped-bars.tmpl
│       ├── paginated-legend.tmpl
│       └── htmx-integration.tmpl
├── handlers/
│   ├── dashboard.go
│   ├── charts.go
│   └── api.go
├── models/
│   ├── cashflow.go
│   └── metrics.go
└── main.go

SupportLink to section

For integration questions:

For bugs or feature requests: