Summary#
A shop's front page changes every week — a new photograph, this month's offer, a banner before the holidays — and none of it should need a developer. Write the pieces once as blocks, mark the places on a page where they may go as regions, and the people who run the site add, remove, reorder and fill in blocks themselves, at a desk (the PageBuilder kit), with a preview and a publish button.
// app.osy
use Osysharp.Blocks; // keeps what staff arrange
use Osysharp.PageBuilder@1; // the desk they arrange it atSignature#
[Block("What staff call it", Hint = "One sentence on the desk.")]
component Name([Label("Field"), Hint("…"), MaxLength(80)] string field, Picture photo = …, Color ground = Colors.…,
PageLink link = "/…", [Range(1, 8)] int count = 4, SomeEnum choice = SomeEnum.…, SomeEntity? row = null,
Markdown words = "") { render { … } }
Region("name", allow: [BlockA, BlockB]) { BlockA(field: "…"); } // one arrangement for the page
Region("name", key: slug, allow: [BlockA, BlockB]) { BlockA(field: "…"); } // one per address (/c/{slug})Description#
What makes a component a block?#
[Block("Label")] on a component. The label is what the desk calls it ("Add Quote"); without one the component's name
is used. The first sentence of its doc comment — or a Hint = "…" on the attribute — is the line under it on the desk.
A block is an ordinary component in every other way: it reads the data it shows itself, with its own live var
queries, and it wears the app's theme. A block that shows products reads them exactly as the page around it would, as
the visitor looking at it — so a staff member can place a row of products, but never make a customer see a row the
customer may not read.
app ReadingRoom {
use Osysharp.Ui;
use Osysharp.Blocks;
model "model/**/*.osy";
}
using Osysharp.Blocks;
theme ReadingRoom {
Colors {
Paper = "#FBF8F2";
Ink = "#1B1B1B";
Clay = "#B5523B";
Night = "#1F2A44";
OnNight = "#FBF8F2"; // the ink words take on Night — `Colors.On(Colors.Night)`
}
}
public enum Side { [Label("Photo on the left")] Left, [Label("Photo on the right")] Right }
public entity Shelf {
[Required, MaxLength(80)] string Name;
security { allow read when IsAnonymous || IsAuthenticated; }
}
/// A photograph with a headline over it — the top of a page.
[Block("Hero")]
component Hero(
[Label("Headline"), MaxLength(80)] string title,
[Label("Words under it"), Hint("A sentence or two.")] Markdown words = "",
[Label("Photograph")] Picture photo = "",
[Label("The button goes to")] PageLink link = "/books",
[Label("Ground")] Color ground = Colors.Paper) {
render {
Stack(gap: 3, p: 8, bg: ground, color: Colors.On(ground, Colors.Ink), label: "Hero") {
if (photo != "") { Image(picture: photo, alt: title, w: "100%", h: "320px", objectFit: ObjectFit.Cover); }
Text(title, fontSize: FontSize.Title, fontWeight: FontWeight.Bold);
if (words != "") { Markdown(words); }
Link(href: link) { Text("See the books", color: Colors.Clay); }
}
}
}
/// One shelf, named, with a line about it.
[Block("Shelf note")]
component ShelfNote(Shelf? shelf, [Label("Words"), MaxLength(120)] string note = "On the table this week",
[Label("Photo on")] Side side = Side.Left, [Label("How many"), Range(1, 8)] int count = 3) {
render {
Row(gap: 2, p: 6, label: "Shelf note") {
Text(note + ":", fontWeight: FontWeight.Semibold);
Text(shelf != null ? shelf.Name : "every shelf");
}
}
}
[Route("/")]
[AllowAnonymous]
component Front() {
render {
Stack(gap: 0) {
Region("front", allow: [Hero, ShelfNote]) {
Hero(title: "Second-hand books, read and loved", words: "Open every day but Monday.");
ShelfNote(note: "New in this week");
}
}
}
}
[Route("/books")]
[AllowAnonymous]
component Books() { render { Text("The books", p: 4); } }Which types can a parameter have — and what does staff get to fill it in?#
A parameter is a field on the desk, and its TYPE chooses the editor. A parameter with no default is required: the desk will not publish a block until it is filled in.
| parameter type | the editor | stored and checked as |
|---|---|---|
string | a line of text — [MaxLength(n)] bounds it | text, in every language the app speaks |
Markdown | a paragraph with bold, italics and links | text, rendered as Markdown — never as markup |
int, decimal | a number — [Range(min, max)] bounds it | a number inside the range |
bool | a switch | on or off |
| an enum | a choice of its members, each by its [Label] | one of the members |
Color | the theme's own colours, each by the theme's name for it — only its grounds when the block paints a background with it (see below) | a theme colour — never a free colour value |
Picture | the app's own pictures (textures) or an upload | a texture's name or a picture the app stores |
PageLink | a page of the app (/books) or an https:// address | an address the app serves, or a web address |
an entity, Shelf? | a pick from its rows | a row the visitor may read — none, if they may not |
[Label("…")] names the field, [Hint("…")] is the line under it. Every value is checked when it is saved as well as
when the page is shown: a colour that is not the theme's, a javascript: link or a picture that is not the app's is
refused with the field's own label, and a stored value that no longer fits (a colour the theme dropped) shows the
parameter's default instead.
Which colours does a Color field offer?#
The theme's own colours — the app's theme, plus any a block's default names — each once and each under the theme's
name for it ("Blush", "Surface"), in the reader's language. Two tokens with the same value in every mode are one colour
on the desk, offered under the name the theme declares first; a value stored under the other name is still that colour.
See-through and borrowed values (a scrim, transparent, currentColor) are not offered.
What the field offers follows where the block uses the parameter: handed to bg:, it is a ground, and the desk
offers the theme's grounds; handed to color:, an ink, and it offers the inks. Which colours are grounds is the
app's own evidence — a token paired with an On… token (Surface/OnSurface), or one the app's components mostly
use as a background — so nothing is declared twice.
What colour are the words on the ground staff chose? — Colors.On#
A block that paints a ground someone else picks cannot write one text colour and be right: dark words on the dark
ground are unreadable, and every text assertion still passes on them, because the words are there. Set the words in
Colors.On(ground, <your ink>) — the ink the theme pairs with that ground:
Stack(bg: ground, color: Colors.On(ground, Colors.Ink)) { Text(title); }| the theme declares | the words on ground = Colors.Night are |
|---|---|
OnNight = … | OnNight — the pair, by name (Surface/OnSurface, Primary/OnPrimary) |
Night = Palette.From("#1F2A44") and no OnNight | the palette's own contrast ink |
| neither | the ink you passed — the block's words on its default ground |
It answers a Color, so it goes wherever a colour goes, and it takes only a theme colour: Colors.On("#1F2A44") is
refused, because a raw colour has no pair to find. The choice is made by the browser — the call writes
var(--colors-onnight, var(--colors-night-oncolor, …)) — so dark mode and a site version's theme variant follow with no
recompile, and a mail inlines the same choice at send time. Without the second argument, a ground with no pair leaves
the words in the ink around them.
A quieter line (a caption, a byline) can keep its own ink on the block's default ground and take the pair elsewhere — two theme colours compare like any two values:
Text(who, color: ground == Colors.Surface ? Colors.TextMuted : Colors.On(ground, Colors.TextMuted));⚑ Test it where it can be seen: Assert.Readable("the words") under osy test --pixels measures the contrast of the
words against the ground actually painted behind them (Layout assertions — is it actually usable on screen?).
What does the "Add a block" list show?#
Each block drawn small, in the app's theme and with the app's own data, beside its name and its one-line hint. The
drawing uses the block's defaults; a parameter whose default is no picture of the block — a required quote has none —
can name one for the thumbnail with [Sample(…)], a constant of its type:
[Block("Quote")]
component Quote([Label("What they said"), Sample("Exactly as good as the last one.")] string quote,
[Label("Ground")] Color ground = Colors.Surface) { render { … } }A sample is never stored and never shown to a customer — a newly placed block still starts from its defaults.
Where can a block go? — a Region#
Region("name", allow: [...]) { ... } is a place on a page. allow: lists the blocks staff may place there, and what
is written inside is the default arrangement: the page as it stands until staff publish an arrangement of their
own. The default is DATA staff change later, so each value in it is a constant — a text, a number, an enum member, a
theme colour, a texture — never an expression.
Each region's name is unique in the app. It is how an arrangement is found again: rename a region and staff's arrangement of it is left behind.
One page per address — a campaign page#
key: gives a region one arrangement PER VALUE — a campaign page at /c/{slug} is one region with the slug as its key.
Staff make a new page at the desk by naming it; an address nobody has arranged has no arrangement, and the page can say
so instead of showing an empty frame:
using Osysharp.Blocks;
[Route("/c/{slug}")]
[AllowAnonymous]
component Campaign(string slug) {
live var page = RegionArrangement.Where(a => a.Region == "campaign" && a.Key == slug).ToList();
render {
if (page.Count == 0) {
EmptyState("This page is not here", body: "It may have ended, or not started yet.");
} else {
Region("campaign", key: slug, allow: [Hero, ShelfNote]) {
Hero(title: "Something new");
}
}
}
}A customer reads only arrangements that are LIVE, so page is empty for a campaign whose version of the site is not
showing — it ends when its version does.
A block the region must keep — require:#
require: [BlockA] lists blocks staff may move but never take out: the desk shows the last one as Stays, a draft
without it cannot be published, and the code's default arrangement must place it. Use it for what the page — or the
mail — must always carry.
Can a mail hold blocks?#
A mail has regions too ([[ui-email#region]]): the words an order confirmation greets with, and a photograph or a
quote staff place beside them. A mail's block is an [Email] part marked [Block] — drawn by the mail renderer, in
tables and inline styles — and a mail's region takes only those, as a page's region takes only a page's:
[Block("The order")]
[Email]
component OrderLines(Order order, [Label("Heading"), MaxLength(60)] string heading = "Your order") { render { … } }
[Email]
component Confirmation(Order order) {
subject => $"Your order {order.Reference}";
placeholders { order.reference => order.Reference; }
render {
Region("confirmation", allow: [EmailWords, EmailPicture, OrderLines], require: [OrderLines], sample: ConfirmationSample) {
EmailWords(words: "Your order {order.reference} is reserved for you.");
OrderLines();
}
}
}- An entity a mail block takes is HANDED to it from the mail —
orderis the confirmation's own; staff never pick it, and the default arrangement leaves it out. sample: F—RenderedEmail? F(string language)— is how the desk shows the mail beside its blocks, rendered for something real.- A mail's region is arranged in the same versions of the site, dated the same way: the confirmation sent while the
Christmas version is live is the Christmas one. A mail rendered for a member of staff who is previewing is marked
IsPreview, and is not sent. - A package may ship mail blocks (Osysharp.EmailTemplates does). In an app that does not
use Osysharp.Blocks;they are just parts; only a region needs the tables.
What happens to staff's arrangement when the code changes?#
It stays. An arrangement is data, so a recompile keeps it — including every language it was written in. What changes is what the desk says about it:
- a block whose parameters changed in the code is marked Changed in code: its stored values are read against the new parameters, and one that no longer fits shows the new default;
- a block no longer in the app is marked No longer in the app, and is not shown to customers — the rest of the
arrangement is. A block the region no longer
allow:s is not shown either; - a region removed from the code simply stops being shown; its arrangement waits in case it comes back.
Who may arrange the pages?#
Nobody, until the app says so. What staff arrange lives in five tables, and each grants customers the live site and
nothing else. The package asks the app one question — ArrangesPages, who arranges the site — and whoever the app's
answer admits may do everything staff do here: versions, drafts, placed blocks and their words, their own previews:
using Osysharp.Blocks;
[Role] public enum AppRole { Authenticator, Staff }
[Principal]
public entity Member {
[Required, Unique, MaxLength(200)] string Email;
security { allow read when IsAuthenticated where Id == user.Id; }
}
[RoleGrant] public entity RoleGrant {
[Required] Member Member;
[Required] AppRole Role;
security { allow read when IsAuthenticated; }
}
policy IsStaff => RoleGrant.Any(g => g.Member == user && g.Role == AppRole.Staff);
policy ArrangesPages => IsStaff; // Osysharp.Blocks' one question: who arranges the siteAnd an app with no [Principal]?#
It can use blocks too. Nobody ever signs in to such an app, so the package's own preview rule — which asks who started
the preview (CreatedBy == user) — is asked about nobody, and admits no row: every visitor sees the live site,
and no preview row anybody else made is ever honoured. Answer ArrangesPages with whoever arranges the site — here
everyone, a single-operator kiosk, which with no [Principal] is simply true (every caller is anonymous, so a policy
may not ask). A preview the operator starts is then theirs alone, honoured in the browser that started it and nowhere
else:
app Kiosk {
use Osysharp.Ui;
use Osysharp.Blocks;
model "model/**/*.osy";
}
using Osysharp.Blocks;
policy ArrangesPages => true; // nobody signs in to a kiosk: every visitor is its operator
[Block("Note")]
component Note([MaxLength(80)] string words = "Open today") { render { Text(words); } }
[Route("/")]
[AllowAnonymous]
component Front() { render { Region("front", allow: [Note]) { Note(); } } }This is the rule for every package: in an app with no [Principal], a package's rule that names user means nobody —
see [[security-principal-predicates#package-user]]. Your OWN rule naming user is still refused there, because it
has no one to mean.
What can a customer see?#
Only what is published, in the version of the site that is live, between each block's own dates — never a draft, never a version scheduled for next week, never a block whose dates have ended. That is the tables' own rule, so it holds for every read of them, not only the region's.
A placed block is drawn by YOUR component with the values staff saved. Staff choose which block, in which order, with which words — they never supply markup, a style or a script, and a value is checked against the parameter's type before it is stored.
What does the compiler refuse?#
Each is refused where it is written, naming the fix:
- a
Regionwithoutuse Osysharp.Blocks;in the manifest, and two regions with one name; allow:naming something that is not a[Block], and a default arrangement placing a blockallow:does not list;- a value in the default arrangement that is not a constant, or not of its parameter's type;
- a block that is also a
[Route], is generic, or has aSlot— a placed block has no children; - a block parameter of a type no editor exists for (a list, a class, a function);
require:naming a blockallow:does not list;- in a mail: a region in a part (it belongs to a mail), a page's block in it, a block taking an entity the mail has
none — or two — of,
key:, and a mail block with asubject =>; on a page: a mail block, andsample:.
See also#
- The PageBuilder kit — the desk staff arrange the site at — the desk staff arrange regions at: versions of the site, a schedule, a preview as of a date.
- The site's frame — a menu, a strip and a footer staff change — a region in the layout (the strip across the top, the footer) and the menu as data (
SiteMenu). - [[ui-theming#variants]] — a version of the site that wears a seasonal look.
- textures — the app's own pictures, which a
Picturefield offers first. - Markdown — rendering markdown text — how a
Markdownfield is drawn. - Looks — a site's whole design, picked per version — a block TYPE as a contract (
abstract component) each look draws in its own design, and switching a version's look.