Skip to content

Align the site with the LeanCode palette, from one source - #80

Draft
claude[bot] wants to merge 21 commits into
mainfrom
claude/lncd-brand-palette
Draft

claude[bot] wants to merge 21 commits into
mainfrom
claude/lncd-brand-palette

Conversation

@claude

@claude claude Bot commented Sep 9, 2026

Copy link
Copy Markdown

Summary

Brings advanced-forms.leancode.co in line with the LeanCode design system (the LNCD handoff from Claude Design) and makes the palette a single source of truth across the whole site — the follow-up to leancodepl/ciach#47.

Why one source. The colors lived in nine places across four languages: landing/web/landing.css (light + dark), docs_app/app/global.css (the same --af-* set again, plus a third copy as literal hex in the Fumadocs @theme blocks), both OG routes (TS constants), components/logo.tsx, island_frame.dart (the accent inside the live Flutter demos), and four SVGs (app/icon.svg, public/logo-*.svg, landing-icon.svg). They had drifted: three different yellows (#edff2f, #f9ff07, #f6ff6b), a #050505 ground, blue-tinted grays.

The source. docs_app/palette.json names the design system's swatches (black, white, ctaYellow #f0ff00, bodyGray, mutedGray, surface/surface2, error, success, plus the site's own tints for the light theme) and the light/dark --af-* themes built from them, each token a swatch or a swatch at an alpha.

The generator. npm run palette:generate (scripts/palette.mjs) writes every consumer's copy, all committed:

  • app/af-tokens.css and landing/web/af-tokens.css — the tokens as CSS variables (global.css imports one; the landing links the other before landing.css, and landing.mjs copies it into public/)
  • lib/palette.generated.ts — for the OG cards and LogoMark, via the small lib/palette.ts wrapper (withAlpha)
  • flutter/lib/support/palette.generated.dartafLight/afDark for islandTheme
  • landing/lib/palette.generated.dart — the landing's theme-color
  • recolors SVG shapes marked data-palette="ctaYellow" in place, so the logos stay single files of artwork

npm run palette:check fails CI when any of them drifts (wired into docs.yml next to examples:check).

Also in here. The Fumadocs --color-fd-* variables are now @theme inline references to the tokens (no dark block, no third copy); alpha variants derive via color-mix() in CSS and withAlpha() in TS; the unused --af-accent-2 is gone (hover borders use the accent). Light theme values that the design system doesn't specify (body gray, strong hairline, the accent and signals as text on white) are named …OnLight tints in palette.json, derived from the design-system hues.

Also merged in: the landing's move to Jaspr @css getters (claude/busy-bell-xjh9eo, commit 9bd3648, combined in d1c5acc). landing.css is gone; every component styles itself next to its build, and lib/styles.dart holds the fonts, tokens, reset and utilities — ciach's structure. The palette plugs in the same way it does there: _tokens spreads afLight/afDark's cssVariables (no more typed-out colors), the hero glows, primary-button hover and pill wash derive through colorMix() and tokens, and --af-accent-2 is gone. Because main.server.dart now imports the gitignored main.server.options.dart that jaspr_builder writes, the docs CI runs dart run build_runner build before analyzing the landing page.

Also in here: every landing class has one owner (d55102a, from the review thread on button.dart). Each component keeps the classes it renders as typed ClassName constants and spells its selectors and classes: attributes from them; variants carry their class as enum values. Pieces two files styled or rendered by string are now components of their own — CopyButton, Logo (with large), ButtonRow (af-actions), the Card family in card.dart, the Section vocabulary (Eyebrow, Lead, Checklist, MoreLink, DemoSlot, Section.plain) — and styles.dart keeps exactly one class of its own, container. The names landing.js and the docs' global.css depend on are unchanged; the rendered stylesheet differs only in the moved and renamed rules, and both themes' screenshots pixel-match the previous build. Because one stylesheet covers the whole page, class names are locally scoped by jaspr_class_scope 0.1.0 (e5b1952, 56a287c, d06bd19). A component carries @scopedCss and a part 'hero.scopes.dart'; the builder writes its scope into that part file from the component's package, path and name, and a check phase fails the build when two components anywhere in the package graph would share a suffix. _class('af-grid') then renders as af-grid-<suffix>, and ClassName.shared keeps the names landing.js and the docs' global.css know as written. The part files are generated on every build and not committed, so the docs workflow's build_runner step covers them next to the Jaspr options.

Out of scope on purpose: typography and shape (Space Grotesk / JetBrains Mono, the 14px radii and pill buttons stay).

Test plan

  • npm run lint, npm run format, npm run types:check, npm run palette:check — clean
  • dart analyze --fatal-infos and dart format --set-exit-if-changed in landing/ — clean
  • dart format --set-exit-if-changed lib/support in docs_app/flutter under the package's 3.8 language version — clean
  • npm run landing:build (Jaspr) and next build — succeed; /og/landing.png and /og/[...slug] prerender
  • Landing refactor: rendered <style> diffed rule-by-rule against the previous build (only the moved/renamed rules differ); full-page screenshots of / in both themes pixel-match it
  • Scoping: build_runner writes the 11 part files, jaspr build renders 67 scoped class names, and the names outside a component — af-landing, af-container, the af-example* and af-tab-input contract with landing.js and the docs' global.css — are unchanged.
  • Screenshots of /, /docs, /docs/faq in both themes via next start: computed --af-bg/--af-accent and --color-fd-* resolve to the palette in each theme, no page errors; both OG cards render with the new yellow
  • flutter analyze / flutter test in docs_app/flutter — no Flutter SDK in my environment; CI's build job runs them
  • Eyeball the preview: hero glow, the highlighter "Simple code." in light mode, a live demo's accent (yellow focus/buttons inside the island), docs callouts and diff markers

