Add the shared design token system to StreamCoreUI - #81
Conversation
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.
| public lazy var inputRadiusOptionCard: CGFloat = radiusXl | ||
| public lazy var inputRadiusPollOptionInput: CGFloat = radiusXl |
There was a problem hiding this comment.
Look chat specific, buy probably fine
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
@jurgenploeger But this one: inputRadiusPollOptionInput should be chat-specific? Or could it be used for Feeds or something like this? 🤔
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
MainActor? These are mutable and meant for UI.
There was a problem hiding this comment.
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
| public final class DesignSystemTokens { | ||
| /// Shared colour tokens. | ||
| public var colors: Colors | ||
| /// Shared layout tokens. | ||
| public var layout: Layout |
There was a problem hiding this comment.
Should we go with MainActor for this?
There was a problem hiding this comment.
Yes that is the goal
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| private lazy var subject: DesignSystemTokens! = .init() | ||
|
|
||
| override func tearDown() { | ||
| subject = nil | ||
| super.tearDown() | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah I didnt noticed this, maybe it is the pattern here on video?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 👍
| // Copyright © 2026 Stream.io Inc. All rights reserved. | ||
| // | ||
|
|
||
| import UIKit |
There was a problem hiding this comment.
Oh man it's really weird seeing UIKit in or Core designSystem in 2026 :/
There was a problem hiding this comment.
We still need to support Chat UIKit :/
There was a problem hiding this comment.
So StreamChatUI also uses these tokens and I think there was some complexity around creating Color with different light and dark colors.
There was a problem hiding this comment.
Yeah, seems like asset catalog is the only way without UIColor.
| public final class DesignSystemTokens { | ||
| /// Shared colour tokens. | ||
| public var colors: Colors | ||
| /// Shared layout tokens. | ||
| public var layout: Layout |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
TBH i'm not sure core should care how Chat or Video consumer its tokens
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
| public final class DesignSystemTokens { | ||
| /// Shared colour tokens. | ||
| public var colors: Colors | ||
| /// Shared layout tokens. | ||
| public var layout: Layout |
There was a problem hiding this comment.
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.
Forcing a dynamic product was only a Video test workaround and constrained every consumer.
1cee7d8 to
fd69c3b
Compare
Generated by 🚫 Danger |
Adds
DesignSystemTokenstoStreamCoreUI: the shared color and layout tokens every Stream SDK draws from. Fonts, icons and images stay on each product SDK.Architecture
DesignSystemTokensis a class grouped by kind (colors,layout). Each SDK owns its own appearance (VideoAppearance, laterChatAppearance) and is constructed with aDesignSystemTokensinstance. 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 endColor and layout tokens are
lazy var. Override thebrandandchromeramps (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 existingAppearancefor views until they migrate.Token scope
The
design-system-tokensgenerator emits one flat file covering every Stream product. Product groups live on the SDK that owns them:The split is applied by hand.
DesignSystem/TokenScope.mdrecords 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
chatBackgroundMentionresolves to a raw ramp color that is internal here, and whichUIColor.init(light:dark:)survives once Chat links CoreUI.