CARLOS rastrillo docs

πŸ€– ui

amadan.net/rastrillo/rastrillo/ui

Rastrillo's server-shape component library: html/template partials, a design-token stylesheet, and the template helpers they need. They are vendored the same way icons are, so you pull in a working component with an import and a ParseFS call instead of a hand-copy.

It is a component library, not a screen generator. Nothing here generates a screen, decides a route, or owns rendering.

Templates and the UI vocabulary is the guide.

Templates

func Templates() fs.FS

The partial set. ui.Pages parses it for you, before your layout, so a page of yours that defines a partial's name wins.

The partials span the list-screen, display, form and route families. Each takes exactly one data value, built inline with dict, and each partial's file carries its data contract in a comment above the {{define}}. ui_test.go's TestAllPartialsAreDefined is the authoritative list.

The partials assume three containers they do not emit, because those belong to your page markup: <div rst-page>, <div rst-list> and <form rst-form>.

media-grid is the list family's grid of image tiles, for an Images page: an even 4:3 frame per tile, a caption under it, and the same checkbox and kebab a list row carries. See Templates.

Funcs

func Funcs(opts ...Option) template.FuncMap

The function map the partials need, for an app that renders through its own template tree instead of ui.Pages. It holds twenty-eight: dict, list, menuGroup, searchClear, icon, iconAssets, opt, stageArt, displayURL, safeHref, rowMenuItems, paginationItems, localizer, withL, strong, path, needs, neededScripts, pageStylesheet, pageScripts, and the locale functions T, Tf, Tbdi, number, displayValue, dateWords, money and actor. Add your own on top; do not drop any. rowMenuItems checks row-menu's items and stops the render on one it cannot show. Outside ui.Pages, pageStylesheet links the whole tokens.css at /static/tokens.css, without a hash, pageScripts gives every optional script, and neededScripts gives the three component scripts; bind your own pageStylesheet to link the hashed file or a slice from Assets.Stylesheet.

opt reads an optional key off a partial's data, whether it is a map or a struct, and gives nil when it is missing. Tbdi is Tf for a sentence that shows back something a visitor typed: each value is escaped and wrapped in <bdi>. stageArt draws the stage shell's backdrop from a word; the same word always draws the same picture.

path puts the page's base path in front of a root-absolute URL, as basepath.Join does: {{path $ (asset "static/tokens.css")}}, {{path $ "/"}}. It reads the base from the data's Base, which Localized carries, and returns the URL unchanged when there is none. The shells send every URL they write through it.

displayURL and safeHref are form.DisplayURL and form.SafeHref, for showing an address that field-url collected. displayURL is the address without its scheme or trailing slash. safeHref is the address if it is an http or https URL with no username or password in it, and "" otherwise, so wrap the link in {{with safeHref .Site}}. See Templates.

dict builds a partial's single data value at the call site:

{{template "badge" dict "L" $L "Label" "Draft" "Tone" "muted"}}

searchClear is where list-bar-search's clear βœ• points: the app's own ClearHref if it passed one, otherwise the same screen with q dropped and every other Hidden pair kept. See Templates.

Pages

func NewPages(cfg PagesConfig) (*Pages, error)
func (p *Pages) Execute(w io.Writer, page string, data any) error
func (p *Pages) Render(w http.ResponseWriter, r *http.Request, status int, page string, data func(takeFlash bool) (any, error)) error
func (p *Pages) ExecuteRegions(w io.Writer, page string, names []string, data any) ([]string, error)
func (p *Pages) WriteRegions(w http.ResponseWriter, r *http.Request, page string, names []string, data any) error
func (p *Pages) HasRegions(page string, names []string) bool
func (p *Pages) Middleware(next http.Handler) http.Handler
func (p *Pages) Localizer(identity string) *Localizer
func (p *Pages) LocalizerFor(r *http.Request) *Localizer

type PagesConfig struct {
	App      fs.FS               // the app's templates
	Layout   string              // the layout file in App, parsed into the base
	Shared   []string            // other shared files in App, parsed into the base
	Pages    map[string]PageFile // page name -> the page's own file
	Entry    string              // the template Execute runs; "layout" when empty
	Locale   locales.Config      // locales.CheckConfig's result
	Catalog  rastrillo.Catalog   // Options.BaseCatalog
	LocaleFS fs.FS               // Options.LocaleFS: locales/<code>.toml
	Funcs    template.FuncMap    // the app's request-independent functions (icon, iconAssets, asset)
	Assets   *rastrillo.Assets   // the app's static files; nil links the whole tokens.css
	Compat   bool
	Logger   *slog.Logger // nil means slog.Default()
}

type PageFile struct {
	FS   fs.FS
	Path string
}

NewPages builds your pages once, at boot. It parses ui's partials, your layout and your Shared files once, then makes one copy per page with that page's own file. A PageFile with a nil FS is read from App. It also makes one localizer per language Locale offers. Nothing is copied per language or per request, and the function map never changes after NewPages returns.

Pages are tmpl templates. Once rastrillo generate has compiled them they render as compiled Go, and until then, or when a template has changed since, they render through html/template as before. You change nothing in your app.

Funcs adds your own functions, such as your icon set's icon and iconAssets and your asset. A function named after a locale function, localizer or withL is refused: ui: app function "T" would replace a framework function.

Assets is the app's rastrillo.Assets. With it, each page links a slice of static/tokens.css for its hooks, and pageScripts does not list an optional script the app has deleted. Without it, every page links the whole file. See Templates.

Execute renders a page. Its data must carry a non-nil localizer as L. It renders into a buffer and writes nothing unless the whole page rendered, so a failure is a clean error, not half a page.

Render is the call to make from a handler. It answers a request for a page, whole or as the regions a live page asks for, in the order live.Render gives, and with that page's own templates. status 0 means 200, and a fragment keeps the status. data builds the page data: it is told whether this render may take the flash, and is not called when the answer is a navigation. Like Execute, the data must carry a localizer as L. An error from data, the localizer check or a template comes back with nothing written. An error while writing the response can come after it has started. An unknown page is an error.

HasRegions reports whether a page defines a region:<name> template for any of names other than notice, which every layout has. A page that does not exist has none. Ask it before you take the flash: a GET that names only regions this page lacks is answered by navigating, and the page navigated to must still find the flash.

ExecuteRegions renders those regions into w, in the order you name them. A name the page has no region for is skipped. The result lists what was rendered, for live.Answer. It has Execute's localizer check, and like Execute it writes nothing unless every region rendered. The region: prefix is the whole safety rule: a name from a request reaches only templates the page marked as regions, never its layout or a partial.

WriteRegions answers a fragment request. It renders with ExecuteRegions, sets Rastrillo-Regions, then writes the body. When nothing rendered, a GET or HEAD gets a 204 with Rastrillo-Location and no body. Pass it the ResponseWriter. Do not write what ExecuteRegions gave you yourself: the body would commit the headers first and Rastrillo-Regions would be lost. On error nothing is sent, so you can write your own 500. See Live pages and live.

Middleware puts the request's localizer on the request, for ForRequest. Wrap your routes in it. LocalizerFor finds the localizer for a request the middleware has not seen, such as the one an error page gets after a panic. Localizer is the localizer for one offered language, and the default language's for any other code.

Pages are strict: a locale function called without $L, or reached by data without L, is an error (ErrLegacyCall or ErrNoLocalizer). Compat turns that off while you migrate: those calls render in your default language instead, and each is logged in capitals. Turn it off again when rastrillo migrate-templates reports nothing.

The localizer

type Localizer struct{ /* unexported */ }

func NewLocalizer(identity, format string, t func(key string, args ...any) string) *Localizer
func (l *Localizer) Identity() string
func (l *Localizer) Format() string
func (l *Localizer) Dir() string

func WithLocalizer(r *http.Request, l *Localizer) *http.Request
func LocalizerFrom(r *http.Request) *Localizer

type Localized struct {
	L       *Localizer
	Locales []rastrillo.LocaleItem
	Return  string
	Scheme  string
	Base    string
}

func ForRequest(r *http.Request) Localized
func (l Localized) Localizer() *Localizer
func (l Localized) Request() Localized

var LocaleFuncs = []string{"T", "Tf", "Tbdi", "number", "displayValue", "dateWords", "money", "actor"}
var ErrNoLocalizer = errors.New(noLocalizerMsg)
var ErrLegacyCall = errors.New(legacyCallMsg)

