toolshell

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 16, 2026 License: MIT Imports: 9 Imported by: 0

README

toolshell

CI Go Reference

An admin shell for server-rendered Go apps. Your app brings its pages and its nav. toolshell brings everything around them: the sidebar, the header, the stylesheet, one script, the charts and the error pages. It has no dependencies outside the standard library and needs no build step.

Light, English Dark, Arabic (right to left)
Dashboard in the light theme Activity page in the dark theme, in Arabic

Features

  • Layout. A sidebar that folds to icons and slides in on phones, and a header with a profile menu and a language menu.
  • Navigation. A Ctrl/Cmd-K command palette and g then key jumps.
  • Languages. Pages translate with {{.T "text"}}, and a language written right to left turns the whole layout.
  • Feedback. Tooltips and toasts. A blocking veil covers the page while a long form runs, and confirm dialogs guard dangerous buttons.
  • Data. Sortable, searchable and paged tables, with CSV export that is guarded against spreadsheet formulas.
  • Charts. Server-rendered SVG: spark, donut, cols, ring, meter and bars. They need no JavaScript.
  • Themes. Light, dark and auto. The choice lives in a cookie, so the first paint is already right.
  • Error pages. Styled pages for 404, 500 and any other status the app cannot serve.
  • Security. A strict Content-Security-Policy with no 'unsafe-inline'. Every asset is a file under /_ts/.

Install

go get github.com/zoolcoder/toolshell@latest

toolshell requires Go 1.26 or later.

Usage

ts := toolshell.New(toolshell.Config{
	Brand:  toolshell.Brand{Name: "mail", Accent: "ctl"},
	Cookie: "mailctl",
	User:   toolshell.User{Name: "you"},
	Langs: []toolshell.Lang{
		{Code: "en", Name: "English"},
		{Code: "ar", Name: "العربية", Dir: "rtl"},
	},
	Messages: map[string]map[string]string{
		"ar": {"Domains": "النطاقات", "%d domains": "%d نطاقات"},
	},
})

tmpl, err := ts.Templates(appFS, "templates/*.html", funcs)
if err != nil {
	log.Fatal(err)
}
mux := http.NewServeMux()
ts.Mount(mux)

nav := []toolshell.NavGroup{{Items: []toolshell.NavItem{
	{Key: "domains", Label: "Domains", Href: "/domains", Icon: "globe"},
}}}

mux.HandleFunc("GET /domains", func(w http.ResponseWriter, r *http.Request) {
	page := struct {
		toolshell.Chrome
		Count int
	}{ts.Chrome(r, "domains", nav), 3}
	toolshell.Render(w, tmpl, http.StatusOK, "domains.html", page)
})

Each page template starts and ends with the shell:

{{template "ts-top" .}}
<h1 class="pg">{{.T "Domains"}}</h1>
<p>{{.T "%d domains" .Count}}</p>
<div class="dial sm">{{donut "ok bad" (ints 7 2)}}</div>
{{template "ts-bottom" .}}

The package documentation covers every type and template function.

Render executes the page into a buffer before it writes anything. A template error therefore becomes the styled 500 page, never half a page. Write ErrorPage for any other status.

Translations
  • {{.T "English text" args...}} looks the text up in the page's language, then formats it.
  • {{.TH ...}} does the same for a sentence that carries markup. The translation is escaped and only the arguments are trusted.
  • The shell ships its own Arabic for its menus and dialogs. Strings the app supplies win over them.
  • The chosen language is kept in the <cookie>-lang cookie.
  • GET /lang?set=ar&back=/path switches the language. back is kept on this site.

In a right-to-left page, add class="flip" around an icon that points forward, such as a "next" arrow, and class="back" around one that points back. The icon mirrors only where it needs to.

Content-Security-Policy

SetHeaders sets the policy and the other strict headers that every page is meant to carry. Call it from a middleware, or at the top of each handler. The policy allows only the page's own origin. Keep inline <script> and style="" out of your templates: put behaviour in data- attributes the shell's script already reads, and put styles in classes.

Packages for CLI tools

Three subpackages hold the plumbing a local admin tool needs besides its pages. They need nothing beyond the standard library.

  • guard keeps the page on this machine. ListenAndServe refuses a non-loopback address, then serves with timeouts and a graceful shutdown. Handler refuses a foreign Host (DNS rebinding) and a write started by another site (CSRF), sets the headers and caps the body.
  • mcp is a stdio MCP server whose tools are the CLI's own commands, run in-process with an empty stdin. A Plan tool saves its plan and answers with a plan_id; an Apply tool takes only that id. Skills serves an embedded skills/*/SKILL.md tree as prompts.
  • doctor is the doctor report: ok, warn or fail per check, as text or as versioned JSON.
  • activity is the JSONL log of what the tool did, for its Activity page.

toolshell.Back answers a form post: it redirects to the form's return_to with the outcome as the flash the next page shows.

err := guard.ListenAndServe(ctx, "127.0.0.1:0", handler, func(host string) {
	fmt.Printf("ui on http://%s/\n", host)
})

Used by

  • mailctl: declarative mail domains, and its local mailctl ui admin page.

Contributing

See CONTRIBUTING.md. Report security issues privately, as described in SECURITY.md.

License

MIT

Documentation

Overview

Package toolshell is the ZoolCoder app shell for server-rendered Go apps: a sidebar that folds to icons and slides in on phones, a header with a profile menu and a language switch (right-to-left included), a Ctrl/Cmd-K command palette, keyboard jumps, tooltips, toasts, a blocking veil while a long form runs, confirm dialogs, styled error pages, light/dark/auto themes, sortable and searchable tables, SVG charts, and a strict Content-Security-Policy. An app brings its pages and its nav; the shell, the stylesheet and the one script come from here.

Every asset is a file under /_ts/, so the CSP needs no 'unsafe-inline'. Theme and sidebar state live in cookies the server reads, so the first paint is already right without a script.

Wiring, in short:

ts := toolshell.New(toolshell.Config{Brand: toolshell.Brand{Name: "mail", Accent: "ctl"}, Cookie: "mailctl"})
tmpl, _ := ts.Templates(appFS, "templates/*.html", funcs)
ts.Mount(mux)
page := myPage{Chrome: ts.Chrome(r, "domains", nav)}

and each page template starts with {{template "ts-top" .}} and ends with {{template "ts-bottom" .}}.

Index

Constants

View Source
const ContentSecurityPolicy = "default-src 'self'; script-src 'self'; style-src 'self'; " +
	"img-src 'self' data:; font-src 'self'; connect-src 'self'; " +
	"form-action 'self'; frame-ancestors 'none'; base-uri 'none'; object-src 'none'"

ContentSecurityPolicy keeps a page to its own origin. The shell's styles and script are files, so nothing inline runs or applies.

View Source
const Prefix = "/_ts"

Prefix is where the shell serves its stylesheet, script and mark.

Variables

View Source
var Icons = map[string]template.HTML{
	"grid":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1"/><rect x="9" y="1.5" width="5.5" height="5.5" rx="1"/><rect x="1.5" y="9" width="5.5" height="5.5" rx="1"/><rect x="9" y="9" width="5.5" height="5.5" rx="1"/></svg>`,
	"box":      `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2.5 3.5h11v9h-11z"/><path d="M2.5 6.5h11"/></svg>`,
	"share":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="3.5" cy="8" r="2"/><circle cx="12.5" cy="3.5" r="2"/><circle cx="12.5" cy="12.5" r="2"/><path d="M5.3 7.2l5.3-2.7M5.3 8.8l5.3 2.7"/></svg>`,
	"merge":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V7a2 2 0 0 0-2-2H8"/></svg>`,
	"bot":      `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="5" width="10" height="8" rx="2"/><path d="M6 8.5h.01M10 8.5h.01M8 2v3M5 5l-1.5-1.5M11 5l1.5-1.5"/></svg>`,
	"pulse":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1.5 8h3l2-5 3 10 2-5h3"/></svg>`,
	"gear":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/></svg>`,
	"globe":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2.2 2 2.2 11 0 13M8 1.5c-2.2 2-2.2 11 0 13"/></svg>`,
	"mail":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path d="M2 4.5l6 4.5 6-4.5"/></svg>`,
	"list":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M5 4h9M5 8h9M5 12h9M2 4h.01M2 8h.01M2 12h.01"/></svg>`,
	"check":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="6.5"/><path d="M5 8.2l2 2 4-4.4"/></svg>`,
	"cloud":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4.5 12.5h7a3 3 0 0 0 .4-6A4 4 0 0 0 4.2 8a2.3 2.3 0 0 0 .3 4.5z"/></svg>`,
	"shield":   `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 1.5l5.5 2v4c0 3.3-2.4 5.6-5.5 7-3.1-1.4-5.5-3.7-5.5-7v-4z"/></svg>`,
	"terminal": `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M4.5 6l2.5 2-2.5 2M8.5 10h3"/></svg>`,
	"arrow":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h10M9 4l4 4-4 4"/></svg>`,
	"plus":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3v10M3 8h10"/></svg>`,
	"edit":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10.5 2.5l3 3-8 8h-3v-3z"/></svg>`,
	"trash":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4h11M6 4V2.5h4V4M4 4l.7 9.5h6.6L12 4"/></svg>`,
	"search":   `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/></svg>`,
	"menu":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4h11M2.5 8h11M2.5 12h11"/></svg>`,
	"fold":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><path d="M6 2.5v11M10.5 6.5L9 8l1.5 1.5"/></svg>`,
	"close":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4l8 8M12 4l-8 8"/></svg>`,
	"copy":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2"/></svg>`,
	"external": `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 2.5h4.5V7M13.5 2.5L7 9M11.5 9.5v3a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h3"/></svg>`,
	"info":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M8 7.5v4M8 4.8h.01"/></svg>`,
	"keyboard": `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="3.5" width="13" height="9" rx="1.5"/><path d="M4 6.5h.01M6.5 6.5h.01M9 6.5h.01M11.5 6.5h.01M5 9.5h6"/></svg>`,
	"alert":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l6.5 11.5h-13z"/><path d="M8 6.5v3M8 11.6h.01"/></svg>`,
	"xcircle":  `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/></svg>`,
	"clock":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6.5"/><path d="M8 4.5V8l2.5 1.5"/></svg>`,
	"refresh":  `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 3v3.5H10"/><path d="M13.2 6.5A5.5 5.5 0 1 0 13.5 9"/></svg>`,
	"play":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3.2v9.6L12.5 8z"/></svg>`,
	"download": `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.8 7.5L8 10.7l3.2-3.2M2.5 13.5h11"/></svg>`,
	"sun":      `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.8"/><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"/></svg>`,
	"moon":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 9.5A5.8 5.8 0 0 1 6.5 2.5a5.8 5.8 0 1 0 7 7z"/></svg>`,
	"monitor":  `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="2.5" width="13" height="8.5" rx="1.5"/><path d="M5.5 14h5M8 11v3"/></svg>`,
	"zap":      `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/></svg>`,
	"server":   `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2.5" width="12" height="4.5" rx="1"/><rect x="2" y="9" width="12" height="4.5" rx="1"/><path d="M4.5 4.75h.01M4.5 11.25h.01"/></svg>`,
	"key":      `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="5" cy="11" r="2.8"/><path d="M7 9l6.5-6.5M11 5l1.8 1.8M9.5 6.5l1.5 1.5"/></svg>`,
	"link":     `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6.8 9.2a3 3 0 0 0 4.2 0l2-2a3 3 0 0 0-4.2-4.2l-.8.8"/><path d="M9.2 6.8a3 3 0 0 0-4.2 0l-2 2a3 3 0 0 0 4.2 4.2l.8-.8"/></svg>`,
	"chevron":  `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3.5L10.5 8 6 12.5"/></svg>`,
	"inbox":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 9l2-6h9l2 6v4.5h-13z"/><path d="M1.5 9h4l1 1.5h3l1-1.5h4"/></svg>`,
	"trend":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1.5 12l4-4 3 3 6-6.5"/><path d="M10.5 4.5h4v4"/></svg>`,
	"chart":    `<svg width="16" height="16" aria-hidden="true" focusable="false" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 2v12h12"/><path d="M5 11V8M8 11V5M11 11V7"/></svg>`,
}

Icons are 16×16 stroke glyphs that take the text colour. Nav entries name them; templates draw one with {{icon "name"}}. The width and height attributes are a fallback: any CSS size wins, and an icon no rule sizes stays 16px instead of filling its container.

View Source
var Themes = []string{"light", "dark", "auto"}

Themes are the choices in the sidebar switch; auto follows the OS.

