@servicetitan/carto-tokens
v0.2.3
Published
Carto design tokens. DTCG JSON → vanilla-extract, Swift Package, Gradle module. (CSS / SCSS targets present in source, not currently built.)
Keywords
Readme
@servicetitan/carto-tokens
Carto design tokens. DTCG JSON → a native TypeScript build pipeline → three active outputs:
| Output | Consumer |
|---|---|
| Vanilla-extract module (three tier-scoped createTheme layers) | import { cartoThemeClass, primitive, semantic, component } from "@servicetitan/carto-tokens/vanilla-extract"; |
| Motion values (raw JS literals for the motion.* namespace) | import { easing, duration, scale } from "@servicetitan/carto-tokens/motion"; |
| Swift Package | TBD |
| Kotlin | In gradle/libs.versions.toml: carto-tokens = { module = "com.servicetitan:carto-tokens", version = "<insert_version>" } |
| React Native | import { light, dark } from "@servicetitan/carto-tokens/react-native"; |
CSS and SCSS targets are present in source (src/formats/scss.ts and the buildCss driver in scripts/build-pipeline.ts) but are excluded from the production build today — vanilla-extract is the web source of truth. To re-enable, drop the excludePlatforms option in scripts/build.ts and re-add the subpath exports + files: entries in package.json.
How the build works — DTCG-spec handling, the Carto pipeline, and how to add a new token /
$type/ platform / extension: see PIPELINE.md.
Token source format
DTCG-flavored JSON with a few Carto extensions. Source files live in src/primitive/, src/semantic/, src/component/.
{
"radius": {
"$extensions": { "com.figma.scopes": ["CORNER_RADIUS"] },
"400": { "$type": "dimension", "$value": { "value": 16, "unit": "px" } }
}
}Value shapes per $type
| $type | Shape |
|---|---|
| dimension | { "value": <px>, "unit": "px" \| "%" } |
| color | Ref string "{color.X}" or { "ref": "{color.X}", "alpha": <0–1> } (primitive + alpha overlay) or DTCG { "colorSpace": "srgb", "components": […], "alpha"?: <0–1>, "hex"?: "#rrggbb" } |
| shadow | Composite { "color": <colorValue>, "offsetX": <dim>, "offsetY": <dim>, "blur": <dim>, "spread": <dim> } — color accepts the same three forms |
| fontFamily | String (single family) or array of strings (font stack, most-preferred first). Web joins the stack with , ; Figma/Swift/Kotlin take the head. |
| fontWeight | Number (CSS 100–900) |
| number | Unitless number (e.g. line-height ratios) |
| duration | { "value": <ms>, "unit": "ms" } (motion timing) |
$extensions Carto reads
| Key | Purpose |
|---|---|
| appearance.dark | A full DTCG token ({ $type, $value, $extensions? }) for the dark mode variant. The root $value is implicitly the light variant. |
| excludePlatforms | Array<"web" \| "swift" \| "kotlin" \| "figma"> — skip emission on the named platforms. Group-inherited; deepest level wins. |
| preservePxForWeb | Boolean — skip the px → rem conversion on web (used for breakpoints and the radius.contained pill sentinel). |
| com.figma.scopes | Array<string> — Figma variable scope list (e.g. ["SHAPE_FILL", "STROKE_COLOR"]). |
Rules
- Dimensions default to px; the web transform divides by
base(16) to emit rem unlesspreservePxForWebis set orunitis%. iOS and Android transforms emit the raw value. - Durations are authored in
ms. Web (vanilla-extract / CSS) emits themsliteral (with the unit even for0— CSS<time>requires it); the/motionJS export emits seconds as a unitless number (100ms→0.1, matching Framer Motion'sdurationprop); Swift emits seconds asTimeInterval(100ms→0.1, matchingAnimation(duration:)); Kotlin emits rawmsasInt(100ms→100, for Composetween(durationMillis:)). - Easing is authored as a
cubicBeziercontrol-point array. Web (vanilla-extract / CSS) emits acubic-bezier(...)string; the/motionJS export emits the raw[x1, y1, x2, y2]tuple (Framer Motion'seasedoes not accept the CSS string); Swift emits a(CGFloat, CGFloat, CGFloat, CGFloat)tuple; Kotlin emits ComposeCubicBezierEasing(...). - Tier names (
primitive,semantic,component) are organizational and stripped from emitted CSS variable names:semantic.background.color.standard.resting→--carto-background-color-standard-resting. - Tier chains: a token whose
$valueis"{radius.400}"emits as a reference (var(--carto-radius-400)/primitive.radius["400"]/Radius.r400), not the resolved literal. - Alpha lives in the value, not in extensions.
$extensions.modification.alphais not used — author{ "ref": "{color.X}", "alpha": <N> }as the$valueinstead. The platform emitters preserve the primitive reference and apply the alpha ascolor-mix(...)(web),.opacity()(Swift), or.copy(alpha = ...)(Kotlin).
Development
pnpm install
pnpm --filter @servicetitan/carto-tokens build # regenerates build/
pnpm --filter @servicetitan/carto-tokens test
pnpm --filter @servicetitan/carto-tokens dev # watch modebuild/ is generated output and is not committed — including the Swift sources under build/swift/. It's gitignored and rebuilt on demand by each consumer (the npm tarball via prepack, and the Swift/Android mirror workflows in CI).
Figma sync
The package ships a Figma sync pipeline under scripts/figma/ that pushes the same DTCG tokens to the Carto Design Tokens Figma library as chained variable aliases with Light / Dark modes.
pnpm --filter @servicetitan/carto-tokens figma:validate # offline; no creds needed
pnpm --filter @servicetitan/carto-tokens figma:sync --dry-run # plan only (needs creds)
pnpm --filter @servicetitan/carto-tokens figma:sync # write only your changes (default)
pnpm --filter @servicetitan/carto-tokens figma:sync --all # full resync of every token
pnpm --filter @servicetitan/carto-tokens figma:auth # mint a refresh token (one-time)By default figma:sync only pushes the tokens you changed relative to origin/main
(committed changes plus uncommitted edits), so a stale branch can't overwrite tokens
that already landed on main. It requires origin/main to be fetched; if the baseline
can't be resolved it fails loudly rather than syncing everything. Use --all (or
FIGMA_SYNC_ALL=1) for a full resync — initial seeding, recovery, or CI runs on main
where there is no meaningful diff. --dry-run previews the plan and composes with both.
Credentials
Drop these into a .env.local at the repo root (gitignored):
FIGMA_CLIENT_ID=…
FIGMA_CLIENT_SECRET=…
FIGMA_REFRESH_TOKEN=…The current values are stored in 1Password — search the shared Carto vault for the Figma Token Sync entry. Ask a maintainer if you don't have access.
Alternatively, a Personal Access Token works for local one-offs (skips OAuth entirely):
FIGMA_ACCESS_TOKEN=figd_… pnpm --filter @servicetitan/carto-tokens figma:sync --dry-runMint a PAT at https://www.figma.com/settings → Personal access tokens. Note: the Variables REST API is Enterprise-only — verify the PAT scope checkbox for file_variables:write is available.
CI
.github/workflows/figma-sync.yml validates tokens on every PR, dry-runs on push to main, and performs a real sync on manual workflow_dispatch. The sync itself doesn't notify Slack — that fires from release.yml only when changesets publishes a new @servicetitan/carto-tokens version. Designers get pinged about actual releases, not every manual sync run; the message links to the Figma library so they can verify and publish.
Required repo secrets: FIGMA_CLIENT_ID, FIGMA_CLIENT_SECRET, FIGMA_REFRESH_TOKEN, CARTO_FIGMA_SLACK_WEBHOOK. All four live in 1Password alongside the local credentials. The Slack channel, message format, and Figma library link are owned by a Slack Workflow Builder workflow (same pattern hammer-token uses); the GH Action POSTs { "version": "..." } and the Workflow expands it into the visible message.
Figma variable IDs
Every leaf token in src/ carries the server-assigned Figma variable ID under its $extensions["com.figma.variableId"]. The sync uses this ID — not the variable name — as the durable join key between source and Figma, so a token can be renamed in source and the Figma variable gets renamed in place rather than orphaned.
Atomic leaves (color, dimension, fontFamily, fontWeight, number) store a single string ID:
"0": {
"$type": "color",
"$value": { "colorSpace": "srgb", "components": [1, 1, 1], "hex": "#ffffff" },
"$extensions": { "com.figma.variableId": "VariableID:1234:5678" }
}Composite leaves ($type: shadow, $type: typography) decompose into multiple Figma variables — one per subprop. They store a nested map keyed by subprop name:
"resting": {
"$type": "shadow",
"$value": { ... },
"$extensions": {
"com.figma.variableId": {
"color": "VariableID:1:1",
"offsetX": "VariableID:1:2",
"offsetY": "VariableID:1:3",
"blur": "VariableID:1:4",
"spread": "VariableID:1:5"
}
}
}The loader treats each map entry as a synthetic child token (e.g. shadow/raised/resting/color) with its own scope and type. Designers can then bind each subprop independently in Figma.
figma:validate (which runs on every PR, offline) requires every token to have a variableId — either a string for atomic leaves or a complete subprop map for composite leaves. Adding a new token in a PR therefore needs the ID to land in the same PR — see below.
Adding, renaming, and removing tokens
| What you did in source | What the sync does in Figma |
|---|---|
| Added a new token | Creates a new variable, captures its ID from the API response, and writes com.figma.variableId into your *.tokens.json file. Commit the writeback alongside your new token. |
| Changed only the value (same ID, same name) | Updates the existing variable's mode values in place. Aliases to / from this variable keep working. |
| Renamed a token (same ID, new path/key) | Looks up the variable by ID, sees the desired name has changed, emits an UPDATE that renames the Figma variable in place. No orphan, no duplicate. Existing aliases in Figma continue to resolve because they reference the variable by ID. |
| Removed a token from source | Currently no-op — the sync is additive only. The variable stays in Figma until a designer deletes it manually. |
Workflow: adding a new token
Adding a brand-new token requires a local sync run, because Figma assigns the variable's ID server-side and you can't know it ahead of time.
- Add the token to the relevant
src/<tier>/*.tokens.jsonfile (withoutcom.figma.variableId). - Ensure your Figma OAuth credentials are set up in
.env.local(see Credentials above; runpnpm figma:authif you haven't). - Run
pnpm --filter @servicetitan/carto-tokens figma:sync. The script creates the variable in Figma, captures the new ID, writes it into your token file, and reports the change. - Commit the resulting diff (your new token and its inserted ID) and open the PR. CI validate will pass.
Workflow: renaming a token
No Figma round-trip needed at PR time — the ID is already in $extensions and travels with the token entry. The next manual figma:sync will rename the Figma variable to match.
Failure modes
Token X has no Figma variable ID.— CI validate is blocking on a new token. Runpnpm figma:synclocally and commit the writeback.Token X has Figma variable ID Y but no variable with that ID exists.— The Figma variable was deleted manually. Downstream consumers are also broken; restore the variable in Figma rather than auto-healing the source.Two source tokens claim the same Figma variable ID.— Source-side inconsistency. One of the tokens has a copy-pasted or merge-conflicted ID that needs correction.
Syncing tokens on a PR
When a PR touches packages/carto-tokens/src/**/*.tokens.json, figma-pr-sync.yml (separate from the required-check figma-sync.yml) dry-runs the sync. If any variables would be created or updated, a bot posts a sticky comment on the PR requesting a Figma branch URL for the Carto Tokens library.
Once a contributor replies with a URL (https://www.figma.com/design/<fileKey>/branch/<branchKey>/…):
- The bot validates the URL points at the Carto Tokens file — any other file is rejected.
- It syncs tokens to that Figma branch (never to the main library from PR flow), which mints server-side variable IDs.
- The new
com.figma.variableIdvalues are committed straight to the PR branch as a single commit (pushed withsecrets.PATso the required check re-runs). figma:validate— the merge gate — re-runs on that commit and turns green, because it requires every token to carry acom.figma.variableId.- The contributor who supplied the URL is pinged that the Figma branch is ready. Merging that Figma branch into the main library is the designer's call, done in Figma whenever they're ready.
Safety: the comment-triggered job runs only for commenters with repo association OWNER/MEMBER/COLLABORATOR, and it never writes to the main Figma library from PR flow.
Rotating Figma OAuth credentials
The OAuth refresh token Figma issues is long-lived (months to years in practice), but Figma's API may rotate it on any refresh call — the response to POST /v1/oauth/refresh can include a new refresh_token field, in which case the previously-stored value is invalidated server-side. When this happens, scripts/figma/auth.ts detects the change and warns. It does not auto-write the new value anywhere — that's a manual operator step.
The warning behaviour differs between local runs and CI to avoid leaking the new token through workflow logs:
| Where the rotation happens | What auth.ts does | Operator action |
|---|---|---|
| Local terminal (no GITHUB_ACTIONS env) | Prints the new refresh_token plainly to stderr, along with the three update locations. | Copy the printed value → update .env.local, 1Password, and the GitHub repo secret with it. |
| GitHub Actions runner (GITHUB_ACTIONS=true) | Emits ::add-mask::<token> first, then prints a warning that does not include the value. The mask command registers the new token as a secret on the runner; any subsequent log line containing it gets replaced with ***, so the rotated value is not retrievable from run logs (intentionally — protects against accidental leak). | Re-mint a fresh token via pnpm --filter @servicetitan/carto-tokens figma:auth locally, then update all three locations (.env.local, 1Password, the GitHub secret). Note that re-minting issues yet another new token, which is what you want — the rotated one is gone either way once the CI run ends. |
Symptom of a missed rotation (auth.ts already had the rotated value by the time you noticed, or rotation happened in CI but the secret wasn't updated):
AuthenticationError: OAuth refresh failed (400): {"error":"invalid_grant","error_description":"The refresh token is invalid or expired"}If CI fails this way but pnpm figma:sync --dry-run works locally, the local .env.local has a value that's still valid and the GH secret is stale — copy local → GH and you're back. If both local and CI fail, re-mint with pnpm figma:auth and update all three locations.
The trade-off is intentional: locally-readable rotated tokens are convenient for solo dev work; CI-masked tokens force a re-mint but never leak through Actions read-access. If the team later decides the convenience-of-CI-readability outweighs the leak risk for the workflow runs that designers actually care about, the masking branch in auth.ts is a one-line flip.
Theming
Web (vanilla-extract)
The vanilla-extract output is structured as three createTheme layers — primitive, semantic, component — so alias relationships survive into the generated CSS variables.
Apply the theme at the root
// app/layout.tsx (or your root component)
import { cartoThemeClass } from "@servicetitan/carto-tokens/vanilla-extract";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html className={cartoThemeClass}>
<body>{children}</body>
</html>
);
}cartoThemeClass is the composed primitive + semantic + component class string. Apply it once at the root; the CSS variables cascade everywhere underneath.
Build a component
Use vanilla-extract's style() helper in a .css.ts sibling file to author styles that reference the token proxies. Each proxy property resolves to a var(--…) reference, so updates to a token at any tier cascade live without a recompile.
// IconCard.css.ts
import { style } from "@vanilla-extract/css";
import {
component,
semantic,
primitive,
} from "@servicetitan/carto-tokens/vanilla-extract";
export const card = style({
// Component tier — token assigned to a specific component slot.
width: component.icon.size.medium,
// Semantic tier — intent-based.
backgroundColor: semantic.background.color.standard.resting,
color: semantic.foreground.color.standard,
borderColor: semantic.border.color.subtle,
borderRadius: semantic.radius.moderate,
padding: semantic.spacing.base,
":hover": {
// Alpha-overlay token — resolves to color-mix(in srgb, var(--carto-color-neutral-400), transparent 92%).
backgroundColor: semantic.background.color.ghost.hover,
},
// Primitive tier — raw scale values, used when no semantic token fits.
outlineWidth: primitive.size["200"],
});Light/dark
Light/dark switching is handled by the CSS light-dark() function — the browser tracks prefers-color-scheme automatically. To force a mode, set color-scheme: light or color-scheme: dark on a wrapping element.
Web (Motion / JS values)
The vanilla-extract output exposes motion tokens only as CSS custom properties — great for CSS transition / animation, but unusable from a JS animation library. Framer Motion (motion) needs the raw values: its ease prop wants a [x1, y1, x2, y2] tuple (never a cubic-bezier(...) string) and its duration prop wants a unitless number in seconds. The /motion subpath provides exactly that — inlined literals mirroring the motion.* token namespace.
import { motion } from "motion/react";
import { easing, duration, scale } from "@servicetitan/carto-tokens/motion";
function Toast() {
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
whileTap={{ scale: scale.feedback }}
transition={{ duration: duration.transition, ease: easing.enter }}
/>
);
}Each group is also available on a combined motion object (import { motion as motionTokens } from "@servicetitan/carto-tokens/motion"). easing values are typed as the exported CubicBezier tuple; duration values are seconds; scale values are unitless multipliers (scale.1000 = 1.0). For CSS-driven motion, keep using the vanilla-extract semantic.motion.* CSS variables instead.
Swift
Build a component
Coming soon!
Kotlin
Usage
Import the dependency via our Internal Maven repository
- Add
carto = { group = "com.servicetitan", name = "carto-tokens", version.ref = "<insert_version>"}to libs.versions.toml - Add the carto dependency to any module using the tokens
- Reference the tokens using
CartoTokensas the root
Example:
Icon(
icon = iconStart,
contentDescription = label,
loading = loading,
size = CartoTokens.Icon.sizeMedium,
tint = iconTint
)Build a component
Coming soon!
React Native
import { light, dark } from "@servicetitan/carto-tokens/react-native";
import { useColorScheme } from "react-native";
function useCartoTheme() {
return useColorScheme() === "dark" ? dark : light;
}
// in a component:
const t = useCartoTheme();
<View style={{ backgroundColor: t.semantic.background.color.standard.resting }} />;Values are fully resolved for the platform: dimensions are unitless numbers,
alpha colors are baked #rrggbbaa, shadows are boxShadow strings (RN 0.76+),
and cubicBezier tokens are [x1,y1,x2,y2] tuples (spread into Easing.bezier(...)).