🤖 Generated with Claude Code

https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv

The landing page, the docs, their social cards, the Flutter demos and the
logos each carried their own copy of the colors — nine in all, across CSS,
Dart, TypeScript and SVG — and they had drifted: three different yellows,
a #050505 ground instead of black, blue-tinted grays where the design
system wants warm ones.

Name the design system's swatches once in docs_app/palette.json, together
with the light and dark --af-* themes built from them, and let
scripts/palette.mjs write every copy: af-tokens.css for the landing page
and the docs, palette.generated.ts for the OG cards and the logo mark,
palette.generated.dart for the live demos, the landing page's theme-color,
and the fill of the SVG shapes marked data-palette. The generated files
are committed and `palette:check` fails the docs CI when they drift, the
same way examples:check guards the manifest.

While at it: the Fumadocs variables become @theme inline references to
the tokens instead of a third literal copy, alpha variants derive through
color-mix() and withAlpha(), and the unused --af-accent-2 goes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
web/landing.css held every rule for the page in one 1,350-line file, and
main.server.dart read, minified and inlined it at render time. The styles
now live in Dart, the way the ciach website does it: each component
declares the rules for the classes it renders in a `@css static
List<StyleRule> get styles` next to its `build`, and lib/styles.dart holds
what is not any one component's — the @font-face rules, the --af-* tokens
for both themes, the reset, the container and skip-link utilities and the
reduced-motion rule. The token colours for highlighted code sit in
highlight.dart, next to the code that emits the classes.

jaspr_builder bundles the getters into lib/main.server.options.dart, and
the entrypoint passes `defaultServerOptions`, so Jaspr renders them as the
one `<style>` in the head; nothing render-blocking is fetched, as before.
The `_Stylesheet` component and web/landing.css are gone, and the
docs_app build script and both READMEs describe where styles live now.

Where Jaspr's typed properties cannot say something — color-mix(), :has(),
counter(), an infinite animation, a @font-face with a weight range — the
rule uses its `raw` map. The generated CSS was diffed rule by rule against
the old sheet (only equivalent shorthand expansions differ), and
screenshots of the hero, an example frame, the features and the footer are
pixel-identical to main in both themes at 1280px and 390px. The Lighthouse
fixes from #79 (contrast tokens, underlined footer links, the tab strip
layout, selection and focus ring, :has() panel switching) carry over.

Requested by Komoszek in review of #79.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U992t9eVSDf5TZXpaxRbju
main now inlines landing.css into the page instead of copying it to
public/, so the generated af-tokens.css is inlined the same way, ahead of
the rest of the sheet, and the copy-and-ignore entries for it go. main's
Lighthouse contrast fixes to the old palette are carried by darkening the
light muted gray one step so it reads AA on the paper footer too.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

Docs preview: https://advanced-forms-n76s40gec-leancode.vercel.app

Built from 9987735; the landing page is at /, the docs under /docs.

Comment thread docs_app/flutter/lib/support/island_frame.dart Outdated
Comment thread landing/lib/palette.generated.dart
Comment thread landing/lib/palette.generated.dart
Review: the landing page's generated Dart held one color, and islandTheme
copied its tokens into locals one line at a time. Both Dart consumers now
get the same shape as ciach's landing page — an `enum Palette` of the
swatches with color, hex and alpha(), and an AfTheme built from it for
each theme — in Jaspr's Color for the landing and dart:ui's for the
Flutter demos; the landing's theme-color derives from afDark. islandTheme
destructures the theme with an object pattern.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Review: the landing's whole theme should come from these colors, not from
a second generated stylesheet. The generated AfTheme gains a cssVariables
map and _Stylesheet writes the :root and .dark blocks from afLight and
afDark ahead of landing.css, so landing/web/af-tokens.css goes; the docs
keep their generated af-tokens.css, since Next consumes CSS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Combines the landing page's move to Jaspr @css getters with the palette.
The tokens block in styles.dart is written from afLight and afDark rather
than typed out, the hover, glow and pill colors become tokens and
colorMix(), --af-accent-2 goes (hover borders use the accent), and the
docs CI runs build_runner before analyzing the landing page, since
main.server.dart now imports the generated options.
@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Author

Merged claude/busy-bell-xjh9eo (the landing's move to Jaspr @css getters) into this branch in d1c5acc, with the palette wired into its styles.dart the way ciach does it — _tokens is written from afLight/afDark, the remaining literal colors became tokens and colorMix(), and CI now runs build_runner before analyzing the landing so the generated options exist. Verified: analyze, format (including the generated options), Jaspr build, palette:check, and renders in both themes. The busy-bell branch itself can be deleted once this lands.

🤖 Generated with Claude Code


Generated by Claude Code

Comment thread landing/lib/highlight.dart Outdated
Comment on lines +32 to +39
'keyword': '--tk-keyword',
'type': '--tk-type',
'string': '--tk-string',
'number': '--tk-number',
'comment': '--tk-comment',
'annotation': '--tk-annotation',
'function': '--tk-function',
'operator': '--tk-operator',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To iterate over such an ad-hoc collection, I'd use a list of pairs; might be simpler

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 8d4cd30 — and one step simpler than pairs: the variable is always --tk-<token>, so it's now a plain list of the token names, for (final token in const ['keyword', 'type', …]) css('.tk-$token').styles(color: Color.variable('--tk-$token')).

🤖 Generated with Claude Code


Generated by Claude Code

Review: iterating a map's entries for an ad-hoc collection was more than
the job needed, and the variable is always --tk-<token> anyway.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv

@Komoszek Komoszek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should definitely centralize the branding/colors do we don't end up with 1000 different palettes 🙈 the color generation script could definitely be improved, but it's really important

Comment thread landing/README.md Outdated
## Styles

There is no stylesheet file. Every component declares the rules for the classes it renders in a
`@css static List<StyleRule> get styles` getter next to its `build`, the way [ciach's

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO we shouldn't mention ciach or aby other package that is not the real branding package (maybe we should create the company-wide branding package? 🤔 )

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — every ciach mention in the READMEs and code comments is gone in cb6d87e. On a company-wide branding package: palette.json is shaped for exactly that (the swatches named as the design system names them, the themes as tokens built from them), so lifting it and the generator out into a shared package is the natural next step — out of scope for this PR, though.

🤖 Generated with Claude Code


Generated by Claude Code

Comment thread landing/lib/components/button.dart Outdated

@css
static List<StyleRule> get styles => [
css('.af-button').styles(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT about extracting the af-button to some const and reusing it instead of the raw strings? The same applies to sll the other components

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, but it's a bigger bite than it looks here: 71 distinct classes across 125 selectors and 83 classes: strings in 10 components, and many selectors are compound (.af-button-primary:hover, .af-hero-grid > *) or shared between files. That code just landed with the @css refactor, so I'd rather not reshuffle it inside this PR — leaving the call to @PiotrRogulski whether to fold it in here or follow up separately.

🤖 Generated with Claude Code


Generated by Claude Code

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compound selectors arę not a problem, just do string interpolation. If for some reson we had to export the base classnames then IMO something iż wrong with the code. We shouldn't need to target the internal classname

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Measured it, and you're right that it isn't just a rename: of 82 classes, 17 cross a file boundary. 3 are the intended global utilities from styles.dart (af-container, af-landing, af-skip-link); the other 14 are real couplings — footer.dart renders hero.dart's af-hero-actions/af-center, sections.dart renders five of section.dart's classes (af-checklist, af-eyebrow, af-lead, af-section-demo, af-section-more), footer.dart styles nav_bar.dart's af-logo, and af-copy-idle/af-copy-done are styled in both hero.dart and example_frame.dart. Consts would surface each of those as an import, which is the point — but resolving them properly means moving shared rules (an actions row, a copy button, the logo) to shared homes, i.e. a component-boundary pass over the @css refactor rather than string interpolation alone. Happy to do it; whether here or as a follow-up is @PiotrRogulski's call.

🤖 Generated with Claude Code


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do whatever you want, make it good, future-proof, and scalable 🥰

Listen to your heart, there's nothing else you can do
~Roxette

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in d55102a, and it went further than a rename.

Typed names. ClassName is an extension type over the string (in styles.dart); every component keeps the classes it renders as static const ClassNames and spells both sides from them — css(_hero.selector), '${_grid.selector} > *' for selectors, classes: _hero.name / (container + _grid).name for the attribute. Compound selectors are interpolation, as you said; + combines two classes on one element. Variants carry their class as enum values (ButtonVariant.primary.className, CopyButtonVariant), so 'af-button-${variant.name}' is gone.

Boundaries. The 14 real cross-file couplings I listed are resolved by giving the shared piece an owner rather than exporting base names:

  • CopyButton — the hero's install line and the example toolbar rendered and styled the same af-copy-idle/done swap in two files, with a global [data-copied="true"] { !important } rule doing the work for both. One component now, two variants (framed, ghost), no !important.
  • Logo — was a function in nav_bar.dart styled there, overridden by the footer (.af-footer-brand .af-logo img). Now its own component with large: true, and the theme-switch classes are af-logo-light/dark instead of borrowing af-theme-* from the toggle.
  • ButtonRowaf-hero-actions (+ af-center) was used by the footer and the skill band and refined by .af-band .af-hero-actions; now af-actions with center/flush options.
  • Card, CardGrid, CardIcon in card.dart — the mode cards reused af-card/af-feature-icon from Features.styles by string; both grids now compose these and pass their own className for their own rules.
  • Eyebrow, Lead, Checklist, MoreLink, DemoSlot in section.dart — the section vocabulary sections.dart rendered by string; Section.plain covers the skill band's headerless section.
  • The > * min-width rules moved from styles.dart to the grids they belong to; af-accent to the hero; af-landing and the skip link to App; rich/externalLink to text.dart. styles.dart exports exactly one class, container.

The one export in the other direction is CopyButton.labelClassName, which the hero's install line hides under 540px — a parent reaching into a child it composes, which I think is the legitimate case.

Kept as they are: the names landing.js looks up (af-example*, af-tab-input, af-code-panel) and the ones the docs' global.css shares for the frame and the logo. The README's Styles section describes the rule.

Verified: dart analyze --fatal-infos and dart format clean; the rendered <style> differs from the previous build only in the moved/renamed rules (166 of 193 rules byte-identical, the rest are the ones above); full-page screenshots of both themes pixel-match the previous build (light: 0 pixels; dark: the 36 pixels of the pulsing demo dot).


Generated by Claude Code


/** The LeanCode swatches, as `#rrggbb`. */
export const swatches = {
/** Primary surface. LeanCode is black-first; yellow is the exception. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are those swatches used anywhere? Dart counterparts uses them to build the themes, but both CSS and TS variants inlines the colors into the themes

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes — LogoMark in components/logo.tsx takes its yellow from swatches.ctaYellow (that is what keeps the docs favicon and the OG cards on the palette), and as of cb6d87e the TS themes are built from the swatches too, like the Dart ones: bg: swatches.white, accentSoft: withAlpha(swatches.ctaYellow, 0.45), with withAlpha emitted alongside and lib/palette.ts reduced to re-exports. The CSS stays resolved on purpose: a --lc-black-style indirection would add a hop nothing else reads, and resolved values are what you want to see in DevTools. If a consumer ever needs the swatches as CSS variables, that's a few lines in the generator.

🤖 Generated with Claude Code


Generated by Claude Code

Review: the TS palette inlined the theme colors while the Dart one built
them from the enum, so now themes reference swatches with withAlpha()
emitted alongside, and lib/palette.ts only re-exports. The READMEs and
comments stop pointing at another package for the branding.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Each component keeps the classes it renders as typed `ClassName`
constants and spells its selectors and `classes:` attributes from them,
so a name is written once and a rename cannot miss a use. Pieces two
places styled or rendered by string are now their own components:
`CopyButton` (the install line and the example toolbar), `Logo` (header
and footer, with a `large` size instead of the footer's override),
`ButtonRow` (the action rows, `af-actions`), the `Card` family, and the
`Section` vocabulary (`Eyebrow`, `Lead`, `Checklist`, `MoreLink`,
`DemoSlot`; `Section.plain` for the skill band). The `> *` min-width
rules move to the grids they belong to, `af-accent` to the hero,
`af-landing` and the skip link to `App`; `rich` and `externalLink` get
their own file.

The class names `landing.js` looks up and the ones the docs' `global.css`
shares are unchanged. The rendered stylesheet differs only in the moved
and renamed rules; screenshots of both themes match the previous build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Comment thread landing/lib/components/footer.dart Outdated
Comment on lines +15 to +20
static const _footer = ClassName('af-footer');
static const _cta = ClassName('af-cta');
static const _ctaInner = ClassName('af-cta-inner');
static const _grid = ClassName('af-footer-grid');
static const _brand = ClassName('af-footer-brand');
static const _bottom = ClassName('af-footer-bottom');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question, if some other component used raw af-footer classname then would we have name collision? Is there any way in jaspr to avoid that?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would collide, and Jaspr has nothing built in to stop it. The @css getters are collected into one global <style>; there are no CSS modules, no hashed class names, no scoping — Styles inline on an element is the only scoped form, and it can't express hover, media queries or descendants. So the guarantee has to come from us:

  • By construction: a class exists only as a ClassName constant on the component that owns it, and everyone else gets to it through that component (composing it, or a public constant it exports). A raw 'af-footer' string in another file would be the one way to break that.
  • By check, as of fee6bbb: tool/check_class_names.dart reads lib/ and fails when the same ClassName('…') literal is declared in two places, or when a class or selector is written as a raw string ('af-…', '.af-…') outside a declaration. It runs in the docs workflow right after dart analyze. I probed it with a duplicate af-footer, a raw classes: 'af-hero af-x' and a raw css('.af-band h2') — all three are reported with file and line.

It can't catch a string built at runtime that happens to spell an existing class, but nothing in the codebase does that now, and the check would flag the literal pieces of most attempts. The README's Styles section mentions it.


Generated by Claude Code

@Komoszek Komoszek Sep 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revert this commit, I would prefere something more in line of css modules, idk, built time suffix scoped to the specific component, like some key or smth.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted in 9ddf572, and d804a71 does the CSS-modules thing instead.

ClassName now takes its owner: ClassName('af-footer', owner: SiteFooter) renders as af-footer-14zoy — the suffix is five base-36 digits of an FNV-1a hash of the owner's type name, computed when the static site is rendered, so it is stable across builds and short enough to read in the inspector. Two components can both call something af-grid and never meet in the stylesheet, and a raw 'af-footer' string anywhere else matches nothing. Nothing changes at the call sites: selectors and classes: are still spelled from the constant (css(_footer.selector), classes: (container + _grid).name), so the suffix is never written by hand. Variants carry it through their enum (af-button-eaqh0 af-button-secondary-eaqh0).

ClassName.shared('af-example') renders as written, for the names that are a contract with another file: what landing.js looks up (af-example*, af-tab-input, af-code-panel), what the docs' global.css uses for the same frame and logo, af-landing, and the af-container utility. styles.dart documents the rule; the README's Styles section too.

Verified: the rendered stylesheet with the suffixes stripped is rule-for-rule the same as the previous build, and full-page screenshots of both themes are pixel-identical (0 differing pixels).


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why a custom hash and the built-in hashCode?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we somehow simplifi this? Is it possible to infer the type from the usage in class? Do not commit anything just answer the question

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not from the enclosing class, no. Dart has no static self-type: inside class Hero, a static const _class = ClassScope() has nothing to infer T from — the enclosing declaration isn't part of the expression's type context, and typing the field (static const ClassScope<Hero> _class = …) just moves the name. There's no this in a static context, the @css getters have to be static for jaspr_builder to collect them, so there's no instance to take runtimeType from, and macros are gone. A StackTrace.current in the initializer would technically show Hero._class, but that's a trick I wouldn't put in a codebase.

The one way to get the owner for free is to make the names instances of something that knows its type — an enhanced enum per component:

enum _Hero with ClassNames {
  hero('af-hero'),
  grid('af-hero-grid');

  const _Hero(this.local);
  final String local;
}

css('${_Hero.grid.selector} > *')
div(classes: (container + _Hero.grid).name)

ClassNames is a mixin that scopes by runtimeType, so nothing is passed anywhere; the enum is the scope. It also absorbs the variant enums (_Button { button, primary, secondary }), which removes the _variantClass indirection. The cost: every call site changes from _grid to _Hero.grid, and each enum needs a distinct name (_Hero, _Footer, …) because that name is what gets hashed — the same one-name-per-component ClassScope<Hero>() spells today, just in a different place, and the same collision check would guard it.

Beyond that, the only route to zero declarations is a build_runner builder generating the scope per component. I wouldn't add a builder for one line.

My take: ClassScope<Hero>() is one line per component and leaves call sites alone; the enum is the same information with grouping and a bigger diff. Happy to switch if you prefer the grouping — say the word.


Generated by Claude Code

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think it's possible to genericise it and package it for reuse in different Jaspr projects?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, and cheaply. Nothing in ClassScope/ClassName is specific to this site — or to Jaspr, for that matter: it's ~60 lines of dart:core that produce strings, and Jaspr only ever sees the strings (css(x.selector), classes: x.name). The project-specific parts (container, the af- prefix, which names are shared) stay on the consumer's side.

What a package would add beyond a copy-paste:

  • Configurable scope: prefix, suffix length, and a strategy for the suffix. In particular a dev mode that renders af-hero-grid--Hero instead of af-hero-grid-174ao (what CSS modules do in development), switched by Jaspr.isDevelopment/kDebugMode or a builder flag, so the inspector reads like the source while jaspr build stays short and stable.
  • Jaspr sugar: extension on ClassName { StyleRule styles(Styles) } so a rule is _grid.styles(display: .grid) instead of css(_grid.selector).styles(…), plus child, hover, descendant helpers for the compound selectors we currently spell as strings.
  • The collision guard as it is, and a test helper (expectUniqueScopes()) for projects that want it in a test rather than at render time.
  • Tests and a README with the "one scope per component, shared for contracts" convention, so the rule travels with the code.

Where: a small package in the leancodepl org — jaspr_scoped_classes or similar — used by ciach's website/ and this landing first (that's already two consumers, and ciach has the same "one component, one file, global stylesheet" shape), published to pub.dev once it stops moving. Since the core has no Jaspr dependency it could even be a plain Dart package with the Jaspr sugar as an optional import.

I'd do it as a follow-up rather than in this PR: extract the current code verbatim, add the dev-mode names and the sugar, then point both sites at it. Happy to open that PR when you want it.


Generated by Claude Code

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update for this thread: as of e5b1952 the scoping no longer lives in this repo. It moved to jaspr_class_scope in flutter_corelibrary (pulled from a git ref until it's released), with a builder that answers both open questions here:

  • Inferring the owner: a component carries @scopedCss and a part 'footer.scopes.dart'; the builder writes _$siteFooterScope into that part file, so static const _class = _$siteFooterScope — no type spelled, nothing hashed at runtime.
  • The hash: computed at build time from the component's package, path and name, and the build fails on two components that would share a suffix.

ClassName.shared, .name, .selector and + are unchanged at the call sites; every suffix changes, so the class names in the earlier screenshots differ, the styles don't. styles.dart is back to fonts, tokens, reset and container. CI is green on e5b1952 and the README's Styles section describes the new shape.


Generated by Claude Code

Jaspr collects the `@css` getters into one global stylesheet and scopes
nothing, so two components declaring the same class would silently style
each other. `tool/check_class_names.dart` reads `lib/` and fails when a
`ClassName('…')` literal is declared more than once or a class is written
as a raw string outside a declaration; the docs workflow runs it next to
`dart analyze`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Reverts fee6bbb, per review: a source scan is the wrong tool. The next
commit scopes class names to their component instead, the way CSS
modules do, so a collision cannot happen in the first place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
Jaspr collects the `@css` getters into one global stylesheet and scopes
nothing, so `ClassName` now does what CSS modules do at build time:
`ClassName('af-grid', owner: Hero)` renders as `af-grid-<suffix>`, the
suffix five base-36 digits of an FNV-1a hash of the owner's type name.
Two components can use the same local name without meeting in the
stylesheet, and a raw string elsewhere matches nothing. Selectors and
`classes:` attributes are still spelled from the constant, so the suffix
is never written by hand.

`ClassName.shared` renders a name as written, for the classes that are a
contract with another file: the ones `landing.js` looks up, the ones the
docs' `global.css` uses for the same example frame and logo,
`af-landing`, and the `af-container` utility.

The rendered stylesheet differs from the previous build only in the
suffixes; screenshots of both themes are pixel-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
`ClassScope<T>` replaces the `owner:` argument on every class: a component
declares `static const _class = ClassScope<Hero>()` once and makes its
classes from it, `_class('af-hero-grid')`. The suffix is unchanged, so
the rendered page is byte-identical to the previous build. Two scopes
that would hash alike now fail the build the first time either renders.
The button variants carry their class before scoping, and the component
scopes it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
The landing carried its own ClassScope/ClassName in lib/styles.dart,
which hashed the Type at runtime. That mechanism now lives in
jaspr_class_scope, where the suffix is computed at build time from the
component's package, path and name, and a build step fails on two
components that would share one.

Each component that styles itself carries @scopedCss and a part
directive; the builder writes its scope into the part file, which is
generated on every build and not committed.

Every suffix changes, so the rendered class names differ from the ones
in this PR's screenshots; nothing about the styles themselves does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
jaspr_class_scope_builder now checks scopes across every package in the
build, not only this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
jaspr_class_scope now keeps the component's name in the constant it
writes, so `_$heroScope` reads `_$HeroScope`. The suffixes, and with
them every rendered class name, are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
The builder now asks which library `@scopedCss` comes from, which this
site needs to keep working: its components reach the annotation through
`styles.dart`, which re-exports the package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Komoszek added a commit to leancodepl/flutter_corelibrary that referenced this pull request Sep 15, 2026
* Add jaspr_class_scope

Jaspr collects every `@css` getter into one global stylesheet and scopes
nothing, so a class name is a global name and two components that both
style `.grid` style each other. `ClassScope` does at build time what CSS
modules do: a component declares one scope, makes its classes from it,
and each renders as `grid-<suffix>` — five base-36 digits of an FNV-1a
hash of the scope's name. `ClassName.shared` keeps the names another file
knows as written, `+` puts two classes on one element, and both the
`classes:` attribute and the selector are spelled from the same constant.

The hash multiplies in 16-bit halves so no intermediate product exceeds
2^53: on the web an `int` is a double, and a plain `hash * prime` rounds
there but not on the VM, which would hand the same component two
different suffixes and leave server-rendered markup not matching its
client-rendered stylesheet. CI runs the tests on the VM and in Chrome to
hold the two to the same goldens.

Two scopes that would hash alike throw the first time either one renders,
so a collision cannot slip through unnoticed.

Extracted from the advanced_forms landing page, which grew this in
leancodepl/advanced_forms#80.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Hash the file a component lives in, not its name

A scope named after a class is only as unique as the class name:
`Type.toString()` drops the library, so two `Card` components in two
files hash alike. The runtime can only notice that and throw — it has no
way to tell the two apart, and a suffix handed out in first-render order
would not survive a rebuild, let alone agree between server and client.

So the disambiguation moves to where the location is known.
`jaspr_class_scope_builder` writes the scope of every `@scoped` component
from the asset it is declared in — `site|lib/components/hero.dart#Hero` —
as `const _$heroScope = ClassScope.literal('Hero', '16rv7')` in a part
file next to it. Two `Hero` classes in two files are then two scopes by
construction, nothing throws, the class name never reaches the page
through `Type.toString()`, and no hash runs in the browser. It is what
CSS modules do, where the hash covers the file path.

The hand-written forms stay for projects without build_runner, and the
runtime check behind them is now keyed by what a scope *is* — the type it
was made for, or its literal name — rather than by the name it renders,
so two same-named classes throw instead of quietly sharing a namespace.

`classScopeSuffix` is now public: the builder hashes with it, so both
sides of the package agree by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Check for suffix collisions at build time

The generator makes two same-named components two scopes, but five base-36
digits still leave room for two unrelated files to hash alike, and until
now the only thing watching for that was a registry consulted while
rendering — work a shipped page did on every scope, to catch something a
build could have caught.

`class_scope_check` runs once per package after the scopes are written,
reads them back and fails `build_runner` when two components hold one
suffix, naming both files. The runtime registry moves behind an `assert`:
loud in development and in tests, where a hand-written scope can still
collide, and compiled out of a release build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Rename the annotation to @scopedCss

`@scoped` says nothing about what it scopes, and a component file that
already carries jaspr's `@css` reads better when the two sit next to each
other. `ScopedCss` is the class behind it; the builder accepts either
spelling, prefixed or not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Cut the docs down to what a reader needs

Both readmes take the shape the other packages in this repo use — badges,
a short description, `## Usage`, then the maintainer block — and lose the
paragraphs that argued with alternatives instead of showing the package.
The doc comments lose the same: what a member does and the one trap worth
knowing, not the reasoning behind it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Leave only the scopes the builder writes

A scope named by hand is only as unique as the name, and `ofType` reads it
through `Type.toString()`, which a minifying compiler rewrites and which
drops the library. Both invited the mistake the builder exists to prevent,
so both are gone: `ClassScope(name, suffix)` is now the one constructor,
written by `jaspr_class_scope_builder` and never by hand.

What follows from that: the hash moves into the builder, where it runs on
the VM and its result is baked into a `const`, so a suffix can no longer
differ between the server and the browser — the runtime carries no hashing
at all, and the runtime package's Chrome job is gone with the reason for
it. The builder no longer depends on `jaspr_class_scope`, so its CI stops
linking the sibling package and the two can be published in either order.
The debug registry stays: within a package the check phase catches a
clash, and this also covers two packages meeting on one page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Say what the packages do, not how

Someone reading the pub.dev card or the first paragraph wants to know they
get unique class names. The stylesheet Jaspr builds, the hash, the file it
covers — that is all still there, further down, for whoever is choosing
between this and writing the names by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Six digits, taken modulo rather than cut short

Five was picked by eye, for something short enough to read in the
inspector, and it does not hold up when measured. Base 36 of a 32-bit
hash is six-and-a-bit digits, so keeping the first five crowded every
long hash into the suffixes beginning with a 1: over simulated projects
of 500 components, one in 43 had a clash. Taking the hash modulo 36^6
spreads it evenly and gives a suffix per two billion — measured at 3 in
10,000 for the same 500 components, and comparable to the base-52 name
styled-components generates, which is six characters wide too.

The check phase stays; it is a formality now rather than the only thing
between us and a silent clash.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Stir the hash before cutting it to six digits

FNV-1a mixes its low bits poorly, and those are exactly the bits the
suffix is taken from. Measured over file names differing in one letter,
one output bit never changed at all and the lowest four flipped 37.6% of
the time where 50% is the mark — sibling files could land a digit apart,
or on the same suffix. djb2, which styled-components uses, is worse
still: 25%.

Murmur3's finalizer after the FNV loop puts the worst bit at 49.2% and
the low four at 50.0%, matching the md5 that css-loader hashes idents
with, in six lines and without a dependency. Collisions were already
negligible at 36^6; this is about two neighbouring files not looking
related in the stylesheet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Hash with Murmur, rather than FNV wearing Murmur's finalizer

The hybrid was an accident of where the code came from: FNV-1a arrived
with the scope, and the finalizer was bolted on when its low bits turned
out to be too still. Nobody can check a hybrid against anything.

MurmurHash3 x86_32 over the UTF-8 bytes is the same six lines of
arithmetic, one named algorithm, and it can be verified: the tests now
assert its published vectors ('' to 0, 'a' to 0x3c2569b2, 'abc' to
0xb3dd93fa), and every suffix here was cross-checked against the
murmurhash3js package, which agrees on all of them except the one
non-ASCII string, where it hashes code units and we hash bytes as the
algorithm specifies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Check the sources, not the code the builder wrote

The check phase recovered its input by regex-matching the exact rendering
of `ClassScope('Hero', 'iur6ms')`, in a file `build_to: source` puts in
the consumer's tree, where their formatter runs over it: one long
component name past 80 columns and the call splits across lines, the
regex matches nothing, and the check passes while detecting nothing. A
collision guard that silently stops guarding is the worst failure it
could have.

It needs none of that. The suffix is a function of where a class is
declared, which is in the source, so the check now parses the same
sources the generator does and hashes them the same way, through helpers
both builders share. The regex is gone, `required_inputs` with it, and
the error names `lib/components/hero.dart` rather than its part file. The
test feeds sources rather than hand-written generated text — and a real
colliding pair, found by searching the hash, instead of two fixtures
typed to match.

The runtime registry goes too. It was a second implementation of the same
check, kept a mutable static in a value library, made `suffix` a getter
with a global side effect, put `resetRegistry` in the public API to make
that testable, and cost the package its only dependency — all to catch
two components from different packages, which it could not do anyway: it
compares by name, so the two `Hero`s it was there for record as one
owner and pass.

Also: the generated part file is excluded in `build.yaml` rather than by
an `if` the build graph cannot see; `ClassName` renders without building
a list per read; the hash drops the JS-safe multiplication a builder can
never need; and the docs stop saying the suffix comes from the file when
it comes from the file and the class name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Hash with md5, and delete the Murmur we were carrying

Measured over six million distinct paths, both land on the birthday
bound — 2779 collisions for Murmur against 2854 for md5 where a perfect
hash gives 2756, inside the noise. Murmur is the faster of the two by
0.4ms across a five-hundred-component project, which is not a reason for
anything. What is left is 36 lines of block loop, tail handling, rotates
and masking against five, and `crypto` is already in the lock through
`build`, so it costs a version constraint rather than a package.

It also gets easier to check: `printf '…' | md5sum` says what the builder
says, and the tests pin the two digests everyone knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Cut the docs to what a reader uses

The readmes kept explaining the design where the code is enough: how the
suffix follows the file, what the check reads, what does not reach the
page. What is left is the description, the code you write, what it
renders, and the two things you cannot guess — a shared class and two
classes on one element. The doc comments lose the same, keeping the one
line each member needs and the two notes that are not obvious from the
code: why the parse is unresolved and why the annotation is matched by
name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Let the example show a generated scope, not a typed one

It spelled `ClassScope('Hero', 'lz7xyh')` by hand under a comment saying
the builder writes it — the one thing the package tells you never to do.
Now the example is the real arrangement: `hero.dart` carries the part
directive and `@scopedCss`, `hero.scopes.dart` beside it is what the
builder would write for that asset, down to the suffix, which is the md5
of `jaspr_class_scope|example/hero.dart#Hero`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Say what the packages are for, not what they are like

The first paragraph explained how Jaspr collects stylesheets and which
JavaScript tool this resembles, neither of which tells a reader whether
they want the package. It now says what they get: every component with
its own namespace, so a name can be reused without the styles meeting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Drop the three components and the grid nobody has seen yet

The opening line counted components and named a class before any code
had introduced either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Describe the packages the way pub.dev reads them

The cards now lead with what the package spares you rather than what it
is, in the 60-180 characters pana scores in full, and both carry five
topics instead of four. Where the suffix comes from is out: a reader
choosing a package does not care that it is hashed from a path, only
that two components cannot collide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Keep the suffixes out of the prose

`grid-lz7xyh` told a reader nothing they could act on: which six
characters land on a class is the one thing about this package that never
matters. The readmes now say a name carries a suffix belonging to its
component and leave it there, and the clash error says which two files
collided rather than what they collided on.

The first line also says what the package does rather than what it
prevents: every component gets its own class names, so a name used twice
cannot carry one component's styles into another's markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Borrow the vocabulary people already search for

"Locally scoped" is the phrase CSS modules put on this twenty years of
stylesheets ago, and it is what someone types into pub.dev when they want
it. The card and the readme now open with it, and say what it buys in the
same breath: a name written in one component cannot style another's
markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Call the same thing by the same name everywhere

The two packages had three vocabularies for one idea: class names of
their own, a namespace per component, a class-name scope. Everything now
says scope, and the phrase both cards lead with is the one CSS modules
put on this long ago: locally scoped class names. Suffix survives only
where it is the subject — the field that holds it, the hash that makes
it, the check that catches two of them meeting — and the generated header
says whose scope it is rather than what it was hashed from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Trim the doc comments to what the code does not say

`ClassScope` explained that the builder writes it and never to write one
by hand, which the annotation, the part file and the readme all say
already, and carried a code sample for a class with one constructor and
two fields. Same for the sample suffixes in `ClassName`, the aside about
scripts and stylesheets, and the builder's restatement of its own output.
What remains is one line per member and the two notes the code cannot
make: why the parse is unresolved, and why the check reads sources rather
than what the generator wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Keep the comments that say something the names do not

Two tests carried a comment restating the name of the test above it; the
annotation's second doc repeated its own first line; the example's header
explained that a component is a component. Those are gone. What stays is
one line per public member, because the package lints require it, and
five notes: why the example has no Jaspr in it, why the parse is
unresolved, why the annotation is matched by name, why the check reads
sources, why the assets are sorted, where the colliding test pair came
from, and why the golden suffixes may not move.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Compile against the analyzer a Jaspr project actually has

jaspr_builder 0.23.4 depends on analyzer ^12.1.0, so pinning this builder
to >=14.3.0 made it unusable in the one kind of project it exists for —
the first real consumer could not resolve it.

The only thing that needed analyzer 14.3 was reading a class name through
`namePart.typeName`, a getter that replaced `name` in that release. Off
the tokens the name is the identifier after the `class` keyword, which
has been true of the Token API for years, so the constraint widens to
>=12.1.0 <15.0.0. Verified both ends: the tests pass on 14.4, and on an
analyzer 12.1 override the parse returns the same components.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: check scopes across the whole build

The check globbed its own package, so two components that scope to the
same suffix were only found when both were in the package being built.

A builder now lists every package's scopes into lib/ — the only
directory of a dependency a build can read — and the check merges those
manifests, reading the package graph off BuildStep.packageConfig. It
applies to all packages, not only dependents: a package that uses
jaspr_class_scope keeps this builder to itself, in its dev_dependencies.

Reading a manifest costs a glob and a read per package, so parsing is
skipped for a source that does not mention the annotation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: keep the formatter off the part files

A component named at any length makes a line dart format would rewrite,
so a project that commits its part files and runs the formatter in CI
fails on a file it does not write.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: say what the manifest is for

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope: drop the tests that cannot fail

Two asserted properties of their own fixtures rather than of the code:
one compared the rendering of two hardcoded suffixes, the other that two
md5s of different strings differ. A third repeated goldens for inputs
the builder never produces.

Two more could not fail either, but for a reason worth fixing instead:
no input reached the padding, and the part file the manifest is supposed
to skip held nothing it could have read. Both now carry a case that
fails when the code stops doing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: spell the expected suffixes out

The tests computed what they expected with the same functions under
test, so an assertion read f(x) == f(x) and the format the suffix is
hashed from was pinned only by the literal beside it. They now hold the
suffix itself, which is what ends up in a page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope: test what the tests claim

A value type was tested for == and not for hashCode, so a set could
have held it twice unnoticed. The token walk that reads a class name
past its modifiers had no input with a modifier. The manifest test said
"every" component and listed one. The shape assertion on the suffix
could not fail without a golden failing first.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* Simplify ClassName constructors

Co-authored-by: Piotr Rogulski <piotr.rogulski@leancode.pl>

* jaspr_class_scope_builder: say where the manifest lands

The name and the comment read as though the builder wrote into the
package's own `lib/`. It writes a build cache asset, whose path begins
with `lib/` because nothing else of a dependency is visible to another
package's build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope: point the constructor's doc at what it can see

The parameters became `this._local` and `this._scope`, so `[local]` and
`[scope]` no longer resolved and `dart analyze` failed on
comment_references.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope: take the review

The scope constant keeps the component's name (`_$HeroScope`), which an
acronym survives: `CTAButton` was giving `_$cTAButtonScope`.

A class is this package's to scope only if its file asks for the part
file this builder writes. Checking the annotation's import instead would
miss a project that re-exports it, and resolving the annotation would
tie the builder to one version of the analyzer.

Also block syntax in build.yaml, and the year in both LICENSE files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: take the second review pass

The packages to read are a plain list now, and each manifest entry is
destructured where it is read. The list still comes from a set, because
`packageConfig` lists the package the check runs in and reading its
manifest twice reports it as clashing with itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: match the annotation by where it is declared

The name was all this checked, which took anyone else's `@scopedCss` for
ours. It now resolves the class and asks which library the annotation
comes from, which also follows a re-export: the advanced_forms landing
reaches `scopedCss` through its own `styles.dart` and its components
import nothing of this package's.

The element API this needs — `LibraryElement.classes`, `Element.library`,
`ElementAnnotation.element` — reads the same on analyzer 12 and 14, and
resolution only happens for a file that names the annotation and asks for
the part file, so a clean build of that landing takes the time it did.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: read the packages off the package config alone

`BuildPackages.asPackageConfig` is built from every package in the build,
the one being built included, so adding it by hand only made the list
need deduplicating.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: keep the comments that say something

The manifest's entries are a list literal now, the README no longer
explains that a same-named annotation from elsewhere is left alone, and
the comments that restated their code are gone.

`libraryFor` no longer allows syntax errors: the part file missing on a
first build is a resolution error, which it never minded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: show the generated file in the README

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: say which components the suffix tells apart

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: say only what the suffix tells apart

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

* jaspr_class_scope_builder: name what a rename changes

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Piotr Rogulski <piotr.rogulski@leancode.pl>
Both packages are published at 0.1.0, so the git refs go.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XirTyFcvXRo3Hs9PBUnt7r
Comment thread landing/README.md Outdated
component carries `@scopedCss` and a `part 'hero.scopes.dart'`, and its scope — `_$HeroScope`, written by the builder
into that part file — makes its classes: `_class('af-grid')` renders as `af-grid-<suffix>`, unique to this component.
Both the selector (`css(_hero.selector)`, `'${_grid.selector} > *'`) and the attribute
(`classes: _hero.name`, `(container + _grid).name`) are spelled from the constant, so the suffix is never written by

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need all this explanation here?

The mechanics belong to jaspr_class_scope's own README; this one keeps
the rule (a component owns its classes as scoped constants) and the
shared names other files know.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013Tw8hY1dZyp7s18pUTCacv
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.

3 participants