Skip to content

Add the shared design token system to StreamCoreUI - #81

Open
nuno-vieira wants to merge 18 commits into
developfrom
core-ui-design-tokens
Open

Add the shared design token system to StreamCoreUI#81
nuno-vieira wants to merge 18 commits into
developfrom
core-ui-design-tokens

Conversation

@nuno-vieira

@nuno-vieira nuno-vieira commented Aug 28, 2026

Copy link
Copy Markdown
Member

Adds DesignSystemTokens to StreamCoreUI: the shared color and layout tokens every Stream SDK draws from. Fonts, icons and images stay on each product SDK.

Architecture

DesignSystemTokens is a class grouped by kind (colors, layout). Each SDK owns its own appearance (VideoAppearance, later ChatAppearance) and is constructed with a DesignSystemTokens instance. Pass the same instance into both so they reskin together.

flowchart TB
  subgraph core ["StreamCoreUI"]
    DT["DesignSystemTokens"]
    DT --> C["Colors\n175 tokens, lazy"]
    DT --> L["Layout\n66 tokens, lazy"]
  end

  subgraph video ["StreamVideoSwiftUI"]
    VA["VideoAppearance"]
    VA --> DT
    VA --> VC["VideoAppearance.Colors\n14 tokens"]
    VC --> C
  end

  subgraph chat ["StreamChat — follow-up"]
    CA["ChatAppearance"]
    CA --> DT
    CA --> CHC["ChatAppearance.Colors\n37 chat* tokens"]
    CHC --> C
  end
Loading
let tokens = DesignSystemTokens()
tokens.colors.accentPrimary = .red
tokens.layout.spacingMd = 16
let videoAppearance = VideoAppearance(tokens: tokens)
let chatAppearance = ChatAppearance(tokens: tokens)

Color and layout tokens are lazy var. Override the brand and chrome ramps (or a scale token) before the first read so semantic tokens pick up the override.

CoreUI does not import Chat or Video. Product colors live on each SDK's appearance (videoAppearance.colors.indicatorSpeaking). Video keeps the existing Appearance for views until they migrate.

Token scope

The design-system-tokens generator emits one flat file covering every Stream product. Product groups live on the SDK that owns them:

Shared (here) Chat Video
Color tokens 175 37 14
Layout tokens 66 8 0

The split is applied by hand. DesignSystem/TokenScope.md records it so a re-sync can re-apply it. Moving that upstream into the generator is IOS-2000.

UIColor.init(light:dark:) stays internal. Chat already vends a public equivalent; a second one would be ambiguous at any call site that imports both modules.

Testing

StreamCoreUI tests cover instance identity and the lazy-derivation boundary (override a ramp before vs after the first read).

Follow-ups

  • Video adoption: GetStream/stream-video-swift#1258, pinned to this branch until this merges and ships.
  • Chat adoption: IOS-1999. Two things need deciding there: chatBackgroundMention resolves to a raw ramp color that is internal here, and which UIColor.init(light:dark:) survives once Chat links CoreUI.

Brings the generated design system tokens into StreamCoreUI so Video and
Chat build against one source instead of each keeping a copy.

ColorPalette holds the semantic colour tokens and DesignSystemTokens the
layout ones (spacing, radii, icon sizing, stroke widths, elevations). Both
are classes of lazy properties, so semantic tokens derive from the ramps
and foundation scale: overriding brand500 or radiusXl before the first read
cascades to everything built on it.

The full token set is kept, including the messaging surfaces, so neither
SDK has to re-derive what the other needs on every re-sync.
The generator emits one flat token file covering every Stream product, but
each SDK only draws part of it. Keep the shared set here and hand the
messaging and calling groups to the SDKs that own them, so neither product
exposes the other's vocabulary. TokenScope.md records the split, since it is
applied by hand and has to survive the next re-sync.

Add fonts and generic icons alongside the tokens, and a SharedAppearance that
holds all four. Every SDK defaults to the same instance, so an app running
more than one of them reskins them together.

Fonts and images are vended for both UIKit and SwiftUI rather than bridged: a
symbol that reaches SwiftUI through Image(uiImage:) stops scaling with the
surrounding font and needs its rendering mode forced before it tints.
ColorPalette and DesignSystemTokens now live on Appearance. Product SDKs
attach their own configuration through a bag store, so CoreUI never imports
Chat or Video. @dynamicMemberLookup on ColorPalette lets each SDK flatten
product tokens onto the palette without a public .chat / .call property.
Icons stay on each product SDK; this module is not the place for a shared image set.
The product prefix is Video, not Call.
Appearance is UI configuration and is only meant to be read and written on the main thread, so the bag stores no longer need an unchecked Sendable escape.
Shared typography is out of scope for this pass, so CoreUI only vends the shared colour palette and layout tokens.
Appearance is a shared configuration object, and marking it MainActor forced assumeIsolated at every injection boundary. Keep it a plain class and leave thread use to the caller.
The type is not actor-isolated, so the compiler requires an explicit escape for the singleton.
…tokens

Each product owns its own appearance; Core now exposes a class with lazy colour and layout groups that Chat and Video can pass around together.
The type is the shared design-system token bag, so the name should match.
@nuno-vieira
nuno-vieira marked this pull request as ready for review August 31, 2026 16:27
Comment thread Sources/StreamCoreUI/DesignSystem/DesignSystemTokens+Colors.swift
Comment on lines +65 to +66
public lazy var inputRadiusOptionCard: CGFloat = radiusXl
public lazy var inputRadiusPollOptionInput: CGFloat = radiusXl

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look chat specific, buy probably fine

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yup, yes they kook chat specific, that is the thing, AI will make mistakes on interperting these, so we need @jurgenploeger to set some metadata or something to identify Chat-only or - Video-only stuff

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both chat, agreed on Option Card too. Though if the SDKs expand I can see Option Card becoming core, it's a generic enough pattern

@ipavlidakis ipavlidakis Sep 1, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can move the optionscard color in core when it's needed. let's not extend core with things that aren't needed for now

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jurgenploeger But this one: inputRadiusPollOptionInput should be chat-specific? Or could it be used for Feeds or something like this? 🤔

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Depends on where the component comes from rather than where it shows up

If Feeds renders the poll by pulling in the Chat SDK's poll component, then it's chat, same as the chat drawer we show in the video sample. That drawer is literally the Chat SDK component, so it brings chat tokens with it even though it's on a call screen

If Feeds ever ships its own poll rather than reusing the Chat SDK, then that's a separate component with its own token, not a reason to promote this one to core

So keeping it in chat. My rule of thumb would be: the token follows whoever owns the component, not whoever displays it

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, so this one is should not be in Core? I'm asking because this was probably AI trying to figure out stuff. At the moment AI is deciding which tokens go to Chat, Core and Video, since our Tokens script is not doing this

///
/// Tokens derive lazily, so override the ramps before the first read.
extension DesignSystemTokens {
public final class Colors {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MainActor? These are mutable and meant for UI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My goal is to make it MainActor, but doing so produced some changes in views, but that was before I using this already in some views. So I can probably but back main actor

Comment on lines +23 to +27
public final class DesignSystemTokens {
/// Shared colour tokens.
public var colors: Colors
/// Shared layout tokens.
public var layout: Layout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we go with MainActor for this?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes that is the goal

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent here is that integrators initialise and inject. After that there are no expected changes.

The current structure (class with var properties) allows changing the properties at runtime whenever. This change will propagate to all consumers of the DesignSystemTokens but because this class isn't observable (and we are decorating with @ObservedObject or @EnvironmentObject) views won't be updated.

I believe this is confusing and actually error prone. I would prefer either making this one a struct or change the properties to let or make the whole class Observable so that changes are propagated as needed. If you decide to do option or 2 then the MainActor requirement wouldn't be needed any more and you can let the object be accessible from any thread.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't think we need to have it observable, nor a struct. The goal is to change it before injecting it to the UI and passing the same object to any product specific appearance objects.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was never observable. And it is not meant to be. The colours should be set in the initialisation. If customers want to play around with changing themes, that is their responsibility, since they will also need to handle that on their own components not related to Chat or Video

Comment on lines +10 to +15
private lazy var subject: DesignSystemTokens! = .init()

override func tearDown() {
subject = nil
super.tearDown()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is slightly confusing compared to the typical setup and teardown we have for other tests. I would personally prefer the old style of explicit setUp although this works because how xctest runs tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I didnt noticed this, maybe it is the pattern here on video?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer this one :) as it only initialises things when needed in comparison to a setup method that initialises everything every time. However this is a preference and you should do it how you prefer

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use it here on video, then let's stick to it here. I'm not strongly opinionated on this one; I'm OK with either 👍

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, up to you. 👍

// Copyright © 2026 Stream.io Inc. All rights reserved.
//

import UIKit

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh man it's really weird seeing UIKit in or Core designSystem in 2026 :/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need to support Chat UIKit :/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So StreamChatUI also uses these tokens and I think there was some complexity around creating Color with different light and dark colors.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, seems like asset catalog is the only way without UIColor.

Comment on lines +23 to +27
public final class DesignSystemTokens {
/// Shared colour tokens.
public var colors: Colors
/// Shared layout tokens.
public var layout: Layout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The intent here is that integrators initialise and inject. After that there are no expected changes.

The current structure (class with var properties) allows changing the properties at runtime whenever. This change will propagate to all consumers of the DesignSystemTokens but because this class isn't observable (and we are decorating with @ObservedObject or @EnvironmentObject) views won't be updated.

I believe this is confusing and actually error prone. I would prefer either making this one a struct or change the properties to let or make the whole class Observable so that changes are propagated as needed. If you decide to do option or 2 then the MainActor requirement wouldn't be needed any more and you can let the object be accessible from any thread.


The Video SDK contributes no layout tokens today.

## Product appearances

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH i'm not sure core should care how Chat or Video consumer its tokens

@nuno-vieira nuno-vieira Sep 1, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean? This Markdown file is temporary; it is only until @jurgenploeger changes the Design System to clearly define Chat-only tokens and Video-only tokens

@martinmitrevski martinmitrevski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! I would throw a size check here as well - would be good to know how much we save in case both video and chat are used.

Comment on lines +23 to +27
public final class DesignSystemTokens {
/// Shared colour tokens.
public var colors: Colors
/// Shared layout tokens.
public var layout: Layout

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I don't think we need to have it observable, nor a struct. The goal is to change it before injecting it to the UI and passing the same object to any product specific appearance objects.

Comment thread Sources/StreamCoreUI/DesignSystem/TokenScope.md Outdated
Comment thread Package.swift Outdated
Forcing a dynamic product was only a Video test workaround and
constrained every consumer.
@GetStream GetStream deleted a comment from github-actions Bot Sep 1, 2026
@nuno-vieira
nuno-vieira force-pushed the core-ui-design-tokens branch 2 times, most recently from 1cee7d8 to fd69c3b Compare September 1, 2026 15:10
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
1 Warning
⚠️ Big PR

Generated by 🚫 Danger

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants