Skip to content

feat(tokens): read SCSS variables as a project token source - #151

Merged
awdr74100 merged 12 commits into
mainfrom
feat/scss-token-source
Aug 16, 2026
Merged

feat(tokens): read SCSS variables as a project token source#151
awdr74100 merged 12 commits into
mainfrom
feat/scss-token-source

Conversation

@awdr74100

Copy link
Copy Markdown
Owner

Third source after Tailwind v3 (#148) and UnoCSS (#149), and the one that forced the ref model to generalize.

The gap

scss has been a detected styling system since before the token join existed, but every token source was CSS — so a SCSS project joined against an empty pool and token_map returned nothing for it. Same for the design-context value annotation, which shares the loader.

What made this different: the ref is not self-sufficient

I ranked SCSS as the easy next one on the grounds that $color-primary-500 is an unambiguous reference. The compiler says otherwise, and I checked before designing rather than after:

@use './tokens';        →  tokens.$color-primary-500   OK
@use './tokens';        →  $color-primary-500          FAIL: Undefined variable
@use './tokens' as *;   →  $color-primary-500          OK
@use './tokens' as t;   →  t.$color-primary-500        OK
@import './tokens';     →  $color-primary-500          OK (deprecated)

The namespace comes from the consuming file, not the declaring one. Both styles are live in real projects: Bootstrap's 952-entry _variables.scss is consumed through @import, Vuetify's per-component ones through a namespaced @use.

Codegen writes the consuming file, so it can choose — but only if it is told which file to import. So a SCSS token carries from, and TokenMapping.candidate.from surfaces it. This is the shape icon_map already uses: return the svg's path, don't fabricate an import specifier. The tool description, the codegen prompt and the figma-codegen skill all state that emitting the ref without the @use is a compile error, not a style nit.

ProjectToken's ref union gains a third arm, keeping refOf total by construction. That generalization is also what Panda and DTCG were blocked on.

Decisions worth reviewing

  • Both declaration kinds in a .scss file are read. $name variables (need the @use) and :root { --name } custom properties (compile through untouched, so var(--name) resolves with no import). Modern SCSS projects use both; reading one would leave half the project joining against nothing — an incomplete fix I'd have to come back for.
  • No utility or category is derived. SCSS generates no classes, and a namespace-shaped stem would put bg-primary-500 back into circulation on a project that has none.
  • Rule-scoped variables are dropped. Sass scopes them to the block, so referencing one from a generated component does not compile.
  • The scanner is generalized, not copied — one implementation, a dialect per language. SCSS adds // line comments and !default/!global flags. A brace inside a // comment would otherwise open a block that never closes, tagging every later variable with a bogus scope so the caller drops it.
  • .sass (indented syntax) is deliberately not walked. Its declarations are newline-terminated, which the value reader would run straight past — a token whose value swallows the following lines is worse than no token.

Verification

  • 13/13 ref+import pairs compile through dart-sass, across both @use styles, for every token the reader emits.
  • Real-file corpus — Bootstrap (952 vars), Vuetify, Bulma, Foundation: every invariant clean, ≤2.2ms per file. Values spot-checked on the hardest file are exact, including quoted font stacks with commas, rgba($black, .15), and multi-line maps. The one self-contained file round-trips 65/65 refs; the rest fail to compile standalone at all, having been lifted out of their repos — a harness limit, not a reader defect, and I verified that distinction rather than assuming it.
  • Live end-to-end against a real Figma file: 0 tokens → 6 tokens, 6 high-confidence matches, each carrying its declaring file, with spacing/4 correctly unmapped rather than a fabricated p-4 (SCSS is not utility-first).
  • Five mutations tested; two initially survived and both were real test gaps — nothing asserted the join surfaces from, and my //-comment test passed either way because the prelude guard happened to save it. Both closed, both now fail on mutation.

Full gate green: typecheck · lint · format:check · knip · build · test (1614).

`scss` has been a detected styling system since before the token join existed,
but every source was CSS — so a SCSS project joined against an empty pool and
token_map returned nothing for it.

The reference form is what made this more than a third parser. Verified against
dart-sass rather than assumed: under a plain `@use './tokens'` — the modern
default — the bare `$color-primary-500` is an undefined-variable error, and the
reference is `tokens.$color-primary-500`. The namespace comes from the
*consuming* file, not the declaring one. Both styles are live in real projects:
Bootstrap's 952-entry `_variables.scss` is consumed through `@import`, Vuetify's
per-component ones through a namespaced `@use`.

Codegen writes the consuming file, so it can choose — but only if it is told
which file to import. So a SCSS token carries `from`, and `TokenMapping
.candidate.from` surfaces it, the same shape as icon_map returning an svg's path
rather than fabricating an import specifier. The tool description, the codegen
prompt and the figma-codegen skill all say the ref is not self-sufficient:
emitting it without the `@use` is a compile error, not a style nit.

ProjectToken's ref union gains a third arm for it, keeping refOf total by
construction. That generalization is also what Panda and DTCG were blocked on.

Both kinds of declaration in a `.scss` file are read, because modern projects
use both and reading one leaves half the project joining against nothing: `$name`
variables (which need the `@use`) and `:root { --name }` custom properties (which
compile through untouched, so `var(--name)` resolves with no import). No utility
or category is derived — SCSS generates no classes, and a namespace-shaped stem
would put `bg-primary-500` back into circulation on a project that has none.

Variables declared inside a rule are dropped: Sass scopes them to that block, so
referencing one from a generated component does not compile.

The scanner is generalized rather than copied — one implementation, a dialect
per language. SCSS adds `//` line comments and `!default` / `!global` flags; a
brace inside a `//` comment would otherwise open a block that never closes, and
every later variable would be tagged with a bogus scope and dropped.

Verified: 13/13 ref+import pairs compile through dart-sass across both `@use`
styles; a real-file corpus (Bootstrap 952 vars, Vuetify, Bulma, Foundation)
parses with every invariant clean at ≤2.2ms per file, and the one file that is
self-contained round-trips 65/65 refs — the rest fail to compile standalone at
all, having been lifted out of their repos. Live end-to-end against a real Figma
file: 0 tokens before, 6 tokens and 6 high-confidence matches after, each
carrying its declaring file.
…and pool a SCSS project's CSS

Two gaps found reviewing the branch, the first the same class of miss as the
icon_map one in #149 — fix the forward join, forget the other surface.

**`get_design_context`'s `projectTokens` annotation dropped `from`.** It emitted
`$color-primary-500` with no way to know which file to `@use`, which is exactly
the compile error the rest of this branch exists to prevent. That annotation is
the surface a caller reads when the document has no bound variables to join —
the commoner case, not the rarer one — so this was the more likely of the two
paths to be hit. `ProjectTokenMatch` gains an optional `from` (additive on the
wire) and `toMatch` carries it.

**A SCSS project's `.css` files were not pooled.** The JS-config path pools the
config with the repo's CSS; the SCSS path returned only its `.scss` pool, so a
project keeping Sass variables in `.scss` and a global `:root` block in a plain
`.css` file got half its tokens. Both walks are already aggregations, so pooling
does not turn a precise source into a fuzzy one — the argument that kept the
Tailwind v4 entry unpooled does not apply here.

Also verified while reviewing, and worth recording: the dialect generalization
touched the scanner every project's CSS path runs through, so its CSS output was
differentially compared against main over the repo's own stylesheets plus the
shapes the scanner's comments name as its historical failures (minified
trailing declaration, `/*` inside a url string, `//` which is *not* a comment in
CSS, unterminated string and comment). Byte-identical across all of them.
… join behaviours

`get_design_context`'s description spells out the projectTokens annotation's
shape for agents, and still described it without `from` after the previous
commit added it — so the surface that actually carries a SCSS ref in the common
case (no bound variables to join) documented a ref with no way to make it
resolve.

Two behaviours verified while reviewing and now asserted, both previously
untested:

- `from` follows the token that *matched*, not the first one seen. Per-component
  variable files repeat names across files — Vuetify ships ~90 — so a value-match
  landing on the second file must carry that file. Pointing the `@use` at a file
  that does not declare the given value is a compile error that reads as a token
  problem.
- A `docs/figma-token-map.md` row resolves against a SCSS variable written with
  or without its sigil, and the resulting candidate still carries `from`. A
  recorded override that came back unusable would silently degrade the one
  mechanism meant to be authoritative.

Also checked, no change needed: the walk excludes vendored (`node_modules`),
gitignored and `.sass` files; `ProjectTokenMatch` has no plugin-side consumer, so
adding an optional field is additive on the wire.
All seven were verified against dart-sass before and after; five would have
produced a wrong or missing token on ordinary real-world input.

**`//` inside an unquoted `url()` was read as a line comment.** Stylesheets are
full of `url(http://…)` and `url(//cdn…)`, which Sass parses as raw text. Read
as a comment, the phantom comment ate the rest of the line including its `)` and
`;`, so the enclosing rule never closed and every later variable was tagged
rule-scoped and dropped. `.hero { background: url(http://cdn.io/a.png); }`
followed by any `$var` returned **nothing at all**, and
`@import url(https://fonts.googleapis.com/…)` — a common first line — did the
same. As a value it was worse: `$cdn: url(//cdn.io/a.png);` produced
`url($other: 4px;`, a value that had swallowed the next declaration, which is
precisely the failure the scanner's own header says it exists to prevent.

**The CSS half of a `.scss` read used the plain-CSS dialect**, which has no `//`
comments — so `:root { // brand \n --brand: … }` silently dropped `--brand`, and
a `//` comment containing a `}` dropped the whole file. The branch's own test
asserts this case for `$variables`; the custom properties beside them had no
such protection.

**The `@use` instruction was wrong for any consuming file not at the repo root.**
`from` is repo-relative, but Sass resolves `@use` against the *importing* file.
Verified: from `src/components/card.scss`, `@use 'src/styles/_tokens.scss'`
fails with "Can't find stylesheet to import". Every surface — the note, both
tool descriptions, the codegen prompt and the skill — told the model to emit
that path verbatim, so following the guidance produced exactly the compile error
the `from` mechanism was added to prevent. All five now say the path is
repo-relative and must be re-resolved from the file being written.

**A name-only match reported the first file at full confidence.** `bestNameMatch`
dedupes by name, which was free while repeats differed only in value and shared
a ref — but a SCSS ref resolves *through* its file, so two files declaring
`$radius-lg` yielded the wrong file and the wrong value at `confidence: 1`,
`status: 'high'`. It is now capped to the same "verify me" level the
value-ambiguous path uses, rather than inventing a winner.

**Pooling the two walks could report a token ambiguous with itself.** A `:root`
block in a `.scss` file and the compiled `.css` committed beside it are one
declaration seen twice; left duplicated, an exact name+value hit degraded to
`medium` with `ambiguousWith` naming the token itself. Deduped on full identity,
so two *different* files declaring one name are still kept apart.

Two smaller ones: `!default !global` on one declaration (legal Sass) left
`!default` in the value, and a top-level `#{…}` interpolation truncated the
value at the interpolation's `}`.

The CSS path remains byte-identical to main — re-proven differentially over the
repo's own stylesheets plus every shape above. That check caught one of my own
fixes overreaching: making the flag strip repeat had also changed CSS output for
`!important !important`, so the CSS dialect keeps its single strip (a second
`!important` is invalid CSS; repeating is a SCSS-only need).
…tion from hiding scope

Found by a new kind of check rather than another read-through: dart-sass's own
`meta.module-variables()` is an authoritative list of what a file declares, so
comparing this reader against it over whole cloned repositories (Bootstrap and
Bulma, 146 files) answers "did we get this file right" without anyone having to
imagine the syntax first. Every SCSS defect so far had been the same shape — a
real-world construct the parser had never been shown — which unit tests cannot
find, because they only contain syntax someone already thought of.

It disagreed in three places, all in the direction that matters (a name emitted
that the compiler says does not exist), and they were two classes:

**Private variables were emitted.** Sass treats a leading `-` or `_` as private:
the member is not exported, `meta.module-variables()` does not list it, and
`@use`-ing the file and naming it is an error. Bootstrap's `$_luminance-list` is
one. A ref to it cannot resolve from any other file — exactly what this reader
exists not to produce.

**Interpolation in a selector hid the enclosing scope.** In
`.table-#{$state} { $color: … }` the interpolation's braces are not block
braces; read as structure they open and close a phantom block, so the real
rule's contents looked module-level. Bootstrap's mixin-local `$color`,
`$hover-bg`, `$striped-bg` and friends were emitted as project tokens. Values
already handled interpolation; preludes did not.

With both fixed the oracle reports full agreement across all 146 comparable
files. The two remaining differences before the fix were the only ones in the
corpus, so this closes the class rather than another instance of it.
A review agent recreates scratch probes in the test tree while it runs, and a
`git add -A` between two of its writes picked one up. It has no assertions, so
CI's lint gate caught it — the right outcome, but the file should never have
reached the commit. Added to .git/info/exclude so the pattern cannot repeat.
…stop a fileless token capping a match

Five findings from review, three of them one root cause: the *mirror* layout —
one file declaring both forms of the same token — which this reader set out to
support and never tested as a single token.

    $brand: #6266F0;
    :root { --brand: #{$brand}; }

That is one logical token with two reference forms, not two tokens. Left as two,
an exact name+value hit degraded to `medium` and reported the token
`ambiguousWith` *itself* — the failure `dedupeTokens` exists to prevent, which it
could not catch because its key included the reference form that differs. The
pair now collapses, and the custom property wins: `var(--brand)` compiles from
any consumer with no import at all, while `$brand` needs an `@use` resolved
against the consuming file. Preferring the self-sufficient ref is the point of
having a choice. Two *different* .scss files declaring one name are still kept
apart — those are genuinely different declarations.

That also fixes the annotation handing codegen the import-requiring ref while
discarding the import-free one, which was the same duplication seen from the
value index.

**A pooled custom property has no declaring file, and its absence counted as
"a different file"** — so `fileAmbiguous` capped every name-only match on the
mirror layout, where exactly one file declares the name. Only another
file-bound token can make that choice ambiguous.

Two smaller ones: the aggregate note counted the raw walks rather than the
collapsed result, so a repo committing its compiled .css claimed more tokens
than it was handed; and an explicit `tokenSource` pointing at a `.sass` file
fell through to the CSS reader, returned nothing, and then dropped into the repo
pool under a note that never mentioned the file the caller asked for — it now
says the indented syntax is not readable here.
…ix had missed

The previous commit collapsed the mirror layout on the aggregate path and left
the explicit `tokenSource` path reading the same file without it — the same
half-fix shape as the icons-only detection earlier in this branch: fix the path
I was looking at, leave the sibling.

It matters more here, not less. A caller passes `tokenSource` to narrow a large
repo to the file that declares its tokens, which is exactly the file that
carries the mirror, so `token_map --tokenSource src/_tokens.scss` was the most
likely way to hit the self-ambiguity.

Also pins two properties of the collapse that had no test: a mirror whose two
forms hold *different* values stays two tokens (two real declarations the
value-match join needs), and the collapse is order-independent.
…w findings

The headline one: the mirror fix did not work on the form its own docstring uses
as the example. `:root { --brand: #{$brand} }` gives the custom property the
literal value `#{$brand}` while `$brand` holds `#6266F0`, so a repo-wide
name+value fold never fired for it — only for the literal spelling, which is
what every test used. I wrote the example and tested something else.

Fixed at the level the idiom actually lives at: `parseScssFile` reads one file's
two halves together, resolves a custom property whose whole value is `#{$var}`
against the variables declared beside it, and folds the pair there. That also
removes the repo-wide fold, which could let a `--x` in an unrelated stylesheet
displace a genuinely declared `$x` from another file, and makes the interpolated
custom property carry a value that can value-match at all.

The rest:

- An explicit `.scss` `tokenSource` that is a barrel (`main.scss` =
  `@use './tokens';`) returned an empty pool with no diagnostic — SCSS's
  commonest entry shape, and omitting tokenSource entirely would have worked. It
  now falls back to the pool and says why, as the CSS branch beside it already did.
- The `.sass` refusal note was computed and then dropped whenever a pool was
  found, i.e. on every real repo — the exact "note never mentions the file the
  caller asked for" the guard was added to prevent. Refusals now ride along.
- A custom property declared inside a `@mixin` or `@function` body became a
  project token. It only exists where that mixin is included, so `var(--x)`
  resolves to nothing in a component that never includes it — the asymmetric
  twin of the rule-scoped `$var` case this branch already fixed.
- Two files declaring one name+value listed the candidate as `ambiguousWith`
  its own name, which reads as a data error and tells the caller nothing. Those
  are the same token in two files, distinguished only by `from`; the confidence
  cap stays, the useless alternative is gone. A same-value sibling with a
  *different* name is still listed, which is what the field is for.
- A map-file override resolving to several file-bound tokens returned an
  arbitrary file at confidence 1. A recorded ref cannot name a file, so that
  choice is this join's, not the author's — capped like the name-only path. One
  file, and a recorded mapping keeps the certainty it earns.
- `dedupeTokens` had a branch that could never fire, since the two key spaces
  were disjoint; with mirroring moved per-file it reduces to the cross-walk
  duplicate it was originally for.
…ected

Found by measuring the SCSS read on a real 122-file project (Bootstrap) rather
than a fixture — 57ms cold, 1192 tokens, and a note that opened with:

    no token source detected; pass tokenSource; aggregated 1192 token(s) from
    22 .scss file(s): …

A sentence contradicting itself in its own second clause. `resolveTokenSource`
returns that phrase for any project with no config file to find, which is every
SCSS project — there is no such thing to detect there — and the fallback
forwarded it unconditionally alongside its own description.

The two are different kinds of message and now have different fields: `refusal`
answers what the *caller asked for* (a `.sass` tokenSource this cannot read) and
must survive into whatever the loader falls back to, while `note` describes the
project and is replaced by whatever actually read it.

Measured while there and left alone: the walk is 57ms cold on Bootstrap and the
annotation index caches to 0ms warm, so the extra per-project scan this branch
adds is not a cost worth complicating the code for.
…lose two value defects

Four findings, one of which is a repeat of a bug I fixed earlier on this same
branch.

**scss-file.ts was a binary file.** I wrote the dedup separator as a literal NUL
instead of the escape every sibling module uses — the exact mistake I fixed in
token-index.ts a few commits ago, made again in a file added while fixing it.
Git classifies the whole module as binary, so `git diff main...HEAD` renders the
core new logic of this PR as "Binary files differ", and every future diff, blame
and grep on it is dead. Nothing in CI caught it: the runtime string is identical,
so typecheck, lint and the tests all passed.

Twice is a pattern, so there is now a test: no `.ts` file under src or test may
contain a raw control byte. It catches a reintroduced NUL, and it caught its own
prose embedding the byte it warns about, which is how I found that the first
draft of the guard was itself binary.

**`sass-embedded` was not detected as SCSS.** It is the compiler Vite documents
alongside `sass` and the common choice on a modern Vite/Vue/Nuxt project, so the
whole SCSS token source silently never ran on exactly the projects it was built
for — `system: 'unknown'`, every Figma variable unmapped, and a note that never
mentions SCSS.

**Resolving an interpolation could newly create duplicates.** `--brand: #{$brand}`
in `:root` and a literal `--brand: #6266F0` in a theme class are one token once
resolved, but the CSS parser deduped *before* resolution, so both survived and
the join reported the token ambiguous with itself — on the explicit `tokenSource`
path, which is the one most likely to be pointed at a mirrored file.

**`!important` was left in SCSS values.** It is legal in a Sass variable value,
and `CssDeclaration.value`'s own contract says it is stripped; leaving it made
`$spacer: 1rem !important` unable to match its Figma counterpart or its
`--spacer: 1rem` twin, which is the reason the CSS arm strips it.
…in every cap

Six findings, one of them a regression against this server's own previous
behaviour — the bar I set for this whole line of work.

**The cross-file mirror was a regression.** `_vars.scss` for the build-time Sass
variables plus a hand-written `global.css` `:root` block for runtime theming,
same palette, is a standard SCSS layout. Keeping both halves cost such a project
twice: an exact hit degraded from 'high' to 'medium' (the join saw two same-value
tokens and could not split them), and the surviving ref flipped to `$primary`,
which needs an `@use` codegen must invent — where reading only the CSS, which is
what this server did before SCSS was a source at all, returned `var(--primary)`
at full confidence. `parseScssFile` already stated the right rule ("the custom
property wins, because var(--brand) compiles from any consumer with no import");
it just was not applied across files. Now it is.

**And the same rule was too eager within a file.** A `--brand` declared under
`.theme` resolves to nothing outside that selector, yet it displaced the
`$brand` that is referenceable anywhere through `@use` — losing the only usable
ref. Only a document-wide declaration may stand in for a variable now, which is
the trade the docblock actually describes.

**A cap that explains nothing is worse than no cap.** Two files declaring one
name+value produced `status: medium` with no field saying why, so `from` read as
a resolved answer rather than one of several candidates — and `ambiguousWith`
cannot express it, since it carries token *names* and these siblings share one.
`candidate.ambiguousFrom` now lists the other declaring files, on all three
paths that cap (value, name and map-file), and the tool contract says so.

Three smaller ones: the `.sass` refusal was still dropped on the one path where
nothing at all was found; a project with zero `.scss` files got a note reading
"from 0 .scss file(s):  and 1 .css file(s)"; and that same note attached the
`@use` instruction to a pool where no token carries a declaring file — telling
the model to import something for a `var()` that needs nothing, in the file it
then writes.

Re-verified after the changes rather than citing the earlier runs: dart-sass
oracle 146/146 files in agreement, 395 emitted refs compile with 0 failures, and
the CSS path is still byte-identical to main across 16 inputs.
@awdr74100
awdr74100 merged commit ec4470b into main Aug 16, 2026
2 checks passed
@awdr74100
awdr74100 deleted the feat/scss-token-source branch August 16, 2026 17:17
awdr74100 added a commit that referenced this pull request Aug 16, 2026
`walkRepoFiles` fed five callers an order that changed between runs. fdir crawls
directories concurrently, so the same unchanged repo returns the same *set* in a
different sequence each time — measured, not inferred: two consecutive runs over
Bulma disagreed on the first three files.

It reached the output. `token_map`'s note sampled a different six files each
call, and on a repo where several files declare one token name the `from` handed
back changed run to run — pkg0 on one call, pkg1 on the next, same input. That
matters more since #151, because `from` is now the file a caller must `@use`,
and it is the input to any future comparison of one run against another.

The walk already buffers the whole crawl before yielding, so ordering it costs
one sort and no change in memory or streaming behaviour.

Shallowest-first, then by code unit. Depth leads as a tie-break *preference*, not
as a repair: one caller takes the first match rather than aggregating
(`findTailwindCssEntry`), and a v4 entry is conventionally shallow, so ranking
`src/index.css` above `src/a/b/deep.css` is the better guess where plain a-z
would invert them. Stated as a preference because no fixture made that caller
vary between runs — three tries, including 300 packages of decoys — so its
stability here is a by-product, not evidence of a bug fixed. Code units rather
than `localeCompare`, since locale-aware collation is environment-dependent,
which is the property this change exists to remove.

Never worse, checked at the level a caller consumes rather than the level that
changed: over Bootstrap, Bulma and vue/core, every joined mapping is identical to
main's — no status change, no ref change, no pool-size change. Each of the five
consumers is byte-stable across three runs, and each returns the same content as
main as a set.

Four mutations pinned: no sort, plain a-z, locale collation, and the ordering
tests' own blind spot — the existing suite's helper sorted its results, which is
why none of its seven tests could see the order the walk actually produced.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant