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
- Variables
- func Back(w http.ResponseWriter, r *http.Request, fallback, msg string, err error)
- func Bars(values []int) template.HTML
- func Columns(tones string, cols []Column) template.HTML
- func Donut(tones string, values []int) template.HTML
- func Icon(name string) template.HTML
- func Ints(v ...int) []int
- func Meter(tones string, values []int) template.HTML
- func Render(w http.ResponseWriter, t *template.Template, status int, name string, data any)
- func Ring(part, total int) template.HTML
- func SafeNext(next string) string
- func SetHeaders(h http.Header)
- func Spark(values []int) template.HTML
- type Brand
- type Chrome
- type Column
- type Config
- type ErrorPage
- type Lang
- type NavGroup
- type NavItem
- type PaletteItem
- type Shell
- type Status
- type User
Constants ¶
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.
const Prefix = "/_ts"
Prefix is where the shell serves its stylesheet, script and mark.
Variables ¶
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.
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
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 Columns ¶
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 ¶
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 Meter ¶
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 ¶
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 SetHeaders ¶
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.
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
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
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 ¶
Hotkeys are the nav entries that have a "g then key" jump, for the shortcut list.
func (Chrome) ScriptText ¶
ScriptText is the script's own text in the page's language.
func (Chrome) T ¶
T translates English text into the page's language, then formats it with args when there are any. Text with no translation stays English.
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 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 ¶
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 NavItem ¶
type NavItem struct {
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 (*Shell) Mount ¶
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 ¶
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".
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. |

