basalt-ui 2.0.1 → 2.0.3

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.
Files changed (45) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/MIGRATING.md +37 -19
  3. package/configs/check.yml +5 -5
  4. package/dist/cli/config.d.ts +10 -0
  5. package/dist/cli/config.d.ts.map +1 -1
  6. package/dist/cli/config.js +1 -1
  7. package/dist/cli/doctor-upgrade.d.ts +1 -1
  8. package/dist/cli/doctor-upgrade.d.ts.map +1 -1
  9. package/dist/cli/doctor-upgrade.js +36 -1
  10. package/dist/cli/doctor.d.ts.map +1 -1
  11. package/dist/cli/doctor.js +28 -3
  12. package/dist/cli/index.d.ts +28 -0
  13. package/dist/cli/index.d.ts.map +1 -1
  14. package/dist/cli/index.js +60 -7
  15. package/dist/cli/init.d.ts.map +1 -1
  16. package/dist/cli/init.js +7 -1
  17. package/dist/cli/sync.d.ts.map +1 -1
  18. package/dist/cli/sync.js +5 -0
  19. package/dist/provider/index.d.ts +8 -1
  20. package/dist/provider/index.d.ts.map +1 -1
  21. package/dist/provider/index.js +3 -2
  22. package/dist/provider/spotlight-actions.d.ts +3 -2
  23. package/dist/provider/spotlight-actions.d.ts.map +1 -1
  24. package/dist/provider/spotlight-actions.js +5 -3
  25. package/dist/shell/app-sidebar.d.ts.map +1 -1
  26. package/dist/shell/app-sidebar.js +9 -2
  27. package/dist/shell/app-sidebar.module.css +6 -0
  28. package/dist/shell/app-sidebar.module.css.d.ts +1 -0
  29. package/dist/tokens/core.d.ts +12 -11
  30. package/dist/tokens/core.d.ts.map +1 -1
  31. package/dist/tokens/core.js +9 -1
  32. package/llms.txt +2 -2
  33. package/package.json +1 -1
  34. package/src/cli/config.ts +11 -1
  35. package/src/cli/doctor-upgrade.ts +49 -2
  36. package/src/cli/doctor.ts +37 -3
  37. package/src/cli/index.ts +93 -7
  38. package/src/cli/init.ts +10 -1
  39. package/src/cli/sync.ts +6 -0
  40. package/src/provider/index.tsx +10 -2
  41. package/src/provider/spotlight-actions.tsx +8 -3
  42. package/src/shell/app-sidebar.module.css +6 -0
  43. package/src/shell/app-sidebar.module.css.d.ts +1 -0
  44. package/src/shell/app-sidebar.tsx +16 -2
  45. package/src/tokens/core.ts +25 -9
@@ -5,6 +5,7 @@ import type { BuildPaletteOpts } from '../tokens/core.js';
5
5
  import type { SizeClass } from '../shell/use-size-class.js';
6
6
  import type { Telemetry } from '../telemetry.js';
7
7
  import type { BasaltRouter } from './create-router.js';
8
+ import type { FlatNavItem } from '../router-tanstack/nav.js';
8
9
  export type BasaltProviderProps = {
9
10
  /** From `createBasaltRouter`; the provider mounts it and reads its `queryClient`. */
10
11
  router: BasaltRouter;
@@ -41,6 +42,12 @@ export type BasaltProviderProps = {
41
42
  * can answer. Default `'compact'`. SSR only; an SSR desktop app SHOULD pass it.
42
43
  */
43
44
  sizeClassHint?: SizeClass;
45
+ /**
46
+ * Label for the `nav.<id>` Spotlight entries generated from `defineNav`, e.g.
47
+ * `(item) => `Go to ${item.label}``. Default: the destination's own label. A `defineCommands`
48
+ * entry with id `nav.<id>` replaces the generated one outright.
49
+ */
50
+ navCommandLabel?: (item: FlatNavItem) => string;
44
51
  } & Omit<MantineProviderProps, 'children' | 'theme' | 'defaultColorScheme' | 'cssVariablesResolver'>;
45
52
  type RootBoundaryProps = {
46
53
  children: ReactNode;
@@ -76,6 +83,6 @@ export declare function composeInjectedCss(base: string, fontsCss: string, radiu
76
83
  * closes the gap between hydration and the next client paint.
77
84
  */
78
85
  export declare function useHostAttribute(): void;
79
- export declare function BasaltProvider({ router, theme, telemetry, fallback, injectPalette, paletteOptions, defaultColorScheme, nonce, sizeClassHint, ...rest }: BasaltProviderProps): import("react").JSX.Element;
86
+ export declare function BasaltProvider({ router, theme, telemetry, fallback, injectPalette, paletteOptions, defaultColorScheme, nonce, sizeClassHint, navCommandLabel, ...rest }: BasaltProviderProps): import("react").JSX.Element;
80
87
  export {};
81
88
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/provider/index.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AASzD,OAAO,EAAE,SAAS,EAAqE,MAAM,OAAO,CAAA;AACpG,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAStD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAExD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAE7C,OAAO,KAAK,EAAE,YAAY,EAAuB,MAAM,iBAAiB,CAAA;AAIxE,MAAM,MAAM,mBAAmB,GAAG;IAChC,qFAAqF;IACrF,MAAM,EAAE,YAAY,CAAA;IACpB,qFAAqF;IACrF,KAAK,CAAC,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAA;IACrC;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,CAAC,CAAA;IACtD;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;OAGG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAA;IACjC,8CAA8C;IAC9C,kBAAkB,CAAC,EAAE,oBAAoB,CAAC,oBAAoB,CAAC,CAAA;IAC/D;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,aAAa,CAAC,EAAE,SAAS,CAAA;CAC1B,GAAG,IAAI,CAAC,oBAAoB,EAAE,UAAU,GAAG,OAAO,GAAG,oBAAoB,GAAG,sBAAsB,CAAC,CAAA;AAIpG,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,SAAS,CAAA;IACnB,IAAI,EAAE,SAAS,CAAA;IACf,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC,CAAA;CAC1C,CAAA;AAED,KAAK,aAAa,GAAG;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAA;AAwB1D,mGAAmG;AACnG,qBAAa,YAAa,SAAQ,SAAS,CAAC,iBAAiB,EAAE,aAAa,CAAC;gBAC/D,KAAK,EAAE,iBAAiB;IAKpC,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa;IAIrD,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,GAAG,IAAI;IAczD,MAAM,IAAI,SAAS;CAO7B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM,CAER;AAYD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAmBvC;AAqMD,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,KAAK,EACL,SAAS,EACT,QAAQ,EACR,aAAoB,EACpB,cAAc,EACd,kBAA2B,EAC3B,KAAK,EACL,aAAyB,EACzB,GAAG,IAAI,EACR,EAAE,mBAAmB,+BA6ErB"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/provider/index.tsx"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA;AASzD,OAAO,EAAE,SAAS,EAAqE,MAAM,OAAO,CAAA;AACpG,OAAO,KAAK,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAKjD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAA;AAStD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAA;AAExD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAA;AAE7C,OAAO,KAAK,EAAE,YAAY,EAAuB,MAAM,iBAAiB,CAAA;AAExE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AAGzD,MAAM,MAAM,mBAAmB,GAAG;IAChC,qFAAqF;IACrF,MAAM,EAAE,YAAY,CAAA;IACpB,qFAAqF;IACrF,KAAK,CAAC,EAAE,oBAAoB,CAAC,OAAO,CAAC,CAAA;IACrC;;;;OAIG;IACH,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,yGAAyG;IACzG,QAAQ,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,SAAS,CAAC,CAAA;IACtD;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;OAGG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAA;IACjC,8CAA8C;IAC9C,kBAAkB,CAAC,EAAE,oBAAoB,CAAC,oBAAoB,CAAC,CAAA;IAC/D;;;OAGG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd;;;OAGG;IACH,aAAa,CAAC,EAAE,SAAS,CAAA;IACzB;;;;OAIG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,CAAA;CAChD,GAAG,IAAI,CAAC,oBAAoB,EAAE,UAAU,GAAG,OAAO,GAAG,oBAAoB,GAAG,sBAAsB,CAAC,CAAA;AAIpG,KAAK,iBAAiB,GAAG;IACvB,QAAQ,EAAE,SAAS,CAAA;IACnB,IAAI,EAAE,SAAS,CAAA;IACf,QAAQ,EAAE,mBAAmB,CAAC,UAAU,CAAC,CAAA;CAC1C,CAAA;AAED,KAAK,aAAa,GAAG;IAAE,QAAQ,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAA;AAwB1D,mGAAmG;AACnG,qBAAa,YAAa,SAAQ,SAAS,CAAC,iBAAiB,EAAE,aAAa,CAAC;gBAC/D,KAAK,EAAE,iBAAiB;IAKpC,MAAM,CAAC,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,aAAa;IAIrD,iBAAiB,CAAC,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,GAAG,IAAI;IAczD,MAAM,IAAI,SAAS;CAO7B;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAChC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,GACjB,MAAM,CAER;AAYD;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,IAAI,IAAI,CAmBvC;AAqMD,wBAAgB,cAAc,CAAC,EAC7B,MAAM,EACN,KAAK,EACL,SAAS,EACT,QAAQ,EACR,aAAoB,EACpB,cAAc,EACd,kBAA2B,EAC3B,KAAK,EACL,aAAyB,EACzB,eAAe,EACf,GAAG,IAAI,EACR,EAAE,mBAAmB,+BA6ErB"}
@@ -242,6 +242,7 @@ function BasaltProvider({
242
242
  defaultColorScheme = "dark",
243
243
  nonce,
244
244
  sizeClassHint = "compact",
245
+ navCommandLabel,
245
246
  ...rest
246
247
  }) {
247
248
  useDuplicateProviderGuard();
@@ -259,8 +260,8 @@ function BasaltProvider({
259
260
  const [devTool, setDevTool] = useState(null);
260
261
  const [actions, setActions] = useState([]);
261
262
  const refreshActions = useCallback(
262
- () => setActions(buildSpotlightActions(router, setDevTool)),
263
- [router]
263
+ () => setActions(buildSpotlightActions(router, setDevTool, navCommandLabel)),
264
+ [router, navCommandLabel]
264
265
  );
265
266
  useEffect(refreshActions, [refreshActions]);
266
267
  const resolvedTheme = useLabTheme(theme);
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The provider's Spotlight list: the `defineCommands` registry, then the `defineNav` destinations
3
- * (via `flattenNav`), then the three dev-dock commands in dev. A snapshot rebuilt each time Spotlight
3
+ * (via `flattenNav`, skipping any id a registered `nav.<id>` command already owns), then the three dev-dock commands in dev. A snapshot rebuilt each time Spotlight
4
4
  * opens, so a command's `when()` is current. Not for use during render (`detectMac` reads `navigator`).
5
5
  */
6
6
  import type { SpotlightActionData } from '@mantine/spotlight';
7
7
  import type { AnyRouter } from '@tanstack/react-router';
8
+ import type { FlatNavItem } from '../router-tanstack/nav.js';
8
9
  import type { BasaltDevDockTool } from '../dev-dock.js';
9
- export declare function buildSpotlightActions(router: AnyRouter, openDevTool: (tool: BasaltDevDockTool) => void): SpotlightActionData[];
10
+ export declare function buildSpotlightActions(router: AnyRouter, openDevTool: (tool: BasaltDevDockTool) => void, navCommandLabel?: (item: FlatNavItem) => string): SpotlightActionData[];
10
11
  //# sourceMappingURL=spotlight-actions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"spotlight-actions.d.ts","sourceRoot":"","sources":["../../src/provider/spotlight-actions.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAC7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAA;AAIvD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AASpD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,WAAW,EAAE,CAAC,IAAI,EAAE,iBAAiB,KAAK,IAAI,GAC7C,mBAAmB,EAAE,CA8CvB"}
1
+ {"version":3,"file":"spotlight-actions.d.ts","sourceRoot":"","sources":["../../src/provider/spotlight-actions.tsx"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAA;AAC7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAA;AAIvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAA;AACzD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AASpD,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,SAAS,EACjB,WAAW,EAAE,CAAC,IAAI,EAAE,iBAAiB,KAAK,IAAI,EAC9C,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,MAAM,GAC9C,mBAAmB,EAAE,CAiDvB"}
@@ -7,9 +7,10 @@ const DEV_TOOLS = [
7
7
  { tool: "query", label: "Query devtools" },
8
8
  { tool: "theme", label: "Theme lab" }
9
9
  ];
10
- function buildSpotlightActions(router, openDevTool) {
10
+ function buildSpotlightActions(router, openDevTool, navCommandLabel) {
11
11
  const actions = [];
12
- for (const [id, cmd] of Object.entries(getActiveCommands())) {
12
+ const commands = getActiveCommands();
13
+ for (const [id, cmd] of Object.entries(commands)) {
13
14
  if (cmd.when !== void 0 && !cmd.when()) continue;
14
15
  actions.push({
15
16
  id,
@@ -27,9 +28,10 @@ function buildSpotlightActions(router, openDevTool) {
27
28
  if (nav !== void 0) {
28
29
  for (const item of flattenNav(nav)) {
29
30
  if (item.disabled === true) continue;
31
+ if (`nav.${item.id}` in commands) continue;
30
32
  actions.push({
31
33
  id: `nav.${item.id}`,
32
- label: item.label,
34
+ label: navCommandLabel?.(item) ?? item.label,
33
35
  group: item.groupLabel,
34
36
  leftSection: item.icon,
35
37
  // `link` is the destination's own `linkOptions`, so a store-backed `search` thunk restores
@@ -1 +1 @@
1
- {"version":3,"file":"app-sidebar.d.ts","sourceRoot":"","sources":["../../src/shell/app-sidebar.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAC5D,OAAO,KAAK,EAAE,YAAY,EAAe,cAAc,EAAE,MAAM,aAAa,CAAA;AAI5E,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAE1E,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAgBjF,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG;IAC1C;;;;OAIG;IACH,KAAK,EAAE,WAAW,GAAG;QAAE,IAAI,CAAC,EAAE,eAAe,EAAE,CAAA;KAAE,CAAA;IACjD,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACtC;;;;;;;;;;;;;;;;OAgBG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAC5B;;;;OAIG;IACH,MAAM,CAAC,EAAE,mBAAmB,GAAG;QAAE,OAAO,CAAC,EAAE,oBAAoB,CAAA;KAAE,CAAA;IACjE;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;CACxB,CAAA;AAmTD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,+BA2KhD"}
1
+ {"version":3,"file":"app-sidebar.d.ts","sourceRoot":"","sources":["../../src/shell/app-sidebar.tsx"],"names":[],"mappings":"AAoCA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAA;AAElD,OAAO,KAAK,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,SAAS,CAAA;AAC5D,OAAO,KAAK,EAAE,YAAY,EAAe,cAAc,EAAE,MAAM,aAAa,CAAA;AAI5E,OAAO,KAAK,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAA;AAE1E,OAAO,KAAK,EAAE,oBAAoB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAgBjF,MAAM,MAAM,eAAe,GAAG,WAAW,GAAG;IAC1C;;;;OAIG;IACH,KAAK,EAAE,WAAW,GAAG;QAAE,IAAI,CAAC,EAAE,eAAe,EAAE,CAAA;KAAE,CAAA;IACjD,QAAQ,EAAE,cAAc,EAAE,CAAA;IAC1B,SAAS,EAAE,OAAO,CAAA;IAClB;;;;OAIG;IACH,gBAAgB,EAAE,MAAM,IAAI,CAAA;IAC5B;;;;;;;;;;;OAWG;IACH,iBAAiB,CAAC,EAAE,gBAAgB,EAAE,CAAA;IACtC;;;;;;;;;;;;;;;;OAgBG;IACH,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAA;IACvC;;;;OAIG;IACH,OAAO,CAAC,EAAE,kBAAkB,CAAA;IAC5B;;;;OAIG;IACH,MAAM,CAAC,EAAE,mBAAmB,GAAG;QAAE,OAAO,CAAC,EAAE,oBAAoB,CAAA;KAAE,CAAA;IACjE;;;;;;;;;;OAUG;IACH,MAAM,CAAC,EAAE,YAAY,EAAE,CAAA;CACxB,CAAA;AAiUD,wBAAgB,UAAU,CAAC,KAAK,EAAE,eAAe,+BA2KhD"}
@@ -88,14 +88,21 @@ function NavLinkBody({
88
88
  collapsed = false
89
89
  }) {
90
90
  const Anchor = item.Anchor;
91
+ const icon = iconOrInitial(item.icon, item.label);
92
+ const railDot = collapsed && (item.badge !== void 0 && item.badge !== null || Boolean(item.count));
91
93
  const shared = {
92
94
  classNames: { root: classes.link },
93
95
  label: item.label,
94
- leftSection: iconOrInitial(item.icon, item.label),
96
+ leftSection: railDot ? /* @__PURE__ */ jsxs("span", { className: classes.navIconDotHost, children: [
97
+ icon,
98
+ /* @__PURE__ */ jsx("span", { className: classes.blockDot, "data-basalt-rail-dot": true, "aria-hidden": true })
99
+ ] }) : icon,
95
100
  // Only the collapsed rail hides the label (CSS), leaving the icon alone — name the link there.
96
101
  // Expanded, the visible text IS the name and carries the count badge; an `aria-label` would
97
102
  // override both and drop the count ("Inbox 5" → "Inbox").
98
- ...collapsed && { "aria-label": item.label },
103
+ ...collapsed && {
104
+ "aria-label": item.count ? `${item.label}, ${item.count}` : item.label
105
+ },
99
106
  rightSection: item.badge ?? (item.count ? /* @__PURE__ */ jsx(NavCountBadge, { count: item.count }) : void 0),
100
107
  active,
101
108
  // Basalt is the one that stamps `aria-current`, not the theme and not a router `<Link>`'s own
@@ -472,6 +472,12 @@
472
472
  box-shadow: 0 0 0 2px var(--mantine-color-body);
473
473
  }
474
474
 
475
+ /* Host for a nav row's rail dot: the dot anchors on the icon itself, not the 48px row. */
476
+ .navIconDotHost {
477
+ position: relative;
478
+ display: inline-flex;
479
+ }
480
+
475
481
  .blockItems {
476
482
  display: flex;
477
483
  flex-direction: column;
@@ -29,6 +29,7 @@ declare const classes: {
29
29
  readonly blockHeaderButton: string
30
30
  readonly blockIcon: string
31
31
  readonly blockDot: string
32
+ readonly navIconDotHost: string
32
33
  readonly blockItems: string
33
34
  readonly blockRow: string
34
35
  readonly blockRowStatic: string
@@ -190,18 +190,19 @@ export declare function seriesTokens<const T extends SeriesMap>(map: T, prefix?:
190
190
  * not the widened SeriesMap, enabling downstream exact-key checks in groupTokens / seriesTokens).
191
191
  */
192
192
  export declare function defineSeries<const T extends SeriesMap>(map: T): T;
193
- /** Runtime key of the {@link SeriesGroup} brand. */
194
- declare const SERIES_GROUP: unique symbol;
193
+ declare const GROUP_REF: unique symbol;
194
+ /** A `var(--vx-<name>-<key>)` ref minted by `groupTokens`. The brand lives on the VALUES, so it never shows up in `keyof` the group. */
195
+ export type GroupRef = string & {
196
+ readonly [GROUP_REF]: true;
197
+ };
195
198
  /**
196
- * The brand `groupTokens` puts on its return value. `BuildPaletteOpts.groups` takes these, so the
197
- * `${name}-` key that emits the variables and the one that references them come from one call and
198
- * cannot be spelled two ways.
199
+ * What `groupTokens` returns. `BuildPaletteOpts.groups` takes these, so the `${name}-` key that
200
+ * emits the variables and the one that references them come from one call and cannot be spelled
201
+ * two ways. A bare series map (object values) and a plain string record (unbranded values) are
202
+ * both rejected; the runtime payload sits under the non-enumerable {@link SERIES_GROUP} symbol.
199
203
  */
200
204
  export type SeriesGroup = {
201
- readonly [SERIES_GROUP]: {
202
- readonly prefix: string;
203
- readonly map: SeriesMap;
204
- };
205
+ readonly [key: string]: GroupRef;
205
206
  };
206
207
  /**
207
208
  * Build a single namespaced group's token refs (`var(--vx-<name>-<key>)`).
@@ -210,8 +211,8 @@ export type SeriesGroup = {
210
211
  * error at the call site rather than a silent `Record<string, string>` widening.
211
212
  */
212
213
  export declare function groupTokens<const T extends SeriesMap>(name: string, map: T): {
213
- [K in keyof T]: string;
214
- } & SeriesGroup;
214
+ readonly [K in keyof T]: GroupRef;
215
+ };
215
216
  /**
216
217
  * The attribute + values the per-scheme blocks key off. Defaults reproduce Mantine's own toggle
217
218
  * (`data-mantine-color-scheme` = `dark` / `light`), so a consumer only names the axis it differs
@@ -1 +1 @@
1
- {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/tokens/core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../src/tokens/core.ts"],"names":[],"mappings":"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"}
@@ -596,7 +596,15 @@ function buildPaletteCss(opts = {}, data = buildPaletteData()) {
596
596
  const derived = extraDerived ? `${derivedBlock}
597
597
  ${extraDerived}` : derivedBlock;
598
598
  const side = (s) => {
599
- const extra = groups.map((group) => groupSide(group[SERIES_GROUP].prefix, group[SERIES_GROUP].map, s)).join("\n");
599
+ const extra = groups.map((group) => {
600
+ const payload = group[SERIES_GROUP];
601
+ if (!payload) {
602
+ throw new Error(
603
+ "BuildPaletteOpts.groups: every group must come from groupTokens(name, map)"
604
+ );
605
+ }
606
+ return groupSide(payload.prefix, payload.map, s);
607
+ }).join("\n");
600
608
  const framework = frameworkPrimitives(s, data, legacyAliases);
601
609
  return extra ? `${framework}
602
610
  ${extra}` : framework;
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v2.0.1
1
+ # basalt-ui v2.0.3
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets
3
3
  #
4
4
  # Entry points: use the exact import specifier below; do not guess.
@@ -13,7 +13,7 @@
13
13
  # A subpath's `Install:` line is its required peers; `OptionalPeers:` are import()-only, install on use.
14
14
 
15
15
  Package: basalt-ui
16
- Version: 2.0.1
16
+ Version: 2.0.3
17
17
 
18
18
  ## basalt-ui
19
19
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, TanStack Charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
package/src/cli/config.ts CHANGED
@@ -4,6 +4,16 @@ import type { GuardKind, GuardSeverity } from '../guard'
4
4
 
5
5
  /** Shape of the optional `"basalt"` key in a consumer's package.json. */
6
6
  export type BasaltConfig = {
7
+ /**
8
+ * The Vite app's directory relative to the install root, for a workspace monorepo whose manifest,
9
+ * `.claude/` and AGENTS block live at the root. App-scoped checks (`tsconfig-base`, `app-wiring`)
10
+ * and the router seeds target it. Default: the single workspace package that depends on basalt-ui
11
+ * and has a Vite entry, else the root itself.
12
+ *
13
+ * @example
14
+ * { app: 'apps/dashboard' }
15
+ */
16
+ app?: string
7
17
  /** Source roots to scan. Default: `['src']`. Set explicitly for a monorepo layout. */
8
18
  roots?: string[]
9
19
  /** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
@@ -181,7 +191,7 @@ export function resolveRoots(cfg: BasaltConfig): string[] {
181
191
 
182
192
  /** Every `BasaltConfig` key, as data for `doctor`'s `config-keys` (a test pins it to the type). */
183
193
  export const BASALT_CONFIG_KEYS = (
184
- 'roots exempt profile include spacingSteps severity forbiddenAccents accentHue rawRadius rawSurface ' +
194
+ 'app roots exempt profile include spacingSteps severity forbiddenAccents accentHue rawRadius rawSurface ' +
185
195
  'cardWithBorder offSystemSurfaceVar mantineShadeIndex rawHtmlLayout inlineSpacing inlineDisplay ' +
186
196
  'rawMotionValue rawFormControl sub16InputFont seriesModulePath exemptRules aiMajorSkewReason'
187
197
  ).split(' ') as (keyof BasaltConfig)[]
@@ -352,9 +352,53 @@ function registersRouterPlugin(src: string): boolean {
352
352
  })
353
353
  }
354
354
 
355
+ const RELATIVE_IMPORT =
356
+ /(?:\b(?:import|export)\b[^'";]*?\bfrom|\bimport)\s*['"](\.{1,2}\/[^'"]+)['"]/g
357
+
358
+ /** The source file a relative specifier names: as written, `.ts`/`.tsx` appended, or a directory index. */
359
+ function resolveRelative(from: string, spec: string): string | null {
360
+ const base = resolve(dirname(from), spec.replace(/\.[jt]sx?$/, ''))
361
+ return (
362
+ [
363
+ resolve(dirname(from), spec),
364
+ `${base}.ts`,
365
+ `${base}.tsx`,
366
+ `${base}/index.ts`,
367
+ `${base}/index.tsx`,
368
+ ].find((f) => /\.[jt]sx?$/.test(f) && existsSync(f)) ?? null
369
+ )
370
+ }
371
+
372
+ /**
373
+ * Whether a module the entry imports (relative imports, three deep) calls `createBasaltRouter` —
374
+ * the router often lives in a singleton module other code also imports.
375
+ */
376
+ function routerCreatedInImports(cwd: string, entry: string): boolean {
377
+ const seen = new Set([resolve(cwd, entry)])
378
+ let frontier = [...seen]
379
+ for (let depth = 0; depth < 3 && frontier.length > 0; depth++) {
380
+ const next: string[] = []
381
+ for (const file of frontier) {
382
+ for (const m of (readIfExists(file) ?? '').matchAll(RELATIVE_IMPORT)) {
383
+ const dep = resolveRelative(file, m[1] ?? '')
384
+ if (dep === null || seen.has(dep)) continue
385
+ seen.add(dep)
386
+ const { code, inLiteral } = tokenize(readIfExists(dep) ?? '')
387
+ const called = [...code.matchAll(/\bcreateBasaltRouter\b/g)].some(
388
+ (c) => !inLiteral(c.index),
389
+ )
390
+ if (called) return true
391
+ next.push(dep)
392
+ }
393
+ }
394
+ frontier = next
395
+ }
396
+ return false
397
+ }
398
+
355
399
  /**
356
400
  * `app-wiring`: the entry module `index.html` loads (default src/main.tsx) mounts `BasaltProvider`
357
- * on a `createBasaltRouter` router, and the router tree is generated: the vite config calls a
401
+ * on a `createBasaltRouter` router (called in the entry or a module it imports), and the router tree is generated: the vite config calls a
358
402
  * binding imported from `@tanstack/router-plugin` in `plugins`, or a `routeTree.gen.*` already sits
359
403
  * beside the entry (or in src/). `basaltViteConfig` does not register that plugin, so naming it
360
404
  * proves nothing.
@@ -371,7 +415,10 @@ export function appWiringCheck(cwd: string): Check {
371
415
  const { code, inLiteral } = tokenize(text)
372
416
  const used = (name: string): boolean =>
373
417
  [...code.matchAll(new RegExp(`\\b${name}\\b`, 'g'))].some((m) => !inLiteral(m.index))
374
- const missing = ['BasaltProvider', 'createBasaltRouter'].filter((name) => !used(name))
418
+ const missing = ['BasaltProvider', 'createBasaltRouter'].filter(
419
+ (name) =>
420
+ !used(name) && !(name === 'createBasaltRouter' && routerCreatedInImports(cwd, entry)),
421
+ )
375
422
  if (missing.length > 0) {
376
423
  return {
377
424
  fail:
package/src/cli/doctor.ts CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  readBasaltConfig,
27
27
  readIfExists,
28
28
  readManifest,
29
+ resolveAppDir,
29
30
  resolveProjectDir,
31
+ ROUTER_SEED_DESTS,
30
32
  sameFile,
31
33
  scannableFiles,
32
34
  scanBlock,
@@ -135,7 +137,18 @@ function resolveAiMajorSkewReason(cfg: BasaltConfig): {
135
137
  export function doctor(invocationCwd: string = process.cwd(), flags: string[] = []): number {
136
138
  if (conflictingProfileFlags('doctor', flags)) return 1
137
139
  const project = resolveProjectDir(invocationCwd)
138
- const cwd = project.dir
140
+ // Inside the app package of a monorepo the manifest is above: read the install root instead.
141
+ const aboveRoot =
142
+ project.relocatedFrom === null && !existsSync(resolve(project.dir, MANIFEST_PATH))
143
+ ? findManifestAbove(project.dir)
144
+ : null
145
+ const insideApp =
146
+ aboveRoot !== null &&
147
+ (() => {
148
+ const app = resolveAppDir(aboveRoot)
149
+ return !('error' in app) && app.dir === project.dir
150
+ })()
151
+ const cwd = insideApp && aboveRoot !== null ? aboveRoot : project.dir
139
152
  const cfg = readBasaltConfig(cwd)
140
153
  const result: DoctorResult = { hardFailures: 0, warnings: 0, skipped: 0 }
141
154
  const lines: string[] = [`\nbasalt-ui doctor — ${cwd}\n`]
@@ -160,6 +173,11 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
160
173
  lines.push(` → BASALT_CWD relocated from ${project.relocatedFrom} to ${cwd}.\n`)
161
174
  }
162
175
 
176
+ if (insideApp)
177
+ lines.push(` → ${project.dir} is the app package; checking the install root ${cwd}.\n`)
178
+
179
+ const app = resolveAppDir(cwd)
180
+ const appDir = 'error' in app ? cwd : app.dir
163
181
  const profile = declaredProfile(cfg, flags)
164
182
  if (profile === 'tokens-only') {
165
183
  lines.push(
@@ -192,6 +210,22 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
192
210
 
193
211
  const install = findBasaltInstall(cwd)
194
212
 
213
+ // ── App placement: the agent layer is at the root, the app is where `resolveAppDir` says ────
214
+ if ('error' in app) {
215
+ fail(`app-dir: ${app.error}`)
216
+ } else if (app.rel !== '') {
217
+ pass(`app-dir: the app is ${app.rel} (agent layer and manifest stay at the install root)`)
218
+ const ledger = readManifest(cwd).files
219
+ for (const seed of Object.values(ROUTER_SEED_DESTS)) {
220
+ if (ledger[seed] !== undefined && existsSync(resolve(cwd, seed))) {
221
+ warn(
222
+ `app-dir: ${seed} was seeded at the install root by an earlier \`sync\`, but the app is ` +
223
+ `${app.rel} — it is dead code there. Delete it (\`sync\` seeds ${app.rel}/${seed} instead).`,
224
+ )
225
+ }
226
+ }
227
+ }
228
+
195
229
  // ── Hard check 2: install matches the manifest, and doctor says WHERE (the AGENTS-block's claim)
196
230
  const manifestVersion = profile === 'tokens-only' ? undefined : readManifest(cwd).basaltVersion
197
231
  const at = install.dir === null ? '' : `${relative(cwd, install.dir)} (${install.version})`
@@ -380,7 +414,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
380
414
  )
381
415
  } else {
382
416
  const upgrade: [string, Check][] = [
383
- ['tsconfig-base: tsconfig.json extends the shipped preset', tsconfigBaseCheck(cwd)],
417
+ ['tsconfig-base: tsconfig.json extends the shipped preset', tsconfigBaseCheck(appDir)],
384
418
  [
385
419
  'retired-lint-rules: .oxlintrc.json sets no retired basalt/* rule',
386
420
  retiredLintRulesCheck(cwd, install),
@@ -391,7 +425,7 @@ export function doctor(invocationCwd: string = process.cwd(), flags: string[] =
391
425
  ],
392
426
  [
393
427
  'app-wiring: the entry mounts BasaltProvider and the router tree is generated',
394
- appWiringCheck(cwd),
428
+ appWiringCheck(appDir),
395
429
  ],
396
430
  ['exact-peers: installed exact-pinned peers match basalt-ui', exactPeersCheck(cwd, install)],
397
431
  [
package/src/cli/index.ts CHANGED
@@ -474,6 +474,8 @@ type TemplateVars = {
474
474
  * `bunx basalt-ui` only as the pre-install fallback.
475
475
  */
476
476
  BASALT_BIN: string
477
+ /** The CI gate steps after install: `make check` when the repo owns one, else the built-in set. */
478
+ GATE_STEPS: string
477
479
  }
478
480
 
479
481
  // The block markers the AGENTS.md template emits. The begin marker carries the framework version
@@ -536,6 +538,26 @@ export function basaltBinCommand(install: BasaltInstall, cwd: string): string {
536
538
  return `${relativePosix(cwd, binDir).replace(/^\.\//, '')}/basalt-ui`
537
539
  }
538
540
 
541
+ /**
542
+ * The consumer owns the CI gate when its Makefile has a `check:` target: `doctor` and `sync --check`
543
+ * stay as steps of their own (drift is never skippable), then `make check` replaces the built-in
544
+ * lint/theme/format steps. Without one, the built-in steps run. Rendered at `{{GATE_STEPS}}`.
545
+ */
546
+ export function ciGateSteps(cwd: string): string {
547
+ const owned = /^check\s*:/m.test(readIfExists(resolve(cwd, 'Makefile')) ?? '')
548
+ const drift = ["- run: '{{BASALT_BIN}} doctor'", "- run: '{{BASALT_BIN}} sync --check'"]
549
+ const steps = owned
550
+ ? [...drift, '- run: make check']
551
+ : [
552
+ '- run: bunx oxlint .',
553
+ drift[0],
554
+ "- run: '{{BASALT_BIN}} check-theme'",
555
+ drift[1],
556
+ "- run: bunx oxfmt {{ROOTS_GLOBS}} '!**/dist/**' --check",
557
+ ]
558
+ return steps.join('\n ')
559
+ }
560
+
539
561
  /** Build the template-variable map from the framework root + consumer cwd/config. */
540
562
  function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): TemplateVars {
541
563
  const install = findBasaltInstall(cwd)
@@ -548,15 +570,20 @@ function buildTemplateVars(pkgRoot: string, cwd: string, cfg: BasaltConfig): Tem
548
570
  OXLINT_PRESET_PATH: shippedAssetPath(install, cwd, OXLINT_FILES.preset),
549
571
  LEFTHOOK_PRESET_PATH: shippedAssetPath(install, cwd, 'configs/lefthook.yml'),
550
572
  BASALT_BIN: basaltBinCommand(install, cwd),
573
+ GATE_STEPS: ciGateSteps(cwd),
551
574
  }
552
575
  }
553
576
 
554
577
  /** Substitute every `{{KEY}}` placeholder from the variable map; unknown keys are left verbatim. */
555
578
  function fillTemplate(tpl: string, vars: TemplateVars): string {
556
- return tpl.replace(/\{\{(\w+)\}\}/g, (m, key: string) => {
557
- const v = (vars as Record<string, string>)[key]
558
- return v ?? m
559
- })
579
+ const fill = (text: string): string =>
580
+ text.replace(/\{\{(\w+)\}\}/g, (m, key: string) => {
581
+ const v = (vars as Record<string, string>)[key]
582
+ return v ?? m
583
+ })
584
+ // The marker is a YAML comment so the template stays valid YAML; GATE_STEPS itself carries
585
+ // placeholders, so it expands before the generic pass.
586
+ return fill(tpl.replace('# {{GATE_STEPS}}', vars.GATE_STEPS))
560
587
  }
561
588
 
562
589
  /**
@@ -654,6 +681,56 @@ export const ROUTER_SEED_DESTS = {
654
681
  main: 'src/main.tsx',
655
682
  } as const
656
683
 
684
+ /** A router seed's dest, relative to the install root: under the app dir when it is not the root. */
685
+ export function routerSeedDest(appRel: string, seed: keyof typeof ROUTER_SEED_DESTS): string {
686
+ return appRel === '' ? ROUTER_SEED_DESTS[seed] : `${appRel}/${ROUTER_SEED_DESTS[seed]}`
687
+ }
688
+
689
+ /** The app directory a command's app-scoped half targets: `rel` is '' when it is the install root. */
690
+ export type AppResolution = { dir: string; rel: string } | { error: string }
691
+
692
+ /** `dir` relative to `root` without relativePosix's leading `./` ('' for the root itself). */
693
+ const appRelative = (root: string, dir: string): string =>
694
+ relativePosix(root, dir).replace(/^\.\/?/, '')
695
+
696
+ const hasViteEntry = (dir: string): boolean =>
697
+ existsSync(resolve(dir, 'src/main.tsx')) || existsSync(resolve(dir, 'index.html'))
698
+
699
+ /**
700
+ * Where the Vite app lives relative to the install root `root` (manifest, `.claude/`, AGENTS block).
701
+ * ONE resolution for `doctor`, `sync` and `init`, in order: `basalt.app` in the root package.json;
702
+ * else the single workspace package that depends on basalt-ui AND has a Vite entry; else `root`
703
+ * itself (a single-package app). Two such packages is an error — the choice is not inferable.
704
+ */
705
+ export function resolveAppDir(root: string): AppResolution {
706
+ const self = { dir: root, rel: '' }
707
+ const explicit = readBasaltConfig(root).app
708
+ if (explicit !== undefined) {
709
+ const dir = resolve(root, explicit)
710
+ const rel = appRelative(root, dir)
711
+ if (rel.startsWith('..') || !existsSync(resolve(dir, 'package.json'))) {
712
+ return {
713
+ error: `basalt.app "${explicit}" must be a directory with a package.json inside ${root}.`,
714
+ }
715
+ }
716
+ return dir === root ? self : { dir, rel }
717
+ }
718
+ const candidates = collectWorkspacePackages(root).packages.filter(
719
+ (pkg) => pkg.dir !== root && hasDependency(pkg.dir, 'basalt-ui') && hasViteEntry(pkg.dir),
720
+ )
721
+ if (candidates.length === 0) return self
722
+ const [only] = candidates
723
+ if (candidates.length === 1 && only !== undefined) {
724
+ return { dir: only.dir, rel: appRelative(root, only.dir) }
725
+ }
726
+ const names = candidates.map((pkg) => appRelative(root, pkg.dir)).join(', ')
727
+ return {
728
+ error:
729
+ `${candidates.length} workspace packages depend on basalt-ui and have a Vite entry (${names}) — ` +
730
+ 'cannot tell which is the app. Set `basalt.app` (e.g. `"basalt": { "app": "apps/web" }`) in the root package.json.',
731
+ }
732
+ }
733
+
657
734
  /** True when `pkgName` appears in the consumer's dependencies, devDependencies, or peerDependencies. */
658
735
  function hasDependency(cwd: string, pkgName: string): boolean {
659
736
  try {
@@ -687,12 +764,18 @@ export type PlacementFlags = {
687
764
  /** Absolute path of the detected repo root. Meaningful only when `isPackageRepoRoot` is false —
688
765
  * otherwise it equals the package dir and is never read. */
689
766
  repoRoot: string
767
+ /** The app directory relative to the install root ('' = the root itself); see {@link resolveAppDir}. */
768
+ appRel: string
769
+ /** Non-null when the app directory is ambiguous or invalid: commands refuse rather than guess. */
770
+ appError: string | null
690
771
  }
691
772
 
692
773
  /** The default placement — today's single-package-at-repo-root behaviour, unconditionally. */
693
774
  export const ROOT_PLACEMENT: PlacementFlags = {
694
775
  isPackageRepoRoot: true,
695
776
  repoRoot: '',
777
+ appRel: '',
778
+ appError: null,
696
779
  }
697
780
 
698
781
  /**
@@ -725,9 +808,12 @@ export function oxlintBinResolves(dir: string): boolean {
725
808
  /** Resolve a run's placement flags once, from the consumer cwd. */
726
809
  export function resolvePlacement(cwd: string): PlacementFlags {
727
810
  const repoRoot = findRepoRoot(cwd)
811
+ const app = resolveAppDir(cwd)
728
812
  return {
729
813
  isPackageRepoRoot: repoRoot === cwd,
730
814
  repoRoot,
815
+ appRel: 'error' in app ? '' : app.rel,
816
+ appError: 'error' in app ? app.error : null,
731
817
  }
732
818
  }
733
819
 
@@ -854,7 +940,7 @@ export function managedFiles(placement: PlacementFlags = ROOT_PLACEMENT): Manage
854
940
 
855
941
  /** TanStack Router root route with QueryClient context wiring (seed — consumer-owned after init). */
856
942
  const rootRoute: ManagedFile = {
857
- dest: ROUTER_SEED_DESTS.root,
943
+ dest: routerSeedDest(placement.appRel, 'root'),
858
944
  mode: 'seed',
859
945
  source: 'agent/templates/__root.tpl',
860
946
  render: (ctx) => {
@@ -866,7 +952,7 @@ export function managedFiles(placement: PlacementFlags = ROOT_PLACEMENT): Manage
866
952
 
867
953
  /** The leaf `NAV` module `__root.tsx` imports (seed — consumer-owned after init). */
868
954
  const navModule: ManagedFile = {
869
- dest: ROUTER_SEED_DESTS.nav,
955
+ dest: routerSeedDest(placement.appRel, 'nav'),
870
956
  mode: 'seed',
871
957
  source: 'agent/templates/nav.tpl',
872
958
  render: (ctx) => readSource(ctx.pkgRoot, 'agent/templates/nav.tpl'),
@@ -874,7 +960,7 @@ export function managedFiles(placement: PlacementFlags = ROOT_PLACEMENT): Manage
874
960
 
875
961
  /** The 2.0 entry: mounts the router and declares the `Register` augmentation `defineNav` needs. */
876
962
  const mainEntry: ManagedFile = {
877
- dest: ROUTER_SEED_DESTS.main,
963
+ dest: routerSeedDest(placement.appRel, 'main'),
878
964
  mode: 'seed',
879
965
  source: 'agent/templates/main.tpl',
880
966
  render: (ctx) => readSource(ctx.pkgRoot, 'agent/templates/main.tpl'),
package/src/cli/init.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  declaredProfile,
14
14
  readBasaltConfig,
15
15
  ROUTER_SEED_DESTS,
16
+ routerSeedDest,
16
17
  findBasaltInstall,
17
18
  managedFiles,
18
19
  mergeOxlintExtends,
@@ -82,6 +83,12 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
82
83
  )
83
84
  }
84
85
 
86
+ const appError = resolvePlacement(cwd).appError
87
+ if (appError !== null) {
88
+ console.error(`✖ basalt-ui init: ${appError}`)
89
+ return 1
90
+ }
91
+
85
92
  const rootsState = reconcileRoots(cwd, { write: true })
86
93
 
87
94
  // Built AFTER the roots patch — every roots-derived template variable reads the key just written.
@@ -120,7 +127,9 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
120
127
  const placement = resolvePlacement(cwd)
121
128
  // A tokens-only consumer has no use for the Mantine/React scaffolds (doctor skips them there too).
122
129
  const tokensOnly = declaredProfile(readBasaltConfig(cwd), []) === 'tokens-only'
123
- const routerSeeds: string[] = Object.values(ROUTER_SEED_DESTS)
130
+ const routerSeeds: string[] = (
131
+ Object.keys(ROUTER_SEED_DESTS) as (keyof typeof ROUTER_SEED_DESTS)[]
132
+ ).map((seed) => routerSeedDest(placement.appRel, seed))
124
133
  const files = managedFiles(placement).filter((f) => !(tokensOnly && routerSeeds.includes(f.dest)))
125
134
 
126
135
  const writtenFiles: string[] = []