Functions

func Back added in v0.3.0

func Back(w http.ResponseWriter, r *http.Request, fallback, msg string, err error)

Back answers a form post: it redirects to the form's return_to field, or to fallback, with the outcome as the flash the next page's Chrome shows (Msg from ?msg=, Err from ?err=). A return_to naming another site is ignored.

func Bars

func Bars(values []int) template.HTML

Bars draws one bar per value, scaled to the largest. {{bars .PerDomain}}

func Columns

func Columns(tones string, cols []Column) template.HTML

Columns draws stacked columns with their labels, scaled to the tallest, each stack segment classed by the matching word in tones, e.g. {{cols "ok bad" .Days}}. Three faint rules mark quarters of the scale.

func Donut

func Donut(tones string, values []int) template.HTML

Donut draws a ring split into segments, each classed by the matching word in tones, e.g. {{donut "ok warn bad" (ints 3 1 0)}}. An empty one is the bare track.

func Icon

func Icon(name string) template.HTML

Icon returns the named glyph, or nothing for an unknown name.

func Ints

func Ints(v ...int) []int

Ints turns template arguments into a slice for bars and meter.

func Meter

func Meter(tones string, values []int) template.HTML

Meter draws a stacked bar of values, each segment classed by the matching word in tones, e.g. {{meter "ok warn bad" (ints 3 1 0)}}.

func Render

func Render(w http.ResponseWriter, t *template.Template, status int, name string, data any)

Render runs a template into a buffer before it writes anything, so a template that fails is a clean 500 and not half a page.

func Ring

func Ring(part, total int) template.HTML

Ring draws a donut showing part of total, from 0 to 100%. {{ring 3 4}}

func SafeNext

func SafeNext(next string) string

SafeNext keeps a redirect on this site: a path, never another origin.

func SetHeaders

func SetHeaders(h http.Header)

SetHeaders sets the strict headers every toolshell page is meant to carry: the CSP, no framing, no sniffing, no referrer to other sites, no caching, no indexing. The referrer stays same-origin rather than off: with none at all, Chrome sends Origin: null on the page's own form posts, which a CSRF check cannot tell from a foreign one.

func Spark

func Spark(values []int) template.HTML

Spark draws a line over its own filled area, scaled to the largest value, for a trend in a small space. {{spark .PerDay}}

Types

type Brand

type Brand struct {
	Name, Accent, Tagline string
	Mark                  template.HTML
	Home                  string // where the brand links; "/" when empty
}

Brand is the sidebar wordmark: Name then Accent in the accent colour, with an optional Tagline under it. Mark replaces the ZoolCoder mark.

type Chrome

type Chrome struct {
	Brand    Brand
	Nav      []NavGroup
	Active   string // Key of the current entry
	Title    string // the entry's label; a page may overwrite it
	Theme    string // light, dark or auto
	Mini     bool   // the sidebar is folded to icons
	ReturnTo string // where a form on this page sends the user back
	Msg, Err string // flash from ?msg= and ?err=
	Status   Status
	Footer   template.HTML
	Palette  bool   // the app serves palette entries
	Cookie   string // the cookie prefix, for the script
	Lang     Lang   // the language this page is in
	Langs    []Lang // the language menu; drawn when there are two or more
	User     User
	Menu     []NavItem
	// contains filtered or unexported fields
}

Chrome is what the shell needs on every page. Page data embeds it.

func (Chrome) Hotkeys

func (c Chrome) Hotkeys() []NavItem

Hotkeys are the nav entries that have a "g then key" jump, for the shortcut list.

func (Chrome) ScriptText

func (c Chrome) ScriptText() map[string]string

ScriptText is the script's own text in the page's language.

func (Chrome) T

func (c Chrome) T(text string, args ...any) string

T translates English text into the page's language, then formats it with args when there are any. Text with no translation stays English.

func (Chrome) TH

func (c Chrome) TH(text string, args ...any) template.HTML

TH is T for a sentence that carries markup: the translation is escaped, string arguments are escaped, and only template.HTML arguments, such as what code and strong return, go in as markup. {{$.TH "Run %s in a terminal" (code "mailctl apply")}}

type Column

type Column struct {
	Label  string
	Values []int
}

Column is one bar of a column chart: a label under it and stacked values.

