Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions .agents/skills/add-component/angular.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ don't hand-write helm code. Config lives in `packages/angular/components.json`
to `tsconfig.json`. The schematic is interactive unless `components.json` already
exists (it does) — pass `--defaults` for non-interactive runs.

Immediately after generating, run `pnpm --filter @surfnet/angular fix-helm-imports` —
see the note below on why the alias must not survive into the vendored files.

3. **Tie the component to the contract — for every axis it has.** Import the `*Name` unions
from `@surfnet/contracts` and wire them in. Two styles, by how the helm code models the
axis:
Expand Down Expand Up @@ -116,9 +119,17 @@ pnpm format

## Notes

- Helm files import each other through the `@spartan-ng/helm/*` alias, which the tsconfig
paths resolve to local source; `ng-packagr` inlines them. Don't rewrite these to
relative imports — keeping the alias lets future `ng g` runs work unchanged.
- The Spartan CLI always vendors cross-component imports through the `@spartan-ng/helm/*`
alias. `@spartan-ng/helm` isn't a real npm package — the alias only resolves when a
consuming *app*'s own bundler reads the tsconfig `paths` mapping at build time.
`@surfnet/angular` instead builds itself into a redistributable package via `ng-packagr`,
which does not consult tsconfig `paths`: it leaves the alias as an unresolved external
import in the published bundle (and can leave the real symbol duplicated wherever it's
also reached via a relative import elsewhere). Run
`pnpm --filter @surfnet/angular fix-helm-imports` (`packages/angular/scripts/rewrite-helm-imports.ts`)
after every `ng g` to rewrite the new alias imports to relative ones — verify with
`pnpm --filter @surfnet/angular build` and confirm `dist` has no `@spartan-ng/helm` left
(`grep -r "@spartan-ng/helm" packages/angular/dist`).
- The library has no global stylesheet in its build output; the theme tokens in
`src/styles.css` are loaded by Storybook (via the `styles` option) and are meant to be
imported by consuming apps.
Expand Down
76 changes: 57 additions & 19 deletions docs/decision-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,25 +25,26 @@ the replacement.

## Index

| # | Decision | Status | Date |
| --- | ----------------------------------------------------------------------------------------- | -------- | ---------- |
| 01 | [Monorepo structure](#adr-001--monorepo-structure) | Accepted | 2026-06-30 |
| 02 | [Base UI over Radix (React)](#adr-002--base-ui-over-radix-react) | Accepted | 2026-06-30 |
| 03 | [Phosphor icons](#adr-003--phosphor-icons) | Accepted | 2026-06-30 |
| 04 | [Tokens as single source of truth](#adr-004--tokens-as-single-source-of-truth) | Accepted | 2026-06-30 |
| 05 | [Figma → code sync](#adr-005--figma--code-sync) | Accepted | 2026-06-30 |
| 06 | [Theming via a class on `<html>`](#adr-006--theming-via-a-class-on-html) | Accepted | 2026-06-30 |
| 07 | [Token naming & roles](#adr-007--token-naming--roles) | Accepted | 2026-06-29 |
| 08 | [Explicit colors over opacity](#adr-008--explicit-colors-over-opacity) | Accepted | 2026-06-29 |
| 09 | [Modes vs themes](#adr-009--modes-vs-themes) | Accepted | 2026-06-29 |
| 10 | [Deviating from shadcn is manageable](#adr-010--deviating-from-shadcn-is-manageable) | Accepted | 2026-06-29 |
| 11 | [Cross-framework parity via contracts](#adr-011--cross-framework-parity-via-contracts) | Accepted | 2026-06-30 |
| 12 | [Tailwind v4 for styling](#adr-012--tailwind-v4-for-styling) | Proposed | 2026-06-30 |
| 13 | [Storybook + token docs](#adr-013--storybook--token-docs) | Accepted | 2026-06-30 |
| 14 | [Versioning & publishing via Changesets](#adr-014--versioning--publishing-via-changesets) | Accepted | 2026-06-30 |
| 15 | [Tree-shakeable React build](#adr-015--tree-shakeable-react-build) | Accepted | 2026-06-30 |
| 16 | [Component scope built in parity](#adr-016--component-scope-built-in-parity) | Accepted | 2026-06-30 |
| 17 | [Prove it in a real app](#adr-017--prove-it-in-a-real-app) | Proposed | 2026-06-30 |
| # | Decision | Status | Date |
| --- | -------------------------------------------------------------------------------------------------------------------- | -------- | ---------- |
| 01 | [Monorepo structure](#adr-001--monorepo-structure) | Accepted | 2026-06-30 |
| 02 | [Base UI over Radix (React)](#adr-002--base-ui-over-radix-react) | Accepted | 2026-06-30 |
| 03 | [Phosphor icons](#adr-003--phosphor-icons) | Accepted | 2026-06-30 |
| 04 | [Tokens as single source of truth](#adr-004--tokens-as-single-source-of-truth) | Accepted | 2026-06-30 |
| 05 | [Figma → code sync](#adr-005--figma--code-sync) | Accepted | 2026-06-30 |
| 06 | [Theming via a class on `<html>`](#adr-006--theming-via-a-class-on-html) | Accepted | 2026-06-30 |
| 07 | [Token naming & roles](#adr-007--token-naming--roles) | Accepted | 2026-06-29 |
| 08 | [Explicit colors over opacity](#adr-008--explicit-colors-over-opacity) | Accepted | 2026-06-29 |
| 09 | [Modes vs themes](#adr-009--modes-vs-themes) | Accepted | 2026-06-29 |
| 10 | [Deviating from shadcn is manageable](#adr-010--deviating-from-shadcn-is-manageable) | Accepted | 2026-06-29 |
| 11 | [Cross-framework parity via contracts](#adr-011--cross-framework-parity-via-contracts) | Accepted | 2026-06-30 |
| 12 | [Tailwind v4 for styling](#adr-012--tailwind-v4-for-styling) | Proposed | 2026-06-30 |
| 13 | [Storybook + token docs](#adr-013--storybook--token-docs) | Accepted | 2026-06-30 |
| 14 | [Versioning & publishing via Changesets](#adr-014--versioning--publishing-via-changesets) | Accepted | 2026-06-30 |
| 15 | [Tree-shakeable React build](#adr-015--tree-shakeable-react-build) | Accepted | 2026-06-30 |
| 16 | [Component scope built in parity](#adr-016--component-scope-built-in-parity) | Accepted | 2026-06-30 |
| 17 | [Prove it in a real app](#adr-017--prove-it-in-a-real-app) | Proposed | 2026-06-30 |
| 18 | [Relative imports for vendored helm cross-references](#adr-018--relative-imports-for-vendored-helm-cross-references) | Accepted | 2026-07-01 |

### Open questions (not yet decided)

Expand Down Expand Up @@ -370,3 +371,40 @@ Storybook.

**Consequences.** Marked **Proposed** while the richer screens land. Apps stay consumers,
not published packages; keep the demo simple. See [AGENTS.md](../AGENTS.md).

---

## ADR-018 — Relative imports for vendored helm cross-references

**Status:** Accepted · **Date:** 2026-07-01

**Context.** The Spartan CLI vendors `helm` components into `src/lib/ui/<name>/` and wires
cross-component references through a `@spartan-ng/helm/<name>` tsconfig `paths` alias
(`packages/angular/components.json` → `importAlias`). That alias isn't a real npm package
— it only resolves when a consuming **app**'s own bundler reads the tsconfig mapping at
build time. `@surfnet/angular` instead builds itself into a redistributable package via
`ng-packagr`. `ng-packagr`'s rollup step hardcodes any bare (non-relative) import specifier
as external unless it matches a registered secondary entry point
(`node_modules/ng-packagr/.../flatten/rollup.js` → `isExternalDependency`) — it never
consults tsconfig `paths`. With one entry point (`src/public-api.ts`), every alias-based
cross-import leaked into the published bundle as an unresolved `@spartan-ng/helm/*` import,
and in some cases duplicated the real symbol (once correctly inlined via a relative import
path elsewhere, once left dangling as external).

**Decision.** Rewrite all vendored cross-component imports from the
`@spartan-ng/helm/<name>` alias to relative paths. Added
`packages/angular/scripts/rewrite-helm-imports.ts` (run via
`pnpm --filter @surfnet/angular fix-helm-imports`, `jiti`) as a codemod to re-apply this
after every `ng g @spartan-ng/cli:ui <component>` run, since the CLI always writes the
alias form.

**Rationale.** No supported config exists to make `ng-packagr` inline the alias for a
single-entry-point library — not `components.json`'s `importAlias`, not `ng-package.json`.
Moving to per-component secondary entry points (matching how Spartan's own `@spartan-ng/helm`
package ships subpath exports) would fix it too, but is a much larger structural change;
relative imports are the minimal fix within the current single-entry-point architecture.

**Consequences.** The `angular.md` add-component playbook's "don't rewrite to relative
imports" guidance was reversed — see the **Notes** section there. Run
`fix-helm-imports` after every future `ng g` run and verify with
`grep -r "@spartan-ng/helm" packages/angular/dist` (should be empty) before publishing.
1 change: 1 addition & 0 deletions packages/angular/ng-package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@angular/router",
"@ng-icons/phosphor-icons",
"@spartan-ng/brain",
"@tanstack/angular-table",
"class-variance-authority",
"clsx",
"tailwind-merge",
Expand Down
5 changes: 4 additions & 1 deletion packages/angular/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "ng build angular --watch --configuration development",
"lint": "ngc --noEmit -p tsconfig.json",
"storybook": "ng run angular:storybook",
"build-storybook": "ng run angular:build-storybook"
"build-storybook": "ng run angular:build-storybook",
"fix-helm-imports": "jiti scripts/rewrite-helm-imports.ts"
},
"peerDependencies": {
"@angular/common": "^22.0.0",
Expand All @@ -27,6 +28,7 @@
"@angular/router": ">=21.0.0 <23.0.0",
"@ng-icons/phosphor-icons": ">=32.0.0 <34.0.0",
"@spartan-ng/brain": "0.0.1-alpha.715",
"@tanstack/angular-table": "^8.21.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"tailwind-merge": "3.6.0",
Expand All @@ -50,6 +52,7 @@
"@surfnet/tokens": "workspace:*",
"@surfnet/typescript-config": "workspace:*",
"@tailwindcss/postcss": "4.3.1",
"jiti": "2.7.0",
"ng-packagr": "22.0.0",
"rxjs": "7.8.2",
"storybook": "10.4.5",
Expand Down
60 changes: 60 additions & 0 deletions packages/angular/scripts/rewrite-helm-imports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Rewrites `@spartan-ng/helm/<name>` imports to relative paths.
*
* The Spartan CLI always vendors cross-component references through the
* `@spartan-ng/helm/<name>` tsconfig path alias. That alias only resolves at
* build time in a consuming *app* (whose own bundler reads tsconfig `paths`).
* `@surfnet/angular` instead builds itself into a redistributable package via
* ng-packagr, which does not consult tsconfig `paths` and leaves the alias as
* an unresolved external import in the published bundle. Run this after every
* `ng g @spartan-ng/cli:ui <component>` to convert the new alias imports to
* relative ones so ng-packagr inlines them correctly.
*
* Usage: pnpm --filter @surfnet/angular fix-helm-imports
*/

import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';

const packageRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const srcRoot = join(packageRoot, 'src');
const importPattern = /from (['"])@spartan-ng\/helm\/([a-z0-9-]+)\1/g;

function walk(dir: string, files: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full, files);
} else if (entry.endsWith('.ts')) {
files.push(full);
}
}
return files;
}

function toRelativeSpecifier(fromFile: string, name: string): string {
const targetDir = join(srcRoot, 'lib', 'ui', name, 'src');
let rel = relative(dirname(fromFile), targetDir).replace(/\\/g, '/');
if (!rel.startsWith('.')) rel = `./${rel}`;
return rel;
}

let changedFiles = 0;
let changedImports = 0;

for (const file of walk(srcRoot)) {
const original = readFileSync(file, 'utf8');
let fileChanged = false;
const updated = original.replace(importPattern, (_match, quote: string, name: string) => {
fileChanged = true;
changedImports++;
return `from ${quote}${toRelativeSpecifier(file, name)}${quote}`;
});
if (fileChanged) {
writeFileSync(file, updated);
changedFiles++;
}
}

console.log(`Rewrote ${changedImports} import(s) across ${changedFiles} file(s).`);
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmAvatarBadge],hlm-avatar-badge',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Directive } from '@angular/core';
import { BrnAvatarFallback } from '@spartan-ng/brain/avatar';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmAvatarFallback]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmAvatarGroupCount],hlm-avatar-group-count',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmAvatarGroup],hlm-avatar-group',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Directive, inject } from '@angular/core';
import { BrnAvatarImage } from '@spartan-ng/brain/avatar';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: 'img[hlmAvatarImage]',
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/avatar/src/lib/hlm-avatar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { BrnAvatar } from '@spartan-ng/brain/avatar';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';
import type { AvatarSizeName } from '@surfnet/contracts';

@Component({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { phosphorDotsThree } from '@ng-icons/phosphor-icons/regular';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import { HlmIcon } from '../../../icon/src';
import { hlm } from '../../../utils/src';
import type { ClassValue } from 'clsx';

@Component({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmBreadcrumbItem]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Directive, input } from '@angular/core';
import { RouterLink } from '@angular/router';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmBreadcrumbLink]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmBreadcrumbList]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmBreadcrumbPage]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { NgIcon, provideIcons } from '@ng-icons/core';
import { phosphorCaretRight } from '@ng-icons/phosphor-icons/regular';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Component({
// eslint-disable-next-line @angular-eslint/component-selector
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/button/src/lib/hlm-button.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Directive, input, signal } from '@angular/core';
import { BrnButton } from '@spartan-ng/brain/button';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';
import type { ButtonSizeName, ButtonVariantName } from '@surfnet/contracts';
import { cva, type VariantProps } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardAction]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardContent]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardDescription]',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardFooter],hlm-card-footer',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardHeader],hlm-card-header',
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/card/src/lib/hlm-card-title.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';

@Directive({
selector: '[hlmCardTitle]',
Expand Down
2 changes: 1 addition & 1 deletion packages/angular/src/lib/ui/card/src/lib/hlm-card.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Directive, input } from '@angular/core';
import { classes } from '@spartan-ng/helm/utils';
import { classes } from '../../../utils/src';
import { HlmCardConfig, injectHlmCardConfig } from './hlm-card.token';

@Directive({
Expand Down
4 changes: 2 additions & 2 deletions packages/angular/src/lib/ui/checkbox/src/lib/hlm-checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import { phosphorCheck } from '@ng-icons/phosphor-icons/regular';
import { BrnCheckbox } from '@spartan-ng/brain/checkbox';
import { BrnFieldControlDescribedBy } from '@spartan-ng/brain/field';
import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
import { HlmIcon } from '@spartan-ng/helm/icon';
import { hlm } from '@spartan-ng/helm/utils';
import { HlmIcon } from '../../../icon/src';
import { hlm } from '../../../utils/src';
import type { ClassValue } from 'clsx';

export const HLM_CHECKBOX_VALUE_ACCESSOR = {
Expand Down
14 changes: 14 additions & 0 deletions packages/angular/src/lib/ui/data-table/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { HlmDataTableContent } from './lib/hlm-data-table-content';
import { HlmDataTablePagination } from './lib/hlm-data-table-pagination';
import { HlmDataTableToolbar } from './lib/hlm-data-table-toolbar';

export * from './lib/inject-data-table';
export * from './lib/hlm-data-table-content';
export * from './lib/hlm-data-table-pagination';
export * from './lib/hlm-data-table-toolbar';

export const HlmDataTableImports = [
HlmDataTableContent,
HlmDataTablePagination,
HlmDataTableToolbar,
] as const;
Loading
Loading