@transtyle/exporter-shadcn 0.1.0-alpha.2 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,126 @@
1
+ # @transtyle/exporter-shadcn
2
+
3
+ ## 0.1.0-alpha.4
4
+
5
+ ### Minor Changes
6
+
7
+ - dc79f14: Breaking (alpha): `semantic.color.border` is now a ladder, `semantic.color.border.{subtle, base, strong, field}`, and the catalog gains an inverse pair, `semantic.color.inverse.{surface, text}` (proposal 0005).
8
+
9
+ Move your border token from `semantic.color.border` to `semantic.color.border.base`: a token left at the old path is now an error (`TST1126`) that names the new one, and the css-variables exporter writes `--color-border-base` instead of `--color-border`. `border.base` derives from `text.base` when you don't author it, so a minimal design system now has a border; `border.subtle` and `border.strong` (the smallest step toward the text that reaches 3:1) derive from it, and `border.field`, the form-field border at rest, aliases it. `inverse.surface` / `inverse.text` default to `neutral.text-strong` / `elevation.0.surface` and are contrast-checked as a pair.
10
+
11
+ Exporters read the new slots where the target has a matching variable: shadcn's `--input` and Storybook's `inputBorder` read `border.field`; PrimeNG's field, card and overlay borders now follow your border instead of the neutral outline, its field, list and menu icons follow the text ladder, its `contrast` severity reads the inverse pair, and its tooltip does too when you author the pair; Bootstrap's tooltip follows an authored pair in both modes, and its close button and select chevron take `text.base` instead of staying `#000` and `#343a40`; Chakra's `bg.inverted`, `fg.inverted` and `border.subtle` read the new slots. Unauthored, Bootstrap, shadcn, daisyUI, ECharts, Radix, Storybook, Mantine and MUI colours are unchanged apart from those two Bootstrap glyphs.
12
+
13
+ A token that has both a `$value` and child tokens is now an error (`TST1314`), as DTCG requires; its children used to be dropped silently.
14
+
15
+ `transtyle bind --suggest` proposes `border.base` and the inverse pair, and its name table is now `synonyms@2`: a text or surface name with an inverse word (`text-inverse`, `background-inverse`) reads as `inverse.text` / `inverse.surface`, never as `text.inverse`.
16
+
17
+ - 0ae9eb5: A JSON mapping table can now be an exporter, and a target can name the framework version it is built for.
18
+
19
+ **Declarative exporters.** Point a target at a mapping file next to the config (`"exporter": "./ourlib.mapping.json"`): rows of `{ variable, slot, part?, format?, class?, note? }` in CSS custom properties, Sass, Less or JSON files, with selector blocks per mode value. Core runs it like any exporter and derives the coverage report from the rows; no code runs. A package can ship a mapping instead of code (`"transtyle": { "declarative": "mapping.json", … }`): the CLI reads it and never imports the package. A malformed mapping is `TST1014`, a row naming an unknown slot `TST1015`; the format is published as `schemas/mapping/v0.json`. `@transtyle/core` exports `createDeclarativeExporter`, `validateMapping`, `loadDeclarativePackage` and friends, so `conformance(createDeclarativeExporter(mapping))` tests a mapping like any plugin.
20
+
21
+ **Version profiles.** `targets.<t>.version: "5.3.8"` selects the exporter manifest range that covers it; the exporter gets `ctx.targetVersion` and `ctx.targetProfile`, and `report.json` records `version: { requested, profile }`. A version outside every range is `TST1313`. shadcn's manifest now declares Tailwind ranges (`>=3 <4`, `>=4 <5`) instead of era names, so `"version": "3.4.17"` selects the tailwind-v3 output; `options.era` still wins when set, with a `TST2105` warning if the two disagree. plugin-kit's new `manifest-targets-ranges` check rejects a `targets` entry that isn't a version range. Without `version`, output is unchanged.
22
+
23
+ **css-variables** now writes `palette.categorical.*` in its dark block too; its dark values were missing.
24
+
25
+ - a8afe0f: The reserved `contrast`, `motion` and `brand` mode dimensions now reach the targets. A mode-scoped layer may name several dimensions (`"mode": { "color-scheme": "dark", "contrast": "more" }`): its values apply to that combination only, and win there; a token with values on two dimensions and no such layer is reported `TST1125`. Under `contrast: more` the derived on-colors and content text aim at 7:1 (rule `contrast-more`) and `check` holds every `more` combination to 7:1; under `motion: reduced` every unauthored duration is `0ms` (rule `motion-reduced`). `check` now measures every combination of the mode matrix, and `explain --mode` lists the combination keys it accepts.
26
+
27
+ CSS targets (css-variables, shadcn, Radix, Bootstrap's `bootstrap-theme.css`) add `[data-contrast]`, `[data-motion]` and `[data-brand]` blocks, a compound block for each combination the separate ones would get wrong (dark + more contrast used to get the light values in css-variables), and `@media (prefers-contrast: more)` / `(prefers-reduced-motion: reduce)` copies so the OS setting applies until the page sets the attribute (css-variables: `options.mediaQueries: false` turns them off). daisyUI emits one theme per brand and contrast combination, ECharts one theme per brand and scheme, Storybook a toolbar per dimension, and Bootstrap's Sass files, PrimeNG, Mantine, Chakra and MUI one file per brand (`preset.transtyle.<brand>.ts`). A target that can't express a dimension reports a `dropped` row with its reason. `brand` values must be lowercase names (`^[a-z0-9][a-z0-9-]*$`). `@transtyle/ir` exports the helpers (`modeBlocks`, `emitPerValue`, `pinDimension`, `MODE_MEDIA_QUERIES`). A design system that declares none of these dimensions compiles to the same files as before.
28
+
29
+ - 053f796: New info `TST2104`: when `radius.md` is above 0 but at most 4px, shadcn's own offsets bring `--radius-sm` (and `--radius-md` at 2px or less) to 0 or below, so `rounded-sm` renders square. The exporter now says so once per instance, in both eras (`borderRadius.sm`/`md` in tailwind-v3). `radius.md: 0` is a square design and stays silent.
30
+ - a786b21: Carry DTCG `$description` and `$deprecated` through to the outputs.
31
+
32
+ Both fields used to load and then disappear. A token's `$description` is now written as a comment line above its variable in css-variables, shadcn and daisyUI (and above Bootstrap's `$primary…$danger`), added as `description` to the matching items of every `report.json`, and printed by `transtyle explain` under the value line. `$deprecated` (`true` or a reason, inherited from its group, `false` to opt out) reaches the same places, and a semantic or component slot whose value still comes from a deprecated token warns `TST1122` with the reason as the hint; every target's `usage.md` lists those variables under "Deprecated tokens", and their report items carry `deprecated` and `deprecatedBy`. A `$description` that isn't a string, or a `$deprecated` that isn't `true`, `false` or a string, warns `TST1311` and is ignored. Design systems that describe their tokens (GOV.UK and Carbon among the examples) see new comment lines in those files; nothing else changes. Exporter authors read `entry.description` and `entry.deprecated`, with `entryNotes()`, `blockComment()` and `lineComment()` from `@transtyle/ir`.
33
+
34
+ ### Patch Changes
35
+
36
+ - 305dd4e: Accept a `fontFamily` written as one string, and list every diagnostic of a build in every `report.json`.
37
+
38
+ A `fontFamily` token (or a `typography` member) authored as a CSS list in one string, `"Inter, system-ui, sans-serif"`, which DTCG allows, crashed the shadcn, ECharts, Bootstrap and Storybook exporters with `TST3001`. NORMALIZE now splits it into the array of names exporters read, so both forms compile to the same bytes on every target; a quoted name (`'Helvetica Neue'`) loses its quotes like the array form, a `var(…)` stays whole, and an empty name or a non-string entry is `TST1106`. `@transtyle/ir` exports the two helpers the official exporters now share, `fontNames()` and `fontStack()`, for third-party exporters that read font families.
39
+
40
+ Each target's `report.json` was serialised as soon as that target ran, so a diagnostic an exporter raised for a later target (shadcn's `TST2104`) was missing from the earlier targets' reports. Reports are now built after the last target, from the final list, and `check.suppress` runs at that point too: an exporter's own diagnostic can be suppressed like any other, where a rule for it used to match nothing (`TST1012`) and leave it printed.
41
+
42
+ - 5602f4a: Make the packages findable on npm.
43
+
44
+ All twelve alphas published with no `keywords`, which on npm is the same defect as the blank README the previous alpha fixed: search ranks on name, description and keywords, so a package with none is reachable only by someone who already knows what it is called — and the people it most needs to reach are precisely the ones who don't. Someone looking for "design tokens bootstrap theme" found nothing.
45
+
46
+ Every package now carries the two shared anchors (`design-tokens`, `design-system`) plus its own terms — the exporters name their target and that ecosystem's vocabulary, so `exporter-primeng` answers a PrimeNG search and `exporter-radix` a Radix Colors one. `check:manifests` was extended to enforce all of it: the field must exist, carry both anchors, and hold lowercase space-free tokens with no duplicates. Verified by breaking each of the four rules in turn and watching the message name the real problem.
47
+
48
+ - e5bac21: Put the project's logo on every package page.
49
+
50
+ Each README now opens with the Transtyle mark, linked to the documentation site. It is referenced by absolute URL and in the on-dark variant on purpose: npm renders package pages outside the repository, so a relative path 404s there, and it renders them on both a light and a dark background from a single `<img>` with no way to swap per theme — the on-dark variant is the one that reads on both.
51
+
52
+ The mark itself is generated rather than maintained: one description in `scripts/gen-brand.mjs` renders both SVG variants, their PNG rasters, and the site's favicon and app icons, and a new `check:brand` guard fails on a stale asset or on a surface that has quietly stopped carrying it — including these twelve pages.
53
+
54
+ - 570b458: Add `targets.<t>.modes` to emit only some mode values for a target.
55
+
56
+ A project declares its modes once and every target used to receive the whole matrix, so a light-only Bootstrap site next to a light+dark shadcn app meant a second config or unwanted dark blocks. A target can now narrow it: `"bootstrap": { "modes": { "color-scheme": ["light"] } }` keeps only that value (a dimension you don't name keeps all of its values). Derivation and checks still run once on the full matrix; the exporter receives only the kept combinations, so a single-value dimension behaves like today's single-mode projects. A subset that names an undeclared dimension or value, or leaves out the dimension's default, is the new error `TST1308` and nothing is emitted for any target. A dimension narrowed this way is a deliberate exclusion: no `dropped` coverage row, and the target's `usage.md` lists the modes its files contain. The Bootstrap, shadcn, daisyUI and css-variables `usage.md` no longer describe a dark mode they didn't generate. The config JSON schema gains the `modes` target key.
57
+
58
+ - Updated dependencies [dc79f14]
59
+ - Updated dependencies [abff3a3]
60
+ - Updated dependencies [305dd4e]
61
+ - Updated dependencies [5602f4a]
62
+ - Updated dependencies [e5bac21]
63
+ - Updated dependencies [ed1445d]
64
+ - Updated dependencies [a8afe0f]
65
+ - Updated dependencies [a786b21]
66
+ - @transtyle/ir@0.1.0-alpha.4
67
+
68
+ ## 0.1.0-alpha.3
69
+
70
+ ### Patch Changes
71
+
72
+ - 3d71e2f: Ship `CHANGELOG.md` in every published package.
73
+
74
+ npm always includes `README` and `LICENSE` regardless of the `files` allowlist, but not the changelog — so the first three alphas published with no version history at all, on the one page a stranger reads to decide whether upgrading is safe. Changesets has been writing the file since `0.1.0-alpha.0`; only the allowlist was keeping it in the repo.
75
+
76
+ `check:manifests` now requires it, so a package added later cannot quietly ship without one.
77
+
78
+ - af10209: Ship the MIT licence text in every published package.
79
+
80
+ npm adds a `LICENSE` file to a tarball only when one sits in the package directory. Only the monorepo root had one, so the first three alphas published twelve packages that each declared `"license": "MIT"` without carrying the text those terms ask to be distributed with them.
81
+
82
+ Each package now holds a copy, and `check:manifests` requires it to be byte-identical to the root one — twelve copies of a licence being exactly the sort of thing that drifts a copyright year with nobody noticing.
83
+
84
+ - Updated dependencies [3d71e2f]
85
+ - Updated dependencies [af10209]
86
+ - @transtyle/ir@0.1.0-alpha.3
87
+
88
+ ## 0.1.0-alpha.2
89
+
90
+ ### Patch Changes
91
+
92
+ - 16dc98d: Give every package a README, and point `homepage` at its documentation page.
93
+
94
+ All twelve packages shipped `0.1.0-alpha.0` and `.1` with no README, so each rendered as a blank page on npmjs.com — the surface most people meet the project on, and the only one nobody working in the repo ever reopens. Each now says what it emits, how to use it, and that it is an experimental alpha, and links into the documentation site rather than at the monorepo root.
95
+
96
+ `@transtyle/exporter-primeng`'s description also claimed it was "not yet registered in the CLI". It has been since C6; the sentence was published to npm twice after it stopped being true.
97
+
98
+ `check:manifests` grew two checks for the class: a package must have a README that is more than a stub, and a `homepage` naming a docs page must name one that exists — a 404 baked into published metadata cannot be fixed without another release.
99
+
100
+ - Updated dependencies [16dc98d]
101
+ - @transtyle/ir@0.1.0-alpha.2
102
+
103
+ ## 0.1.0-alpha.1
104
+
105
+ ### Patch Changes
106
+
107
+ - @transtyle/ir@0.1.0-alpha.1
108
+
109
+ ## 0.1.0-alpha.0
110
+
111
+ ### Minor Changes
112
+
113
+ - First public alpha.
114
+
115
+ Transtyle compiles a framework-agnostic description of a design system — DTCG tokens plus semantics and modes — into native theme artifacts for eight targets: Bootstrap, shadcn/ui, daisyUI, Apache ECharts, Storybook, Radix Colors/Themes, PrimeNG, and plain CSS variables. Unauthored values are filled by deterministic, inspectable derivation rules rather than hidden defaults, every generated value carries provenance you can query with `transtyle explain`, and every build emits a coverage report saying what mapped natively, what was derived, what was approximated, and what the target cannot express.
116
+
117
+ All three token tiers — raw/option, semantic and component — are mapped on the two component-heavy targets, Bootstrap and PrimeNG, each measured against a checked-in inventory of the target's real theming surface so gaps are reported rather than quietly skipped.
118
+
119
+ Import is DTCG only in this release. Tailwind, Figma and CSS-custom-property importers are post-alpha.
120
+
121
+ **This is an experimental alpha.** Breaking changes ship without a deprecation cycle: the token vocabulary, the generated output, the config format and the CLI surface can each change between alpha releases, and none of it carries a stability or support promise. Pin an exact version, and treat generated files as disposable output you regenerate rather than hand-edit. The reasoning is recorded in ADR-0010; the release process is documented in RELEASING.md.
122
+
123
+ ### Patch Changes
124
+
125
+ - Updated dependencies
126
+ - @transtyle/ir@0.1.0-alpha.0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Julien Déramond and Transtyle contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <a href="https://transtyle.github.io/transtyle/"><img src="https://raw.githubusercontent.com/transtyle/transtyle/main/brand/transtyle-mark-on-dark-256.png" alt="Transtyle" width="88" height="88"></a>
3
+ </p>
4
+
1
5
  # @transtyle/exporter-shadcn
2
6
 
3
7
  shadcn/ui backend for **[Transtyle](https://transtyle.github.io/transtyle/)**, a design system compiler.
package/package.json CHANGED
@@ -1,14 +1,26 @@
1
1
  {
2
2
  "name": "@transtyle/exporter-shadcn",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Transtyle exporter for shadcn/ui (tailwind-v4 era).",
5
+ "keywords": [
6
+ "design-tokens",
7
+ "design-system",
8
+ "dtcg",
9
+ "theming",
10
+ "shadcn",
11
+ "shadcn-ui",
12
+ "tailwindcss",
13
+ "tailwind-v4",
14
+ "radix",
15
+ "css-variables"
16
+ ],
5
17
  "type": "module",
6
18
  "main": "./src/index.js",
7
19
  "exports": {
8
20
  ".": "./src/index.js"
9
21
  },
10
22
  "dependencies": {
11
- "@transtyle/ir": "0.1.0-alpha.2"
23
+ "@transtyle/ir": "0.1.0-alpha.4"
12
24
  },
13
25
  "transtyle": {
14
26
  "kind": "exporter",
@@ -17,8 +29,8 @@
17
29
  "pluginApi": "0",
18
30
  "targets": {
19
31
  "shadcn": [
20
- "tailwind-v3",
21
- "tailwind-v4"
32
+ ">=3 <4",
33
+ ">=4 <5"
22
34
  ]
23
35
  },
24
36
  "modes": [
@@ -29,7 +41,8 @@
29
41
  ]
30
42
  },
31
43
  "files": [
32
- "src"
44
+ "src",
45
+ "CHANGELOG.md"
33
46
  ],
34
47
  "publishConfig": {
35
48
  "access": "public"
package/src/index.js CHANGED
@@ -2,11 +2,15 @@
2
2
  * @transtyle/exporter-shadcn — emits a shadcn/ui theme from the resolved IR.
3
3
  * Spec: docs/specs/exporters/shadcn.md. Two era profiles (ADR-0006 mapping
4
4
  * profiles): "tailwind-v4" (OKLCH + @theme inline) and "tailwind-v3"
5
- * (HSL channel triplets + tailwind.config snippet). Selected via target
6
- * options.era in transtyle.config.json — never via CLI flags.
5
+ * (HSL channel triplets + tailwind.config snippet). shadcn has no version of
6
+ * its own: both profiles follow Tailwind CSS, so the manifest's ranges are
7
+ * Tailwind versions (`>=3 <4`, `>=4 <5`) and `targets.shadcn.version` is the
8
+ * project's Tailwind version, from which core selects the profile
9
+ * (ctx.targetProfile). `options.era` stays as an explicit override. Both are
10
+ * set in transtyle.config.json — never via CLI flags.
7
11
  */
8
12
 
9
- import { droppedDimensions } from '@transtyle/ir';
13
+ import { droppedDimensions, entryNotes, blockComment, fontNames, fontStack, modeBlocks, formatModeBlocks, modeBlocksUsage } from '@transtyle/ir';
10
14
 
11
15
  const S = 'semantic.color.';
12
16
  const P = 'semantic.palette.categorical.';
@@ -32,9 +36,9 @@ const MAPPING = [
32
36
  { css: '--accent-foreground', slot: `${S}accent.on-tint`, cls: 'native' },
33
37
  { css: '--destructive', slot: `${S}danger.solid`, cls: 'native' },
34
38
  { css: '--destructive-foreground', slot: `${S}danger.on-solid`, cls: 'native' },
35
- { css: '--border', slot: `${S}border`, cls: 'native' },
36
- // shadcn distinguishes input borders; the IR does not (exercise F4)
37
- { css: '--input', slot: `${S}border`, cls: 'approximated' },
39
+ { css: '--border', slot: `${S}border.base`, cls: 'native' },
40
+ // shadcn's input border is the catalog's field border (exercise F4, proposal 0005)
41
+ { css: '--input', slot: `${S}border.field`, cls: 'native' },
38
42
  { css: '--ring', slot: `${S}ring`, cls: 'native' },
39
43
  { css: '--chart-1', slot: `${P}1`, cls: 'native' },
40
44
  { css: '--chart-2', slot: `${P}2`, cls: 'native' },
@@ -48,11 +52,13 @@ const MAPPING = [
48
52
  { css: '--sidebar-primary-foreground', slot: `${S}primary.on-solid`, cls: 'native', note: 'exporter convention' },
49
53
  { css: '--sidebar-accent', slot: `${S}accent.tint`, cls: 'native', note: 'exporter convention' },
50
54
  { css: '--sidebar-accent-foreground', slot: `${S}accent.on-tint`, cls: 'native', note: 'exporter convention' },
51
- { css: '--sidebar-border', slot: `${S}border`, cls: 'native', note: 'exporter convention' },
55
+ { css: '--sidebar-border', slot: `${S}border.base`, cls: 'native', note: 'exporter convention' },
52
56
  { css: '--sidebar-ring', slot: `${S}ring`, cls: 'native', note: 'exporter convention' },
53
57
  ];
54
58
 
55
59
  const ERAS = ['tailwind-v4', 'tailwind-v3'];
60
+ /** The manifest's Tailwind ranges (package.json `transtyle.targets.shadcn`) → era profile. */
61
+ const PROFILE_ERAS = { '>=3 <4': 'tailwind-v3', '>=4 <5': 'tailwind-v4' };
56
62
 
57
63
  export default {
58
64
  name: 'shadcn',
@@ -65,10 +71,22 @@ export default {
65
71
  },
66
72
 
67
73
  emit(normalized, ctx) {
68
- const era = ctx.targetConfig.options?.era ?? 'tailwind-v4';
74
+ const explicit = ctx.targetConfig.options?.era;
75
+ const fromVersion = PROFILE_ERAS[ctx.targetProfile];
76
+ const era = explicit ?? fromVersion ?? 'tailwind-v4';
69
77
  if (!ERAS.includes(era)) {
70
78
  throw new Error(`exporter-shadcn: unknown era "${era}" (supported: ${ERAS.join(', ')})`);
71
79
  }
80
+ // A requested Tailwind version and an explicit era that disagree: the
81
+ // explicit option wins (it is the override), and the build says so.
82
+ const eraConflict = explicit && ctx.targetVersion && fromVersion && fromVersion !== explicit
83
+ ? {
84
+ severity: 'warning',
85
+ code: 'TST2105',
86
+ message: `options.era "${explicit}" overrides version ${ctx.targetVersion}, whose profile ("${ctx.targetProfile}") is ${fromVersion}; the output follows ${explicit}`,
87
+ hint: 'Remove options.era to follow the Tailwind version, or set "version" to the Tailwind release the project really uses.',
88
+ }
89
+ : null;
72
90
  // Mode polarity: shadcn's structure is fixed (:root = light, .dark = dark).
73
91
  // Bind mode NAMES, never the DS's default flag — a dark-native design
74
92
  // system still compiles to shadcn's light-first layout (ir.md#modes).
@@ -107,16 +125,69 @@ export default {
107
125
  }
108
126
  // Mode dimensions this exporter doesn't express (T8, ir.md#modes) — a
109
127
  // no-op unless the compile actually declares one, e.g. `density`.
110
- coverage.push(...droppedDimensions(normalized.dimensionNames, ['color-scheme']));
128
+ // `contrast` and `brand` are expressed as attribute blocks next to `.dark`
129
+ // (#49, #50); shadcn's theme has no duration variable for `motion`.
130
+ coverage.push(...droppedDimensions(normalized.dimensionNames, ['color-scheme', 'contrast', 'brand'], {
131
+ motion: "shadcn's theme carries no duration or easing variable, so reduced-motion values have nowhere to go",
132
+ }));
133
+
134
+ // Blocks for the extra dimensions: the same variables, rendered per combo.
135
+ const fmt = era === 'tailwind-v4' ? (c) => ctx.formatColor(c) : (c) => ctx.formatHslTriplet(c).text;
136
+ const blocks = modeBlocks(normalized, {
137
+ dims: ['contrast', 'brand'],
138
+ darkSelector: dark ? '.dark' : undefined,
139
+ render: (map) =>
140
+ vars.flatMap((v) => {
141
+ const e = map.get(v.slot);
142
+ if (!e?.value) return [];
143
+ const value = fmt(e.value);
144
+ return [{ name: v.css, value, line: cssLine(v.css, value, v.slot, e.provenance.kind) }];
145
+ }),
146
+ });
111
147
 
112
148
  // EMIT: era profile decides artifacts
113
- const shared = { vars, radius, fontSans, fontMono, ctx };
149
+ const shared = { vars, radius, fontSans, fontMono, ctx, blocks };
114
150
  const files = era === 'tailwind-v4' ? emitV4(shared) : emitV3(shared);
115
- files.push({ path: 'usage.md', contents: renderUsage(ctx, coverage, era), kind: 'doc' });
116
- return { files, coverage };
151
+ files.push({
152
+ path: 'usage.md',
153
+ contents: renderUsage(ctx, coverage, era, Boolean(normalized.modes.dark)) + modeBlocksUsage(normalized, blocks, { scheme: 'the `dark` class' }),
154
+ kind: 'doc',
155
+ });
156
+ const collapsed = radius && collapsedRungs(radius.value, era, ctx.units);
157
+ const diagnostics = [eraConflict, collapsed].filter(Boolean);
158
+ return { files, coverage, diagnostics };
117
159
  },
118
160
  };
119
161
 
162
+ /** shadcn's own radius rungs, as offsets from --radius in px (both eras write the same arithmetic). */
163
+ const RADIUS_OFFSETS = { sm: -4, md: -2 };
164
+
165
+ /**
166
+ * TST2104 (info): shadcn derives its smaller rungs by subtracting a fixed
167
+ * offset from --radius, so a small non-zero radius.md (2px, say) gives
168
+ * `rounded-sm` and `rounded-md` a negative radius, which browsers clamp to 0:
169
+ * the author asked for rounded corners and gets square ones. Never fires for
170
+ * radius.md = 0, where every rung at 0 is exactly what was asked. Lengths are
171
+ * read in px, rem at the config's `units.remBase` through ctx.units (16px for a
172
+ * ctx without it); any other unit is left alone.
173
+ */
174
+ function collapsedRungs(value, era, units) {
175
+ const m = /^(\d*\.?\d+)(px|rem)$/.exec(String(value).trim());
176
+ if (!m) return null;
177
+ const px = units?.toPx(m[0]) ?? parseFloat(m[1]) * (m[2] === 'rem' ? 16 : 1);
178
+ if (px <= 0) return null;
179
+ const rungs = Object.entries(RADIUS_OFFSETS).filter(([, off]) => px + off <= 0).map(([rung]) => rung);
180
+ if (!rungs.length) return null;
181
+ const name = (rung) => (era === 'tailwind-v4' ? `--radius-${rung}` : `borderRadius.${rung}`);
182
+ const shown = m[2] === 'rem' ? `${value} (${+px.toFixed(3)}px)` : value;
183
+ return {
184
+ severity: 'info',
185
+ code: 'TST2104',
186
+ message: `${rungs.map(name).join(' and ')} ${rungs.length > 1 ? 'resolve' : 'resolves'} to 0 or less with radius.md = ${shown}; browsers clamp ${rungs.length > 1 ? 'them' : 'it'} to 0`,
187
+ hint: `shadcn rounds its sm and md rungs at radius − 4px and radius − 2px. Author radius.md above ${+(4 / (units?.remBase ?? 16)).toFixed(4)}rem (4px) to keep every rung rounded, or 0 for a square design.`,
188
+ };
189
+ }
190
+
120
191
  // ---------- shared helpers ----------
121
192
 
122
193
  const header = (ctx, era) => [
@@ -130,11 +201,12 @@ const header = (ctx, era) => [
130
201
  const cssLine = (name, value, slot, prov) =>
131
202
  ` ${name}: ${value}; /* ${slot.replace('semantic.', '')}${prov === 'derived' ? ' · derived' : ''} */`;
132
203
 
133
- const fontList = (value) => value.map((f) => (/[^a-z-]/.test(f) ? `"${f}"` : f)).join(', ');
134
-
135
204
  function colorBlocks(vars, fmt) {
136
205
  const lines = { light: [], dark: [] };
137
206
  for (const v of vars) {
207
+ // The slot's own $description / $deprecated (#30), above its light
208
+ // declaration only (the dark block re-declares the same variable).
209
+ for (const note of entryNotes(v.lightEntry)) lines.light.push(` ${blockComment(note)}`);
138
210
  lines.light.push(cssLine(v.css, fmt(v.lightEntry.value), v.slot, v.lightEntry.provenance.kind));
139
211
  if (v.darkEntry?.value) lines.dark.push(cssLine(v.css, fmt(v.darkEntry.value), v.slot, v.darkEntry.provenance.kind));
140
212
  }
@@ -143,12 +215,12 @@ function colorBlocks(vars, fmt) {
143
215
 
144
216
  // ---------- tailwind-v4 profile ----------
145
217
 
146
- function emitV4({ vars, radius, fontSans, fontMono, ctx }) {
218
+ function emitV4({ vars, radius, fontSans, fontMono, ctx, blocks }) {
147
219
  const lines = colorBlocks(vars, (c) => ctx.formatColor(c));
148
220
  const themeVars = vars.map((v) => ` --color${v.css.slice(1)}: var(${v.css});`);
149
221
  const extras = [];
150
- if (fontSans) extras.push(` --font-sans: ${fontList(fontSans.value)};`);
151
- if (fontMono) extras.push(` --font-mono: ${fontList(fontMono.value)};`);
222
+ if (fontSans) extras.push(` --font-sans: ${fontStack(fontSans.value)};`);
223
+ if (fontMono) extras.push(` --font-mono: ${fontStack(fontMono.value)};`);
152
224
  if (radius) {
153
225
  extras.push(' --radius-sm: calc(var(--radius) - 4px);');
154
226
  extras.push(' --radius-md: calc(var(--radius) - 2px);');
@@ -165,6 +237,7 @@ function emitV4({ vars, radius, fontSans, fontMono, ctx }) {
165
237
  '.dark {',
166
238
  ...lines.dark,
167
239
  '}',
240
+ ...formatModeBlocks(blocks),
168
241
  '',
169
242
  '@theme inline {',
170
243
  ...themeVars,
@@ -177,7 +250,7 @@ function emitV4({ vars, radius, fontSans, fontMono, ctx }) {
177
250
 
178
251
  // ---------- tailwind-v3 profile ----------
179
252
 
180
- function emitV3({ vars, radius, fontSans, fontMono, ctx }) {
253
+ function emitV3({ vars, radius, fontSans, fontMono, ctx, blocks }) {
181
254
  const lines = colorBlocks(vars, (c) => ctx.formatHslTriplet(c).text);
182
255
  const css = [
183
256
  header(ctx, 'tailwind-v3'),
@@ -190,6 +263,7 @@ function emitV3({ vars, radius, fontSans, fontMono, ctx }) {
190
263
  ' .dark {',
191
264
  ...lines.dark.map((l) => ' ' + l),
192
265
  ' }',
266
+ ...formatModeBlocks(blocks, ' '),
193
267
  '}',
194
268
  '',
195
269
  ].join('\n');
@@ -226,8 +300,8 @@ function emitV3({ vars, radius, fontSans, fontMono, ctx }) {
226
300
  ' },',
227
301
  ...(fontSans || fontMono ? [
228
302
  ' fontFamily: {',
229
- ...(fontSans ? [` sans: ${JSON.stringify(fontSans.value)},`] : []),
230
- ...(fontMono ? [` mono: ${JSON.stringify(fontMono.value)},`] : []),
303
+ ...(fontSans ? [` sans: ${JSON.stringify(fontNames(fontSans.value))},`] : []),
304
+ ...(fontMono ? [` mono: ${JSON.stringify(fontNames(fontMono.value))},`] : []),
231
305
  ' },',
232
306
  ] : []),
233
307
  '};',
@@ -242,7 +316,7 @@ function emitV3({ vars, radius, fontSans, fontMono, ctx }) {
242
316
 
243
317
  // ---------- usage ----------
244
318
 
245
- function renderUsage(ctx, coverage, era) {
319
+ function renderUsage(ctx, coverage, era, hasDark = true) {
246
320
  const counts = {};
247
321
  for (const c of coverage) counts[c.class] = (counts[c.class] ?? 0) + 1;
248
322
  const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(' · ');
@@ -258,7 +332,7 @@ function renderUsage(ctx, coverage, era) {
258
332
  \`\`\`
259
333
 
260
334
  (Or replace the \`:root\`/\`.dark\`/\`@theme inline\` blocks that \`npx shadcn init\` created.)
261
- 3. Dark mode: add the \`dark\` class on \`<html>\` (class strategy), as in standard shadcn setups.`
335
+ ${hasDark ? '3. Dark mode: add the \`dark\` class on \`<html>\` (class strategy), as in standard shadcn setups.' : '3. Light only: this theme was generated without a dark mode, so the \`.dark\` block is empty.'}`
262
336
  : `## Tailwind v3 project (shadcn "old" era, hsl(var(--x)) convention)
263
337
 
264
338
  1. Copy \`globals.transtyle.css\` into your app and import it in your global CSS
@@ -269,7 +343,7 @@ function renderUsage(ctx, coverage, era) {
269
343
  const transtyle = require("./tailwind.theme.transtyle.cjs");
270
344
  module.exports = { darkMode: ["class"], theme: { extend: transtyle } };
271
345
  \`\`\`
272
- 3. Dark mode: class strategy (\`darkMode: ["class"]\`), toggle \`dark\` on \`<html>\`.`;
346
+ ${hasDark ? '3. Dark mode: class strategy (\`darkMode: ["class"]\`), toggle \`dark\` on \`<html>\`.' : '3. Light only: this theme was generated without a dark mode, so the \`.dark\` block is empty.'}`;
273
347
 
274
348
  return `# Using this shadcn theme
275
349