π€ 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.
MenuGroupDefault
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.