diff --git a/.claude/skills/update-design-tokens/SKILL.md b/.claude/skills/update-design-tokens/SKILL.md new file mode 100644 index 00000000..8bb65881 --- /dev/null +++ b/.claude/skills/update-design-tokens/SKILL.md @@ -0,0 +1,446 @@ +--- +name: update-design-tokens +description: > + Sync color design tokens from the GetStream/design-system-tokens repo into stream_core_flutter, and assess what + an upstream token change means for this package. Use whenever a design-system-tokens PR or commit needs + reviewing ("what does this token PR do to us?", "assess the impact of this change"), whenever a color value or + a new semantic token has to land in `theme/primitives/internal/tokens/`, whenever a new `StreamColorScheme` + field is being added or wired up, and whenever someone asks where a color comes from or why a token resolves + the way it does. Covers tracing the blast radius into the consuming SDKs (stream-chat-flutter for chat tokens, + stream-video-flutter for video), where the derived values actually live. Also use before hard-coding any `Color(0x...)` in this package — the answer is almost always a + token or a colorScheme field instead. +allowed-tools: + - Bash + - Read + - Edit + - Write +--- + +# Updating design tokens + +Colors in `stream_core_flutter` originate in +[GetStream/design-system-tokens](https://github.com/GetStream/design-system-tokens) +— the same repo the icons come from. This skill covers reading a change upstream +and landing it here. + +Two things make this less mechanical than it sounds: **this package deliberately +vendors only a fraction of what upstream publishes**, and **the generated output +is sorted**, so a diff of it hides value changes in re-sort noise. Both are +covered below. + +## What lives where + +| | | +| --- | --- | +| source of truth | `tokens/{core,chat,video}/semantics/{light,dark}.json` upstream | +| upstream Flutter build | `build/flutter/tokens/lib/src/{android,ios,web}/{light,dark}/stream_tokens.dart` | +| vendored here | `packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` | +| root semantics | `lib/src/theme/semantics/stream_color_scheme.dart` | +| derived values | each component's `build` / defaults, reading `colorScheme.*` | + +The vendored files are **maintained by hand** — there is no sync command, and the +upstream build output is not copied in verbatim. Only `stream_colors.dart` and +`stream_color_scheme.dart` import them; no component theme or widget ever +references `StreamTokens`. (`stream_color_swatch_helper.dart` generates shades in +HCT from a seed and never reads a token — it is measured *against* the vendored +values, not driven by them.) + +**Colors are byte-identical across the three upstream flavors; typography is not +— and for type the flavor is `web`.** Only web carries the `Geist` family this +package ships; android resolves to Roboto, iOS to SF Pro, and iOS also runs a +size up at every step (`typographyFontSizeMd` is 17 there against 16 on +android/web). So read colors from any flavor and type from `web`. + +Dimensions — spacing, radius, sizes, line heights, weights — are identical across +all three, so no flavor choice arises. They do come from upstream, but +`StreamSpacing`, `StreamRadius` and `StreamTokensTypography` hard-code the values +instead of reading the vendored constants, so a dimension change upstream has to +be applied to those classes by hand. + +Only core and chat semantics are vendored into `internal/tokens/`; the video +namespace is not. That is about *vendoring*, not about impact — a video token can +still be implemented by a core component here, and chat components live in this +repo outright. See [Downstream component defaults](#downstream-component-defaults). + +## Reading an upstream change + +Diff the **flattened source**, never the generated Dart/Kotlin/Swift. Upstream's +generator sorts keys, so adding one token re-sorts the file and a real value +change hides among hundreds of moved lines. This is not hypothetical — it is how +a live `accent/warning` change once reached review unnoticed. + +```bash +# from a checkout of design-system-tokens +git fetch origin +python3 /scripts/flatten_tokens.py --diff main FETCH_HEAD tokens/core/semantics/light.json +``` + +To catch up rather than review one PR, diff from the last synced commit — recorded +under **Last sync** in `references/derived-token-map.md`, and worth updating there +whenever you land a sync, since nothing in the repo itself records it: + +```bash +python3 /scripts/flatten_tokens.py --diff origin/main tokens/core/semantics/light.json +``` + +Run it for each namespace and mode the change touches (`core`/`chat` here, +`light` and `dark` both — they alias different primitives and can drift apart). +Output is `ADDED` / `REMOVED` / `CHANGED` per token path. Without `--diff` the +script just prints one revision as sorted `path = value` lines. + +Values stay as authored (`{yellow.200}`, not the resolved hex) so that an alias +change reads differently from a raw-value change. + +Then classify what you found: + +- **`CHANGED` on a token this package vendors or maps** — the real work. Trace it + to its `StreamColorScheme` field and to any component reading that field. +- **`ADDED`** — additive; adopt it only when a component actually needs it (see + below). +- **`REMOVED` / renamed** — check whether the old name appears here at all before + treating it as breaking, then check the SDK that owns the namespace, since a + renamed derived token is often implemented there rather than here. + +When assessing impact, distinguish a token **definition** from a **paint site**. +A grep for the field name will mostly hit the color scheme and generated +`.g.theme.dart` plumbing; what matters is whether a widget renders with it: + +```bash +grep -rn "accentWarning" packages/stream_core_flutter/lib/src --include="*.dart" \ + | grep -v "theme/semantics/stream_color_scheme" +``` + +A change to a field nothing paints with is real API surface but no visual change +— say so plainly rather than reporting it as a regression. + +## Downstream component defaults + +A clean bill of health *here* does not mean no impact. Because this package +vendors only root semantics, upstream's derived tokens are implemented as +**component defaults in a consuming SDK** — so a derived-token change has no +counterpart in this repo at all and can only be assessed downstream. + +The namespace does **not** tell you which repo to open. It tells you which SDK to +check *in addition to* this one: + +| changed under | also check | why | +| --- | --- | --- | +| `tokens/core/**` | both SDKs | core is shared by everything | +| `tokens/chat/**` | stream-chat-flutter | but chat components live *here*, under `chat.dart`, so the work is usually in this repo | +| `tokens/video/**` | stream-video-flutter | video tokens can still land here — see below | + +A `tokens/video/**` change reaching this repo is not hypothetical: +`control/call-control-error-badge/*` is a video token whose only implementation is +`StreamErrorBadge`, a core component that video merely wraps. Never conclude "video +namespace, not our problem" from the path alone. + +### A token for an internal component + +Upstream giving a component its own token is a signal that the component is part +of the design system's surface, so a themeable, public component is usually the +right shape — even where today's implementation is internal. Public components are +named with a `Stream` prefix. + +**Ask before making one public.** Widening the public API is a maintenance +commitment the SDK carries until the next major version, and that is the owner's +call, not a detail to slip into a token sync. Implement the theme, note that the +widget it themes is internal, and put the question to them. + +Which ref to inspect: + +| repo | ref | +| --- | --- | +| stream-chat-flutter | `origin/master` | +| stream-video-flutter | `origin/v2` — the design-system work lives there, not on `main` | + +Both are normally checked out as siblings of this repo; locate them rather than +assuming a path, and ask if neither is present. Prefer a local checkout over +GitHub code search, which indexes only default branches and would miss video +entirely. + +### Finding the readers + +For a **root semantic** (anything with a `StreamColorScheme` field), the mapping is +derivable — don't keep notes on it, generate it: + +```bash +python3 /scripts/map_token_usage.py origin/v2 accentWarning +# accentWarning connection_quality_indicator_defaults, connection_quality_indicator_theme +``` + +Omit the field name for the whole scheme (~28 lines for video). The script matches +both `colorScheme.x` and the `_colorScheme.x` used inside `_Defaults` classes, skips +tests, and inspects a ref directly so nothing needs checking out. + +Two limits worth knowing, both by design: + +- It reports where a field is **read**, one indirection from the widget that renders + it — a component theme's defaults class shows up rather than the widget consuming + that theme. Follow the theme field on to the widget when the answer needs to name + a component. +- It cannot see **derived** tokens at all, because they have no field. Those are in + `references/derived-token-map.md`, hand-traced, with the caveat that it points at + repos which move independently — verify a row before acting on it. + +And one that is not by design: the pattern matches the bare string `colorScheme.`, +so **Material's** `Theme.of(context).colorScheme.surface` is reported exactly like a +`StreamColorScheme` read. There are none in this repo, which uses +`StreamTheme.of(context).colorScheme`, but the consuming SDKs are Material apps +where the collision is real. Check the `Theme.of` receiver before reading a hit as +a Stream token — and read a *clean* result with the same suspicion, since app and +example directories are not filtered either. + +When you do trace a derived token by hand, add the row. That is the only mapping +worth recording: the greppable half goes stale the moment someone edits a widget, +while the non-greppable half is what nobody can reconstruct without repeating your +work. + +## Coordinating a change across repos + +A token change that needs work in both this package and an SDK cannot be validated +in one branch: chat and video depend on `stream_core_flutter` **from pub**, so an +edit here is invisible to them until it is released. Wire them together with a git +dependency override, in this order: + +1. Branch and push here first — the override resolves against the remote, so a + local commit is not enough. +2. Take the pushed SHA (`git rev-parse HEAD`), branch in the consumer under the + **same name** (see below), and point its `stream_core_flutter` at that SHA. +3. `melos bootstrap` in the consumer. Both halves are now buildable and reviewable + together. + +### Branch naming + +The `ref:` is a SHA, so the branch name is not load-bearing for resolution — but +it is shared vocabulary across repos, and matching names are what let someone find +the other half of a change. + +- **If core is already on a branch for this work, use that branch everywhere.** + Reuse it rather than cutting a second one, and give the consumer's branch the same + name. +- **Otherwise name the core branch `feat/update-tokens-{feature}`**, where + `{feature}` is the main thing that changed in the tokens — the subject of the + change, not a token path. The PR that added the on-elevation pair and restructured + the indicators would be `feat/update-tokens-on-elevation`. + +The override needs a `path:`, because this package is not at the repo root: + +```yaml +dependency_overrides: + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: dbf84703ceb4dc19a7c847707428e8727d867203 # a commit, never a branch + path: packages/stream_core_flutter +``` + +**Where that block goes differs per consumer**, and getting it wrong looks like the +override being silently ignored: + +| consumer | resolution | put `dependency_overrides` in | +| --- | --- | --- | +| stream-video-flutter | pub workspace (`resolution: workspace`) | the **root** `pubspec.yaml` — it already has an overrides block | +| stream-chat-flutter | melos, no pub workspace | the **consuming package's** pubspec, `packages/stream_chat_flutter/pubspec.yaml` | + +**The override is expected to merge — do not treat it as something to strip before +the consumer's PR lands.** This package is not released on every change, so gating +each consumer PR on a core release would stall them. The override lives on the +consumer's default branch and comes off only when that SDK is released: at that +point this package is released too, and the dependency goes back to a published +version constraint. + +**Always pin a commit SHA, never a branch name.** A branch ref resolves to +whatever the tip happens to be at `pub get` time and writes that SHA into +`pubspec.lock`, so the locked version drifts unpredictably as the branch moves and +two checkouts of the same consumer commit can resolve differently. A SHA is stable +and survives the branch being rebased or deleted. + +**Repoint only the package your change touches.** `stream_core` and +`stream_core_flutter` come from this repo but are separate git dependencies with +separate pins, and a consumer is often pinned to an older core than `main`. +Dragging `stream_core` forward for a change that only touches +`stream_core_flutter` pulls in unrelated churn — the error layer, for one. + +Distinct from the sibling-path override this repo's CI guidance warns about — a git +ref is reproducible off-machine, where a `path:` to a sibling checkout is not. Do +not reach for a path override to make a cross-repo change build. + +## Naming + +Upstream's generator flattens `group/subgroup/name` into camelCase. The +`StreamColorScheme` field then **drops the `core` / `utility` group segment**, +because Flutter has no such layer: + +``` +upstream token vendored constant colorScheme field +border/utility/warning borderUtilityWarning borderWarning +background/core/on-accent backgroundCoreOnAccent backgroundOnAccent +``` + +Some fields also shorten further where the upstream suffix carried no meaning +here (`background/core/surface-default` → `backgroundSurface`). Match the +existing neighbors in `stream_color_scheme.dart` rather than deriving the name +mechanically. + +## Wiring a field default + +Look at what the token aliases upstream. The answer decides whether a vendored +constant is needed at all — and getting it wrong is how a custom brand color +silently stops applying: + +- **`{chrome.*}` or `{brand.*}`** → resolve through the generated swatch: + `chrome.shade100`, `brand.shade500`, `chrome[0] ?? StreamColors.white`. + Never the baked hex. These scales are regenerated from a seed color, so a + hard-coded value ignores `StreamColorScheme.light(brand: ...)`. +- **another semantic** → alias the field: `borderWarning ??= accentWarning`. + Check both modes before generalising — `textLink` aliases `accentPrimary` in + light but resolves `brand.shade600` in dark, so the two factories can differ. +- **a raw hex, or a primitive outside those two scales** (a `yellow`, a + transparent black) → add a constant to both vendored files and read it: + `light_tokens.StreamTokens.backgroundCoreHighlight`. + +Only the third case earns a vendored constant. Add the same name to **both** +`light/` and `dark/`, keeping the file's existing ordering. + +### Writing the dartdoc + +The field's dartdoc comes from the token's own `$description`, which upstream +carries alongside `$value` in the semantics JSON: + +```bash +python3 -c "import json;d=json.load(open('tokens/core/semantics/light.json'));\ +print(d['border']['core']['on-elevation'])" +``` + +Quote it rather than inventing prose — it is the designer's statement of intent, +and matching wording is what lets the next person recognise the field as that +token. Swap upstream's token paths for `[fieldName]` references. + +**Read the claim against the resolved light and dark values first**, and note +what it is *not* saying. A description usually tracks the token's own light→dark +progression, not a comparison with the sibling it names: `border/core/on-elevation` +"steps up in dark mode" because it goes `{chrome.150}` → `{chrome.300}` as the +elevated surface lightens — while in that same mode it lands on `{chrome.300}`, +exactly `border/core/on-surface`, the token it tells you to use instead. Both +halves are true and they are easy to read as contradictory. Resolve the aliases +before deciding a description is wrong, and add the nuance the description omits +rather than replacing wording that is accurate. + +## Root semantics only + +Upstream also publishes derived semantics — `badge/*`, `button/*`, `avatar/*`. +**Do not vendor or map those.** They are re-derived in Dart from the root +semantics, at the component's defaults: + +```dart +// lib/src/components/badge/stream_badge_notification.dart +Color get errorBackgroundColor => _colorScheme.accentError; +``` + +So upstream `badge/bg-error` has no counterpart here, by design — a component +theme reads `colorScheme.accentError` instead. This keeps the token surface small +and keeps every component overridable through one seedable color scheme. + +**Add a constant only when a field will read it**, and add it to both `light/` and +`dark/`. An unread constant is not harmless: it reads as an invitation to paint a +component from a token, which is the one thing a component must not do — a +constant bypasses the seedable color scheme, so a custom brand or chrome stops +applying. + +Dimensions and type are a separate matter. Their upstream values *are* mirrored +here, but in `StreamSpacing`, `StreamRadius` and `StreamTokensTypography`, which +hard-code them rather than reading a token constant. So a spacing or radius change +is applied to those classes by hand — and taking the type values from the wrong +flavor is a live mistake, since only `web` carries the `Geist` family. + +That hand-copying is the same hazard as baking a hex where a swatch belongs, one +layer up: nothing ties the class to the constant it mirrors, so the two drift +silently. The fix is to have those classes read the constants, not to add more +unread ones. + +## After editing + +```bash +melos run analyze +melos run test:flutter +``` + +Regenerate only if you touched a `.theme.dart` annotation (adding a color-scheme +field does): `melos run generate:flutter`. A new field also needs wiring into the +gallery's Theme Studio (`apps/design_system_gallery/lib/config/theme_configuration.dart` +and `widgets/theme_studio/theme_customization_panel.dart`) — follow the +surrounding fields. + +### Goldens + +Goldens only move if a component actually paints with the changed color. The +palette golden (`test/theme/goldens/ci/stream_theme_color_generation.png`) covers +seed-generated brand/chrome ladders, not semantic accents, so a semantic value +change usually leaves it alone. + +**Expect every macOS-variant golden to fail locally, change or no change.** Only +the `ci/` images are committed — there are no `macos/` ones in the repo at all — +so on a Mac those tests have nothing to compare against. That is the noise you +will see, not evidence your change broke something, and the local failure count +tends to match the number of committed goldens exactly. When in doubt, prove it: +revert your edit, re-run the same test, and watch it fail identically. + +**Regenerate the `ci/` images on CI, not on your machine.** The workflow runs on +ubuntu, which is what makes them match CI in the first place: + +```bash +gh workflow run update_goldens.yml --ref +gh run list --workflow=update_goldens.yml --limit 1 # then: gh run watch +git pull # picks up "chore: Update Goldens" +``` + +It bootstraps the workspace, runs `melos run update:goldens`, and commits every +changed PNG back to the branch you dispatched, as the Stream SDK Bot. Dispatching +it is also the cheapest way to *see* what a color change did — the bot's diff is a +before/after of every affected component, which is worth attaching to the PR when +the change is a value move rather than a new token. + +Two caveats. The regeneration step is `continue-on-error`, so a green run does not +mean the goldens rebuilt cleanly — read the bot's commit and check the images moved +the way you expected, and that nothing you did not touch moved with them. And it +commits to whatever ref you dispatch, so pass your own branch. + +**Run it on the consuming SDKs too.** stream-chat-flutter and +stream-video-flutter each have the same `update_goldens.yml`, and a value change +moves their goldens as surely as it moves this package's — a component they own +paints with the field. Their CI fails on the `variant: CI` goldens, which is the +signal, and dispatching the workflow on the consumer's branch is the fix: + +```bash +gh workflow run update_goldens.yml --repo GetStream/stream-video-flutter --ref +``` + +Do it once the consumer's override points at your core commit, so the images it +renders are the ones the change actually produces. Expect this on any value +change: the call control badge going red to yellow moved +`call_control_button` and `call_feature_button`, neither of which is in this repo. + +## Changelog + +`StreamColorScheme` is exported from `core.dart`, so **every one of its fields is +public API**. A token whose *value* changes is a visual change for anyone reading +the field instead of overriding it, and needs a `### 🔄 Changed` CHANGELOG entry +under `## Upcoming` even though no signature moved. + +**A line or two.** Name the old and new resolved values, since that is what a +consumer diffing screenshots needs, and say if the new value constrains what can +sit on it. Everything else — why upstream changed it, contrast ratios, which +component made it visible — belongs in the PR. + +A field that is removed or renamed follows the deprecation policy in +`STYLE_GUIDE.md` (annotate, `### 🛑 Breaking / Removals`, and a `fix_data.yaml` +transform). + +## Contrast is not automatic + +Token aliases carry no contrast guarantee, and upstream can move a value across +the light/dark divide — a warning color going from orange to a pale yellow flips +which text color is legible on it. When adopting a changed fill, check what text +or icon token is painted on top of it, and say so if the pairing no longer works. +`accent/*` values have no `on-*` counterpart in the core namespace, so this has to +be reasoned about rather than looked up. diff --git a/.claude/skills/update-design-tokens/references/derived-token-map.md b/.claude/skills/update-design-tokens/references/derived-token-map.md new file mode 100644 index 00000000..b2faa360 --- /dev/null +++ b/.claude/skills/update-design-tokens/references/derived-token-map.md @@ -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 /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 /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. diff --git a/.claude/skills/update-design-tokens/scripts/flatten_tokens.py b/.claude/skills/update-design-tokens/scripts/flatten_tokens.py new file mode 100755 index 00000000..ab3c9a83 --- /dev/null +++ b/.claude/skills/update-design-tokens/scripts/flatten_tokens.py @@ -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 :` 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()) diff --git a/.claude/skills/update-design-tokens/scripts/map_token_usage.py b/.claude/skills/update-design-tokens/scripts/map_token_usage.py new file mode 100755 index 00000000..b3fa6d02 --- /dev/null +++ b/.claude/skills/update-design-tokens/scripts/map_token_usage.py @@ -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 # whole scheme + map_token_usage.py 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.*?):(?P.*?):(?P\d+):.*?_?colorScheme\.(?P[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()) diff --git a/.claude/skills/update-icons/SKILL.md b/.claude/skills/update-icons/SKILL.md new file mode 100644 index 00000000..f9162732 --- /dev/null +++ b/.claude/skills/update-icons/SKILL.md @@ -0,0 +1,203 @@ +--- +name: update-icons +description: > + Add, update, retire or debug icons in stream_core_flutter — pulling SVGs from GetStream/design-system-tokens + into `assets_source/icons/`, regenerating the icon font and `StreamIcons` via `melos run generate:icons`, + handling RTL mirroring, deprecations and code points, and updating the multicolor file-type SVGs. Use whenever + an icon needs adding or replacing, whenever `melos run generate:icons` fails or warns, whenever an icon renders + as the wrong glyph or a box, whenever an icon should mirror in RTL, and whenever someone asks whether an icon + exists upstream. Also use before deleting any SVG from `assets_source/icons/` — a bare delete silently + repoints every icon after it. +allowed-tools: + - Bash + - Read + - Edit + - Write +--- + +# Updating icons + +Icons come from +[GetStream/design-system-tokens](https://github.com/GetStream/design-system-tokens/tree/main/assets/icons), +the same repo the color tokens come from. `melos run generate:icons` turns +`assets_source/icons/` into a font (`lib/fonts/stream_icons_font.otf`) plus the +`StreamIcons` / `StreamIconData` classes. + +The single fact that governs everything here: **glyph code points are +append-only**. The font ships as a binary asset, so a shifted code point silently +repoints every icon after it in any app that has not rebuilt. That is why +deleting an SVG needs a deprecation entry, why names must be unique, and why +`assets_source/icon_log.g.txt` is never hand-edited. + +## Finding the icon upstream + +Upstream groups by *product*, then style, then size: + +``` +assets/icons/{core,chat,video}/flat/{12,16,20,32}/ +assets/icons/{core,chat,video}/line/ +assets/icons/chat/filetype/ +``` + +Products follow **component ownership, not subject matter**, so the product tells +you nothing about what an icon depicts: `camera` is a **chat** icon (the +composer's camera button) while `camera-flip-fill` is **video**. Search all three +before concluding an icon does not exist: + +```bash +# from a design-system-tokens checkout (normally a sibling of this repo) +find assets/icons -name "*camera*" -path "*/flat/*" +``` + +Only `flat/` (solid filled paths) goes into the font. `line/` is a stroke-based +outline set covering nearly the same names and is **deliberately unused** — the +two styles do not read as one set, so do not mix them in. `chat/filetype/` is +multicolor and takes a completely separate path (see the end of this file). + +## Copying it in + +This repo carries a single size-keyed tree, no product split: +`assets_source/icons/{16,20,32}/`. The generator emits one font and one +`StreamIcons` class, so there is nothing for a product segment to key off — which +also means a `core` name and a `video` name collide here exactly as two sizes +would. + +Naming on the way in: + +- **`20/` is the default** and holds essentially everything. Strip the upstream + size suffix: `core/flat/20/account-20.svg` → `20/account.svg`. +- **Off-default sizes need a name that cannot collide with their `20/` sibling.** + `32/` established `-large`: `chat/flat/32/camera-32.svg` → `32/camera-large.svg`. + `16/` has no convention yet — its single icon (`xmark-small`) was copied bare + back when nothing in `20/` shared the name. + +Copy in **only the sizes a design actually calls for**. Upstream ships everything +in all four sizes; do not bulk-copy a folder to "have it available" — every name +burns a permanent code point. + +**Names must be unique across sizes and across products.** The generator fails on +a duplicate rather than letting directory order pick a winner. This is a live +trap: upstream ships `xmark-small` in both `core/flat/16` and `core/flat/20` +**with different artwork**. We ship the 16px one. Adopting the 20px variant too +means giving it a distinct name and a new code point — never silently swapping +the artwork behind the existing name, which would repaint the glyph everywhere it +is already used. + +## RTL mirroring + +If the icon should mirror in RTL layouts, add its base name to `_rtlIcons` in +`scripts/generate_icons.dart` so the generator emits +`matchTextDirection: true`. + +This covers the obvious directional glyphs (arrows, chevrons, `reply`, `send`, +`sidebar`) but also icons whose *metaphor* is directional and reads wrong +unmirrored (`audio`, `megaphone`, `search`, `video`). Skip anything symmetric or +brand-owned (a bell, a heart, a logo). When unsure, look at what comparable icons +in the list already do. + +## Regenerating + +```bash +melos run generate:icons +``` + +Two expected messages, both harmless: + +- a warning about **mixed viewBox sizes** — that is the `16/` and `32/` folders + doing their job. +- nothing about re-centering: `stream_icons.yaml` sets `normalize: false` on + purpose. The default re-centers each glyph by its bounding box, which undoes + deliberate optical offsets (the `play-fill` triangle is nudged right inside its + viewBox so it reads as centered to the eye). + +Commit the SVG sources, the regenerated font, the Dart output **and** the updated +`icon_log.g.txt` together — they have to stay in sync. Never hand-edit the +generated `stream_icons.dart` / `stream_icons.g.dart`, the font, or the log. + +The log records the date each icon was first seen and the generator orders glyphs +by it, which is what preserves code points across runs. Reordering it reshuffles +the font. + +## Broken or odd-looking source SVGs + +Fix the artwork **upstream**, with a PR to design-system-tokens — never add a +repair step to the generator. The generator's job is to validate and fail loudly, +not to paper over source defects; a workaround here means every other platform +keeps consuming the broken file. + +## Retiring an icon + +A bare delete is what shifts code points, so deleting an SVG requires a line in +`assets_source/deprecated.txt`: + +``` +deprecated;replacement;included +more;more-horizontal;true +``` + +- **`replacement`** — the icon whose SVG draws the glyph. A deprecated icon always + keeps its glyph and therefore its code point; pointing at a replacement is what + lets you delete the retired SVG and still render something sensible. A + self-reference (`more;more;true`) keeps the original artwork while retiring just + the name. +- **`included`** — whether the name survives in the generated Dart. `true` emits + `StreamIcons.more` and `StreamIconData.more` annotated with + `@Deprecated('Use moreHorizontal instead.')`; `false` drops both while the glyph + stays in the font. + +Entries are effectively permanent — removing one releases its glyph and shifts +every later code point. The generator fails if a replacement has no SVG file, or +if a deprecated name has neither an SVG nor a logged code point (which means a +typo). + +Deprecating also means a `dart fix` migration in +`packages/stream_core_flutter/lib/fix_data.yaml`, plus the usual +`### 🛑 Breaking / Removals` CHANGELOG entry. Two things specific to icons: + +- `element.uris` must list the declaring library **and** every barrel it is + exported from (`core.dart`, `chat.dart`, `stream_core_flutter.dart`). A + transform whose uris miss the barrel the consumer actually imported never + fires. +- `StreamIcons.copyWith(more: ...)` needs its own transform with + `inMixin: "_$StreamIcons"`. `copyWith` is generated onto that private mixin, + which does not inherit the field's `@Deprecated`, so the call raises no warning + while the field exists — the transform only fires once the field is deleted and + the call becomes an `undefined_named_parameter` error. + +**Verify a transform by running it, not by reading the YAML.** Write a throwaway +file exercising each call shape (bare constant, instance field, constructor +argument, `copyWith`), run `dart fix --dry-run`, then re-run with the transform +removed to confirm the fix disappears — the analyzer offers generic "did you +mean" fixes that are easy to mistake for your own. + +## File-type icons + +`chat/filetype/` follows a completely separate path and **none of the rules above +apply** — no generator, no font, no code points. The SVGs are copied into +`packages/stream_core_flutter/assets/file_type/`, declared as assets in +`pubspec.yaml`, and resolved by path at runtime by `StreamFileTypeIcon`. Updating +them is copy-and-rename. + +Upstream names them by t-shirt size; this repo renames each to its **pixel +height**, because the widget interpolates that height straight into the asset path +(`assets/file_type/filetype-pdf-${size.height.toInt()}.svg`): + +| upstream | here | dimensions | +| --- | --- | --- | +| `-sm` | `-24` | 19×24 | +| `-md` | `-32` | 26×32 | +| `-lg` | `-40` | 32×40 | +| `-xl` | `-48` | 40×48 | + +Sizes are uniform across kinds, so `filetype-pdf-lg.svg` → +`filetype-pdf-40.svg`, `filetype-audio-lg.svg` → `filetype-audio-40.svg`. The +glyphs are portrait — the width is *not* what the name encodes. + +All nine kinds (`audio`, `code`, `compression`, `other`, `pdf`, `presentation`, +`spreadsheet`, `text`, `video`) must ship in all four sizes: a missing file is a +runtime asset failure, not a compile error, so keep the set complete. + +When diffing these against upstream, **expect every file to differ even when +nothing changed** — each Figma re-export bumps the `clip0_…` element ids and +jitters path coordinates in the 4th decimal. Compare the rendered artwork, not +the bytes, and do not re-copy all 36 files just to absorb that noise. diff --git a/CLAUDE.md b/CLAUDE.md index 798a3150..5df6310d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,78 +44,20 @@ melos run gen-l10n # regenerate localizations ### Icons -Source SVGs in `packages/stream_core_flutter/assets_source/icons/` come from the [design-system-tokens](https://github.com/GetStream/design-system-tokens/tree/main/assets/icons) repository. When adding or updating icons, pull the latest SVGs from that repo first, then run `melos run generate:icons` to regenerate the icon font and Dart classes. +`StreamIcons` is generated: an icon font (`lib/fonts/stream_icons_font.otf`) plus +Dart constants, built by `melos run generate:icons` from the source SVGs in +`packages/stream_core_flutter/assets_source/icons/`. Those SVGs are copied from the +[design-system-tokens](https://github.com/GetStream/design-system-tokens/tree/main/assets/icons) +repo. Multicolour file-type icons are not part of the font — they ship as runtime +assets in `assets/file_type/` and are resolved by path by `StreamFileTypeIcon`. -Upstream groups its icons by *product* first, then style, then size: - -``` -assets/icons/{core,chat,video}/flat/{12,16,20,32}/ -assets/icons/{core,chat,video}/line/ -assets/icons/chat/filetype/ -``` - -- **Products** — `core/` (~100 icons), `chat/` (~19) and `video/` (~30) mirror the token structure so each SDK ships only the icons it needs. Classification follows *component ownership*, not the Figma category, so a product does not tell you what an icon depicts and vice versa: `camera` is a **chat** icon (the composer's camera button) while `camera-flip-fill` is **video**. Search all three products before concluding an icon does not exist upstream. -- `flat/{12,16,20,32}/` — solid filled paths. **This is the only style that goes into the icon font.** -- `line/` — stroke-based outline variants of (almost) the same names, one unsized folder per product. Deliberately unused; do not mix them in, the two styles do not read as one set. -- `chat/filetype/` — multicolor file-type icons at four sizes. Shipped by the SDK, but **not through the icon font** — they are multicolor, so they could never be font glyphs. See [File-type icons](#file-type-icons) below. - -**This repo does not carry the product split.** `assets_source/icons/` is a single size-keyed tree fed from all three products at once (today 101 core, 23 chat, 28 video), because the generator emits one font and one `StreamIcons` class — there is nothing for a product segment to key off. So a `core` name and a `video` name collide here exactly as two sizes would (see below). Upstream has no cross-product duplicate today; if one ever appears, one of the two has to be renamed on the way in. - -Upstream ships every icon in all four `flat/` sizes. This repo does **not** mirror that: `20/` is the default and holds essentially everything, and a smaller or larger variant is copied in only when a design actually calls for one — today that is `16/xmark-small.svg` and seven `-large` icons in `32/`. Do not bulk-copy a size folder to "have it available"; every name you add burns a permanent code point (see below). - -Upstream names carry a size suffix that this repo strips or rewrites: everything in `20/` keeps its bare name (`core/flat/20/account-20.svg` → `20/account.svg`), while an off-default size must be given a name that cannot collide with its `20/` sibling. `32/` established `-large` for this (`chat/flat/32/camera-32.svg` → `32/camera-large.svg`). `16/` has no such convention yet — its single icon was copied over bare, back when no `20/` icon shared the name. - -**Names must be unique across the size folders — and across the three products.** Glyphs are keyed by bare filename, so copying a whole upstream size folder is how you accidentally end up with e.g. `16/xmark-small.svg` and `20/xmark-small.svg` competing for one glyph. The generator fails on a duplicate rather than letting directory-listing order pick a winner. - -This is a live trap, not a hypothetical: upstream now ships `xmark-small` in both `core/flat/16` and `core/flat/20`, with **different artwork**. We ship the 16px one as `16/xmark-small.svg`. Adopting the 20px variant too would mean giving it a distinct name and a new code point — never silently swapping the artwork behind the existing name, which would repaint the glyph everywhere it is already used. - -**Code points are append-only.** `assets_source/icon_log.g.txt` records the date each icon was first seen, and the generator orders glyphs by that date so every icon keeps its code point across runs. The font ships as `lib/fonts/stream_icons_font.otf`, so a shifted code point silently repoints every icon after it in any app that has not rebuilt. Never reorder or hand-edit the log. - -**Deleting an icon therefore requires a deprecation entry** in `assets_source/deprecated.txt` — one `deprecated;replacement;included` line per icon: - -``` -more;more-horizontal;true -``` - -- `replacement` — the icon whose SVG draws the glyph. A deprecated icon always keeps its glyph, and with it its code point; pointing at a replacement is what lets you delete the retired SVG and still render something sensible. Naming itself (`more;more;true`) keeps the original artwork while retiring the name. -- `included` — whether the name survives in the generated Dart. `true` emits `StreamIcons.more` and `StreamIconData.more` annotated with `@Deprecated('Use moreHorizontal instead.')`; `false` drops both while the glyph stays in the font. - -Entries are effectively permanent — removing one releases its glyph and shifts every later code point. The generator fails if a replacement has no SVG file, or if a deprecated name has neither an SVG file nor a logged code point (which means a typo). - -Deprecating an icon also means adding transforms to `lib/fix_data.yaml`; see [Deprecations](#deprecations). - -#### File-type icons - -`chat/filetype/` follows a completely separate path from everything above. The SVGs are -copied into `packages/stream_core_flutter/assets/file_type/` and shipped as runtime -assets (declared in `pubspec.yaml`), then resolved by path at runtime from -`StreamFileTypeIcon`. No generator, no font, no code points — so none of the -append-only rules above apply, and updating them is just copy-and-rename. - -Upstream names them by t-shirt size; this repo renames each to its **pixel height**, -because `StreamFileTypeIcon` interpolates that height straight into the asset path -(`assets/file_type/filetype-pdf-${size.height.toInt()}.svg`): - -| upstream | here | dimensions | -| --- | --- | --- | -| `-sm` | `-24` | 19×24 | -| `-md` | `-32` | 26×32 | -| `-lg` | `-40` | 32×40 | -| `-xl` | `-48` | 40×48 | - -The sizes are uniform across all kinds, so `filetype-pdf-lg.svg` → `filetype-pdf-40.svg`, -`filetype-audio-lg.svg` → `filetype-audio-40.svg`, and so on. Note the glyphs are -portrait, not square — the width is *not* what the name encodes. - -All nine kinds (`audio`, `code`, `compression`, `other`, `pdf`, `presentation`, -`spreadsheet`, `text`, `video`) ship in all four sizes; a missing file is a runtime -asset failure, not a compile error, so keep the set complete. - -When diffing these against upstream, expect **every file to differ even when nothing -changed**: each Figma re-export bumps the `clip0_…` element ids and jitters path -coordinates in the 4th decimal. Compare the rendered artwork, not the bytes, and do -not re-copy all 36 files just to absorb that noise. +Glyph code points are append-only and recorded in `assets_source/icon_log.g.txt`, +so adding, renaming or retiring an icon has consequences beyond the file you touch. +Never hand-edit the generated Dart, the font, or the log. +**Use the `update-icons` skill** for any icon work — it covers finding an icon +upstream, the naming and size conventions, RTL mirroring, deprecations, and the +file-type assets. ## Design @@ -163,10 +105,34 @@ Uses `theme_extensions_builder` to generate Material 3 theme extensions. The hie 1. **Primitives** — raw design tokens: colors, typography, spacing, radius, icons 2. **Semantics** — semantic mappings (e.g., `primaryColor`, `bodyText`) 3. **Component themes** — per-widget theme classes (50+ components), defined in `theme/components/` -4. **Tokens** — light/dark concrete values in `theme/primitives/internal/tokens/` (figma-generated, not part of the public API) +4. **Tokens** — light/dark concrete values in `theme/primitives/internal/tokens/`, copied by hand from the design-token repo and not part of the public API (see [Design tokens](#design-tokens)) Generated files have `.g.theme.dart` extension. After modifying `.theme.dart` files, run `melos run generate:flutter`. +### Design tokens + +Colors originate in [design-system-tokens](https://github.com/GetStream/design-system-tokens), +the same repo the icons come from. `theme/primitives/internal/tokens/{light,dark}/stream_tokens.dart` +holds the vendored values; it is maintained by hand, is not part of the public API, +and only `stream_colors.dart` and `stream_color_scheme.dart` read it. + +Only the **root semantics** are mapped to a `StreamColorScheme` field. Upstream's +derived tokens (`badge/*`, `button/*`, `avatar/*`) get no field — components +re-derive them from `colorScheme.*` in their own defaults. Typography, spacing and +radius do come from upstream, but `StreamTokensTypography`, `StreamSpacing` and +`StreamRadius` hard-code the values rather than reading a token constant, so a +dimension change is applied to those classes by hand. `StreamColorScheme` is +exported from `core.dart`, so every field on it is public API. + +A field's dartdoc comes from the token's own `$description` in the upstream JSON — +quote it rather than inventing prose, but resolve the aliases first, since a +description tracks the token's own light/dark progression and not a comparison +with the sibling it names. + +**Use the `update-design-tokens` skill** when syncing a token change or assessing +an upstream PR — it covers the naming rules, how a field default should resolve, +and how to read a change without drowning in the generator's re-sort noise. + ### Component Structure (`stream_core_flutter/lib/src/components/`) Components are organized by category: `avatar/`, `buttons/`, `badge/`, `list/`, `message_composer/`, `emoji/`, `context_menu/`, `controls/`, `common/`, `accessories/`. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 23d2f976..e7222878 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1251,8 +1251,12 @@ Themes are generated via `theme_extensions_builder`. **Never hand-roll `copyWith The hierarchy is layered: **primitives** (`theme/primitives/`, raw tokens) → **semantics** (`theme/semantics/`, semantic mappings) → **component themes** -(`theme/components/`, per-widget classes, 50+) → **tokens** (figma-generated, -internal). +(`theme/components/`, per-widget classes, 50+) → **tokens** (vendored from the +design-token repo, internal). + +Only root semantic tokens are vendored; a component's derived values are resolved +from `colorScheme.*` in its own defaults, never from a token constant. Use the +`update-design-tokens` skill when syncing a token change. Adding a new component theme: @@ -1356,28 +1360,19 @@ shape. ### Icons -Source SVGs live in `packages/stream_core_flutter/assets_source/icons/`. They come -from the [design-system-tokens](https://github.com/GetStream/design-system-tokens/tree/main/assets/icons) -repository. - -When adding or updating icons: - -1. Pull the latest SVGs from `design-system-tokens/assets/icons/` into - `assets_source/icons/`. -2. If the icon should mirror in RTL layouts, add its base name to the - `_rtlIcons` list in `scripts/generate_icons.dart` so the generator emits - `matchTextDirection: true` for it. This covers obvious directional glyphs - (arrows, chevrons, `reply`, `send`, `sidebar`) but also icons with - directional metaphors that read wrong when unmirrored (`audio`, `megaphone`, - `search`, `video`). Skip icons that are symmetric or shouldn't mirror - (a bell, a heart, brand logos). If in doubt, look at what comparable icons - already do in `_rtlIcons`. -3. Run `melos run generate:icons` to regenerate the icon font and the - `StreamIcons` class. -4. Commit both the SVG sources and the regenerated font + Dart output together — - they must stay in sync. - -Do not edit the generated `StreamIcons.dart` or the icon font by hand. +Icons are generated, not hand-written: `melos run generate:icons` builds the icon +font and the `StreamIcons` / `StreamIconData` classes from the source SVGs in +`packages/stream_core_flutter/assets_source/icons/`, which are copied from the +[design-system-tokens](https://github.com/GetStream/design-system-tokens/tree/main/assets/icons) +repo. + +Do not edit the generated `stream_icons.dart`, `stream_icons.g.dart`, the font, or +`assets_source/icon_log.g.txt` by hand. Commit SVG sources and regenerated output +together — they must stay in sync. + +Adding, renaming or retiring an icon affects more than the file you touch, because +glyph code points are append-only. **Use the `update-icons` skill** for any icon +work. ## Commits, PRs, and changelogs @@ -1396,8 +1391,10 @@ PR titles follow [Conventional Commits](https://www.conventionalcommits.org/): ### Changelog policy Every PR that changes package behavior updates the affected package's -`CHANGELOG.md` under the `Upcoming` heading. Entries live under one of these -sub-headings: +`CHANGELOG.md` under the `Upcoming` heading. **Keep entries to a line or two** — +what changed and, for a value change, the old and new values. Rationale, contrast +figures and migration detail belong in the PR, not here. Entries live under one of +these sub-headings: ```markdown ## Upcoming @@ -1522,8 +1519,10 @@ only the ones this PR bumped. So: - **Melos commands**: `melos.yaml` — every task the repo runs. - **Design source**: the Chat SDK Design System Figma project — accessed via the Figma MCP when implementing UI. -- **Design tokens**: the [design-system-tokens](https://github.com/GetStream/design-system-tokens) - sibling repo (mirrored internally in the theme primitives). +- **Design tokens and icons**: the [design-system-tokens](https://github.com/GetStream/design-system-tokens) + sibling repo (colours mirrored into the theme primitives, SVGs into + `assets_source/icons/`). The `update-design-tokens` and `update-icons` skills + cover syncing from it. When something isn't covered here and isn't obvious from surrounding code, prefer to ask in the PR rather than guessing. If a convention isn't documented, propose diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index bc8f4899..f3f9335c 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -10,6 +10,7 @@ // ************************************************************************** // ignore_for_file: no_leading_underscores_for_library_prefixes + import 'package:design_system_gallery/components/accessories/stream_audio_waveform.dart' as _design_system_gallery_components_accessories_stream_audio_waveform; import 'package:design_system_gallery/components/accessories/stream_emoji.dart' diff --git a/apps/design_system_gallery/lib/config/theme_configuration.dart b/apps/design_system_gallery/lib/config/theme_configuration.dart index 3e162514..cb4bd26d 100644 --- a/apps/design_system_gallery/lib/config/theme_configuration.dart +++ b/apps/design_system_gallery/lib/config/theme_configuration.dart @@ -52,6 +52,7 @@ class ThemeConfiguration extends ChangeNotifier { Color? _backgroundSurfaceStrong; Color? _backgroundSurfaceCard; Color? _backgroundOnAccent; + Color? _backgroundOnElevation; Color? _backgroundHighlight; Color? _backgroundScrim; Color? _backgroundOverlayLight; @@ -74,6 +75,7 @@ class ThemeConfiguration extends ChangeNotifier { Color? _borderStrong; Color? _borderOnAccent; Color? _borderOnSurface; + Color? _borderOnElevation; Color? _borderOpacitySubtle; Color? _borderOpacityStrong; @@ -139,6 +141,7 @@ class ThemeConfiguration extends ChangeNotifier { Color get backgroundSurfaceStrong => _backgroundSurfaceStrong ?? _themeData.colorScheme.backgroundSurfaceStrong; Color get backgroundSurfaceCard => _backgroundSurfaceCard ?? _themeData.colorScheme.backgroundSurfaceCard; Color get backgroundOnAccent => _backgroundOnAccent ?? _themeData.colorScheme.backgroundOnAccent; + Color get backgroundOnElevation => _backgroundOnElevation ?? _themeData.colorScheme.backgroundOnElevation; Color get backgroundHighlight => _backgroundHighlight ?? _themeData.colorScheme.backgroundHighlight; Color get backgroundScrim => _backgroundScrim ?? _themeData.colorScheme.backgroundScrim; Color get backgroundOverlayLight => _backgroundOverlayLight ?? _themeData.colorScheme.backgroundOverlayLight; @@ -161,6 +164,7 @@ class ThemeConfiguration extends ChangeNotifier { Color get borderStrong => _borderStrong ?? _themeData.colorScheme.borderStrong; Color get borderOnAccent => _borderOnAccent ?? _themeData.colorScheme.borderOnAccent; Color get borderOnSurface => _borderOnSurface ?? _themeData.colorScheme.borderOnSurface; + Color get borderOnElevation => _borderOnElevation ?? _themeData.colorScheme.borderOnElevation; Color get borderOpacitySubtle => _borderOpacitySubtle ?? _themeData.colorScheme.borderOpacitySubtle; Color get borderOpacityStrong => _borderOpacityStrong ?? _themeData.colorScheme.borderOpacityStrong; @@ -231,6 +235,7 @@ class ThemeConfiguration extends ChangeNotifier { void setBackgroundSurfaceStrong(Color color) => _update(() => _backgroundSurfaceStrong = color); void setBackgroundSurfaceCard(Color color) => _update(() => _backgroundSurfaceCard = color); void setBackgroundOnAccent(Color color) => _update(() => _backgroundOnAccent = color); + void setBackgroundOnElevation(Color color) => _update(() => _backgroundOnElevation = color); void setBackgroundHighlight(Color color) => _update(() => _backgroundHighlight = color); void setBackgroundScrim(Color color) => _update(() => _backgroundScrim = color); void setBackgroundOverlayLight(Color color) => _update(() => _backgroundOverlayLight = color); @@ -251,6 +256,7 @@ class ThemeConfiguration extends ChangeNotifier { void setBorderStrong(Color color) => _update(() => _borderStrong = color); void setBorderOnAccent(Color color) => _update(() => _borderOnAccent = color); void setBorderOnSurface(Color color) => _update(() => _borderOnSurface = color); + void setBorderOnElevation(Color color) => _update(() => _borderOnElevation = color); void setBorderOpacitySubtle(Color color) => _update(() => _borderOpacitySubtle = color); void setBorderOpacityStrong(Color color) => _update(() => _borderOpacityStrong = color); @@ -331,6 +337,7 @@ class ThemeConfiguration extends ChangeNotifier { bool get backgroundSurfaceStrongIsCustom => _backgroundSurfaceStrong != null; bool get backgroundSurfaceCardIsCustom => _backgroundSurfaceCard != null; bool get backgroundOnAccentIsCustom => _backgroundOnAccent != null; + bool get backgroundOnElevationIsCustom => _backgroundOnElevation != null; bool get backgroundHighlightIsCustom => _backgroundHighlight != null; bool get backgroundScrimIsCustom => _backgroundScrim != null; bool get backgroundOverlayLightIsCustom => _backgroundOverlayLight != null; @@ -351,6 +358,7 @@ class ThemeConfiguration extends ChangeNotifier { bool get borderStrongIsCustom => _borderStrong != null; bool get borderOnAccentIsCustom => _borderOnAccent != null; bool get borderOnSurfaceIsCustom => _borderOnSurface != null; + bool get borderOnElevationIsCustom => _borderOnElevation != null; bool get borderOpacitySubtleIsCustom => _borderOpacitySubtle != null; bool get borderOpacityStrongIsCustom => _borderOpacityStrong != null; @@ -402,6 +410,7 @@ class ThemeConfiguration extends ChangeNotifier { void resetBackgroundSurfaceStrong() => _update(() => _backgroundSurfaceStrong = null); void resetBackgroundSurfaceCard() => _update(() => _backgroundSurfaceCard = null); void resetBackgroundOnAccent() => _update(() => _backgroundOnAccent = null); + void resetBackgroundOnElevation() => _update(() => _backgroundOnElevation = null); void resetBackgroundHighlight() => _update(() => _backgroundHighlight = null); void resetBackgroundScrim() => _update(() => _backgroundScrim = null); void resetBackgroundOverlayLight() => _update(() => _backgroundOverlayLight = null); @@ -422,6 +431,7 @@ class ThemeConfiguration extends ChangeNotifier { void resetBorderStrong() => _update(() => _borderStrong = null); void resetBorderOnAccent() => _update(() => _borderOnAccent = null); void resetBorderOnSurface() => _update(() => _borderOnSurface = null); + void resetBorderOnElevation() => _update(() => _borderOnElevation = null); void resetBorderOpacitySubtle() => _update(() => _borderOpacitySubtle = null); void resetBorderOpacityStrong() => _update(() => _borderOpacityStrong = null); @@ -475,6 +485,7 @@ class ThemeConfiguration extends ChangeNotifier { _backgroundSurfaceStrong = null; _backgroundSurfaceCard = null; _backgroundOnAccent = null; + _backgroundOnElevation = null; _backgroundHighlight = null; _backgroundScrim = null; _backgroundOverlayLight = null; @@ -494,6 +505,7 @@ class ThemeConfiguration extends ChangeNotifier { _borderStrong = null; _borderOnAccent = null; _borderOnSurface = null; + _borderOnElevation = null; _borderOpacitySubtle = null; _borderOpacityStrong = null; // Border Utility @@ -563,6 +575,7 @@ class ThemeConfiguration extends ChangeNotifier { backgroundSurfaceStrong: _backgroundSurfaceStrong, backgroundSurfaceCard: _backgroundSurfaceCard, backgroundOnAccent: _backgroundOnAccent, + backgroundOnElevation: _backgroundOnElevation, backgroundHighlight: _backgroundHighlight, backgroundScrim: _backgroundScrim, backgroundOverlayLight: _backgroundOverlayLight, @@ -582,6 +595,7 @@ class ThemeConfiguration extends ChangeNotifier { borderStrong: _borderStrong, borderOnAccent: _borderOnAccent, borderOnSurface: _borderOnSurface, + borderOnElevation: _borderOnElevation, borderOpacitySubtle: _borderOpacitySubtle, borderOpacityStrong: _borderOpacityStrong, // Border Utility diff --git a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart index 3ce90aa8..b1a5d947 100644 --- a/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart +++ b/apps/design_system_gallery/lib/widgets/theme_studio/theme_customization_panel.dart @@ -376,6 +376,13 @@ class _ThemeCustomizationPanelState extends State { onColorChanged: config.setBackgroundOnAccent, onReset: config.resetBackgroundOnAccent, ), + ColorPickerTile( + label: 'backgroundOnElevation', + color: config.backgroundOnElevation, + isDefault: !config.backgroundOnElevationIsCustom, + onColorChanged: config.setBackgroundOnElevation, + onReset: config.resetBackgroundOnElevation, + ), ColorPickerTile( label: 'backgroundHighlight', color: config.backgroundHighlight, @@ -552,6 +559,13 @@ class _ThemeCustomizationPanelState extends State { onColorChanged: config.setBorderOnSurface, onReset: config.resetBorderOnSurface, ), + ColorPickerTile( + label: 'borderOnElevation', + color: config.borderOnElevation, + isDefault: !config.borderOnElevationIsCustom, + onColorChanged: config.setBorderOnElevation, + onReset: config.resetBorderOnElevation, + ), ColorPickerTile( label: 'borderOpacitySubtle', color: config.borderOpacitySubtle, diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 082c92e7..ae1391fe 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -7,6 +7,22 @@ (the default) or `.warning` — and `StreamErrorBadge.showBorder`. - Added `StreamErrorBadgeTheme` and `StreamErrorBadgeThemeData`, carrying a background and foreground color per style. +- Added `StreamColorScheme.backgroundOnElevation` and `borderOnElevation`, for + controls inside a floating surface such as a menu or popover. + +### 🐛 Bug Fixes + +- Fixed `lerp` on theme styles carrying a `WidgetStateBorderSide` throwing a cast + error. The border side now steps at the midpoint rather than interpolating. +- Fixed `lerp` on a theme style whose border side is set on one end only. It now + steps at the midpoint instead of applying the non-null side across the whole + transition. + +### 🔄 Changed + +- `StreamColorScheme.accentWarning` — and `borderWarning`, which defaults to it — + moved from orange to yellow: `#F26D10` → `#F6BF57` light, `#FA922B` → `#FCD579` + dark. Pair it with a dark foreground. ## 0.5.1 diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_checkbox_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_checkbox_theme.g.theme.dart index fe589ecb..8566c116 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_checkbox_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_checkbox_theme.g.theme.dart @@ -120,7 +120,7 @@ mixin _$StreamCheckboxStyle { Color.lerp, ), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: WidgetStateBorderSide.lerp(a.side, b.side, t), + side: t < 0.5 ? a.side : b.side, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart index a0f552c6..6785adb8 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_context_menu_theme.g.theme.dart @@ -102,10 +102,10 @@ mixin _$StreamContextMenuStyle { backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), elevation: lerpDouble$(a.elevation, b.elevation, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side + side: a.side == null || b.side == null + ? t < 0.5 + ? a.side + : b.side : BorderSide.lerp(a.side!, b.side!, t), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), ); @@ -144,9 +144,11 @@ mixin _$StreamContextMenuStyle { backgroundColor: other.backgroundColor, elevation: other.elevation, shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: _this.side == null + ? other.side + : other.side == null + ? _this.side + : BorderSide.merge(_this.side!, other.side!), padding: other.padding, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart index aaa19780..e25d48f3 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart @@ -118,7 +118,7 @@ mixin _$StreamEmojiButtonThemeStyle { t, Color.lerp, ), - side: WidgetStateBorderSide.lerp(a.side, b.side, t), + side: t < 0.5 ? a.side : b.side, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart index 8bf65a2e..0285a374 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_chip_theme.g.theme.dart @@ -140,7 +140,7 @@ mixin _$StreamEmojiChipThemeStyle { maximumSize: Size.lerp(a.maximumSize, b.maximumSize, t), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: WidgetStateBorderSide.lerp(a.side, b.side, t), + side: t < 0.5 ? a.side : b.side, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_jump_to_unread_button_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_jump_to_unread_button_theme.g.theme.dart index 9f11f7bd..7ce106c7 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_jump_to_unread_button_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_jump_to_unread_button_theme.g.theme.dart @@ -32,10 +32,10 @@ mixin _$StreamJumpToUnreadButtonThemeData { return StreamJumpToUnreadButtonThemeData( backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side + side: a.side == null || b.side == null + ? t < 0.5 + ? a.side + : b.side : BorderSide.lerp(a.side!, b.side!, t), elevation: lerpDouble$(a.elevation, b.elevation, t), shadowColor: Color.lerp(a.shadowColor, b.shadowColor, t), @@ -93,9 +93,11 @@ mixin _$StreamJumpToUnreadButtonThemeData { return copyWith( backgroundColor: other.backgroundColor, shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: _this.side == null + ? other.side + : other.side == null + ? _this.side + : BorderSide.merge(_this.side!, other.side!), elevation: other.elevation, shadowColor: other.shadowColor, padding: other.padding, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart index 8156bfe1..bc4ab343 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_attachment_theme.g.theme.dart @@ -32,10 +32,10 @@ mixin _$StreamMessageComposerAttachmentThemeData { return StreamMessageComposerAttachmentThemeData( backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side + side: a.side == null || b.side == null + ? t < 0.5 + ? a.side + : b.side : BorderSide.lerp(a.side!, b.side!, t), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), ); @@ -73,9 +73,11 @@ mixin _$StreamMessageComposerAttachmentThemeData { return copyWith( backgroundColor: other.backgroundColor, shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: _this.side == null + ? other.side + : other.side == null + ? _this.side + : BorderSide.merge(_this.side!, other.side!), padding: other.padding, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart index e687a716..39811090 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_edit_message_attachment_theme.g.theme.dart @@ -44,10 +44,10 @@ mixin _$StreamMessageComposerEditMessageAttachmentThemeData { b.thumbnailShape, t, ), - thumbnailSide: a.thumbnailSide == null - ? b.thumbnailSide - : b.thumbnailSide == null - ? a.thumbnailSide + thumbnailSide: a.thumbnailSide == null || b.thumbnailSide == null + ? t < 0.5 + ? a.thumbnailSide + : b.thumbnailSide : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), ); @@ -101,9 +101,11 @@ mixin _$StreamMessageComposerEditMessageAttachmentThemeData { other.subtitleTextStyle, padding: other.padding, thumbnailShape: other.thumbnailShape, - thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null - ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) - : other.thumbnailSide, + thumbnailSide: _this.thumbnailSide == null + ? other.thumbnailSide + : other.thumbnailSide == null + ? _this.thumbnailSide + : BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!), thumbnailSize: other.thumbnailSize, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart index 03f3bf25..d699e331 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_link_preview_attachment_theme.g.theme.dart @@ -43,10 +43,10 @@ mixin _$StreamMessageComposerLinkPreviewAttachmentThemeData { b.thumbnailShape, t, ), - thumbnailSide: a.thumbnailSide == null - ? b.thumbnailSide - : b.thumbnailSide == null - ? a.thumbnailSide + thumbnailSide: a.thumbnailSide == null || b.thumbnailSide == null + ? t < 0.5 + ? a.thumbnailSide + : b.thumbnailSide : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), ); @@ -97,9 +97,11 @@ mixin _$StreamMessageComposerLinkPreviewAttachmentThemeData { other.subtitleTextStyle, padding: other.padding, thumbnailShape: other.thumbnailShape, - thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null - ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) - : other.thumbnailSide, + thumbnailSide: _this.thumbnailSide == null + ? other.thumbnailSide + : other.thumbnailSide == null + ? _this.thumbnailSide + : BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!), thumbnailSize: other.thumbnailSize, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart index 0b048cd5..e376f2d9 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_message_composer_reply_attachment_theme.g.theme.dart @@ -44,10 +44,10 @@ mixin _$StreamMessageComposerReplyAttachmentThemeData { b.thumbnailShape, t, ), - thumbnailSide: a.thumbnailSide == null - ? b.thumbnailSide - : b.thumbnailSide == null - ? a.thumbnailSide + thumbnailSide: a.thumbnailSide == null || b.thumbnailSide == null + ? t < 0.5 + ? a.thumbnailSide + : b.thumbnailSide : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), ); @@ -101,9 +101,11 @@ mixin _$StreamMessageComposerReplyAttachmentThemeData { other.subtitleTextStyle, padding: other.padding, thumbnailShape: other.thumbnailShape, - thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null - ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) - : other.thumbnailSide, + thumbnailSide: _this.thumbnailSide == null + ? other.thumbnailSide + : other.thumbnailSide == null + ? _this.thumbnailSide + : BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!), thumbnailSize: other.thumbnailSize, ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_reaction_picker_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_reaction_picker_theme.g.theme.dart index 1deeceb0..7f9307e8 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_reaction_picker_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_reaction_picker_theme.g.theme.dart @@ -35,10 +35,10 @@ mixin _$StreamReactionPickerThemeData { elevation: lerpDouble$(a.elevation, b.elevation, t), spacing: lerpDouble$(a.spacing, b.spacing, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side + side: a.side == null || b.side == null + ? t < 0.5 + ? a.side + : b.side : BorderSide.lerp(a.side!, b.side!, t), ); } @@ -80,9 +80,11 @@ mixin _$StreamReactionPickerThemeData { elevation: other.elevation, spacing: other.spacing, shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: _this.side == null + ? other.side + : other.side == null + ? _this.side + : BorderSide.merge(_this.side!, other.side!), ); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_snackbar_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_snackbar_theme.g.theme.dart index 9e359f71..00299dc0 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_snackbar_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_snackbar_theme.g.theme.dart @@ -102,10 +102,10 @@ mixin _$StreamSnackbarStyle { backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), foregroundColor: Color.lerp(a.foregroundColor, b.foregroundColor, t), shape: OutlinedBorder.lerp(a.shape, b.shape, t), - side: a.side == null - ? b.side - : b.side == null - ? a.side + side: a.side == null || b.side == null + ? t < 0.5 + ? a.side + : b.side : BorderSide.lerp(a.side!, b.side!, t), elevation: lerpDouble$(a.elevation, b.elevation, t), padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), @@ -162,9 +162,11 @@ mixin _$StreamSnackbarStyle { backgroundColor: other.backgroundColor, foregroundColor: other.foregroundColor, shape: other.shape, - side: _this.side != null && other.side != null - ? BorderSide.merge(_this.side!, other.side!) - : other.side, + side: _this.side == null + ? other.side + : other.side == null + ? _this.side + : BorderSide.merge(_this.side!, other.side!), elevation: other.elevation, padding: other.padding, margin: other.margin, diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_text_input_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_text_input_theme.g.theme.dart index da2f06a7..ca8e81bf 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_text_input_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_text_input_theme.g.theme.dart @@ -126,20 +126,20 @@ mixin _$StreamTextInputStyle { b.borderRadius, t, ), - border: a.border == null - ? b.border - : b.border == null - ? a.border + border: a.border == null || b.border == null + ? t < 0.5 + ? a.border + : b.border : BorderSide.lerp(a.border!, b.border!, t), - focusBorder: a.focusBorder == null - ? b.focusBorder - : b.focusBorder == null - ? a.focusBorder + focusBorder: a.focusBorder == null || b.focusBorder == null + ? t < 0.5 + ? a.focusBorder + : b.focusBorder : BorderSide.lerp(a.focusBorder!, b.focusBorder!, t), - errorBorder: a.errorBorder == null - ? b.errorBorder - : b.errorBorder == null - ? a.errorBorder + errorBorder: a.errorBorder == null || b.errorBorder == null + ? t < 0.5 + ? a.errorBorder + : b.errorBorder : BorderSide.lerp(a.errorBorder!, b.errorBorder!, t), fillColor: Color.lerp(a.fillColor, b.fillColor, t), contentPadding: EdgeInsetsGeometry.lerp( @@ -240,15 +240,21 @@ mixin _$StreamTextInputStyle { other.helperSuccessStyle, helperAffinity: other.helperAffinity, borderRadius: other.borderRadius, - border: _this.border != null && other.border != null - ? BorderSide.merge(_this.border!, other.border!) - : other.border, - focusBorder: _this.focusBorder != null && other.focusBorder != null - ? BorderSide.merge(_this.focusBorder!, other.focusBorder!) - : other.focusBorder, - errorBorder: _this.errorBorder != null && other.errorBorder != null - ? BorderSide.merge(_this.errorBorder!, other.errorBorder!) - : other.errorBorder, + border: _this.border == null + ? other.border + : other.border == null + ? _this.border + : BorderSide.merge(_this.border!, other.border!), + focusBorder: _this.focusBorder == null + ? other.focusBorder + : other.focusBorder == null + ? _this.focusBorder + : BorderSide.merge(_this.focusBorder!, other.focusBorder!), + errorBorder: _this.errorBorder == null + ? other.errorBorder + : other.errorBorder == null + ? _this.errorBorder + : BorderSide.merge(_this.errorBorder!, other.errorBorder!), fillColor: other.fillColor, contentPadding: other.contentPadding, constraints: other.constraints, diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart index f259355e..4a783860 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/dark/stream_tokens.dart @@ -466,7 +466,7 @@ class StreamTokens { static const backgroundElevationElevation3 = Color(0xFF565656); static const borderUtilityFocus = Color(0x4078A8FF); static const borderUtilityError = Color(0xFFFC526A); - static const borderUtilityWarning = Color(0xFFFA922B); + static const borderUtilityWarning = Color(0xFFFCD579); static const borderUtilitySuccess = Color(0xFF00C384); static const borderUtilitySelected = Color(0x26FFFFFF); static const borderUtilityDisabled = Color(0xFF323232); @@ -596,7 +596,7 @@ class StreamTokens { static const avatarPresenceBorder = Color(0xFF000000); static const accentPrimary = Color(0xFF4586FF); static const accentSuccess = Color(0xFF00C384); - static const accentWarning = Color(0xFFFA922B); + static const accentWarning = Color(0xFFFCD579); static const accentError = Color(0xFFFC526A); static const accentNeutral = Color(0xFFABABAB); static const brand50 = Color(0xFF091A3B); diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart index dfe8c11e..8595b4e3 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/internal/tokens/light/stream_tokens.dart @@ -470,7 +470,7 @@ class StreamTokens { static const borderUtilityFocused = Color(0xFFC3D9FF); static const borderUtilityActive = Color(0xFF005FFF); static const borderUtilitySuccess = Color(0xFF00A46E); - static const borderUtilityWarning = Color(0xFFF26D10); + static const borderUtilityWarning = Color(0xFFF6BF57); static const borderUtilityError = Color(0xFFD90D10); static const borderUtilityDisabled = Color(0xFFEBEEF1); static const borderUtilityDisabledOnSurface = Color(0xFFD5DBE1); @@ -595,7 +595,7 @@ class StreamTokens { static const avatarPresenceBorder = Color(0xFFFFFFFF); static const accentPrimary = Color(0xFF005FFF); static const accentSuccess = Color(0xFF00A46E); - static const accentWarning = Color(0xFFF26D10); + static const accentWarning = Color(0xFFF6BF57); static const accentError = Color(0xFFD90D10); static const accentNeutral = Color(0xFF687385); static const brand50 = Color(0xFFF3F7FF); diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart index 93c13e80..b308039d 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.dart @@ -70,6 +70,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundSurfaceCard, Color? backgroundOnAccent, + Color? backgroundOnElevation, Color? backgroundHighlight, Color? backgroundScrim, Color? backgroundOverlayLight, @@ -90,6 +91,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? borderOnAccent, Color? borderOnInverse, Color? borderOnSurface, + Color? borderOnElevation, Color? borderOpacitySubtle, Color? borderOpacityStrong, // Border - Utility @@ -139,6 +141,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong ??= chrome.shade150; backgroundSurfaceCard ??= chrome.shade50; backgroundOnAccent ??= chrome[0] ?? StreamColors.white; + backgroundOnElevation ??= chrome.shade100; backgroundHighlight ??= light_tokens.StreamTokens.backgroundCoreHighlight; backgroundScrim ??= light_tokens.StreamTokens.backgroundCoreScrim; backgroundOverlayLight ??= light_tokens.StreamTokens.backgroundCoreOverlayLight; @@ -161,6 +164,7 @@ class StreamColorScheme with _$StreamColorScheme { borderOnAccent ??= chrome[0] ?? StreamColors.white; borderOnInverse ??= chrome[0] ?? StreamColors.white; borderOnSurface ??= chrome.shade300; + borderOnElevation ??= chrome.shade150; borderOpacitySubtle ??= light_tokens.StreamTokens.borderCoreOpacitySubtle; borderOpacityStrong ??= light_tokens.StreamTokens.borderCoreOpacityStrong; @@ -231,6 +235,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong: backgroundSurfaceStrong, backgroundSurfaceCard: backgroundSurfaceCard, backgroundOnAccent: backgroundOnAccent, + backgroundOnElevation: backgroundOnElevation, backgroundHighlight: backgroundHighlight, backgroundScrim: backgroundScrim, backgroundOverlayLight: backgroundOverlayLight, @@ -246,6 +251,7 @@ class StreamColorScheme with _$StreamColorScheme { borderOnAccent: borderOnAccent, borderOnInverse: borderOnInverse, borderOnSurface: borderOnSurface, + borderOnElevation: borderOnElevation, borderSubtle: borderSubtle, borderStrong: borderStrong, borderOpacitySubtle: borderOpacitySubtle, @@ -295,6 +301,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundSurfaceCard, Color? backgroundOnAccent, + Color? backgroundOnElevation, Color? backgroundHighlight, Color? backgroundScrim, Color? backgroundOverlayLight, @@ -316,6 +323,7 @@ class StreamColorScheme with _$StreamColorScheme { Color? borderOnAccent, Color? borderOnInverse, Color? borderOnSurface, + Color? borderOnElevation, // Border - Utility Color? borderFocus, Color? borderDisabled, @@ -363,6 +371,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong ??= chrome.shade150; backgroundSurfaceCard ??= chrome.shade100; backgroundOnAccent ??= chrome[1000] ?? StreamColors.white; + backgroundOnElevation ??= chrome.shade150; backgroundHighlight ??= dark_tokens.StreamTokens.backgroundCoreHighlight; backgroundScrim ??= dark_tokens.StreamTokens.backgroundCoreScrim; backgroundOverlayLight ??= dark_tokens.StreamTokens.backgroundCoreOverlayLight; @@ -387,6 +396,7 @@ class StreamColorScheme with _$StreamColorScheme { borderOnAccent ??= chrome[1000] ?? StreamColors.white; borderOnInverse ??= chrome[0] ?? StreamColors.black; borderOnSurface ??= chrome.shade300; + borderOnElevation ??= chrome.shade300; // Border - Utility borderFocus ??= brand.shade150; @@ -455,6 +465,7 @@ class StreamColorScheme with _$StreamColorScheme { backgroundSurfaceStrong: backgroundSurfaceStrong, backgroundSurfaceCard: backgroundSurfaceCard, backgroundOnAccent: backgroundOnAccent, + backgroundOnElevation: backgroundOnElevation, backgroundHighlight: backgroundHighlight, backgroundScrim: backgroundScrim, backgroundOverlayLight: backgroundOverlayLight, @@ -473,6 +484,7 @@ class StreamColorScheme with _$StreamColorScheme { borderOnAccent: borderOnAccent, borderOnInverse: borderOnInverse, borderOnSurface: borderOnSurface, + borderOnElevation: borderOnElevation, borderSubtle: borderSubtle, borderFocus: borderFocus, borderDisabled: borderDisabled, @@ -544,6 +556,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.backgroundSurfaceStrong, required this.backgroundSurfaceCard, required this.backgroundOnAccent, + required this.backgroundOnElevation, required this.backgroundHighlight, required this.backgroundScrim, required this.backgroundOverlayLight, @@ -563,6 +576,7 @@ class StreamColorScheme with _$StreamColorScheme { required this.borderOnAccent, required this.borderOnInverse, required this.borderOnSurface, + required this.borderOnElevation, required this.borderOpacitySubtle, required this.borderOpacityStrong, // Border - Utility @@ -666,6 +680,11 @@ class StreamColorScheme with _$StreamColorScheme { /// Surface that must remain white across themes (e.g., media controls over video). final Color backgroundOnAccent; + /// Background for controls sitting inside a floating surface — a menu, dialog + /// or popover. Steps up in dark mode, where the elevated surface has already + /// lightened and a plain surface background would disappear into it. + final Color backgroundOnElevation; + /// Highlight background (e.g., quoted message, search hit). final Color backgroundHighlight; @@ -734,6 +753,13 @@ class StreamColorScheme with _$StreamColorScheme { /// The border color on surface backgrounds. final Color borderOnSurface; + /// Border for controls sitting inside a floating surface — a menu, dialog or + /// popover. Steps up in dark mode to keep the edge visible once the elevated + /// surface has lightened. Use [borderOnSurface] on a plain surface instead — + /// though the two resolve to the same value in dark, so the choice only shows + /// in light. + final Color borderOnElevation; + /// Image frame border treatment (subtle opacity). final Color borderOpacitySubtle; diff --git a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart index 08cadda1..efaa4210 100644 --- a/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/semantics/stream_color_scheme.g.theme.dart @@ -71,6 +71,11 @@ mixin _$StreamColorScheme { b.backgroundOnAccent, t, )!, + backgroundOnElevation: Color.lerp( + a.backgroundOnElevation, + b.backgroundOnElevation, + t, + )!, backgroundHighlight: Color.lerp( a.backgroundHighlight, b.backgroundHighlight, @@ -139,6 +144,11 @@ mixin _$StreamColorScheme { borderOnAccent: Color.lerp(a.borderOnAccent, b.borderOnAccent, t)!, borderOnInverse: Color.lerp(a.borderOnInverse, b.borderOnInverse, t)!, borderOnSurface: Color.lerp(a.borderOnSurface, b.borderOnSurface, t)!, + borderOnElevation: Color.lerp( + a.borderOnElevation, + b.borderOnElevation, + t, + )!, borderOpacitySubtle: Color.lerp( a.borderOpacitySubtle, b.borderOpacitySubtle, @@ -191,6 +201,7 @@ mixin _$StreamColorScheme { Color? backgroundSurfaceStrong, Color? backgroundSurfaceCard, Color? backgroundOnAccent, + Color? backgroundOnElevation, Color? backgroundHighlight, Color? backgroundScrim, Color? backgroundOverlayLight, @@ -211,6 +222,7 @@ mixin _$StreamColorScheme { Color? borderOnAccent, Color? borderOnInverse, Color? borderOnSurface, + Color? borderOnElevation, Color? borderOpacitySubtle, Color? borderOpacityStrong, Color? borderFocus, @@ -254,6 +266,8 @@ mixin _$StreamColorScheme { backgroundSurfaceCard: backgroundSurfaceCard ?? _this.backgroundSurfaceCard, backgroundOnAccent: backgroundOnAccent ?? _this.backgroundOnAccent, + backgroundOnElevation: + backgroundOnElevation ?? _this.backgroundOnElevation, backgroundHighlight: backgroundHighlight ?? _this.backgroundHighlight, backgroundScrim: backgroundScrim ?? _this.backgroundScrim, backgroundOverlayLight: @@ -277,6 +291,7 @@ mixin _$StreamColorScheme { borderOnAccent: borderOnAccent ?? _this.borderOnAccent, borderOnInverse: borderOnInverse ?? _this.borderOnInverse, borderOnSurface: borderOnSurface ?? _this.borderOnSurface, + borderOnElevation: borderOnElevation ?? _this.borderOnElevation, borderOpacitySubtle: borderOpacitySubtle ?? _this.borderOpacitySubtle, borderOpacityStrong: borderOpacityStrong ?? _this.borderOpacityStrong, borderFocus: borderFocus ?? _this.borderFocus, @@ -329,6 +344,7 @@ mixin _$StreamColorScheme { backgroundSurfaceStrong: other.backgroundSurfaceStrong, backgroundSurfaceCard: other.backgroundSurfaceCard, backgroundOnAccent: other.backgroundOnAccent, + backgroundOnElevation: other.backgroundOnElevation, backgroundHighlight: other.backgroundHighlight, backgroundScrim: other.backgroundScrim, backgroundOverlayLight: other.backgroundOverlayLight, @@ -349,6 +365,7 @@ mixin _$StreamColorScheme { borderOnAccent: other.borderOnAccent, borderOnInverse: other.borderOnInverse, borderOnSurface: other.borderOnSurface, + borderOnElevation: other.borderOnElevation, borderOpacitySubtle: other.borderOpacitySubtle, borderOpacityStrong: other.borderOpacityStrong, borderFocus: other.borderFocus, @@ -401,6 +418,7 @@ mixin _$StreamColorScheme { _other.backgroundSurfaceStrong == _this.backgroundSurfaceStrong && _other.backgroundSurfaceCard == _this.backgroundSurfaceCard && _other.backgroundOnAccent == _this.backgroundOnAccent && + _other.backgroundOnElevation == _this.backgroundOnElevation && _other.backgroundHighlight == _this.backgroundHighlight && _other.backgroundScrim == _this.backgroundScrim && _other.backgroundOverlayLight == _this.backgroundOverlayLight && @@ -422,6 +440,7 @@ mixin _$StreamColorScheme { _other.borderOnAccent == _this.borderOnAccent && _other.borderOnInverse == _this.borderOnInverse && _other.borderOnSurface == _this.borderOnSurface && + _other.borderOnElevation == _this.borderOnElevation && _other.borderOpacitySubtle == _this.borderOpacitySubtle && _other.borderOpacityStrong == _this.borderOpacityStrong && _other.borderFocus == _this.borderFocus && @@ -466,6 +485,7 @@ mixin _$StreamColorScheme { _this.backgroundSurfaceStrong, _this.backgroundSurfaceCard, _this.backgroundOnAccent, + _this.backgroundOnElevation, _this.backgroundHighlight, _this.backgroundScrim, _this.backgroundOverlayLight, @@ -486,6 +506,7 @@ mixin _$StreamColorScheme { _this.borderOnAccent, _this.borderOnInverse, _this.borderOnSurface, + _this.borderOnElevation, _this.borderOpacitySubtle, _this.borderOpacityStrong, _this.borderFocus, diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index fccde8d6..f765ee78 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -160,9 +160,8 @@ mixin _$StreamTheme on ThemeExtension { radius: StreamRadius.lerp(_this.radius, other.radius, t)!, spacing: StreamSpacing.lerp(_this.spacing, other.spacing, t)!, typography: StreamTypography.lerp(_this.typography, other.typography, t)!, - colorScheme: - (_this.colorScheme.lerp(other.colorScheme, t) as StreamColorScheme), - textTheme: (_this.textTheme.lerp(other.textTheme, t) as StreamTextTheme), + colorScheme: _this.colorScheme.lerp(other.colorScheme, t), + textTheme: _this.textTheme.lerp(other.textTheme, t), boxShadow: StreamBoxShadow.lerp(_this.boxShadow, other.boxShadow, t)!, appBarTheme: StreamAppBarThemeData.lerp( _this.appBarTheme, diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png index 20879917..7c3ec7d4 100644 Binary files a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_border_toggle.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png index 687cd4cd..399a3901 100644 Binary files a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png index 52dda521..4c4a5aa2 100644 Binary files a/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png and b/packages/stream_core_flutter/test/components/badge/goldens/ci/stream_error_badge_light_matrix.png differ diff --git a/packages/stream_core_flutter/test/theme/component_style_lerp_test.dart b/packages/stream_core_flutter/test/theme/component_style_lerp_test.dart new file mode 100644 index 00000000..25de2f08 --- /dev/null +++ b/packages/stream_core_flutter/test/theme/component_style_lerp_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +/// Guards the border-side branches of the generated `lerp` methods. +/// +/// These are emitted by `theme_extensions_builder`, so a generator bump can +/// change them without anything in this repo being edited. The +/// [WidgetStateBorderSide] case in particular shipped broken in 0.5.1: the +/// existing `StreamTheme.lerp` coverage leaves every nested style null, which +/// short-circuits before the branch is ever reached. +void main() { + group('lerp on a style with a WidgetStateBorderSide', () { + // `WidgetStateBorderSide.lerp` returns Flutter's private `_LerpSides`, + // which implements `WidgetStateProperty` and *not* + // `WidgetStateBorderSide` — so the `as WidgetStateBorderSide?` cast in + // `StreamCheckboxStyle`'s constructor threw on every non-null lerp. + final a = StreamCheckboxStyle.from(side: const BorderSide(color: Color(0xFF112233))); + final b = StreamCheckboxStyle.from(side: const BorderSide(color: Color(0xFFFFFFFF), width: 3)); + + test('does not throw part-way through a transition', () { + for (final t in const [0.0, 0.25, 0.5, 0.75, 1.0]) { + expect(() => StreamCheckboxStyle.lerp(a, b, t), returnsNormally, reason: 't = $t'); + } + }); + + test('steps at the midpoint rather than interpolating', () { + const enabled = {}; + + expect(StreamCheckboxStyle.lerp(a, b, 0.25)?.side?.resolve(enabled), equals(a.side?.resolve(enabled))); + expect(StreamCheckboxStyle.lerp(a, b, 0.75)?.side?.resolve(enabled), equals(b.side?.resolve(enabled))); + }); + + test('holds at both endpoints', () { + const enabled = {}; + + expect(StreamCheckboxStyle.lerp(a, b, 0)?.side?.resolve(enabled), equals(a.side?.resolve(enabled))); + expect(StreamCheckboxStyle.lerp(a, b, 1)?.side?.resolve(enabled), equals(b.side?.resolve(enabled))); + }); + }); + + group('lerp on a style whose plain BorderSide is set on one end only', () { + const withSide = StreamContextMenuStyle(side: BorderSide(color: Color(0xFF00FF00))); + const withoutSide = StreamContextMenuStyle(); + + test('holds at both endpoints', () { + // The pre-7.5.0 generator returned the non-null side across the whole + // range, so a border popped in at t = 0 and lerp(a, b, 0) != a. + expect(StreamContextMenuStyle.lerp(withoutSide, withSide, 0)?.side, isNull); + expect(StreamContextMenuStyle.lerp(withoutSide, withSide, 1)?.side, equals(withSide.side)); + }); + + test('steps at the midpoint', () { + expect(StreamContextMenuStyle.lerp(withoutSide, withSide, 0.25)?.side, isNull); + expect(StreamContextMenuStyle.lerp(withoutSide, withSide, 0.75)?.side, equals(withSide.side)); + }); + }); + + group('merge keeps a border side the argument leaves null', () { + const base = StreamContextMenuStyle(side: BorderSide(color: Color(0xFF00FF00))); + + test('receiver survives an argument with no side', () { + expect(base.merge(const StreamContextMenuStyle()).side, equals(base.side)); + }); + + test('both sides go through BorderSide.merge when both are set', () { + // `BorderSide.merge` asserts `canMerge`, so the two have to share a + // color, and it sums their widths rather than replacing one with the + // other — the argument does not simply win here. + const other = StreamContextMenuStyle(side: BorderSide(color: Color(0xFF00FF00), width: 2)); + + expect(base.merge(other).side?.width, equals(3)); + }); + }); +}