๐ค 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, for ParseFS:
tmpl := template.Must(template.New("").Funcs(ui.Funcs()).
ParseFS(ui.Templates(), "*.html"))
tmpl = template.Must(tmpl.ParseFS(appTemplateFS, "templates/*.html"))
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>.
Funcs
func Funcs(opts ...Option) template.FuncMap
Registers dict, list, menuGroup, searchClear, icon,
iconAssets, T, Tf and dateWords.
dict builds a partial's single data value at the call site:
{{template "badge" dict "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.
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 rebinds the T function.
FuncsWith
func FuncsWith(t func(key string, args ...any) string) template.FuncMap
Funcs with T bound to a request-scoped lookup, so a partial's own
hardcoded-English defaults โ pagination's "Pagination",
confirm-form's "Cancel" โ resolve in the request's locale:
tmpl.Funcs(ui.FuncsWith(func(key string, args ...any) string {
return rastrillo.Tf(r, key, args...)
}))
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. 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.3%, 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 four 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.
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 four, plus brand, nav, account and
locale in the three chrome shells, and foot in topbar and
console. No block reads a field off the data, so a shell renders the
same whether a handler passes a struct, a dict-built map, or nil.
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.
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, 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 SelectJS() []byte
func DatetimeJS() []byte
func CalendarJS() []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); it gives every submit
button a busy state and every form a double-submit guard by default,
with data-busy="false" as the opt-out and data-busy-label as the
label swap; 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. 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.
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.