A Localizer is one language: its catalog, and the CLDR locale its numbers and money are written in. Identity is the language code your app offers (zh-TW), Format the locale that formats it (zh-Hant), and Dir the page's dir, rtl or ltr. Pages makes one for each language you offer; NewLocalizer is for a test or your own rendering.

A page's data carries its localizer as L. ForRequest gives the request's localizer, the language switcher's items, the path to come back to (locale prefix included), the colour scheme the cookie chose (light, dark or "" for System) and the base path. Put them in a map as L, Locales, Return, Scheme and Base, or embed Localized in a view struct. Localized.Request is promoted to every struct that embeds it, so a render function can read those values off any generated view. WithLocalizer and LocalizerFrom put a localizer on a request and read it back; Pages.Middleware uses them.

Base is the base path the request was served under, such as /acme, or "" at the root, from basepath.Base. The path function reads it. Return stays the app's path, without the base.

In a template, {{$L := localizer $}} finds L in the data, and withL adds it to a map passed to a partial. LocaleFuncs are the functions that take $L first:

Call Arguments Notes
{{T $L "key" args…}} *Localizer, key, optional name and value pairs
{{Tf $L "key" "name" v …}} the same
{{Tbdi $L "key" "name" v …}} the same the values are escaped and wrapped in <bdi>
{{number $L v}}, {{displayValue $L v}} *Localizer, any value a nil value renders as before
{{dateWords $L}} *Localizer
{{money $L amount code}} *Localizer, an integer amount or a pointer to one, an ISO code a nil amount writes nothing; there is no form without $L
{{actor $L a}} *Localizer, a rastrillo.Actor see actor

ErrNoLocalizer is a locale call whose $L is nil, or page data with no L. ErrLegacyCall is a locale call without $L. Under ui.Pages both stop the page, unless Compat is on.

money

{{money $L .Price .Currency}}

Writes an amount in the reader's language, from CLDR data. The amount is an integer count of the currency's smallest unit, or a pointer to one; a nil pointer writes nothing. The code is an ISO 4217 code, as a string. The currency comes from your data, never from the reader: 128450 EUR is €1,284.50 in en, 1.284,50 € in de and 1284,50 € in pt-PT.

A float, a string amount, an unknown code or a missing argument is an error, and stops the page. Every digit of the currency's smallest unit is shown: 1.5 dinars, stored as 1500 fils, is KWD 1.500 on an English page.

actor

{{actor $L .Actor}}
var ActorName func(ctx context.Context, subject string) string

Writes who did something, in the reader's language: nothing for the app, Paul for a person, and Claude for Paul for an agent acting for them. It is plain text, with no badge or icon. Style it yourself.

Set ActorName once at startup to turn a subject into a person's name. Without it, the page shows the subject. A template has no request, so ActorName is called with context.Background(). If your lookup needs the request's context, build the words in Go with rastrillo.ActorPhrase instead.

The actor partial writes the same words in a span, for a list cell or a detail row:

{{template "actor" dict "L" $L "Actor" .Actor}}

The span is <span rst-actor>. The app itself, the zero Actor, renders nothing. Pass Display with words you built in Go when your lookup needed the request.

const MenuGroupDefault = "rst-menus"

The <details name> exclusivity group every menu the library emits joins unless its caller names another, so opening one menu closes whichever was open β€” native, no script. dropdown, locale-menu and bulk-bar take a MenuGroup key to override it; the menuGroup template func resolves that key and falls back here.

A nested rst-menu-group must not use this value. <details name> exclusivity is document-wide rather than sibling-scoped, so a submenu sharing its parent's group closes that parent the moment it opens.

Option, WithIcons, WithT

type Option func(*config)
func WithIcons(icon func(string) template.HTML, assets func() template.HTML) Option
func WithT(t func(key string, args ...any) string) Option

WithIcons points both icon seams at your own scaffolded icons package:

tmpl := template.Must(template.New("").
	Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
	ParseFS(ui.Templates(), "*.html"))

{{iconAssets}} goes in the layout's <head> and renders empty for the vendored-inline default, so you can call it unconditionally and never touch the layout when the delivery mode changes.

WithT replaces T and Tf for a call that has no $L.

WithLocale

WithLocale(locale string) Option sets the locale number and displayValue use for a call that has no $L. An empty or invalid locale uses English.

Use {{number $L .Count}} for quantities. It groups numeric Go values without rounding away decimal places. It also groups plain integer strings within the int64 or uint64 range, and leaves strings with leading zeros, decimal strings, currency or percent text, and abbreviations unchanged.

stat.Value and pagination item labels call number automatically. Detail lists, badges, list rows and meters call displayValue, which formats numeric Go values and preserves strings. Detail-list entries with DateTime keep their existing formatting. Keep identifiers, years and versions as strings. Use these helpers for display text, leaving input values, URLs and machine-readable attributes unformatted. Both helpers work without JavaScript.

FuncsWith

func FuncsWith(t func(key string, args ...any) string) template.FuncMap

Funcs(WithT(t)). WithT, WithLocale and FuncsWith remain for an app that keeps its own rendering until it moves to ui.Pages. Binding them on a copy of your templates for each request is no longer the recommendation: build your pages once with ui.Pages, and let the localizer in each page's data choose the language. ui.Pages does not take these options.

A value you supply beats a partial's default, and your catalog entry beats the framework base catalog. See Localization.

Themes

func ThemeNames() []string
func ThemeCSS(name string) ([]byte, bool)

The three shipped themes, day first: day, plain, signal. ThemeCSS returns one theme's bytes and reports false for a name that is not shipped β€” rastrillo new --theme calls it before it writes anything.

A theme is colour, type family and shape; the structure is tokens.css. It is one :root block under color-scheme: light dark, with every colour declared once as light-dark(<light>, <dark>) and two toggle rules at the foot setting nothing but color-scheme, so an explicit [data-theme] choice wins in both directions without restating a colour. The switch that sets it ships with the shells: the scheme-menu partial posts Light, Dark or System to /_theme, every layout writes data-theme on <html> from the cookie (Localized.Scheme) so the first paint is right, and rastrillo.js applies a choice in place. No attribute is System, the OS deciding. The switch picks the scheme only; which theme an app wears is the app's choice. Each file carries its own measured WCAG 2.2 AA contrast table in its header comment, for both schemes; ui's contrast_test.go splits the light-dark() calls back apart and recomputes every pair, so a theme that drifts fails the build rather than shipping.

The chosen theme lands as static/theme.css and is app-owned from that moment. Swapping in a hand-written one means replacing that file; the whole surface a theme has to satisfy is the token set day declares β€” which now includes the radii and the four depth tokens β€” and TestThemesDeclareIdenticalTokenSets holds every theme to it.

Colour

func Pair(hue, chroma float64, background string) (Swatch, error)
func Wash(hue, chroma, separation float64, ink, background string) (Swatch, error)
func Allocate(keys []string, avoid []float64) ([]Intent, bool)
func Offered() []Intent
func CheckIntents(intents []Intent, backgrounds []string) error
func CheckWashes(intents []Intent, separation float64, canvases []Canvas) error
func WorstSeparation(intents []Intent, backgrounds []string) (deltaE float64, background, a, b string)
func ContrastRatio(a, b string) (float64, error)
func DeltaEOK(a, b string) (float64, error)

Three entry points for apps that have to colour things the framework has never seen β€” a cell fill, a text highlight, a presence cursor, an author dot.

Pair resolves one colour intent against one background into an Intent made concrete: a Swatch carrying Fill, the colour you paint, and On, the colour you draw on it. The fill clears ContrastFloorBoundary (3:1) against the background and the on-fill clears ContrastFloorText (4.5:1) against the fill, by construction β€” lightness is chosen to make that true, so the same intent comes back dark on paper white and light on dark paper.

background is a literal #rgb or #rrggbb colour and never a theme or a scheme, because the colour a fill sits on is often not the theme's surface: a document canvas can be paper white in a dark theme, and a conditional format paints under a user fill. Pass a scheme instead and your own contrast test asserts the pair against the same wrong assumption it was built from, and passes.

Wash is Pair's sibling, and the difference is who owns the ink. Use Pair when a rule picks the colour and you write both halves β€” a presence cursor, an author dot, a conditional format. Use Wash when a person picked the colour and kept their own text: someone selects a cell and clicks yellow. They asked for a background, not for their font colour to change, and if you hand them an on-fill you have to persist it β€” which on export is a font colour written into the file that nobody set. Import a workbook, highlight one cell, export, and it comes back with font colours throughout.

So Wash takes the ink the author already has and returns a fill of that hue their ink still reads on. Its two floors: the ink clears 4.5:1 against the fill, and the fill is at least MinSeparation from the background β€” perceptibly different, or the user clicks yellow and nothing appears to happen. That second one is a perceptual distance and not a contrast ratio on purpose: a pale yellow wash is about 1.2:1 against white, a number that would condemn every wash anyone has ever shipped.

separation is how heavy you want the wash, and the contract is as close to it as the ink and background allow, never below the floor. A fill exists to be found β€” someone scans a thousand rows for the cell they flagged β€” so it is a flag and not a texture, and one sitting at the threshold of perceptibility inverts its own job.

Pass 0.12 if you have no particular opinion. That is the middle of the weight Excel's own conditional-formatting presets carry. Two bands are worth naming: 0.10–0.14 is the rule-driven register β€” a conditional format, a status tint, anything a rule applied rather than a person chose β€” and 0.21 and above is the hand-picked register, where somebody deliberately reached for a colour and wants it seen. Below 0.05 you are asking for a tint a scanning eye will miss.

What can actually be delivered

Near-black ink on a white canvas is not an edge case, it is the default one: it is what a cell looks like before anybody touches it, what imported files overwhelmingly carry, and what a user picking a fill has almost always left alone. So that is the case a weight picker has to be built against.

Under any near-black ink on white, every offered hue reaches 0.39. Under pure black specifically, 0.43.

Both named bands sit comfortably inside that, so a picker built on them will not offer weights it cannot honour. Above roughly 0.39 the answer starts to depend on the hue; past about 0.47 nothing is reachable at all, because black text still has to be readable on the result and that caps how dark the fill can go.

The scale underneath

Provenance for those numbers β€” real colours measured against a white canvas on the same scale Swatch.Separation reports, so you can choose by pointing at one you know. The rule marks where these stop being weights you can ask for and become colours you can only paint:

weight colour
0.030 MinSeparation β€” our floor, where a fill stops being visible
0.036 Google Sheets' palest fill, which is a grey, #F3F3F3
0.064 Google Sheets' palest coloured fill, #FFF2CC
0.11 Excel's "light green" preset, #C6EFCE
0.12 Excel's "light yellow" preset, #FFEB9C
0.14 Excel's "light red" preset, #FFC7CE
0.21 flat yellow #FFFF00, the most-clicked fill there is
0.38 a solid green #00B050
β€” ceiling under near-black ink on white
0.45 flat red #FF0000 reachable at some hues, not others
0.63 flat blue #0000FF not reachable under black ink at all

The first row is ours and is attributed to nobody: it is where this engine stops calling something a wash. The two below it are what the palest thing another product actually ships measures β€” a different and more useful claim, and the reason a request near the floor gets you something fainter than any coloured fill in a spreadsheet you have used.

The last two rows are why the line is drawn rather than left implied. Flat red and flat blue are perfectly good colours and you may well store them; they are not weights to ask a wash for under black text, because no fill that heavy can carry black text at 4.5:1.

When the ink cannot carry the weight you asked for, you get the closest it can and SeparationMet comes back false β€” not an error, because the constrained answer is correct and is the one to paint. Flat blue is the worked case: black ink on #0000FF is 2.44:1, so asking for blue at its own 0.63 under black ink returns a much lighter blue with the flag down. If your picker offers weights past the ceiling, though, that flag becomes wallpaper β€” which is the argument for keeping the offered weights inside the reachable band rather than spanning the published one.

SeparationMet is false in one case only: the wash came back lighter than you asked for. A wash that comes back heavier is met β€” you asked for at least that much weight and got at least that much β€” because a warning that fires when nothing is wrong stops being read. It is also true for a swatch from Pair, which requests no weight, so if !sw.SeparationMet is safe to write against any swatch from either function. SeparationRequested sits beside Separation if you want the numbers, but read the boolean rather than comparing them β€” the achievable weights are not evenly spaced, so the comparison needs a tolerance.

Wash returns an error when no fill of that hue can carry that ink at all, and that is the feature. Excel ships a conditional-formatting preset that fails WCAG AA: "Yellow Fill with Dark Yellow Text" pairs #9C6500 on #FFEB9C at 4.12:1. The product's own default does it.

What is claimed is precise: a wash this function produces cannot be unreadable. Not "no cell in your app can be unreadable" β€” if you retain an imported file's fill and font colour verbatim, which is what makes import and export lossless, then a document using that preset displays at 4.12:1 and it should. Faithfully showing a document somebody else authored is a different act from generating a colour, and only the second is Wash's to guarantee.

Wash also never invents a font colour: On is the ink you passed, normalised to lower-case #rrggbb so a Swatch spells all three of its colours one way. Compare against sw.On rather than against the string you stored.

It does need to know that ink, though, and that is a real limit: Wash is the wrong function if you do not control the text colour. Passing a guessed ink buys a guarantee about a colour that is not on the screen, and applying the guess restyles an author's text β€” the same harm the function exists to prevent. A highlight behind text the app did not choose needs a different guarantee ("any ink that was legible on the background stays legible on the wash"), and that is a separate function being designed rather than this one with the argument left out.

background is a literal colour here for the same reason it is on Pair, and export is the clearest case. A stored intent resolves per reader, so a light reader and a dark reader see two washes of one highlight. XLSX carries one hex per cell, so on export that resolution collapses and a background must be chosen β€” and the theme someone happened to be using when they picked a colour must not leak into the file. A fill imported from a workbook exports as its retained original hex untouched; a fill picked in-app exports resolved against a canonical light background, because Excel's canvas is white and that is where the file will be opened. The export surface is not the reader's surface.

CheckWashes is the wash half of the proof, and it inspects the resolved colours rather than only asking whether an error came back. A Canvas is a background together with every ink that can appear on it, because the two are not independent β€” a light canvas carries near-black theme ink β€” and because an author can pin their font colour and keep it when a reader switches theme. "Pinned black on dark paper" is a real canvas and the hardest one: black ink needs a light fill whatever surrounds the cell, so the wash stops being pale. It still resolves.

Across the twelve offered hues and the shipped surfaces there are no gaps: 26 canvases carrying 76 background/ink pairs between them β€” the two document canvases have no theme ink of their own and carry two each, the 24 theme canvases carry three β€” which is 912 combinations.

Allocate gives each of a set of opaque keys a hue from Offered(), chosen so no two share one for as long as the set has room. It returns the intents aligned with keys plus a flag: false once the keys, plus anything in avoid, outrun the twelve offered hues. Separation is the guarantee; cross-document stability is best-effort, because two keys in one document can hash to the same hue and one of them has to move. The allocation is a pure function of the key set β€” sorted, then open addressing from an FNV-1a probe β€” so two clients rendering one document agree about who is which colour.

A key is a stable string you own the meaning of. The framework has no idea what an identity is.

CheckIntents is the build-time proof, exported so you can run it against backgrounds the framework does not know: it resolves every intent against every background and reports each one that fails a floor, comes back a grey, or lands within MinSeparation of another one. ui's own gate runs it over Offered() against paper white and every surface every shipped theme declares, in both schemes.

That last check is the one worth knowing about. Hues thirty degrees apart are far apart as angles and can still resolve to nearly the same colour once a dark background has squeezed the lightness out of them β€” the shipped set's closest pair is two teals Ξ”E_OK 0.045 apart on a dark page, which clears the floor by 49%.

WorstSeparation returns that measurement, so it is also what answers "could we have more than twelve". Measured against the shipped surfaces, evenly spaced hues clear the floor up to seventeen and fail at eighteen. Sixteen is what we would recommend, though, and the gap between those two numbers is the reason: seventeen clears by 1.9% and sixteen by 7.8%, and 1.9% is inside the range a new theme surface or a tweak to the palette could move. Seventeen is the measurement; sixteen is the one with room in it. Run WorstSeparation against your own canvas before relying on either β€” a dark paper we have never seen is exactly the kind of background that squeezes the teals together.

ContrastRatio is the WCAG arithmetic all of it is built on, and DeltaEOK is plain euclidean distance in OKLab. Both are exported so an app gating its own colours β€” or mapping an imported fill onto the nearest offered intent β€” measures with the same ones the framework does.

Colour still never carries meaning alone. Clearing a floor makes a label legible; the name or initials beside it are what say whose it is.

Shells

func LayoutNames() []string
func Layout(name string) ([]byte, bool)

The five shipped page frames, column first: column is the plain centred page, topbar adds a header bar with nav and an account menu, sidebar a left rail that collapses to a <details> chrome bar below 800px, and console is both at once β€” a brand-and-account bar across the top with the navigation rail beneath it down the side, which is the shape most admin consoles are. Layout returns one shell's complete layout.html text and reports false for a name that is not shipped.

stage is one card centred over a full-page backdrop, for a page that stands alone, such as the sign-in screen. Its blocks are title, lang, dir, head, backdrop and foot.

A shell executes {{template "content" .}} for the page body and wraps it in chrome made of blocks with working defaults: title, lang, dir and head in all five, plus brand, nav, account and locale in the three chrome shells, profile, view and up in sidebar and console, and foot in topbar and console. A shell reads L from the data, and the chrome shells read Locales and Return for the language switcher if they are there. So a shell renders whether a handler passes a struct or a dict-built map, as long as it carries L.

head is the one that is not chrome: it is an empty slot at the foot of <head>, for a favicon, a meta tag, an extra stylesheet or a script that must run before the body. Being last means an app's own CSS wins the ties it should against tokens.css and the theme.

rastrillo new --shell writes the chosen one as templates/layout.html. It is an ordinary template from then on β€” no pin, no vendoring test β€” so overriding a block, or rewriting the file outright, is expected on day one. Templates has the block contract with a worked override.

The sign-in screen

signin renders the whole sign-in card from an auth.SigninState, and signin-title the matching tab title. Pass Brand with a Name, and optionally a one-line Pitch and a Mark (an <img> or inline SVG as template.HTML). Every string comes from the base catalogs under rastrillo.ui.signin_*, in all twelve languages. The passkey button loads passkey.JS() from the ScriptURL you set, and only on a page that shows it; without that script it stays hidden, and the email form works as ever. See Magic links.

Styleguide

func Styleguide() map[string]string

The canonical markup for the markup idioms β€” structural components with an arbitrary caller body, such as the section box, the list-grid card, the modal route and the page shells, that a html/template partial can't wrap because it doesn't know that body's shape in advance. tokens.css ships the vocabulary; Styleguide is the exercised markup that goes with it, keyed by idiom name (box, button, stat-band, list-grid, dropdown, form-layout, tblock, modal, help, selbox, shell-topbar, shell-sidebar). The design-system page renders every sample it returns, and ui_test.go's TestIdiomClassesAreStyled holds them honest against tokens.css in both directions: a sample can't write an attribute the stylesheet doesn't style, and an idiom can't ship undemonstrated. The returned map is a copy, safe to mutate.

The vendored assets

func TokensCSS() []byte
func ShimJS() []byte
func BusyJS() []byte
func LiveJS() []byte
func SelectJS() []byte
func DatetimeJS() []byte
func CalendarJS() []byte
func ShellJS() []byte
func TransitionJS() []byte
func ShellCSS() []byte

TokensCSS is the design-token stylesheet rastrillo new writes once into the app's static/. ShimJS is rastrillo.js β€” the progressive-enhancement shim. It drives data-poll and data-poll-push (Background jobs), and it closes an open <details> menu on an outside click or Escape, which is the one part of the menu idiom the native element cannot express.

ShellJS is shell.js and ShellCSS is shell.css: the sidebar and console shells' phone navigation, with the slide between pages, a back control that uses the browser's history when it can, and focus returned to the section you left. Only those two layouts link them, and both are optional.

AnchorJS is anchor.js: in a browser without CSS anchor positioning it opens a dropdown's menu from its summary's other edge when the menu would leave the screen. It does nothing where the browser has the feature, so an app can delete it, and its tag, once all its browsers do.

BusyJS is busy.js, the busy rule: every submit button's label gives way to a spinner for at least 650ms, the button disables and the form refuses a second submit, and Back hands the form back β€” by default, with data-busy="false" as the opt-out and data-busy-label as the label swap. Linking it is the whole opt-in.

LiveJS is live.js, the live pages script. It opens one change stream per page and fetches a region again when a topic it shows has moved, keeping to the order the fetches began in. It leaves a region alone while someone is editing in it, and it never lets focus fall to <body>. It also sends a form marked data-live-form with fetch and swaps in the answer. It does nothing on a page without the rastrillo-live meta tag or a data-live region, so linking it on a static page is harmless. See Live pages and live.

LightJS is light.js, the light the depth styles read: it sets --rst-light-x and --rst-light-y from the hour (its FOLLOW flag, off, eases them toward the pointer instead, at most five times a second); writes the pointer's and a press's position on the button under it; and lets a card's rim brighten near the pointer. Every property has a default the stylesheet carries, so an app can delete the file and its tag, naming it in vendoredIsMine. It never captures the pointer, reads no sensor, and does not run under reduced motion or forced colours.

SelectJS backs the enhanced select: it mirrors a <select> carrying data-rst-select as a filterable ARIA combobox, renders any <optgroup>s as labelled role="group"s rather than flattening them, and never touches one marked data-rst-select="false". DatetimeJS backs the date fields: it turns an input carrying data-rst-date or data-rst-time into a combobox that reads "tomorrow", "next fri 9am" or "in 2 weeks" and writes the result back to the native input, which stays in the form as the value carrier. It holds no month names, no weekday names and no English vocabulary β€” the calendar names come from Intl in the page's language, and the words it matches on arrive on data-rst-date-words from the request's catalog. Its on-screen labels have English fallbacks, the same way select.js does, for a field that reaches it without the attributes.

A select added after the page loads stays native until you ask: dispatch rst:select-scan on document, with {detail: {root}} to look only inside what you added, and select.js enhances every data-rst-select there it has not enhanced already.

The list opens downward unless there is not room for it below and there is more room above. It never goes past the edge of what the reader can see: if neither side has room for the whole list, it takes the bigger side and fits itself to it. A box that scrolls, around the field, is an edge too, unless your stylesheet fixes the list to the screen or anchors it outside that box.

Mark a bar that stays pinned below your form, such as a sticky save bar, with data-rst-select-floor. The list then treats the top of the bar as the bottom of the screen, so it never opens over the bar's buttons. Mark a header that stays pinned above it with data-rst-select-ceiling. The list then treats the bottom of the header as the top of the screen, so it never opens up behind the header.

Inside an iframe, select.js sees only its own frame, so it cannot tell when the page around it hides part of the frame, for example behind a phone's on-screen keyboard. A host that can see the whole page can tell it: dispatch rst:select-viewport on the frame's document with {detail: {top, bottom}}, the part of the frame the reader can see, in the frame's own pixels. Dispatch it again whenever that changes. Send {detail: null} to go back to the frame's own view. Without it, a list in a frame can open under a keyboard or a bar that belongs to the page outside.

CalendarJS is the month grid those fields open when you press their calendar button β€” a real <table> with real column headers under a role="grid", so it is a calendar to a screen reader and not a wall of numbers. Exactly one day sits in the tab order at a time; the arrow keys walk days and weeks, the Page keys walk months, Shift+Page walks years, Home and End reach the ends of the week, and Escape closes the panel and puts focus back in the box. It obeys the input's own min and max, and a range's end will not open before its start. Type while it is open and the grid follows the words, committing nothing until you choose.

It is a separate file from datetime.js because it is a separate job: one reads and writes dates as text, the other draws them. datetime.js looks it up at enhance time through window.rastrilloCalendar, so an app that ships one and not the other still has a working field β€” the button falls back to the browser's own picker. Like the others it knows no month names, no weekday names and no English: the names, the digits and the day a week starts on all come from Intl for the page's lang, and its three visible strings arrive on data-rst-date-calendar, data-rst-date-prev-month and data-rst-date-next-month.

All five are delivered once and yours from then on. Edit them freely; nothing in the framework overwrites them. The scaffold's vendored_test.go pins the delivered copies byte-identical to these, so drift is something you choose rather than discover β€” name the file in that test's vendoredIsMine in the same commit as a deliberate edit. See Assets.

func VendoredNames() []string
func VendoredAssets(theme string) (map[string][]byte, bool)

VendoredAssets is the whole vendored set for one theme, keyed by the name each file takes in an app's static/ directory: the five above plus theme.css, which is ThemeCSS(theme). It reports false for a theme that is not shipped. VendoredNames is the same set as an ordered list of names, for reporting on the files one at a time.

It exists so the list is written down once. rastrillo new writes these bytes, the vendored_test.go it generates compares the app's copies against them, and rastrillo doctor reports the difference β€” three readers of one function rather than three lists that eventually disagree.

Read this page as markdown β€” exact, unstyled, and cheap for an agent to fetch.