type Config

type Config struct {
	Brand  Brand
	Status Status
	User   User
	// Menu is the profile menu's links above the shell's own entries.
	Menu []NavItem
	// Langs are the language menu's choices, the default first. With fewer
	// than two the menu is not drawn.
	Langs []Lang
	// Messages translate the app's text: language code, then the English
	// text, then its translation. Pages call {{.T "Run plan"}}. The shell
	// brings its own for the languages it knows; the app's win.
	Messages map[string]map[string]string
	// Footer is trusted markup under every page.
	Footer template.HTML
	// Cookie prefixes the theme and sidebar cookies so two apps on one
	// host keep their own choices. "ts" when empty.
	Cookie string
	// Palette adds entries to the command palette; nil leaves it with the
	// nav pages only.
	Palette func(*http.Request) []PaletteItem
}

Config is what an app tells the shell once.

type ErrorPage

type ErrorPage struct {
	Chrome
	Code            int
	Heading, Detail string
}

ErrorPage is the shell's own page for a status the app cannot serve: render it with Render(w, t, code, "ts-error", page).

type Lang

type Lang struct {
	Code, Name, Dir string
}

Lang is one choice in the language menu. Dir is "rtl" for a script written right to left.

type NavGroup struct {
	Title string
	Items []NavItem
}

NavGroup is a run of entries under an optional heading.

type NavItem struct {
	Href, Label, Key, Icon string
	Count                  int
	Hotkey                 string
}

NavItem is one sidebar entry. Icon names an entry of Icons. Count shows as a badge when non-zero. Hotkey is the second key of a "g then key" jump.

type PaletteItem

type PaletteItem struct {
	Label  string            `json:"label"`
	Hint   string            `json:"hint,omitempty"`
	Href   string            `json:"href,omitempty"`
	Post   string            `json:"post,omitempty"`
	Fields map[string]string `json:"fields,omitempty"`
}

PaletteItem is one palette entry the app adds beyond its pages: a link (Href) or a button that posts Fields to Post.

type Shell

type Shell struct {
	// contains filtered or unexported fields
}

Shell is a configured toolshell.

func New

func New(cfg Config) *Shell

New fills defaults and returns the shell.

func (*Shell) Chrome

func (k *Shell) Chrome(r *http.Request, active string, nav []NavGroup) Chrome

Chrome builds the shell's data for one request.

func (*Shell) Mount

func (k *Shell) Mount(mux *http.ServeMux)

Mount registers the shell's routes: its assets, the theme switch and, when the app has palette entries, the palette's data.

func (*Shell) T

func (k *Shell) T(r *http.Request, text string, args ...any) string

T translates for a request outside a page, such as a flash message a form handler sends back.

func (*Shell) Templates

func (k *Shell) Templates(app fs.FS, pattern string, funcs template.FuncMap) (*template.Template, error)

Templates parses the shared shell and the app's pages into one set, with the shell's helpers and the app's funcs (the app's win on a name clash). Pages are addressed by base name: "plan.html".

type Status

type Status struct {
	Label, Detail string
	Tone          string // ok (default), warn or bad
}

Status is the light at the foot of the sidebar.

type User

type User struct {
	Name, Detail string
}

User is who the profile menu names. A local tool with no login can name the machine's user.

func (User) Initials

func (u User) Initials() string

Initials are the avatar's letters: the first of each of up to two words.

Directories

Path Synopsis
Package activity is the log a local tool keeps of what it did, for tools without a database: one JSON line per entry, read newest first.
Package activity is the log a local tool keeps of what it did, for tools without a database: one JSON line per entry, read newest first.
Package doctor is the report a tool's `doctor` command prints: one line per check, ok, warn or fail, as text for people or versioned JSON for agents.
Package doctor is the report a tool's `doctor` command prints: one line per check, ok, warn or fail, as text for people or versioned JSON for agents.
Package guard keeps a local admin page local.
Package guard keeps a local admin page local.
Package mcp is a stdio MCP server for a CLI: newline-delimited JSON-RPC 2.0 on stdin/stdout, where every tool is one of the CLI's own commands run in-process.
Package mcp is a stdio MCP server for a CLI: newline-delimited JSON-RPC 2.0 on stdin/stdout, where every tool is one of the CLI's own commands run in-process.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL