๐ค Templates and the UI vocabulary
You render with html/template. There is no template language of its
own, and ui is a component library โ nothing in it generates a screen,
decides a route, or owns rendering.
Pages are built once
Build every page at boot with ui.Pages. It parses ui's partials and
your layout once, then makes one copy per page with that page's own
file, so every page can define "content". Every language and every
request runs these same templates. The localizer in each page's data
decides the language.
This is the render.go that rastrillo new writes:
func newPages() (*ui.Pages, error) {
cfg, err := locales.CheckConfig(genlocales.DefaultLocale, genlocales.Locales)
if err != nil {
return nil, err
}
files := map[string]ui.PageFile{}
for _, name := range []string{"index", "errors"} {
files[name] = ui.PageFile{Path: "templates/" + name + ".html"}
}
return ui.NewPages(ui.PagesConfig{
App: appFS,
Layout: "templates/layout.html",
Pages: files,
Locale: cfg,
Funcs: template.FuncMap{"icon": icons.Icon, "iconAssets": icons.Assets, "asset": assets.Path},
})
}
Render a page with the request's values added to its data:
func render(w http.ResponseWriter, r *http.Request, name string, data map[string]any) {
req := ui.ForRequest(r)
page := map[string]any{}
maps.Copy(page, data)
page["L"], page["Locales"], page["Return"] = req.L, req.Locales, req.Return
if err := pages.Execute(w, name, page); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
}
}
ui.ForRequest gives the request's localizer and the language
switcher's items and return path. It finds the localizer that
pages.Middleware put on the request, so wrap your routes in it.
Execute renders into a buffer and writes nothing unless the whole page
rendered, so a template error is a clean 500, not half a page.
Call flash.Take(w, r) in render.go too, once per page, so the
layout can show a notice. See Forms.
Template functions
ui.Pages registers the template functions dict, list, menuGroup,
searchClear, icon, iconAssets, opt, stageArt, displayURL,
safeHref, rowMenuItems, paginationItems, localizer, withL,
path, needs, pageStylesheet, pageScripts and neededScripts.
It also registers the locale functions T, Tf, Tbdi, number,
displayValue, dateWords, money and actor.
Write a link to one of your own pages with path: {{path $ "/orders"}}. At the root it changes nothing. Under a base path it adds the base, so the link stays in your app.
Start each template body with {{$L := localizer $}}. Pass $L first
to every locale function, and to every partial as "L" $L:
{{define "content"}}{{$L := localizer $}}
<h1>{{T $L "rastrillo.ui.edit"}}</h1>
<p>{{number $L .Count}} ยท {{money $L .Price .Currency}}</p>
<form rst-form method="post">
{{template "field-text" dict "L" $L "Name" "name" "Label" "Name" "Value" .Name "Required" true}}
{{template "field-daterange" dict "L" $L "Label" "When" "Start" (dict "Name" "s" "Label" "From") "End" (dict "Name" "e" "Label" "To")}}
{{template "form-foot" dict "L" $L "Submit" "Save" "CancelHref" (path $ "/") "CancelLabel" "Cancel"}}
</form>
{{template "pagination" dict "L" $L "Prev" (path $ "/p/1") "Next" (path $ "/p/3")}}
{{end}}
Each partial takes exactly one data value, and dict builds it at the
call site. To pass a partial other data, wrap it with withL, which
adds L to a map:
{{template "form-error" (withL (.Error) $L)}}
A struct cannot gain a field that way. Give it its own L by embedding
ui.Localized.
Pages are strict. A locale function called without $L, or reached by
data that has no L, is an error, so the page fails instead of
rendering in the wrong language. rastrillo migrate-templates adds
$L for you; see Upgrading templates.
PagesConfig.Funcs takes your own functions, such as your icon set's
icon and iconAssets. Pass both. {{iconAssets}} goes in the
layout's <head>. It renders empty for the vendored-inline default, so
you can call it unconditionally and never edit the layout when the
delivery mode changes. See Icons.
The page's stylesheet and scripts
Each page sends only the CSS and JS it can use. When ui.Pages starts,
it reads each page's templates for the hooks they show, such as
rst-card and rst-btn, and the scripts they need. The layout links
the result with two functions:
<link rel="stylesheet" href="{{path $ pageStylesheet}}">
{{range pageScripts}}<script defer src="{{path $ (asset .)}}"></script>{{end}}
pageStylesheet is the page's slice of static/tokens.css: the rules
for the hooks the page shows, and the rules every page needs. Slices
are minified, gzipped and cached like your other files, and pages that
show the same hooks share one. Your tokens.css stays one file, and an
edit to it reaches every slice. If it can't be read, for example
because it uses CSS nesting, every page links the whole file.
theme.css is never sliced.
pageScripts lists the scripts the page uses, in the order they load:
busy.jswith a form of the page's own. The layout's language and scheme menus don't count.light.jswith a component it lights:rst-btn,rst-box,rst-card,rst-statsorrst-list.anchor.jswith a menu.select.jswithfield-select, anddatetime.jsandcalendar.jswith the date and time fields.
A page with a live region or a polling fragment links the slice for every page's hooks and loads every script, because the HTML it swaps in later may carry anything.
Pass your app's rastrillo.Assets as PagesConfig.Assets. With it,
pageScripts does not list an optional script you deleted from
static/, such as light.js for the still design. Without it, every
page links the whole tokens.css.
Markup you write in a template is read like a partial's. A field you
write yourself loads its script as long as the markup carries its
attribute: data-rst-select loads select.js, and data-rst-date,
data-rst-time or data-rst-range load datetime.js and
calendar.js.
Asking with needs
Some markup is not in the templates: HTML your Go code builds, an attribute that comes from your data, or markup your own script adds. Ask for what it needs in the template that shows it:
{{needs "select.js"}}<select {{.Hook}}>
{{needs "rst-card"}}{{.Summary}}
needs takes a script or a hook, in quotes, and renders nothing. The
scripts are busy.js, live.js, light.js, anchor.js,
select.js, calendar.js and datetime.js. A hook is rst- and a
component's name. Any other name makes NewPages return an error
naming the page. Don't add a <script> tag for these scripts yourself.
You don't have to find every hook by hand. make perf fails when a
page's slice lacks a rule for a hook that the whole tokens.css would
apply to it, and names the hook to ask for. See
Testing.
A layout written before pageScripts may load the component scripts
with neededScripts, which lists only select.js, calendar.js and
datetime.js. It still works.
The partials
They span the list-screen, display, form and route families:
actor field form-foot pagination
api-tokens field-check job-status person
back-nav field-date list-bar region:notice
badge field-daterange list-bar-search row-menu
bulk-bar field-datetime list-row-action scheme-menu
callout field-money list-search-submit seg-tabs
choice-field field-select live-head signin
confirm-form field-text locale-menu signin-title
detail-list field-textarea media-grid stat
dropdown field-time meter status-pill
empty-state field-url notice
error-page form-error page-header
locale-menu is the language switcher; see
Localization. scheme-menu is the colour-scheme
switch beside it, Light, Dark and System; see
Themes.
Each partial's file carries its data contract in a comment above the
{{define}}, and ui_test.go's TestAllPartialsAreDefined is the
authoritative list.
The elements they use
Where HTML has an element for what a component is, the component is that element rather than a div wearing a role:
| Idiom | Element |
|---|---|
list-bar-search |
<search> around the method="get" form |
pagination, seg-tabs, back-nav |
<nav> |
| the modal panel | <dialog open> |
| every menu | <details> / <summary> |
<search> carries the search role itself, so the form inside it no
longer sets role="search" โ two nested search landmarks for one search
box helps nobody. The strip sizes the landmark ([rst-lbar] > search) as
well as a bare form, so hand-written markup keeps the layout it had.
Clearing a search
When list-bar-search has a Query, it also renders a โ beside the
field, and that โ is a link: the same Action, carrying every
Hidden pair, with q dropped. A real navigation, so it works with
JavaScript off, it is bookmarkable, and Back behaves.
The browser's own โ is suppressed in tokens.css, on purpose. It is
::-webkit-search-cancel-button and it does exactly what it is
specified to do โ it clears the input's value. A method="get" form
submits on submit, so nothing else happens: the results stand and the
address bar still says ?q=. Leaving it beside a link that really does
clear the search would be two affordances, one of which lies.
Filters and sort survive a clear. They ride in Hidden, and clearing
the search is not resetting the screen.
One pair it can decide about is q itself. An app that carries its
whole query string across the GET hands q back in Hidden, and
carrying that into the clear link would hand the reader a โ that puts
the search back โ the exact lie this replaced. It is dropped.
Pagination is the one the framework cannot decide. Hidden is
opaque name/value pairs โ nothing in it says which pair is the page โ so
the default carries all of them, page number included, and a page number
from a searched result set is usually meaningless once the search is
gone. ClearHref is the hook, on both list-bar-search and list-bar:
{{template "list-bar" dict "L" $L
"SearchAction" (path $ "/posts") "Query" .Query "Hidden" .Carry
"ClearHref" (path $ "/posts")}}
Anything you pass wins over the computed default.
The modal panel is a <dialog open>, which is not a change of idiom: a
rendered-open, non-modal dialog is exactly what a modal-as-a-URL already
was. Nothing calls showModal(), so nothing enters the top layer,
::backdrop never paints, and the rst-modal-overlay div stays the
scrim. tokens.css undoes the browser's own dialog block โ absolute
positioning, auto margins, 1em of padding โ so the panel lays out as
before.
Three things stay divs on purpose. The rst-lrow grid is a layout
grid, not a table: its columns come from one --rst-cols custom
property on the card, and CSS grid on real <table> markup means
display: grid on the table and its rows, which throws away the table
semantics you converted for. Use a <table> when the content is a data
table you want announced as one; rst-lrow is for list screens whose
rows are links. A list row (list-row-action's rst-row) is a div for
the same reason โ it lives in a rst-list card your own page markup
writes, and a <li> needs a list around it that the partial does not
own.
A row that stands for a record is a link across its whole width: its name link covers the row, and the row's other buttons, checkboxes and menus sit on top of it. Give each row one name link. A row with no link does not look or act clickable.
Put a row's other actions in row-menu, at the end of the row. Each
item is a link (Href) or a form that posts (Action, with Hidden
fields). Put a destructive item last: it links to its confirm page and
its label ends with โฆ. Inside a bulk-selection form, every item must be
a link. list-row-action takes the same list as Menu and puts the
menu after its action pill.
A page of pictures is media-grid: one tile per image, each in an
even 4:3 frame with the name and description under it. A tall
screenshot shows its top. Pass Fit "contain" for a logo or a banner.
Give a tile Href to make it a link, Select for the checkbox a
bulk-bar form collects, and Menu for a kebab. Render empty-state
when there are no images.
Who did something is actor: a person's name, or Claude for Paul
for an agent acting for them, as plain words. Pass the
rastrillo.Actor. Set ui.ActorName once at boot to turn a subject
into a name.
The API token page is api-tokens: the one-time reveal, the create
form, the list and the revoke buttons, from apitoken's PageData.
Pass it as Page and the page's path as Path, under your own
page-header. See apitoken.
job-status's rst-job is the third, and the reason is behaviour
rather than structure: the shim replaces that element wholesale on
every poll, and whether a live region whose host node keeps being
swapped out still announces is a question for a browser, not a
judgement call. It stays a plain div until someone drives it.
Menus close each other
Every dropdown, row menu, locale-menu and scheme-menu the library
emits carries name="rst-menus", so opening one closes whichever was
open. That is the native <details name> group and costs no
JavaScript. Pass MenuGroup to dropdown, locale-menu, scheme-menu
or bulk-bar to put a menu in a group of its own.
A nested rst-menu-group must use a different name from the menu
around it. <details name> exclusivity is document-wide, not
sibling-scoped, so a submenu sharing its parent's group closes that
parent the moment it opens.
The toggle-block is outside the group: it is not a menu. So is the Menu button of the topbar and console on a phone: the account menu opens inside it, and in the same group it would close it.
Closing an open menu on a click elsewhere, or on Escape, is the one part
native <details> cannot express, so rastrillo.js does it โ two
delegated listeners on the document, no per-element wiring, menus that
arrive in a polled fragment covered for free. Delete that section of the
file to opt out; with no script at all the menus still open, still close
on a second click of their own summary, and still keep one open at a
time.
A button that changes something says so
Every submit button in every form gets a loading state on its way out:
aria-busy="true", a spinner in place of the label, and โ a tick later,
once the submission is under way โ disabled. busy.js does it by
default, with no attribute to remember; linking the file is the whole
opt-in.
The spinner shows for at least 650ms. A response faster than that would
make it flicker, which reads as a glitch rather than an answer, so the
submit is held until 650ms after the click and then sent โ the clicked
button's name and value with it, exactly as the click would have
sent them. Only a submit with a spinner on screen is held: a button that
opted out, or a script's requestSubmit() with no button, goes at once
(the form is still guarded). A script that wants the spinner passes the
button: form.requestSubmit(button). Leaving the page while a submit is held drops it, and going
Back to a page whose form was sent hands the form back, clean. A button that only reveals
something gets nothing: a disclosure, a dropdown, a tab is not doing
work, and dressing it as though it were is a lie the reader has to learn
to ignore.
The button keeps its width while it works, so nothing beside it moves,
and its label is still there for a screen reader to announce. A button
with a data-busy-label shows those words beside the spinner instead.
For someone who has asked for reduced motion, or who uses forced
colours, the label stays and the spinner sits beside it: a spinner that
does not turn, or one drawn over repainted text, is too easy to miss on
its own.
Only the button that was clicked. The others in the same form keep their
name and their value, so a Save / Save-draft pair still tells your
handler which one it was. The form is what carries the guard: a second
click, Enter in a field and a programmatic requestSubmit() all arrive
at the same place and are all refused while the first submission is out.
| Attribute | On | Effect |
|---|---|---|
data-busy="false" |
the <form> |
the whole form opts out |
data-busy="false" |
one submit button | that button opts out; the form is still guarded |
data-busy-label="Savingโฆ" |
either | shows this text beside the spinner, in place of the label |
Three things the rule deliberately does not do. It does not touch a form
whose target sends the result somewhere else โ that page is not going
anywhere, so it has nothing to be busy about. It does not touch the
standard close button inside a <dialog> โ <form method="dialog">, or
a <button formmethod="dialog"> inside an ordinary form, since the
button's attribute beats the form's โ for the same reason and a sharper
one: that submit closes the dialog and leaves the page exactly where it
stood, so a guard armed there would never be cleared and the dialog
could never be closed through its form again. And it hands the form back
if something downstream cancels the submit: an app handler that calls
preventDefault() to do the work itself owns the feedback too.
One state it cannot get itself out of. A submission that never navigates
โ a 204 or 205, or a response the browser hands straight to the
downloads shelf via Content-Disposition โ leaves the page exactly as
the submit left it, so the button stays disabled with no timeout behind
it to rescue it. There is no honest general fix: a timer would either
fire while a slow save was still running or be so long it never helped.
data-busy="false" on that form is the escape hatch, and the same goes
for anything that posts and stays put. Worth knowing while you are
there: disabled on the focused button moves focus to <body>, so a
keyboard user tabs from the top of the document, which is another reason
a form that stays put should opt out.
Going back is handled. The back/forward cache restores a page exactly as
it was left, spinner and all, so the shim clears every busy form on
pageshow โ button re-enabled, label restored, spinner removed, guard
released. That includes a submit button that belongs to the form through
form="id" while living somewhere else on the page, which is the shape
a sticky header's Save takes. A form you came back to is a form you can
submit again.
The spinner is [rst-spin], the same ring job-status wears, and it
stops turning under prefers-reduced-motion โ the ring stays, dimmed,
because the message is "working", not "look at this". An
<input type="submit"> gets the attributes and the label swap but no
spinner: it is a void element with nowhere to put one. Prefer
<button type="submit">, which is what form-foot emits.
This is an enhancement and nothing more. See Forms for what it does not promise you.
The light
light.js is the same kind of file as busy.js: vendored, first-party,
deletable. It moves the light the depth styles read: the shadows lean
with the hour, and the sheen on a button and the rim of a card follow
the pointer. For the still design, delete it and its tag, and name it in
vendoredIsMine in vendored_test.go. On a phone a pressed button
shows its press, not a sheen, and rastrillo.js, not light.js,
registers the touch listener iOS needs to show pressed states, so
deleting light.js loses none of them.
Three containers the partials assume
They belong to your page markup, so the library does not emit them:
<div rst-page> <!-- the centred content column every screen sits in -->
<div rst-list> <!-- the card wrapping a list-bar and a run of rows -->
<form rst-form> <!-- the column a run of fields and a form-foot sit in -->
There is also a markup idiom vocabulary โ section box, list grid,
dropdown, filter tokens, help tooltip, selection checkbox โ that is CSS
rather than a Go partial. The ui package's doc comment has the full
list.
Which card is which
Two of those containers look like cards and are not the card you want
for ordinary content. rst-list and rst-card have no padding by
design: they hold a run of rows, and each row pads itself. Put a form,
a paragraph, a strip of links or anything else that is not a row straight
into one and it renders flush against the border โ the text touching the
edge is the tell.
The padded card for arbitrary content is rst-box, with its heading as
a sibling rst-box-head before it:
<div rst-box-head><h2>Sign in</h2></div>
<section rst-box>
<form rst-form method="post" action="/signin">โฆ</form>
</section>
rst-form is a hook the form partials assume, not a container: it draws
nothing on its own, so it needs a rst-box (or the bare page) around it.
Set a grid's columns in your stylesheet
A rst-card of rst-lrow rows takes its columns from --rst-cols. Set
it with a class in your own stylesheet, not a style attribute. The
baseline content-security policy blocks inline styles, and the rows fall
back to one column.
<div rst-card class="orders">โฆ</div>
.orders { --rst-cols: 2fr 110px var(--rst-col-menu); }
End --rst-cols with var(--rst-col-menu) when the rows have a โฎ
menu. The column is 32px on a desktop and 44px on a phone.
Screens stack vertically
A screen is a column: page-header, then section-header + card, then the
next section-header + card, in reading order. Do not compose a heading, a
paragraph and a button side by side in a flex row โ a three-word heading
wrapped onto three lines beside a full-width paragraph and a tall narrow
button is what that produces at any real width. A notice that needs a
call to action is either a callout whose body ends in a link, or a
rst-box-head (the <h2> plus one compact rst-btn) over a rst-box
holding the explanation. Horizontal arrangement is reserved for the
idioms that ship it: rst-box-head, rst-field-row, rst-lbar,
rst-lrow cells, rst-seg-tabs, rst-stats.
rst-stats is the one a dashboard opens with: a strip of stat cells
in a single card, divided by hairlines, one of them marked
rst-stat="lead" for the headline reading. Any number of cells โ they
wrap onto a second row rather than squeezing, so you do not tell it how
many you have. There is no separate component for the big number; the
lead cell is the same stat at a larger size.
Wrap a person's name in <bdi>
A name is user-supplied and can be in any script, and a right-to-left name sitting inline with punctuation and a number reorders the line around it. This is not theoretical and it is not rare โ it is what the list row's name cell does:
<a class="rst-nm" href="/orders/1"><bdi>Grace Hopper</bdi><small>AB3PX ยท <bdi>grace@example.com</bdi></small></a>
Without the <bdi>, an Arabic or Hebrew name followed by ยท 09:12
renders with the time drawn to the left of the name: European
numbers following an Arabic letter become Arabic numbers, and those run
right to left. The row reads backwards, in a table where every other row
reads forwards.
Wrap the name wherever it sits inline with other content. You do not
need it where a name is alone in its own block โ person puts the name
and the email in blocks of their own, so it does not need one โ and you
do not need it when ordinary words separate the name from the numbers,
which is why job-status does not have one. Both of those were
measured, not assumed.
Group the digits of a quantity
Every number a person reads as a quantity is grouped for their locale:
54,173 in English, 54.173 in German, ูฅูคูฌูกูงูฃ in Arabic. Never a bare
run of digits. Grouping is not "add a comma" โ the separator, the group
size and the digits themselves all change with the locale โ and the
reason is plain legibility: 54173 has to be counted, 54,173 is read.
An identifier is not a quantity. Reference codes, order numbers, years, versions and port numbers are labels that happen to be made of digits. Grouping one changes what it appears to be: order 4471 is not order 4,471.
Use {{number $L .Count}} for a quantity and
{{money $L .Price .Currency}} for money. Both write the reader's
language's digits and marks. money takes the amount in the currency's
smallest unit, or a pointer to it, and the ISO code as a string. A nil
amount writes nothing.
The currency always comes from your data, never from the reader's
language: a price in euros is in euros on every page. 128450 EUR is
โฌ1,284.50 on an English page and 1.284,50 โฌ on a German one.
Mark a moment with <time>
Where a value is a moment or a length of time, give detail-list's
item a DateTime alongside its Value:
{{template "detail-list" dict "L" $L "Items" (list
(dict "Label" "Published" "Value" "2 August 2026" "DateTime" "2026-08-02"))}}
Value stays whatever you formatted for a person to read; DateTime
carries the same moment in the one syntax a machine can parse. The
framework does not format dates โ a locale decides that and you have the
locale โ so the two are supplied together.
Only for moments and durations. An identifier, a quantity or a reference
number is not a time however numeric it looks; Mono is what marks
those.
State is never colour alone
A tone tells you how to feel about a value; it never carries the value.
status-pill always renders its label, meter always prints its
fraction as text beside the bar, and badge is a word before it is a
colour โ so a reader who cannot separate your positive green from your
negative red still reads the same screen you do.
stat's delta is the same rule with a sharper edge, because a change
is the one value people are tempted to draw in colour alone. Pass the
sign in the text โ "+12%", "โ4%" โ and the colour becomes the
second signal rather than the only one. Pass Tone yourself, too: a
fall is good news about half the time a dashboard shows one (a shorter
wait, fewer errors, a smaller backlog), so the partial will not guess
it from the sign, and a component that painted every minus red would be
confidently wrong about half your deltas. callout with Alert
adds role="alert", which interrupts a screen reader mid-sentence:
reserve it for a problem happening now, and leave ambient notes as the
ordinary tones.
Web address fields
field-url takes a web address and does not care whether the person
types https://. It has field-text's keys, Name, Label, Value,
Required, Hint and Error, plus Autocomplete:
{{template "field-url" dict "L" $L "Name" "Website" "Label" "Website"
"Value" .Fields.Website "Error" (T $L (index .Errors "Website"))}}
It is a text input, not <input type="url">, because browsers refuse
example.com in a url input until it has a scheme. It still asks a
phone for the URL keyboard, and it turns off autocapitalise and
autocorrect so the phone leaves the address alone.
Autocomplete defaults to url, which offers the visitor their own
homepage. For a field about someone else's site, such as a customer's,
pass "off".
Read it on the server with form.URL. It turns example.com into
https://example.com and refuses anything that is not an http or https
address, so what you store is safe to link. See
Forms.
To show a stored address, use displayURL for the text and safeHref
for the link:
{{with safeHref .Website}}<a href="{{.}}" rel="noopener noreferrer">{{displayURL .}}</a>{{end}}
displayURL drops the scheme and the trailing slash. safeHref gives
back the address unchanged if it is an http or https URL, and "" for
anything else, so a bad value from before validation shows no link at
all. On its own, html/template would let a stored mailto: into the
href.
Seed the form with the stored value as it is, not with displayURL.
Dropping the scheme would turn a stored http:// address into
https:// the next time the form is saved.
Date and time fields
Four partials, each field-text's envelope โ label, hint, error, the
same aria-describedby wiring โ around a native input:
| Partial | Input | Posts |
|---|---|---|
field-date |
<input type="date"> |
2006-01-02 |
field-time |
<input type="time"> |
15:04 |
field-datetime |
<input type="datetime-local"> |
2006-01-02T15:04 |
field-daterange |
two of the above in a rst-field-row |
both halves |
{{template "field-datetime" dict "L" $L "Name" "Starts" "Label" "Starts"
"Value" .Fields.Starts "Required" true
"Error" (T $L (index .Errors "Starts"))}}
The keys are the ones field-text takes โ Name, Label, Value,
Required, Hint, Error โ plus Min and Max in the same wire
format the field posts, and Plain to emit the bare native input with
no enhancement attributes at all.
field-daterange wraps two of those. Start and End are sub-dicts,
each carrying a whole single-field contract of its own. Legend names
the pair, and LegendHidden keeps that name for a screen reader while
dropping the visible heading. Kind picks the input both halves get โ
"datetime" by default, or "date" โ and Seed is described below.
The two halves must have different Names. Each derives its input
id and its hint and error ids from its own Name, so a shared one
duplicates every id on the page and points both halves'
aria-describedby at the wrong messages.
Seed is a browser-side convenience, not a default: "session" moves
an empty or backwards end to an hour after the start, once the start is
committed. Nothing is seeded server-side, so a submission with scripts
off is exactly what the person typed.
Parse the other end with form.Date, form.Time or form.DateTime,
and check a range with form.Range โ see Forms.
What the enhancement adds
datetime.js turns an armed input into a combobox that reads
"tomorrow", "next fri 9am", "25 Dec 6pm" or "in 2 weeks". The native
input never leaves the DOM: it keeps its name and its wire value, so the
POST is byte-identical to the un-enhanced form and the server parses it
with the same code either way. With scripts off the field is an ordinary
date input, and the browser's own picker still opens.
It holds no English vocabulary. Not one word the parser matches on is written in the file:
- The relative words come from the catalog.
{{dateWords $L}}looks up all seventeen (today, tomorrow, next, in, ago, at, noon, am and the rest) in the localizer's catalog and encodes them as one JSON object ondata-rst-date-words. One attribute, not seventeen. The words are in the page's language, so a field enhanced in Japanese parses Japanese. A key may list several accepted spellings separated by|, and matching is case- and accent-folded. - Weekday and month names come from
Intl, in the page's ownlang, along with the locale's digits folded to ASCII. So "3 mars", "3ๆ3ๆฅ" and "ูฃ ู ุงุฑุณ" all parse with no catalog entry behind them. - The visible strings ride out on
data-rst-date-*attributes, each resolved throughTat render, because this markup is built in the browser where the catalog is out of reach. The file keeps English fallbacks for the eleven labels it puts on screen, the wayselect.jsdoes, so a field that arrives without an attribute still works. {example}and{n}are substituted in the browser, not at render. The hint travels as its raw template so the example date is formatted by the locale's own formatter; the results line counts rows that only exist once the list is built.
Unreadable text is not a value. Type something the parser cannot read and the field puts the old value back rather than guessing, so nothing is committed that nobody chose.
It will, though, offer to finish a word you have half typed. 5j is not
a date, and the fifth of January, of June and of July are three โ so the
list shows all three with their dates written out, nearest first, and
Enter takes the top one. The same goes for 5 ju, tomo and wedn.
Completion never overrules the parser: an exact reading wins outright,
and a run of letters that is the front of nothing is still refused.
The calendar overlay
The button beside a date field opens calendar.js's month grid โ 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. In a right-to-left page the horizontal arrows swap over
with the layout. The grid obeys the input's own min and max, and a
range's end will not open before its start.
The words and the grid stay in step while it is open. Type, and the calendar follows what you are typing โ paging to the month, outlining the day, committing nothing. Set the date, however you set it, and the calendar closes.
A time field has no calendar, so its button opens a clock instead: every half hour of the day, in the field's own locale, scrolled to the one it is already showing.
calendar.js 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, so an app that ships one and
not the other still has a working field: the button falls back to the
browser's own showPicker(). Every shipped shell links both. The picker button is a real labelled
button calling showPicker() โ the browser's own calendar or clock
grid, rather than a hand-built one to keep accessible.
A range pairs by DOM order: two armed inputs inside one
[data-rst-range] wrapper are the start and the end, in that order. The
end's quick picks are relative to the start, and a time typed into the
end with no date lands on the start's day.
The searchable select
field-select carries data-rst-select at ten options or more, and
select.js mirrors a filterable ARIA combobox onto it. Below ten,
search over a handful of items is furniture rather than help, so nothing
is emitted and the script finds nothing to enhance. Plain opts out at
any size.
Options is flat here, so <optgroup> is a hand-written-markup thing โ
and select.js renders those groups rather than flattening them: each
optgroup becomes a role="group" with its label as the group's
accessible name, and loose options sit at the top level. While someone
searches, the best matches come first and the group headings step aside;
clear the search and every row is back in its group.
A search puts the row someone meant first. Accents don't matter, so
osterreich finds รsterreich; the start of a name beats the middle of
one, and an exact match beats both. Leaving the box after a search takes
the one row it can only mean. Two letters never do.
An <option> can say more, all optional. data-rst-terms lists more
words the search matches, such as an ISO code or a calling code.
data-rst-first puts it first when an exact search matches several.
data-rst-name and data-rst-desc split a label into a name and a
quieter description. data-rst-short is what the closed box shows once
it is picked (+44). data-rst-lead is a decorative glyph such as a
flag, drawn before the row and the box. data-rst-class gives the row
classes of its own, and the closed box too while it shows that pick, so
a font menu can set each name in its own face. An <hr> between two options
draws a divider in the list, hidden while searching.
A blank that asks rather than answers is a prompt: the blank of a
required select, a disabled blank, or one marked data-rst-prompt.
The box never shows a prompt as the pick, and a required select never
lists its blank. With nothing picked yet, the list opens with nothing
highlighted, so pressing Enter can't answer the question for someone.
A hand-written select opts out from the markup side with
data-rst-select="false", which is never enhanced whatever its size.
This partial never emits it โ Plain simply emits nothing โ but
select.js honours it, so a select you wrote yourself can say no.
The design system
Every partial, every state, every markup idiom and all five shells, rendered live for all three themes and all twelve base locales: one page per section for each theme and locale, plus a full-page demo for each shell and one for the modal route. It is live at rastrillo.org/design-system.
The gallery is generated by internal/designsystem and is not kept in
the repository: it is about 33 MB of machine output in some 5,700 files,
rewritten whole every time a ui template changes. The site builds it
at deploy time by running cmd/dsgen against the version of the
framework the docs were vendored from, so the gallery documents the
version the prose describes:
go run amadan.net/rastrillo/rastrillo/cmd/dsgen@<version> \
-out src/design-system -mount /design-system
Two arguments and no others, deliberately: -out is where the files go,
-mount is the URL path they will be served from. Every link and asset
URL in the output is an absolute path under -mount, so a tree
generated for one path cannot be served at another.
To read the rendered pages yourself, go generate ./... writes the same
tree into .design-system/, which is git-ignored. Nothing reads that
copy back and no gate holds it to anything โ with no committed tree
there is nothing that can fall out of date, so there is no longer a
freshness gate to run.
Every page is laid out in the sidebar shell, because that shell is one
of the things the gallery exists to show. On a desktop the rail is the
same on every page: a search box over a nav that links every section,
every partial and every markup idiom in the whole gallery, with the
section you are reading expanded and the rest folded away.
TestTheSidebarLinksEverythingOnThePageExactlyOnce derives that list
from the same markers the coverage gates read, so a new partial shows up
there without anyone touching it. Typing in the box hides the entries
that do not match, and it also finds things by the names people call
them: "checkbox", "dialog", "card". Below 800px the gallery works the
way a sidebar app does on a phone: the Overview is an index of every
section, and every other page has a back control that returns you to the
row you left.
The reader-facing names for two of those sections are not the code's.
The gallery calls them Components and UI primitives; ui
returns partials, and this page calls them partials.
Every word on the page is translated, not only the components. The
gallery's headings, leads and notes come from a catalog of its own, in
the same twelve locales the framework ships, so the language switcher
changes the prose as well as the samples.
TestEveryProseKeyIsTranslated fails on a missing entry, and
TestNoEnglishProseReachesATranslatedPage fails on an English string
that reached a page in another language.
Sample content is translated too, so each language's page shows how its own text fits: Russian and Portuguese run long, Arabic reads right to left, Bengali joins its letters. People's names, email addresses and links stay as they are, because they are stand-ins, not words the framework ships. The shell and modal demos, which impersonate a real application, also speak the language you picked.
Every example is shown four ways behind one control: Desktop, Mobile, HTML and Template. The two previews are an <iframe> holding a
document of its own: the sample, the stylesheets, and nothing else.
Desktop is laid out at a fixed width and scaled to fit the column you
are reading in: 900px for a component, which is the width one gets in an
app's column, and 1200px for the examples that are a whole page. On a
narrow screen it stops shrinking and scrolls sideways inside its box
instead. Mobile is the page at a phone's size: 390px on a desktop, and
on a phone the width of your own screen, never scaled, so what you see
is what the phone shows. The tabs are radio inputs and :has(), with no
JavaScript involved in switching them. Each preview is a window on its
document rather than a fit to it, so a tall sample scrolls inside the
box, and the box has a resize grip on its bottom edge.
The HTML tab shows the markup the example renders, formatted and highlighted, ready to copy into any page. The Template tab shows the call that produces it in a rastrillo app: the partial and its arguments, exactly as you would write them in a template. When a partial only works inside something, such as a form or a box, a line above the call says what to put it in. An example with no template call has no Template tab. A sign-in screen is built from template calls alone, so it has no HTML tab. Every block has a Copy button, and what it copies is exactly the text you see. Status pills, badges and meters show every state in one row, with one call per state.
Giving each sample a document of its own is what makes the awkward ones
work. The two shell frames carry their own <main> and the gallery
page already has one; the modal's overlay is position: fixed, so
rendered in the gallery it covered the gallery; a form's save bar is
position: sticky, so it stuck to the bottom of the gallery rather
than to its own form. Each of those is correct inside its own frame.
The shells and the modal keep their full-page demos as well โ a shell
wants a window, and a modal's whole claim is that it is a URL โ and
those links, like every "open the demo" link on the page, open in a new
tab.
The links inside a sample go nowhere on purpose. Sample markup is
written to read like a real application (/posts/1/edit), and this
site serves none of those routes, so every link is rewritten to #
before it is framed and every form is aimed at a hidden sink. The Code
tab beside the preview keeps the routes the sample was written with,
which are the ones worth copying.
On a desktop three switchers sit in a bar pinned above the page; on a
phone they sit in a Display settings menu at the top of every page.
Theme is three links, one per theme, landing on the same page in
that theme's palette. Colour scheme is System, Light and Dark, and
it is the only one of the three that needs JavaScript: it writes
data-theme on <html>, remembers the choice in localStorage, and
puts the same attribute on every preview frame, because a colour scheme
does not reach into an embedded document that declares one of its own.
Language is a menu of twelve links. Switching theme or language
keeps your place: you land on the section you were reading, not the top
of the page.
The script behind those controls is gallery.js, the only JavaScript in
the tree that is not part of the framework: furniture for the page
rather than something an app is ever given, which is why it lives beside
the renderer instead of in ui. It follows the same rules as the
framework's own scripts anyway: first-party, no dependencies, no
network. The scheme toggle, the filter box, the page-wide view and the
Copy buttons are all hidden until the file sets data-rst-js, so with
scripts off you get the nav, the theme's own color-scheme: light dark
and the tabs, rather than controls that cannot do anything.
Every link in that tree โ stylesheets, scripts, the theme and language
switchers, the shell and modal demos โ is an absolute path under
/design-system/, so the tree is served from that path and no other.
Relative links looked more portable and were wrong: the static edge
serves /design-system and /design-system/ as the same page without
redirecting between them, and a relative href resolves differently on
each.
The gallery is scanned to WCAG 2.2 AA on every CI run, and a violation
fails the build. Locally it is go test -tags browser -p 1 ./internal/designsystem/ ./internal/designsystem/sweep/ (the -p 1
matters: two Chromium-heavy packages starting together contend badly
enough to blow a drive's deadline). It injects a pinned copy of axe-core
into a real browser and runs it over the tree as the renderer produces
it, served from memory: the gallery's pages in every theme in both
colour schemes, at desktop and phone widths, an RTL page, the modal, the
shell demos, and the preview documents the components live in. Plus
checks axe cannot make: that nothing scrolls sideways in a 320px
viewport, that a Tab through the page shows a focus ring at every stop
and never gets stuck, and that every preview fits its frame. The slowest
of these run in full only with make browser-sweep, which is worth
running before a release that changes the gallery's words.
That is a floor, not a certificate. Automated scanning reaches roughly half of the WCAG success criteria, and the other half (whether alt text says something true, whether the reading order makes sense, whether a label means what it says) is read by a person. And it is a sample, chosen for what it would catch rather than for coverage.
Styling
ui.TokensCSS() is the design-token stylesheet, and rastrillo new
writes it once into your static/ directory. From that moment it is
yours: edit it freely, and nothing in the framework will overwrite it.
The scaffold ships a vendored_test.go pinning the delivered copy
byte-identical to the library's, so you find out you have drifted when
you meant to, rather than at an upgrade. Name the file in that test's
vendoredIsMine when you intend to diverge, and rastrillo doctor --fix will re-copy the ones you have not. See Assets
and The CLI.
Two stylesheets, not one. tokens.css is structure โ layout, spacing,
the type scale, and every rst- component. A theme, written beside it
as static/theme.css, is the colour, the type family and the shape
those components paint themselves with. The split is what makes a
restyle cheap: swapping one file changes how everything looks, and
nothing about how anything is laid out.
Menus that fit the window
Every menu surface โ [rst-dropdown-menu] and [rst-row-menu-panel] โ
is capped at min(20rem, 100dvh - 6rem) and scrolls past that, so a
long menu on a short window can still be reached. A twelve-locale
language menu is 388px, which is where this came from.
They also flip. position-area with position-try-fallbacks opens a
menu upward when there is no room below it, and the other way inline
when it is against the trailing edge. No script: this is CSS anchor
positioning, which is Chromium-only today, and that is a choice
rather than an oversight. An engine without it lands on the fixed
position every engine has today, so nothing regresses, and Firefox and
Safari gain the behaviour with no release from us. The alternative was
script, which would put positioning behind JavaScript and leave the
scriptless path worse than it is now.
The scrollbar gutter
tokens.css sets scrollbar-gutter: stable on html, through a token:
:root { --rst-scrollbar-gutter: stable; }
html { scrollbar-gutter: var(--rst-scrollbar-gutter); }
The width a scrollbar takes is reserved whether or not a scrollbar is in
it, so moving between a short screen and a long one no longer slides the
whole page sideways โ and neither does opening a modal, whose scroll
lock (body:has([rst-backdrop]) { overflow: hidden }) takes the
scrollbar away the instant it lands.
The opt-out is one line in your own stylesheet:
:root { --rst-scrollbar-gutter: auto; }
What it costs: a page too short to scroll now reserves the strip too, so
there is a thin empty band at the trailing edge where there was none.
both-edges would double that band to keep centred content exactly
centred, and was not taken โ these pages are already a max-width column
inside a wider ground.
Do not over-claim what this fixes. macOS overlay scrollbars take no layout space at all, so on a default Mac there was never anything to shift. It is real on Windows and Linux, on a Mac set to always show scrollbars, and inside an iframe. Chrome 94+, Firefox 97+, Safari 18.2+; an older engine ignores the declaration and behaves as it does today.
Themes
Three ship, and rastrillo new --theme=<name> writes the one you pick:
| Theme | The look |
|---|---|
day |
an everyday blue on white and grey; soft corners (default) |
plain |
the skeleton: greyscale, system type, almost no shape |
signal |
graphite and one live cobalt; milled corners, short dense shadows |
A theme file is one :root block. It sets color-scheme: light dark,
then declares every colour once as light-dark(<light>, <dark>);
single-valued tokens โ the font stack, the radii โ are written plain.
Two rules at the foot of the file are the whole explicit toggle:
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
Setting color-scheme re-resolves every light-dark() in the file at
once, in both directions, so a toggle beats the OS without restating a
single colour. Both schemes are authored โ the dark set is not the light
set inverted โ and there is no second copy of the palette to keep in
sync.
light-dark() needs a recent engine: Chrome and Edge 123, Safari 17.5,
Firefox 120, all of them 2024. Older ones do not fall back to the light
half โ they cannot parse the function at all, so every declaration using
it is dropped and the app renders with no palette rather than a
monochrome one. If you have to support an engine below that floor, write
a theme with a plain :root palette and a prefers-color-scheme block
instead; the token names are the only contract, so nothing else changes.
tokens.css has a floor of its own, lower and far less brittle. It uses
:has() โ Chrome 105, Safari 15.4, Firefox 121 โ and the lh unit,
Chrome 109, Safari 16.4, Firefox 120. On Chrome and Safari lh is the
newer of the two, which is why the phantom label that reserves a line in
a field row writes a calc() fallback ahead of its 1lh; on Firefox
:has() arrived last, so no engine there ever sees that fallback.
Neither degrades badly, so take the highest of all three and the floor
for tokens.css plus a shipped theme is Chrome 123, Safari 17.5,
Firefox 121: light-dark() sets it everywhere except Firefox, where
:has() does.
Below that floor, be precise about what "does not apply" means, because
it is more than the selector. A selector list is invalid as a whole
if any selector in it is invalid, so an engine that cannot parse
:has() drops the entire rule โ every declaration in it, including the
ones that had nothing to do with :has(), and including the ones the
other selectors in the list were carrying. Keep a :has() selector in a
rule of its own, or wrap it in :is(), whose list is forgiving. The
console shell's rail does the first: one rule states the frame and
always applies, and a second, :has()-only rule fights for the single
declaration that the narrow layout contests. That is what makes its
degradation a choice โ the rail stays visible, the wide frame is
untouched โ rather than a coincidence.
One caveat worth knowing: light-dark() is a colour function, so it may
only stand where a colour is expected. A shadow token wraps its colour
and writes the geometry outside the call โ
0 8px 24px light-dark(a, b), never light-dark(0 8px 24px a, โฆ).
Shape is part of the theme, not the structure. --rst-radius,
--rst-radius-sm, --rst-radius-pill and the four depth tokens
(--rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift,
--rst-overlay) live in the theme file, which is why plain can be
nearly square and signal can be milled while tokens.css stays the
same bytes.
So is the page header's rule. --rst-header-rule is one token, derived
from the accent or left as the line:
/* day */ --rst-header-rule: var(--rst-line);
/* plain */ --rst-header-rule: var(--rst-line);
/* signal */ --rst-header-rule: color-mix(in oklab, var(--rst-accent) 45%, var(--rst-line));
Deriving it means a theme that changes its accent gets a matching rule
with nothing left behind to keep in step, which a fourth hand-picked
colour per theme per scheme would not. plain and day draw plain
grey, day keeping its cobalt for things that act; signal draws a
visibly blue one. Set it to whatever you like; it is a line, and one
custom property is the whole of it.
The rule is decoration. It carries no contrast floor and is not in the contrast gate's pair table: the heading's size, weight and spacing carry the structure, so a tinted line under 3:1 is doing its job rather than failing at somebody else's.
color-mix() does not move the floor below. It needs Chrome 111,
Safari 16.2 and Firefox 113, all of them older than the three engines
light-dark() and :has() already ask for.
This rule is the whole of the header's decoration. It did not use to
be: [rst-page-header]::after drew a 2.5rem accent stroke over its
inline start, and that stroke is retired. The pseudo-element is
content: none rather than deleted, so a stylesheet of yours that
styled it is now styling nothing instead of fighting for it. If you
want the stroke back it is eight declarations in your own sheet โ but
read it first with fresh eyes: a thin full-width track in the line
colour with a shorter saturated segment filling it from the leading
edge is a determinate progress bar, and it was being read as one.
Each file carries its own contrast table in the header comment: every
text-on-background and border-on-background pair, in both schemes, with
the measured ratio beside the WCAG 2.2 AA requirement it has to clear.
ui's contrast gate splits every light-dark() back into two tables and
recomputes every pair, failing if one has dropped under its AA floor โ
4.5:1 for text, 3:1 for a control border. What it does not check is the
printed number. A row can go stale and the build stays green, so if you
edit a colour, edit the row: nothing else will.
Swapping in a theme of your own is replacing static/theme.css. The
only contract is the token set: declare every name day declares, and
every component already knows what to do with it. The scaffold's
vendored_test.go pins theme.css to the library copy exactly as it
pins tokens.css, so name it in vendoredIsMine when the edit is
deliberate. rastrillo doctor never diffs a theme it cannot identify โ
a theme of your own reads as "custom or drifted", not as damage.
Shells
The shell is the page frame โ templates/layout.html, written once by
rastrillo new --shell=<name> and yours from then on:
| Shell | The frame |
|---|---|
column |
a centred content column, no chrome (default) |
topbar |
header bar: brand, nav, account menu, locale switcher, footer |
sidebar |
a rail of nav groups beside the page; on a phone, an index page of sections and a back control |
console |
both at once: a bar across the top with the rail beneath it down the side; on a phone, the bar's menu opens as a card and the rail is an index page |
Every shell defines layout, renders {{template "content" .}} for the
page's own body, and puts each piece of chrome in a block with a working
default. A page overrides only what it cares about, by redefining the
block:
{{define "brand"}}<a rst-shell-brand href="/">Notes</a>{{end}}
{{define "nav"}}
<a href="/" aria-current="page">Notes</a>
<a href="/archive">Archive</a>
{{end}}
{{define "account"}}
<a href="/settings">Settings</a>
<form method="post" action="/signout"><button type="submit">Sign out</button></form>
{{end}}
{{define "content"}}<h1>Your notes</h1>{{end}}
The blocks are title, lang, dir and head in all five shells,
plus brand, nav, account and locale in topbar, sidebar and
console, profile, view and up in sidebar and console, foot
in topbar, console and stage, and backdrop in stage.
A shell reads three things from the page's data: L, the localizer,
and Locales and Return for the language switcher. L is required.
The other two are optional, so a shell renders whether your handler
passes a struct or a dict-built map, as long as it carries L.
head is the odd one out: it is not chrome, it is your slot in
<head>. A favicon, an Open Graph tag, one more stylesheet, a script
that has to run before the body โ override it and they go in, last in
the head, so your own CSS wins the ties it should win against
tokens.css and the theme.
account is the same in every shell: the links that go inside the
account menu, such as Settings and Sign out. In sidebar and console
the person who is signed in goes in profile: the avatar, name and
email on the button that opens that menu, at the foot of the rail on a
desktop and at the top of the index on a phone. Move a screen between
shells and neither block changes.
The chrome attributes live in tokens.css like every other idiom:
rst-shell-topbar, rst-shell-bar, rst-shell-brand, rst-shell-nav,
rst-shell-account and rst-shell-foot for the topbar, with
rst-shell-menu and rst-shell-tail for its phone menu;
rst-shell-sidebar, rst-shell-rail, rst-shell-group,
rst-shell-main, rst-shell-title and rst-shell-back for the
sidebar, with rst-shell-rail-foot, rst-shell-profile,
rst-shell-avatar, rst-shell-who and rst-shell-menu-label for its
profile menu; rst-shell-console for the console, which reuses the
rest; and rst-skip, the skip link, in all five shells. None of it
needs JavaScript.
stage has no chrome. It centres one card, usually the sign-in screen,
over a full-page backdrop drawn by {{stageArt "rastrillo"}} unless you
redefine backdrop. {{define "backdrop"}}{{stageArt "your-app"}}{{end}}
draws a pattern of your own from any word, and an <img> or your own
SVG replaces it outright. Its attributes are rst-stage,
rst-stage-scene, rst-stage-art and rst-stage-foot, and it carries
rst-skip like the others.
On a phone: an index and a way back
Below 800px, sidebar and console show each page in one of two ways.
Your index page is the list of sections. Every other page shows its
content, with a back control at the top that returns to that list. The
sections are never behind a menu button: the only one on the index is
your profile, at the top right, which holds the account and language
menus.
Mark your index page with the view block:
{{define "view"}}index{{end}}
Give every other page an up block that points back to its own row on
the index, and give that row the matching id:
{{define "up"}}/#nav-invoices{{end}}
{{define "nav"}}<a id="nav-invoices" href="/invoices" aria-current="page">Invoices</a>โฆ{{end}}
With JavaScript off, the fragment brings the reader back to the row
they left. With shell.js, the back control uses the browser's history
when it can, the pages slide, and focus returns to the row. A page with
no view block is a content page, so a page you forget still shows its
content and a way back.
In topbar and console, the Menu button on a phone opens a card over
the page. A tap outside it, or Escape, closes it. The page underneath
does not move.
In this release's sidebar and console layouts, rastrillo.Serve
prerenders the pages your navigation links to, so the next page is ready
when it is tapped. On a phone, Chrome may prerender a section as soon as
its row is on screen, before anyone taps it; on a desktop, when the
pointer rests on a link. Older layouts and other pages are not
prerendered. A prerendered page is fetched with a GET and runs its
scripts before anyone sees it, so a GET handler must never change
anything, and a script that changes something as the page opens must
wait until document.prerendering is false. To turn it off, set
Options.NoSpeculationRules.
The console on a phone
console has two pieces of chrome to put away below 800px, and each
goes the way it goes in the shell it comes from. The bar's account and
language menus go behind the Menu button, which opens a card, as in
topbar. The rail is an index page, as in sidebar: mark the index
with view and give other pages up.
Nothing is reordered at any width. The DOM order, bar, rail, page, is the reading order and the focus order at 320px and at 1280px, in both directions of the language.
Upgrading to the phone index
A layout from before this release keeps working: its sidebar drawer still opens. To move to the index:
- Upgrade the module and run
rastrillo doctor --fix. It re-copiestokens.cssand addsshell.jsandshell.css. - Replace
templates/layout.htmlwith the new shell (ui.Layout("sidebar")orui.Layout("console")), and carry your own edits across. - Add
{{define "view"}}index{{end}}to your index page, and anupblock to every other page. - If you already have a template called
vieworup, rename it. The shells use those names now. - If your
accountblock shows who is signed in (an avatar, a name, an email), move that into aprofileblock and keep only the links inaccount.rastrillo doctortells you when it finds one. - In a list grid, end
--rst-colswithvar(--rst-col-menu)instead of32px.
rastrillo new --shell=sidebar and --shell=console now write two
pages, an index and an Overview section, to show the shape.
An app on topbar, column or stage can delete shell.js and
shell.css. Add both to vendoredIsMine in vendored_test.go, or
that test fails on the missing files. These apps need transition.js
before the first stylesheet to handle skipped page transitions.
Sidebar and console apps can delete transition.js. Add
"transition.js": true to vendoredIsMine in vendored_test.go so the
test allows the missing file.
Upgrading: the topbar's tail is a level deeper
The topbar grew a narrow layout, and with it a wrapper. Below 800px its
nav, account and locale hide behind a <details>; above it,
[rst-shell-tail] is display: contents, so those three generate no
box of their own and lay out as flex items of [rst-shell-bar] exactly
as they did before โ the rendering is unchanged at every width.
The DOM is not. They are now grandchildren of [rst-shell-bar] through
[rst-shell-tail], and display: contents changes box generation, not
selector matching. If your own CSS or JavaScript reaches into the bar
with a child combinator:
/* stops matching, at every width */
[rst-shell-bar] > [rst-shell-nav] { โฆ }
use a descendant selector, or target the attribute on its own:
[rst-shell-bar] [rst-shell-nav] { โฆ }
[rst-shell-nav] { โฆ }
The block names โ brand, nav, account, locale, foot โ did not
change, and a test holds them. Only the depth did.
The locale block is where the language switcher goes. Every shell
fills it with locale-menu, from the page data's Locales and
Return:
{{block "locale" .}}{{$L := localizer $}}{{template "locale-menu" dict "L" $L "Items" (opt $ "Locales") "Return" (opt $ "Return")}}{{end}}
ui.ForRequest gives both, so you do not wire the switcher yourself.
It renders nothing for a one-locale app. It sits on
rst-dropdown rst-locale, the ordinary
dropdown vocabulary rather than a shell-specific name, so it looks and
behaves the same in every shell, except in sidebar and console,
where it opens in place inside the profile menu. See
Localization.
Error pages
ui's error-page partial is the whole body of an error response: the
status, one honest sentence, a way back to somewhere real, and โ for a
500 โ the reference the operator will grep for. It renders inside your
shell, so the nav and the account menu are still there and the user is
not stranded on a bare page.
rastrillo new scaffolds it as templates/errors.html:
{{define "content"}}{{$L := localizer $}}{{template "error-page" dict "L" $L "Status" .Status "Ref" .Ref}}{{end}}
and points Options.ErrorPage at the render.go helper that renders
it. Wire the same function to Ctx.ErrorPage and the 500 a handler
answers looks identical to the 500 a panic answers:
func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
var buf bytes.Buffer
if pagesErr != nil || pages.Execute(&buf, "errors", map[string]any{"L": pages.LocalizerFor(r), "Status": status, "Ref": ref}) != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
w.WriteHeader(status)
buf.WriteTo(w)
}
The callback owns the status code as well as the body, so it calls
WriteHeader itself. It finds the reader's language with
pages.LocalizerFor(r), because a recovered panic reaches it outside
the middleware.
The partial words five statuses from the framework catalog โ 404, 403,
422, 500 and 503 โ in all twelve base languages. Any other status falls
back to a generic title and sentence rather than rendering a missing
key's name, so handing it a 418 produces a real page. Title and Body
override the catalog when you want your own wording, and HomeHref
moves the "Start page" link.
Ref renders only when it is set. A 500 has one โ six lowercase base32
characters, minted by rastrillo.NewRef,
written to the log line under ref and shown on the page โ because that
is the string a person quotes down a phone line and you grep for. A 404
has nothing to look up later, so it shows no reference.
BackHref renders a second, secondary link, and the rule is that you
supply it and only from a Referer you have checked is same-site.
There is deliberately no javascript:history.back() in the partial:
nothing here needs JavaScript to be usable, and an unvalidated Referer
is an open redirect with better manners. Leave it unset and the page
still has its "Start page" link.
Not everyone gets the page. A client that sent Accept: application/json gets {"status":500,"ref":"k3f9tq"} from the view
helpers instead โ ref is omitted when there is none, so a 404 answers
{"status":404}. Your ErrorPage callback is not consulted at all on
that path: it renders HTML, and the caller asked for something it can
parse. The panic-recovery path is the exception, and serves the HTML
page whatever the Accept header says. The callback receives r, so an
app that wants to sniff it can.
The view helpers
For generated actions working against a *rastrillo.Ctx:
func Render(ctx *rastrillo.Ctx, w http.ResponseWriter, page string, status int, data any)
func Fail(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request, what string, err error)
func NotFound(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request)
func Forbidden(ctx *rastrillo.Ctx, w http.ResponseWriter, r *http.Request)
func ParseID(r *http.Request) (int64, bool)
Fail logs the real error and answers a safe 500, so the detail reaches
your logs and never the response body; NotFound and Forbidden are
the same answer at 404 and 403. All three render through
Ctx.ErrorPage when it is wired, which is what puts the error page
above on the screen. ParseID reads the {id} path value; a malformed
one answers false, which your handler should turn into a 404 rather
than a 400. See Scoping.
Read this page as markdown โ exact, unstyled, and cheap for an agent to fetch.