DEV Community

puffball1567
puffball1567

Posted on

Clay Board Style System update: Link Navigation

Clay Board Style System now provides typed native Link Navigation.

For readability, I will refer to Clay Board Style System as CBSS below.
CBSS is only an abbreviation used in this article; the project's official name
is Clay Board Style System.

The feature lets a native application move between screens with a flow similar
to a Single-Page Application: the window remains open, the navigation menu
remains in place, and only the active screen changes.

Clay Board Style System navigation demo

In this tutorial, we will build a small navigation menu with three destinations:

  • Home
  • Projects
  • Settings

Clicking a menu item switches the visible screen inside the same native SDL3
window. No DOM, browser history, WebView, or URL-string router is required.

Run the finished demo

The complete interactive demo is already included in the repository:

git clone https://github.com/puffball1567/clay-board-style-system.git
cd clay-board-style-system
nimble setupBundled
nimble navigationDemo
Enter fullscreen mode Exit fullscreen mode

The full source is available in
examples/navigation_demo.nim.

Step 1: Define the screens

Start by describing the destinations as a normal Nim enum:

import clay_board_style_system

type Screen = enum
  homeScreen,
  projectsScreen,
  settingsScreen

let navigator = initStackNavigator(homeScreen)
Enter fullscreen mode Exit fullscreen mode

navigator now owns the screen history. The initial destination is
homeScreen.

Because destinations are Nim values, invalid destinations are caught by the
compiler instead of becoming malformed route strings at runtime.

Step 2: Define a small set of styles

The navigation behavior does not impose a visual design. These styles create a
simple sidebar and content area:

proc appStyle(): UiStyle =
  uiStyle([
    decl("width", px(900)),
    decl("height", px(520)),
    decl("flex-direction", keyword("row")),
    decl("background-color", colorValue(rgb(0.04, 0.05, 0.08)))
  ])

proc menuStyle(): UiStyle =
  uiStyle([
    decl("width", px(220)),
    decl("height", px(520)),
    decl("padding", px(20)),
    decl("gap", px(10)),
    decl("flex-direction", keyword("column")),
    decl("background-color", colorValue(rgb(0.07, 0.09, 0.13)))
  ])

proc contentStyle(): UiStyle =
  uiStyle([
    decl("width", px(680)),
    decl("height", px(520)),
    decl("position", keyword("relative")),
    decl("overflow", keyword("hidden"))
  ])

proc screenStyle(): UiStyle =
  uiStyle([
    decl("position", keyword("absolute")),
    decl("left", px(0)),
    decl("top", px(0)),
    decl("width", px(680)),
    decl("height", px(520)),
    decl("padding", px(28)),
    decl("gap", px(12)),
    decl("flex-direction", keyword("column")),
    decl("background-color", colorValue(rgb(0.04, 0.05, 0.08)))
  ])

proc linkStyle(): UiStyle =
  uiStyle([
    decl("width", px(180)),
    decl("height", px(40)),
    decl("padding", px(10)),
    decl("align-items", keyword("center")),
    decl("background-color", colorValue(rgb(0.11, 0.15, 0.21))),
    decl("border-radius", px(6)),
    decl("cursor", keyword("pointer"))
  ])

proc linkTextStyle(): UiStyle =
  uiStyle([
    decl("font-size", px(14)),
    decl("line-height", px(20)),
    decl("color", colorValue(rgb(0.91, 0.94, 0.98)))
  ])
Enter fullscreen mode Exit fullscreen mode

These are ordinary CBSS styles. A component library can replace all of them
without changing the navigation code.

Step 3: Build the menu and screens

Keep the three screen roots so they can be registered with the navigator. We
also keep the Back and Forward buttons for the next step.

type AppView = object
  home: NodeHandle
  projects: NodeHandle
  settings: NodeHandle
  backButton: ButtonHandle
  forwardButton: ButtonHandle
  refreshProjects: ButtonHandle
  notifications: CheckboxHandle

proc buildView(ui: UiRoot; navigator: Navigator[Screen]): AppView =
  ui.box(appStyle()):
    ui.box(menuStyle()):
      ui.text("My application")

      ui.link(
        navigator,
        homeScreen,
        "Home",
        style = linkStyle(),
        textStyle = linkTextStyle()
      )

      ui.link(
        navigator,
        projectsScreen,
        "Projects",
        style = linkStyle(),
        textStyle = linkTextStyle()
      )

      ui.link(
        navigator,
        settingsScreen,
        "Settings",
        style = linkStyle(),
        textStyle = linkTextStyle()
      )

      result.backButton = ui.button("Back")
      result.forwardButton = ui.button("Forward")

    ui.box(contentStyle()):
      ui.box(result.home, screenStyle()):
        ui.text("Home")
        ui.text("Welcome to the native application.")

      ui.box(result.projects, screenStyle()):
        ui.text("Projects")
        ui.text("Project data will be displayed here.")
        result.refreshProjects = ui.button("Refresh projects")

      ui.box(result.settings, screenStyle()):
        ui.text("Settings")
        result.notifications =
          ui.checkbox("Enable notifications", checked = true)
Enter fullscreen mode Exit fullscreen mode

The hierarchy is visible directly in the code:

application
├── navigation menu
│   ├── Home Link
│   ├── Projects Link
│   ├── Settings Link
│   ├── Back button
│   └── Forward button
└── content area
    ├── Home screen
    ├── Projects screen
    └── Settings screen
Enter fullscreen mode Exit fullscreen mode

ui.link() is a style-neutral native Link. It handles pointer activation,
Enter-key activation, keyboard focus, disabled state, and accessibility
semantics.

Step 4: Register the screens

Build the view once, then associate each typed destination with its screen root:

let ui = initUiRoot()
let navigator = initStackNavigator(homeScreen)
let view = buildView(ui, navigator)

let host = initNavigationScreenHost(ui, navigator)
host.registerScreen(homeScreen, view.home)
host.registerScreen(projectsScreen, view.projects)
host.registerScreen(settingsScreen, view.settings)

var interaction = initInteractionState()

if not host.sync(interaction):
  raise newException(ValueError, "initial screen could not be activated")
Enter fullscreen mode Exit fullscreen mode

At this point, only Home is active.

When a Link receives a click or Enter-key event, it updates the navigator. After
the current platform-event batch, synchronize the screen host:

let screenChanged = host.sync(interaction)
Enter fullscreen mode Exit fullscreen mode

In a real application this line belongs in the central event loop, after CBSS
has dispatched the current batch of SDL3 events. The complete demo shows that
integration without hiding it behind tutorial pseudocode.

Only the active screen participates in layout, painting, hit testing, keyboard
focus, and the visible accessibility tree. The other screens remain retained
but inert.

That is the SPA-like part: switching screens does not reconstruct the complete
application tree or open another native window.

Add Back and Forward

The same navigator provides history operations:

view.backButton.onClick = proc(event: DispatchResult): bool =
  if not navigator.back():
    echo "Already at the first screen"
  true

view.forwardButton.onClick = proc(event: DispatchResult): bool =
  if not navigator.forward():
    echo "Already at the latest screen"
  true
Enter fullscreen mode Exit fullscreen mode

push, replace, back, and forward operate on typed history entries. Two
visits to Projects remain two separate entries, which allows CBSS to restore
focus for the correct visit when navigating through history.

Request data with Joubako

Navigation belongs to CBSS, while HTTP communication belongs to the
application's data layer.

I am also developing
Joubako, a separate async transport
client for Nim. It supports HTTP(S), typed JSON, standard Nim await,
result-aware callback composition, WebSockets, local IPC, and optional NIF/BIF
data exchange.

It can be started from a normal CBSS event handler without coupling networking
to the navigation system:

import std/asyncdispatch
import joubako

type Project = object
  id: int
  name: string

let api = newClient(newHttpTransport(), "https://api.example.com/")

proc loadProjects() {.async.} =
  let outcome = await api.getJson("projects", seq[Project])
  if outcome.isErr:
    echo "Request failed: ", outcome.error.msg
    return

  echo "Loaded projects: ", outcome.value.len
  # Update application state and mark the affected view dirty here.

view.refreshProjects.onClick = proc(event: DispatchResult): bool =
  asyncCheck loadProjects()
  true
Enter fullscreen mode Exit fullscreen mode

Joubako is not bundled with CBSS and is not required for Link Navigation. An
application can use Joubako, another HTTP client, IPC, an FFI service, or a
fully local data source behind the same event-handler boundary.

Optional features

The basic menu above is enough for normal in-process screen navigation. CBSS
also provides optional APIs for:

  • focus restoration for each history entry;
  • animated screen transitions;
  • validated application deep links;
  • replacing one retained screen without rebuilding the application; and
  • injecting a custom navigation driver.

These features are documented in the
Native Navigation guide.

Why use typed destinations?

A browser URL is useful when the URL is the application's public address. A
native in-process screen does not always need that string boundary.

With CBSS:

navigator.push(projectsScreen)
Enter fullscreen mode Exit fullscreen mode

is checked against Screen by Nim. A larger application can replace the enum
with a variant object that carries typed parameters, such as a project ID.

External deep links can still be decoded into the same destination type at the
edge of the application. Internal navigation remains typed.

Current status

Clay Board Style System v0.3 is a developer preview. Linux x86_64 with SDL3 is
currently the Tier 1 runtime. Windows and macOS portable builds run in CI, while
complete runtime validation on those platforms remains contributor-driven.

Link Navigation is covered by unit tests, retained-screen tests, transition and
focus tests, performance checks, and a real SDL3/Wayland E2E scenario.

Links

Feedback on the Link API and the navigation-menu authoring experience is very
welcome.

Top comments (0)