strand

A notification stack

Cards at the top right of the focused monitor, one per notification, that slide in when a notification arrives and out when it goes. Strand is the notification server, so nothing else needs to run. The guide's level 4 explains it line by line.

The file

toasts.strand
// toasts.strand
export state dnd = false persist
let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5)

panel Toasts {
  screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none
  open: shown.len > 0
  col { width: 380; gap: $space.2
    for n in shown { Toast n }                    // keyed by n.id automatically
  }
}

component Toast(n: Notification) {
  col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border
        bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg
    enter { x: 420; opacity: 0 }
    exit  { x: 420; opacity: 0; height: 0 }       // siblings slide up to fill the gap
    when hover { bg: $surface.hi }
    when n.urgency == critical { border: 1, $error }
    after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() }
    on click     { n.activate() }
    on secondary { n.dismiss() }
    row { gap: $space.2
      image n.image ?? n.app.icon { size: 36; radius: $radius.sm }
      col { grow: 1
        text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end }
        text n.body { markup: basic; max_lines: 4; color: $fg.muted }
      }
      icon "window-close-symbolic" { size: 14; on click { n.dismiss() } }
    }
    if n.actions.len > 0 {
      row { gap: $space.1
        for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } }
      }
    }
  }
}

Only one program can own the notification service on the session bus. If dunst, mako or another daemon is running, Strand says so, names it and says how to stop it, and takes the name as soon as it is free. Then send yourself one:

notify-send "Hello" "from Strand"

What it looks like

Here a first notification arrives, a second comes under it, and the first is dismissed: it slides out while its height folds, and the card below slides up into its place. The example gives the panel a fixed height, because every frame of a picture here must be one size; in your shell the panel grows and shrinks with its cards.

0 px420 pxset notifications.popups [{"id": 1, "app": {"name": "Mail", "icon": "mail-unread-symbolic"}, "summary": "Ada Lovelace", "body": "Lunch at noon?", "urgency": "normal"}]set notifications.popups …set notifications.popups [{"id": 1, "app": {"name": "Mail", "icon": "mail-unread-symbolic"}, "summary": "Ada Lovelace", "body": "Lunch at noon?", "urgency": "normal"}, {"id": 2, "app": {"name": "Builds", "icon": "emblem-ok-symbolic"}, "summary": "main passed", "body": "All 412 tests passed.", "urgency": "normal"}]set notifications.popups …set notifications.popups [{"id": 2, "app": {"name": "Builds", "icon": "emblem-ok-symbolic"}, "summary": "main passed", "body": "All 412 tests passed.", "urgency": "normal"}]set notifications.popups …2.27 s
0.23 s
toasts.strand
// toasts.strand
export state dnd = false persist
let shown = notifications.popups.filter(n => !dnd || n.urgency == critical).take(5)

panel Toasts {
  screens: focused; layer: overlay; anchor: top_right; margin: $space.3; keyboard: none
  open: shown.len > 0
  height: 180                                     // fixed here so every frame is one size
  col { width: 380; gap: $space.2
    for n in shown { Toast n }                    // keyed by n.id automatically
  }
}

component Toast(n: Notification) {
  col { pad: $space.3; gap: $space.2; radius: $radius.lg; border: 1, $border
        bg: $surface.alpha(0.88); blur: 24; shadow: $elevation.lg
    enter { x: 420; opacity: 0 }
    exit  { x: 420; opacity: 0; height: 0 }       // siblings slide up to fill the gap
    when hover { bg: $surface.hi }
    when n.urgency == critical { border: 1, $error }
    after n.timeout ?? 6s while !hover && n.urgency != critical { n.expire() }
    on click     { n.activate() }
    on secondary { n.dismiss() }
    row { gap: $space.2
      image n.image ?? n.app.icon { size: 36; radius: $radius.sm }
      col { grow: 1
        text join(" · ", n.app.name, n.summary) { weight: 600; ellipsis: end }
        text n.body { markup: basic; max_lines: 4; color: $fg.muted }
      }
      icon "window-close-symbolic" { size: 14; on click { n.dismiss() } }
    }
    if n.actions.len > 0 {
      row { gap: $space.1
        for a in n.actions { button a.label { grow: 1; on click { a.invoke() } } }
      }
    }
  }
}
Drawn by Strand from this config.

How it works

  • notifications.popups is the list of notifications to show now. Each one leaves it when it expires or is dismissed, and its card plays its exit pose.
  • The for is keyed by each notification's id, which the service gives it. When one goes, the other cards keep their state and slide to their new places.
  • after n.timeout ?? 6s while !hover && … counts down only while its condition holds: pointing at a card pauses it, and a critical notification never counts down. The time is the sender's, or six seconds when it gave none. n.expire() ends the popup as a timeout does.
  • exit { …; height: 0 } shrinks the leaving card's height as it slides away, so the cards under it move up smoothly.
  • A click runs the notification's default action (n.activate()); a right click or the cross dismisses it.

Do not disturb

export state dnd = false persist is your own switch. persist keeps it across restarts, and export lets a key reach it:

strand toggle toasts.dnd

While it is on, the filter keeps only critical notifications. The others still arrive and wait in notifications.popups: the server sets no timers of its own, and only a card counts down. Turn it off and they show.

Variations

  • Progress. meter n.progress ?? 0 in the card shows how far a download or a copy has got, for senders that say (0 to 1).
  • More or fewer at once. .take(5) shows at most five cards; change the number.
  • The service's own switch. notifications.dnd is a writable field of the service: while it is true, new notifications that are not critical skip popups altogether (they are still kept in notifications.all).

This page on GitHubThis page as Markdownllms.txt