Osy#betaRun it on Osyrin: osyrin.com

Reference / UI

Building a look — from an empty kit to a design a store can wear

package MyStore.Looks.Linen { use Osysharp.Site.Looks@0; use Osysharp.Shop@1; … } · look Linen { Label = "Linen"; Theme = LinenTheme; } · component LinenHero(…) : Hero { render { … } }

A look is a kit that draws two contracts: the general site contract (`Osysharp.Site.Looks` — a hero, a quote, the footer) and one category's (`Osysharp.Shop.Looks` for a shop — a row of products, the store's frame and pages). This page builds one from an empty folder: the manifest, the `look` and its theme, a design of each part and block type, its fonts, a block of its own, and seeing it in a store with `osy test --pixels`.

stable8 examples compiled by CIuiblocksthemingkits

Source on GitHub: Osysharp.Shop.Looks.Drop — github.com/osysharp/shop/looks/drop ↗

Summary#

A look is a site's whole design, shipped as a kit and picked per site version at the page desk. It sits on two contracts and draws both:

layerpackagewhat it says
a lookyours, in your own namespace — MyStore.Looks.Linenhow everything looks: the frame, the pages, the theme and fonts, a design of each block type, and blocks of its own
the category contractOsysharp.Shop.Looks, inside the shop kitwhat a SHOP has: a row of products, the shelves as tiles, and the parts of a store — its frame, front page, listing, grid, a piece's page, a content column, a "not here"
the site contractOsysharp.Site.Lookswhat ANY site has: a hero, a picture with words, a quote, a sign-up, a banner, a gallery, the strip across the top, the footer's words, links, opening hours and address

A contract is an abstract component — parameters and no body. A look's design writes : Hero and takes exactly the contract's parameters, checked like a class against an interface. What staff arrange is stored as the TYPE and its values, so a store switching from one look to another keeps every word and photograph and draws them in the new design.

The worked example is the shop template's own ten looks (kits/look-atelier … kits/look-loud): the five designs of the Vall fashion store and the five of the Second Round thrift store, each a complete look kit drawing both contracts. This page builds a small one of your own beside them — Linen: undyed linen, ink-blue words, one rust accent — in your own namespace, from an empty folder. Every one of the shop's looks has the same shape, so read any of them beside it.

Signature#

package MyStore.Looks.Linen { … use Osysharp.Shop@1; use Osysharp.Site.Looks@0; use Osysharp.PageBuilder@1; }

namespace MyStore.Looks.Linen;
look Linen { Label = "Linen"; Hint = "…"; Theme = LinenTheme; }
theme LinenTheme { Colors { … } Font { … } FontSize { … } Radius { … } }

component LinenFrame() : ShopFrame { … }                    // one design per part of a store
component LinenHero(<exactly Hero's parameters>) : Hero { … } // one design per block type you draw your way
[Block("A note from the weaver")] component LinenNote(…) { … } // a block only this look has

Description#

What goes in the kit's manifest?#

A look kit is a folder with a package.osy, its source under model/, its fonts under fonts/ and its own osyrin.lock. The manifest names the packages its designs read: the two contracts, the PageBuilder kit (the menu and the look switch every frame carries), and what draws photographs.

// looks/linen/package.osy — a LOOK for a shop. It draws Osysharp.Site.Looks and Osysharp.Shop.Looks and
// carries no data and no logic of its own.
package MyStore.Looks.Linen {
  version     "0.1.0";
  minPlatform "0.9.0";
  summary     "a shop look — undyed linen, ink-blue words, one rust accent.";
  model "model/**/*.osy";
  use Osysharp.Ui;
  use Osysharp.Blocks;
  use Osysharp.Storage;
  use Osysharp.Shop@1;
  use Osysharp.Site.Looks@0;
  use Osysharp.PageBuilder@1;
}

The summary says what the look is FOR — "a shop look" — because a look draws one category's contract. Run osy lock in the folder to pin those packages.

A look kit carries no data and no logic, and asks for no egress and no secret. Its designs read what a store's page reads, as the visitor — so a design written by someone else cannot send a customer's data anywhere.

How does a store wear it?#

The store does not change shape for a look. Its pages keep what is the STORE's — the data they read, the checkout's steps — and hand the look what it draws: its layout places ShopFrame once, its front page is FrontPage(), a piece's page reads the piece and hands it to ProductPage. app.Look names the look the site wears until staff choose another.

app Linen {
  use Osysharp.Ui;
  use Osysharp.Storage;
  use Osysharp.Blocks;
  use Osysharp.Shop@1 { egress "api.resend.com"; secret "ResendApiKey"; }
  use Osysharp.PageBuilder@1;
  use Osysharp.Site.Looks@0;
  model "model/**/*.osy";
}

using Osysharp.Shop;
using Osysharp.Shop.Looks;

app.Look = Linen;

[Layout]
[AllowAnonymous]
component Shop() { render { ShopFrame { Outlet(retain: 8); } } }

[Route("/")]
[Layout(Shop)]
[AllowAnonymous]
component Home() { render { FrontPage(); } }

[Route("/shop")]
[Layout(Shop)]
[AllowAnonymous]
component Catalogue() { render { ShopSearchPage(); } }

[Route("/p/{slug}")]
[Layout(Shop)]
[AllowAnonymous]
component Piece(string slug) {
  live var product = Product.Include(p => p.Images).Include(p => p.Variants).SingleOrDefault(p => p.Slug == slug);
  render { ProductPage(product, note: "Free returns within 30 days."); }
}

[Route("/account")]
[Layout(Shop)]
component AccountPage() { render { ShopSection { ShopAccountPanel(); } } }

In a real kit, the store installs it with one line in its manifest — use MyStore.Looks.Linen@0; — and osy lock. A store may install several; each appears in the look picker on the desk (/staff/pages), and staff pick one per version of the site: drafted, previewed as a whole site, switched on a date ([[ui-pages-kit#looks]]).

Which look is it, and what does it wear?#

The look names itself, and its theme. Put it in a namespace of its own — everything that namespace declares is the look's: its designs, its regions, its own blocks.

namespace MyStore.Looks.Linen;

/// Undyed linen, ink-blue words, one rust accent for the thing to press.
look Linen {
  Label = "Linen";
  Theme = LinenTheme;
}

theme LinenTheme {
  Colors {
    Bg            = "#F6F2EA";
    OnBg          = "#1D2A3A";
    Surface       = "#EDE6DA";
    OnSurface     = "#1D2A3A";
    Border        = "#D9D0C1";
    TextMuted     = "#6B6A66";
    TextSecondary = "#3E4652";
    Primary       = Palette.From("#A2472B");
    OnPrimary     = "#FFFFFF";
    OnImage       = "#FFFFFF";
  }
  Font {
    Display = "\"Fraunces\", Georgia, serif";
    Sans    = "\"Hanken Grotesk\", system-ui, sans-serif";
  }
  FontSize { Mega = "clamp(48px, 7vw, 112px)"; Title = "26px"; Body = "15px"; }
  Radius { Card = "6px"; Md = "6px"; }
}

Label is what staff see in the picker; Hint, the line under it, defaults to the first sentence of the doc comment. The theme is served only while the site wears this look, under :root[data-look="linen"], so several looks' palettes live in one store without taking each other's names. It re-skins EVERYTHING on the page while the look is worn — the store's own pages (the bag, the checkout) and any design that is not the look's. Name the tokens a store's pages read (Bg, OnBg, Surface, Primary, TextMuted, …) so those pages fit too (theme tokens).

Which parts must a shop look draw?#

The shop contract's PARTS are the store's frame and pages. They have no plain design of their own, so a shop look draws all seven. A part the worn look skips is drawn by the design the store's DEFAULT look (app.Look) gives it — or, when that look skips it too, the first installed look that draws it — in the worn look's tokens. That keeps a store whole, but it is somebody else's frame or page in your colours, so a finished look draws its own.

partwhat it is handed
ShopFrame()the page, as its slot — draw the header, the menu, the strip across the top, the footer, the account and the bag around it
FrontPage()nothing — it is a region staff arrange
ShopSection()content, as its slot — the look's column: margins and width
ListingPage(title, lead, products, shelves, current)a shelf, or everything; the store's own slot empty { … } for an empty one
ProductGrid(products)the pieces to lay out
ProductPage(product, note)the piece with its images, variants, specs and shelf read, or null; the store's line under it
NotFound(message, linkLabel, to)what is not here, and where to go instead
namespace MyStore.Looks.Linen;

using Osysharp.Ui;
using Osysharp.Blocks;
using Osysharp.PageBuilder;
using Osysharp.Shop;
using Osysharp.Shop.Looks;
using Osysharp.Site.Looks;

/// The frame around every page: the menu, the strip across the top and the footer — each staff's to arrange.
[Composable]
component LinenFrame() : ShopFrame {
  live var menu = SiteMenu("menu", [ new NavItem { Label = "Shop", To = "/", Exact = true } ]);
  live var chrome = new AppChrome { Product = "Linen", Home = "/", Nav = menu };
  render {
    EditorialShell(chrome) {
      SiteLook();
      ShopVisitSync();
      Slot;
      slot promo {
        Region("announcement", allow: [Announcement]) { Announcement(words: "Free delivery over 800 kr"); }
      }
      slot actions { ShopAccountButton(); }
      slot footer {
        EditorialFooter(chrome, note: "Linen") {
          slot about {
            Region("footer", allow: [FooterWords, FooterLink, OpeningHours, Address, FooterSignUp]) {
              FooterWords(words: "Sheets, towels and table linen, woven slowly.");
            }
          }
        }
      }
    }
  }
}

/// A page's content column.
component LinenSection() : ShopSection {
  variants { base { W = "100%"; MaxW = "1200px"; Mx = "auto"; Px = 5; } }
  render { Box { Slot; } }
}

/// Something that is not here, and where to go instead.
component LinenNotFound(string message, string linkLabel = "", string to = "") : NotFound {
  render {
    LinenSection {
      Stack(gap: 3, py: 16) {
        Text(message, fontFamily: Font.Display, fontSize: FontSize.Title);
        if (linkLabel != "" && to != "") { Link(href: to, label: linkLabel) { Text(linkLabel + " →"); } }
      }
    }
  }
}

Four calls in a frame are what make it a frame staff can run:

  • SiteLook() keeps the browser in the look the version wears, and moves it when staff switch or preview another.
  • SiteMenu("menu", […]) is the menu staff edit at the desk; the list you hand it is what it shows until they do.
  • Region("announcement", …) and Region("footer", …) are the strip and the footer staff arrange.
  • ShopVisitSync() keeps the visitor's bag in step with the store.

⚑ Use the region and menu names every look uses — "menu", "announcement", "footer", "front". A region is keyed by its name, so a store switching from Atelier to Linen keeps the strip, the menu and the footer staff arranged. A region only your look names stays in the arrangement and waits; the desk lists it before a switch.

How does it draw the blocks staff arrange?#

The front page is a Region of blocks. What you write inside it is how it stands until staff arrange it. Draw each block type your way by designing its contract — LinenHero(…) : Hero — with the contract's parameters in the same names, types and order. A design that differs is refused, naming the difference ("'LinenHero' implements Hero, and leaves out its parameter 'ground'").

namespace MyStore.Looks.Linen;

using Osysharp.Ui;
using Osysharp.Blocks;
using Osysharp.Shop.Looks;
using Osysharp.Site.Looks;

/// The front page: a region staff arrange. What is written inside it is how it stands until they do.
[AllowAnonymous]
component LinenFront() : FrontPage {
  render {
    Region("front", allow: [Hero, ProductRow, CategoryTiles, Quote, LinenNote]) {
      Hero(title: "Woven slowly.", words: "Linen for the bed and the table.", button: "Shop the linen", link: "/shop");
      ProductRow(title: "New in", count: 4);
      LinenNote();
    }
  }
}

/// Linen's design of the general Hero: exactly the contract's parameters, drawn its own way.
[Composable]
component LinenHero(string title, Markdown words = "", Picture photo = "", Picture second = "", string button = "Shop now",
                    PageLink link = "/", Color ground = Colors.Surface) : Hero {
  render {
    Box(bg: ground, color: Colors.On(ground, Colors.OnSurface), label: "Hero: " + title) {
      Stack(gap: 4, px: 5, py: 16, maxW: "1200px", mx: "auto") {
        Text(title, fontFamily: Font.Display, fontSize: FontSize.Mega, lineHeight: "1");
        if (words != "") { Markdown(words); }
        if (button != "") { Row { Link(href: link, label: button) { Text(button + " →", color: Colors.Primary); } } }
      }
    }
  }
}

/// A note from the weaver — a block only Linen has.
[Block("A note from the weaver")]
component LinenNote([Label("The note"), MaxLength(200)] string words = "Every sheet is washed once before it leaves us.") {
  render { Text(words, fontFamily: Font.Display, fontStyle: FontStyle.Italic, px: 5, py: 10, textAlign: TextAlign.Center); }
}

Three things to keep:

  • Paint a ground staff chose with its ink. A contract with a Color ground lets staff pick the ground; Colors.On(ground, …) sets the words in the ink the theme pairs with it, so a dark ground never gets dark words.
  • A hero with no photograph is still a hero. Staff may arrange a hero of words in one look and switch to yours; draw that case, not an empty frame waiting for a picture.
  • A block of your own is any [Block] component in the look's namespace. Staff place it like any other, and it is drawn only while the site wears your look: switching away, the desk lists it as not coming along, and it comes back with your look.

How does it draw a listing and a piece?#

The listing, the grid and a piece's page are handed the store's rows; the look decides how they stand. The shop's helpers for a look — LookPrice, LookSecondCover, LookSizeName, CoverPhoto, AddToBag, ShopProductView — do the store's arithmetic, so a design never re-implements a price or a bag.

namespace MyStore.Looks.Linen;

using Osysharp.Ui;
using Osysharp.Storage;
using Osysharp.Shop;
using Osysharp.Shop.Looks;

/// A listing: its name, a line about it, its sub-shelves as links, and the grid.
component LinenListing(string title, string lead, List<Product> products, List<Category> shelves, string current) : ListingPage {
  render {
    LinenSection {
      Stack(gap: 4, py: 10) {
        Text(title, fontFamily: Font.Display, fontSize: FontSize.Title);
        if (lead != "") { Text(lead, color: Colors.TextSecondary); }
        Row(gap: 4, wrap: Wrapping.Wrap) {
          foreach (var s in shelves) {
            Link(href: "/shop/" + s.Slug, label: s.Name) { Text(s.Name, color: s.Slug == current ? Colors.OnBg : Colors.TextMuted); }
          }
        }
        if (products.Count == 0) { Slot("empty"); } else { LinenGrid(products); }
      }
    }
  }
}

/// Pieces in a grid, each with its photograph, name and price.
[AllowAnonymous]
component LinenGrid(List<Product> products) : ProductGrid {
  live var shop = ShopSettings.FirstOrDefault();
  render {
    Box(display: Display.Grid, cols: "repeat(auto-fill, minmax(220px, 1fr))", gap: 5) {
      foreach (var p in products) {
        Link(href: "/p/" + p.Slug, label: p.Name) {
          Stack(gap: 2) {
            Image(src: File.Url(CoverPhoto(p.Images)), alt: p.Name, w: "100%", aspectRatio: "4 / 5", objectFit: ObjectFit.Cover);
            Text(p.Name);
            Text(LookPrice(p.Price, shop), color: Colors.TextMuted);
          }
        }
      }
    }
  }
}

/// One piece's page: its photograph, name and price, and the way into the bag.
[AllowAnonymous]
component LinenProductPage(Product? product, string note = "") : ProductPage {
  live var shop = ShopSettings.FirstOrDefault();
  live var first = product == null ? null : product.Variants.OrderBy(v => v.Rank).FirstOrDefault();
  string problem = "";
  action Add() { problem = first == null ? "This piece cannot be bought." : AddToBag(first, 1); }
  render {
    if (product == null) {
      LinenNotFound("That piece is not in the shop.", linkLabel: "Back to the shop", to: "/shop");
    } else {
      LinenSection {
        Stack(gap: 4, py: 10, maxW: "640px") {
          Image(src: File.Url(CoverPhoto(product.Images)), alt: product.Name, w: "100%", objectFit: ObjectFit.Cover);
          Text(product.Name, fontFamily: Font.Display, fontSize: FontSize.Title);
          Text(LookPrice(product.Price, shop));
          ShopProductView(product);
          Button("Add to bag", onPress: Add, tone: Tone.Primary);
          if (problem != "") { Text(problem, role: UiRole.Alert, color: Colors.Primary); }
          if (note != "") { Text(note, color: Colors.TextMuted); }
        }
      }
    }
  }
}

The shop's own looks (kits/look-*) go further on this page — sizes as chips, every photograph, the spec sheet, "notify me when it is back" — and are the place to copy from.

What happens to a type the look skips?#

Draw only the block types you want in your own design. For the rest:

the typewhile a store wears your look
a block type with a plain design — in a shop, every type (the shop kit draws them)drawn in that plain design, in YOUR theme's tokens
a block type no design drawsnot offered to staff on the desk; one already placed waits in the arrangement and is listed before a switch
a shop PART (the frame, a page)drawn by the store's default look's design of it, in YOUR tokens — so draw all seven (#parts)

That is why Linen designs only the hero: the product row, the shelves, the quote and the footer's blocks come out of the shop kit's plain designs dressed in Linen's palette, type and radii. Add a design of one later and every store that installed the look draws the new one on its next compile — what staff arranged does not move.

How does it ship its fonts?#

A theme NAMES a family; the font file is what makes it render (web fonts — shipping a typeface with your app). Put the files in the kit's fonts/ and register each in the kit's own lock, from the kit's folder:

$ cd looks/linen
$ osy font add fonts/Fraunces.ttf --family "Fraunces"
$ osy font add fonts/HankenGrotesk.ttf --family "Hanken Grotesk"

Keep each font's licence beside it (the looks in this repository ship OFL-*.txt). The look's fonts ship with every compile of a store that installs it — pinned from a workspace or from the published package alike: publishing packs the files under fonts/ with the record of which family each one is, read from the kit's own lock. Two looks naming one face upload it once.

How do I see it in a store?#

A look has no data of its own, so it is seen in a store. Install it in one — the Vall fashion store wears five looks, the Second Round thrift store five more — and write a test that switches the version's look and photographs every page a customer walks through, at a phone's width and a wide screen's. This is the walk Apps/fashion-store/vall runs for each of its looks:

void WalkThrough(string look, string front) {
  runas (Staff) { Assert.Equal("", SetLook(MainVersion().Id, look)); }
  Visit(look, front, 390, 844, "phone");
  Visit(look, front, 1440, 900, "wide");
}

void Visit(string look, string front, int width, int height, string called) {
  Ui.Viewport(width, height);
  Ui.Visit("/");
  Assert.Visible(front);
  Ui.Shot($"{look} — the front, {called}");
  Ui.Visit("/p/cotton-trench-coat");
  Ui.Click("M");
  Ui.Click("Add to bag");
  Ui.Shot($"{look} — a piece, {called}");
}

[Test(Window)]
void the_store_in_linen() { WalkThrough("linen", "Woven slowly."); }
$ osy test --pixels --filter the_store_in_linen

--pixels runs the walk in a real browser and writes each Ui.Shot to osy-shots/. Open them and look: a page that passes every assertion can still put a button off the card, and only a picture shows it. SetLook answers why not, or ""; LookLeavesBehind(version, look) lists what your look has no design or region for ([[ui-looks#switch]]).

Where would a café's contract sit?#

The shop's contract is ONE category's. Another category — a café, a salon, a restaurant — declares its own beside it, on the same site contract: the block types only it has (a menu board, a table booking) and the parts of its site (its frame, its front page). A look for cafés then draws Osysharp.Site.Looks and the café's contract, and a store or a café installs the looks of its own category.

app Corner {
  use Osysharp.Ui;
  use Osysharp.Blocks;
  use Osysharp.Site.Looks@0;
  model "model/**/*.osy";
}

using Cafe.Looks;

app.Look = Morning;

[Route("/")]
[AllowAnonymous]
component Front() { render { CafeFrame { CafeFront(); } } }
namespace Cafe.Looks;

using Osysharp.Blocks;

/// Today's menu on the wall — a heading and the dishes, one per line.
[Block("Menu board")]
public abstract component MenuBoard(
    [Label("Heading"), MaxLength(60), Sample("Today")] string title,
    [Label("The dishes"), MaxLength(800), Hint("One dish a line, with its price.")] Markdown dishes = "");

/// The frame around every page of a café.
public abstract component CafeFrame();

/// A café's front page.
public abstract component CafeFront();
namespace Corner.Looks.Morning;

using Osysharp.Ui;
using Osysharp.Blocks;
using Osysharp.Site.Looks;
using Cafe.Looks;

/// Chalk on slate, and the smell of the first loaf.
look Morning { Label = "Morning"; Theme = MorningTheme; }

theme MorningTheme { Colors { Bg = "#22262A"; OnBg = "#F2EFE8"; Surface = "#2E3338"; OnSurface = "#F2EFE8"; } }

component MorningFrame() : CafeFrame {
  render {
    Stack(gap: 0, bg: Colors.Bg, color: Colors.OnBg, minH: "100vh") {
      Region("announcement", allow: [Announcement]) { Announcement(words: "Open from seven"); }
      Slot;
      Region("footer", allow: [FooterWords, OpeningHours, Address]) { OpeningHours(); }
    }
  }
}

component MorningFront() : CafeFront {
  render {
    Region("front", allow: [Hero, MenuBoard, Quote]) {
      Hero(title: "Bread from six, coffee from seven.");
      MenuBoard(title: "Today", dishes: "Cardamom bun · 35\nSoup of the day · 95");
    }
  }
}

component MorningHero(string title, Markdown words = "", Picture photo = "", Picture second = "", string button = "Shop now",
                      PageLink link = "/", Color ground = Colors.Surface) : Hero {
  render { Text(title, p: 10, fontSize: FontSize.Heading); }
}

component MorningMenuBoard(string title, Markdown dishes = "") : MenuBoard {
  render { Stack(gap: 2, p: 8, bg: Colors.Surface) { Text(title, fontSize: FontSize.Title); Markdown(dishes); } }
}

Here the contract sits in the app, to keep the example in one place. As a kit it ships the way the shop's does: inside the café kit, in a namespace of its own, because its types name the café's data (a dish, a table) — and the café kit carries the PLAIN design of each of its types, which is what a café without a look draws, and what a look that skips a type falls back to. A design of the site contract's types (the strip, the footer's opening hours) belongs with it too: in a café without the shop kit, nothing else draws them.

What does a finished look have?#

  • a package.osy that uses Osysharp.Site.Looks and its category's contract, and a summary that names the category;
  • one look in a namespace of its own, naming a theme that sets the tokens a store's pages read;
  • a design of every PART of its category (for a shop: all seven);
  • a design of each block type it wants in its own hand, and blocks of its own where it has an idea no contract holds;
  • regions and a menu under the names every look uses, so arrangements carry across;
  • its fonts in its own lock, with their licences;
  • a README whose first line says what it covers — "A look for shops: draws Osysharp.Site.Looks and Osysharp.Shop.Looks" — and a picture of a store wearing it, at a phone's width and a wide screen's.

See also#

Related

Looks — a site's whole design, picked per version

A LOOK is a site's whole design — its frame, its pages, its theme and fonts, its design of every block type — declared…

Blocks and regions — pages the staff arrange

A `[Block]` is a component staff can place on a page: its parameters are the fields they fill in, and each parameter's…

The PageBuilder kit — the desk staff arrange the site at

`Osysharp.PageBuilder` is the desk where the people who run a site arrange its [[ui-blocks|regions]]: add, move, fill…

The site's frame — a menu, a strip and a footer staff change

The frame around every page — the menu, the strip across the top, the footer — can be the staff's to change, like a…

theme tokens

A `theme` block names your app's design tokens — colors, spacing, radii, and more — as reusable values. A token can…

web fonts — shipping a typeface with your app

Naming a font in your theme asks for it; `osy font add` ships it. The command pins a font file in your project's lock…

package.osy

The manifest that makes a git repository a publishable Osy# package. It names the package, says which of the…