Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
446 changes: 446 additions & 0 deletions .claude/skills/update-design-tokens/SKILL.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Token sync state

## Last sync

| | |
| --- | --- |
| upstream commit | `4ef9b54bf93f2e42f346340690296dfca480ebc9` |
| upstream PR | [design-system-tokens#73](https://github.com/GetStream/design-system-tokens/pull/73) |
| date | 2026-09-10 |

**Update this on every sync.** Nothing in the repo records which upstream state the
vendored tokens correspond to, and unlike a token's readers it cannot be recovered
by grepping — you would have to bisect upstream comparing values. One line here
turns the next sync into a mechanical diff:

```bash
python3 <skill>/scripts/flatten_tokens.py --diff 4ef9b54 origin/main tokens/core/semantics/light.json
```

Read it as *"every semantic change up to here has been triaged"*, not *"the vendored
files mirror this commit"*. They do not, and knowingly so: the vendored set is
neither a subset nor a superset of upstream. Some names pre-date upstream's
core/chat/video namespace split and survive only here (`backgroundElevationElevation0`,
`avatarPaletteBg1`); many upstream names — mostly derived tokens — are deliberately
not vendored; and `light/` and `dark/` do not even hold the same set. Compare them
yourself rather than trusting a count that rots:

```bash
python3 <skill>/scripts/flatten_tokens.py tokens/core/semantics/light.json
```
Adopting the namespace split is its own migration, not part of a routine sync.

## Derived-token map

Where upstream's **derived** chat and video semantics are actually implemented.

This file exists because that mapping is not greppable. Derived tokens have no
`StreamColorScheme` field — the SDK inlines the value as a swatch or root-semantic
read inside a component-theme default, so nothing in the code carries the token's
name. `indicator/sound-indicator/speaking` is implemented as a `speakingColor`
defaulting to `colorScheme.brand.shade300`; no search for "speaking" or
"soundIndicator" reaches it.

Root semantics need no entry here — they have a field, so
`scripts/map_token_usage.py` finds their readers in seconds. Only add a row when
the connection cost you a manual trace.

**Verify a row before acting on it.** These point at other repos, which move
independently and will not update this file. Opening the named file to confirm is
cheap; trusting a stale row is not. If a row is wrong, fix it in the same change
that discovered the problem — and add rows as you trace new ones, so the next
person pays the cost once.

## Video

Reference ref: `origin/v2` in stream-video-flutter (the design-system branch).

| upstream token | resolves to | implemented in | component |
| --- | --- | --- | --- |
| `indicator/connection-quality/poor` | `{accent.error}` | video · `indicators/connection_quality_indicator_defaults.dart` → `poorColor` | `StreamConnectionQualityIndicator` |
| `indicator/connection-quality/fair` | `{accent.warning}` | video · same file → `fairColor` | `StreamConnectionQualityIndicator` |
| `indicator/connection-quality/great` | `{accent.success}` | video · same file → `greatColor` | `StreamConnectionQualityIndicator` |
| `indicator/sound-indicator/speaking` | `{brand.400}` | video · `theme/components/participant_label_theme.dart` → `speakingColor` | `StreamAudioIndicator` |
| `control/call-control-error-badge/bg` | `{accent.warning}` | **core** · `components/badge/stream_error_badge.dart` → `warningBackgroundColor`, i.e. `colorScheme.accentWarning` | `StreamErrorBadge`, wrapped by video's `CallButtonBadge` |
| `control/call-control-error-badge/text` | `{base.black}` | **core** · same file → `warningForegroundColor`, a literal `StreamColors.black` — `textOnAccent` resolves to white in *both* modes and cannot satisfy `{base.black}` | `StreamErrorBadge` |
| `indicator/microphone-level/bar-active` | `{brand.400}` | not implemented — the lobby level meter is new | — |
| `indicator/microphone-level/bar-inactive` | `{chrome.200}` | not implemented | — |

Two things this table is worth reading for:

- **A video token can land in this repo.** The call-control error badge is a video
token whose only implementation is `StreamErrorBadge`, a core component. Video's
`CallButtonBadge` just wraps it. So "video namespace" never means "not our
problem" — it means check the video SDK *as well*.
- **`speakingColor` resolves `brand.shade300` while the token says `{brand.400}`.**
Whether that is a deliberate deviation or drift is unresolved; treat it as a
question to ask, not a bug to fix silently.

## Chat

Chat components live in this repo, under the `chat.dart` barrel, so a
`tokens/chat/**` change usually means work **here** rather than in
stream-chat-flutter — the reverse of the intuition the namespace suggests.
Chat semantics are also vendored into `internal/tokens/` with a `chat` prefix
(`chatReplyIndicatorIncoming`, `chatTextTypingIndicator`).

Reference ref: `origin/master` in stream-chat-flutter.

No manually-traced rows yet. Add them as they come up, in the same shape as the
video table.
102 changes: 102 additions & 0 deletions .claude/skills/update-design-tokens/scripts/flatten_tokens.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
"""Flatten a design-token semantics JSON into sorted `path = value` lines.

The token repo's generated output is sorted by key, so any change that adds or
renames a token re-sorts the whole file and buries real value changes in
hundreds of lines of move noise. Diffing the flattened source instead makes an
added / removed / changed token obvious.

Usage:

# one revision
flatten_tokens.py tokens/core/semantics/light.json

# compare two revisions of the same file (run from the token repo)
flatten_tokens.py --diff main tokens/on-elevation-and-indicators \\
tokens/core/semantics/light.json

The --diff form shells out to `git show <ref>:<path>` for each ref, so it needs
a git checkout of the token repo but no network. Values are left as authored —
`{yellow.200}` stays an alias rather than being resolved — because an alias
change and a hex change want to be read differently.
"""

from __future__ import annotations

import argparse
import json
import subprocess
import sys


def flatten(node: dict, prefix: str = "") -> dict[str, str]:
"""Collapse a nested token tree into {"group/name": "$value"}."""
out: dict[str, str] = {}
for key, value in node.items():
if not isinstance(value, dict):
continue
if "$value" in value:
out[prefix + key] = value["$value"]
else:
out.update(flatten(value, f"{prefix}{key}/"))
return out


def load(path: str, ref: str | None = None) -> dict[str, str]:
if ref is None:
with open(path) as handle:
return flatten(json.load(handle))
result = subprocess.run(
["git", "show", f"{ref}:{path}"],
capture_output=True,
text=True,
)
if result.returncode != 0:
sys.exit(
f"error: cannot read {path} at ref '{ref}'.\n"
f" git said: {result.stderr.strip()}\n"
f" A PR branch is often not in your local checkout yet — fetch it first:\n"
f" git fetch origin {ref}\n"
f" then pass the ref as 'FETCH_HEAD' or 'origin/{ref}'."
)
return flatten(json.loads(result.stdout))


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("path", help="path to a semantics JSON file")
parser.add_argument(
"--diff",
nargs=2,
metavar=("BASE_REF", "HEAD_REF"),
help="compare the file at two git refs instead of printing one",
)
args = parser.parse_args()

if not args.diff:
for key, value in sorted(load(args.path).items()):
print(f"{key} = {value}")
return 0

base_ref, head_ref = args.diff
base, head = load(args.path, base_ref), load(args.path, head_ref)

removed = sorted(k for k in base if k not in head)
added = sorted(k for k in head if k not in base)
changed = sorted(k for k in base if k in head and base[k] != head[k])

print(f"{args.path}: {len(base)} -> {len(head)} tokens")
for key in removed:
print(f" REMOVED {key} = {base[key]}")
for key in added:
print(f" ADDED {key} = {head[key]}")
for key in changed:
print(f" CHANGED {key}: {base[key]} -> {head[key]}")
if not (removed or added or changed):
print(" (no semantic changes)")
return 0


if __name__ == "__main__":
sys.exit(main())
88 changes: 88 additions & 0 deletions .claude/skills/update-design-tokens/scripts/map_token_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Map `StreamColorScheme` fields to the files that read them, in any repo.

Answers "who paints with this color?" for a consuming SDK without reading it
into context file by file. Run it against a ref rather than a working tree, so a
design-system branch can be inspected without checking it out.

map_token_usage.py <repo-path> <ref> # whole scheme
map_token_usage.py <repo-path> <ref> accentWarning # one field

Both receiver spellings are matched (`colorScheme.x` and the `_colorScheme.x`
used inside `_Defaults` classes), and test files are skipped by default.

Scope note: this finds where a field is *read*, which is one indirection away
from the widget that renders it — a component theme's defaults class will show
up rather than the widget consuming that theme. Follow the theme field to the
widget when the answer needs to name a component.

The match is on the bare string `colorScheme.`, so Material's
`Theme.of(context).colorScheme.surface` is reported identically to a
`StreamColorScheme` read. This repo uses `StreamTheme.of(context).colorScheme` and
has no collisions, but the consuming SDKs are Material apps where it is real —
check the receiver before trusting a hit, and note that app/example directories
are not filtered either, only tests.

It also only sees root semantics, the ones with a `colorScheme` field. Derived
chat/video tokens have no field at all — the SDK inlines them as swatch reads
(`colorScheme.brand.shade300`) inside component-theme defaults, which no token
name will match. Those live in `references/derived-token-map.md` instead.
"""

from __future__ import annotations

import argparse
import collections
import re
import subprocess
import sys

PATTERN = r"_\?colorScheme\.[A-Za-z][A-Za-z0-9]*"
LINE_RE = re.compile(r"^(?P<ref>.*?):(?P<path>.*?):(?P<line>\d+):.*?_?colorScheme\.(?P<field>[A-Za-z0-9]+)")


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("repo", help="path to the consuming SDK checkout")
parser.add_argument("ref", help="git ref to inspect, e.g. origin/v2")
parser.add_argument("field", nargs="?", help="limit to one colorScheme field")
parser.add_argument("--include-tests", action="store_true")
args = parser.parse_args()

pattern = rf"_\?colorScheme\.{args.field}\b" if args.field else PATTERN
result = subprocess.run(
["git", "grep", "-Ion", pattern, args.ref, "--", "*.dart"],
cwd=args.repo,
capture_output=True,
text=True,
)
# git grep exits 1 on "no matches", which is an answer, not an error.
if result.returncode not in (0, 1):
sys.exit(f"error: git grep failed in {args.repo}\n{result.stderr.strip()}")

usage: dict[str, set[str]] = collections.defaultdict(set)
for line in result.stdout.splitlines():
match = LINE_RE.match(line)
if not match:
continue
path = match.group("path")
if not args.include_tests and ("/test/" in path or path.endswith("_test.dart")):
continue
usage[match.group("field")].add(path.split("/")[-1].removesuffix(".dart"))

if not usage:
target = f"field '{args.field}'" if args.field else "any colorScheme field"
print(f"no reads of {target} in {args.repo} at {args.ref}")
print("If this is a repo whose design-system work lives on a branch, check that")
print("branch — the default branch may not depend on stream_core_flutter at all.")
return 0

width = max(len(f) for f in usage) + 2
for field in sorted(usage):
print(f"{field:<{width}} {', '.join(sorted(usage[field]))}")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading