Skip to main contentSkip to docs navigation

Web Applications

How a web application gets the fonts, images, icons and other files of the web output of Chassis Assets, and how it reads them.

Introduction

The web output of an app and a brand is the folder dist/web/<app>/<brand>/. The build copies the source files into it and renames them to kebab-case, with the @2x and @3x indicators kept. It does not convert, resize or optimize a file, so the output holds the formats and the variants the designer exported.

TypeWhat the web output holds
fonts/The WOFF and WOFF2 files, the .css and .scss stylesheets and the license files
images/Every file, in the subfolders of the source
icons/Every file, in the subfolders of the source
Any other folderEvery file

The committed configuration has one web app, docs, and the brands chassis and sinefil. The examples of this page use the job dist/web/docs/chassis/; with another configuration, read the names of your own app and brand in their place.

TEXT
dist/web/docs/
├── chassis/
│   ├── fonts/
│   ├── icons/
│   ├── images/
│   └── other/
└── sinefil/
    └── ...

Installation

Chassis Assets is not published to npm. A web application gets the output from a release archive, from a Git submodule that it builds, or from a build in a clone.

Release archive

The release workflow attaches one archive per platform, app and brand to each GitHub release, from version 0.2.0 on. The archive of a job is named chassis-assets-<platform>-<app>-<brand>-<version>.zip and holds the content of dist/<platform>/<app>/<brand>/ at its top level. Its images are optimized: the archives are built with --optimize, which keeps every name and every visible pixel.

Download the archive of your job from the releases of the repository and extract it into the folder your application serves static files from:

Shell
mkdir -p public/assets
unzip "chassis-assets-web-docs-chassis-<version>.zip" -d public/assets

An archive needs no Node.js, pnpm or Git LFS on the machine that uses it.

Git submodule

A submodule pins the assets to a commit and builds them inside your project. The Chassis sites do this: each one has the repository as the submodule vendor/assets, on the app/docs branch, and builds the job it reads.

Shell
git submodule add -b app/docs https://github.com/chassis-ui/assets.git vendor/assets
cd vendor/assets
git lfs pull
pnpm install --ignore-workspace
pnpm assets:site

pnpm install --ignore-workspace installs the root package only, and the build needs none of it. pnpm assets:site is pnpm assets --clean --brand chassis --app docs: it removes dist/web/docs/chassis/ and writes it again. For another job, run pnpm assets with the filters of that job:

Shell
pnpm assets --brand sinefil --app docs --platform web

While you change the assets, pnpm assets --watch --brand chassis --app docs keeps running and builds the job again on every change under source/.

A project that reads a few files of the output does not have to fetch and build all of it. The --include option builds the files that match a pattern, and pnpm assets:lfs prints the Git LFS paths of the same patterns, so git lfs pull fetches those files only:

Shell
FILTERS=(--brand chassis --app docs --include "images/home/**" "icons/cx-sprite.svg")
git lfs pull --include "$(node packages/assets/build/cli.js lfs "${FILTERS[@]}")"
pnpm assets --clean "${FILTERS[@]}"

A pattern is a path of source/ in the folder of the app. The build system page has the patterns and the --type option.

The fonts and the raster images are Git LFS files. A checkout without Git LFS holds pointer files in their place, and the build fails and lists them. Install Git LFS from git-lfs.com and run git lfs pull in the submodule.

Build from a clone

A clone is the way to go when you change the assets yourself, in a fork or in a repository of your own. It needs Node.js 22.12 or later, pnpm and Git LFS; the Quick Start page has the setup.

Shell
git clone https://github.com/chassis-ui/assets.git chassis-assets
cd chassis-assets
pnpm assets --platform web

--brand, --app and --platform each take one or more values and combine. pnpm assets --dry-run prints the jobs without writing, and pnpm assets --help prints every option. The build system page describes them.

Output layout

The folder of a job holds one folder per type, with the subfolders of the source kept. The build writes this for the job dist/web/docs/chassis/:

TEXT
dist/web/docs/chassis/
├── fonts/
│   ├── text-license.txt
│   ├── text-normal.woff2
│   ├── text.css
│   └── ...
├── icons/
│   ├── cx-sprite.svg
│   ├── icons/
│   │   ├── chassis-icons.css
│   │   ├── chassis-icons.woff2
│   │   └── ...
│   └── svgs/
│       ├── arrow-right-solid.svg
│       └── ...
├── images/
│   ├── site-logo.svg
│   ├── home/
│   │   └── ...
│   └── logo/
│       └── ...
└── other/
    └── default-tokens.json

Copy the output

A web application serves the files from its static folder. Copy the folder of the job as it is, so that the paths inside it stay the ones this page uses. From the root of a project with the submodule at vendor/assets:

Shell
mkdir -p public/assets
cp -R vendor/assets/dist/web/docs/chassis/. public/assets/

The samples of this page read the files from /assets/, as this copy serves them.

Bundler alias

A bundler can read the output where the build wrote it, without a copy. Add an alias to the folder of the job, and import a file by its output name. In vite.config.js:

JavaScript
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'

export default defineConfig({
  resolve: {
    alias: {
      '@assets': fileURLToPath(new URL('./vendor/assets/dist/web/docs/chassis', import.meta.url))
    }
  }
})

In webpack.config.js, the alias goes with a rule that emits the fonts and the images as files:

JavaScript
const path = require('node:path')

module.exports = {
  resolve: {
    alias: {
      '@assets': path.resolve(__dirname, 'vendor/assets/dist/web/docs/chassis')
    }
  },
  module: {
    rules: [{ test: /\.(woff2?|png|jpe?g|webp|svg)$/, type: 'asset/resource' }]
  }
}

With either one, an import gives the URL of the file:

JavaScript
import '@assets/fonts/text.css'
import siteLogo from '@assets/images/site-logo.svg'

Vite gives a small file, such as this logo, as a data: URL in place of a path. The import of the stylesheet needs a webpack that reads CSS without a rule for it; with an older one, add a rule with css-loader.

Parcel needs no alias: it resolves a relative path to a file of the output in url() of a stylesheet and in new URL('<path>', import.meta.url) of a script.

Fonts

The fonts/ folder holds the WOFF and WOFF2 files of the source, the stylesheets and the licenses. A font file is named by its role and its weight, not by its family, so a brand with other families has the same file names. A variable font holds every weight of its role and is named by the role alone, text.woff2, with text-italic.woff2 for the italic.

TEXT
dist/web/docs/chassis/fonts/
├── code-license.txt
├── code-normal.woff2
├── code-strong.woff2
├── code.css
├── code.woff2
├── display-italic.woff2
├── display-normal.woff2
├── display.css
├── display.woff2
├── text-italic.woff2
├── text-normal.woff2
├── text-strong.woff2
├── text.css
├── text.woff2
└── ...

The fonts page has the roles, the weights and what each platform gets.

A font of the default output has every character of its source file. If your pages are written in a few scripts only, build with --subset: the same files, under the same names, hold the characters of the ranges you name and are smaller to download. See Font subsets.

The text font of the chassis brand, Inter, has an optical size axis, and a browser sets it from the font size: text above 14 pixels is drawn in a narrower cut, a heading of 57 pixels by about 7%. A build with --subset fixes the axis at 14 for the brand, so every size shows the same cut. With a default build, font-optical-sizing: none in your styles gives the same look.

Add --split to the build, and it writes each declared font once per range too, such as text-latin.woff2 and text-latin-ext.woff2, and the stylesheets get a rule per range. Your pages stay as they are: link the stylesheets, and a browser downloads the file of a range only for a page that has a character of it. If you preload a font, name the file of the range.

Font stylesheets

The stylesheets text.css, display.css and code.css of a brand declare the fonts of their role, under the family name of the font. A stylesheet declares the variable font of the role where the family has one, and one file per weight where it has none. Link them and the declarations are done:

HTML
<link rel="stylesheet" href="/assets/fonts/text.css" />
<link rel="stylesheet" href="/assets/fonts/display.css" />
<link rel="stylesheet" href="/assets/fonts/code.css" />

Each rule reads its file by a relative URL, url("text.woff2"), so a stylesheet works only beside the font files. The family name to use in font-family is in the comment at the top of each stylesheet.

Own declarations

Write the @font-face rules yourself when the family name has to be one of your own, such as the name a design token holds. Use the file names of the output, and one rule per file:

CSS
@font-face {
  font-family: 'Brand Text';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/text-normal.woff2') format('woff2');
}

@font-face {
  font-family: 'Brand Text';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/assets/fonts/text-strong.woff2') format('woff2');
}

body {
  font-family: 'Brand Text', sans-serif;
}

The weight of a role is the choice of the brand; read it in the stylesheet of the role. To declare a variable font, give the rule the range of weights the file holds, such as font-weight: 100 900; the range is in the stylesheet of the role too. Chassis Tokens owns the names of the font roles, see the Chassis Tokens docs.

Licenses

The build copies the license of each role beside its fonts, as <role>-license.txt. Keep text-license.txt, display-license.txt and code-license.txt with the fonts when you copy them to another folder.

Images

The images/ folder holds every image of the source, in the same subfolders, with kebab-case names and the indicator kept. An image has the formats and the variants the designer exported: the default build writes no @2x file and no WebP file of its own.

TEXT
dist/web/docs/chassis/images/
├── site-logo.svg
├── home/
│   ├── platforms.png
│   ├── platforms.webp
│   ├── platforms@2x.png
│   ├── platforms@2x.webp
│   └── ...
└── logo/
    ├── chassis-logo-brand.png
    ├── chassis-logo-brand.svg
    ├── chassis-logo-brand@2x.png
    ├── chassis-logo-brand@3x.png
    └── ...

The images page has the naming rules and what each platform gets.

Variants

An image with @2x and @3x variants goes into srcset, one variant per pixel density. Use the file without an indicator as src:

HTML
<img
  src="/assets/images/logo/chassis-logo-brand.png"
  srcset="
    /assets/images/logo/chassis-logo-brand.png    1x,
    /assets/images/logo/chassis-logo-brand@2x.png 2x,
    /assets/images/logo/chassis-logo-brand@3x.png 3x
  "
  alt="Chassis"
/>

Formats

An image that the output has as WebP and as PNG goes into a <picture> element, with the PNG as the fallback. In the default output these are the images the designer exported in both formats, so use the element only for an image that has both files. A build with pnpm assets --webp writes a WebP beside every PNG and JPEG, and --avif an AVIF; both need pnpm install, see the build system page.

HTML
<picture>
  <source
    type="image/webp"
    srcset="/assets/images/home/platforms.webp 1x, /assets/images/home/platforms@2x.webp 2x"
  />
  <img
    src="/assets/images/home/platforms.png"
    srcset="/assets/images/home/platforms.png 1x, /assets/images/home/platforms@2x.png 2x"
    alt="Platforms"
  />
</picture>

Icons

The icons/ folder holds every file of the source. In the committed source that is the build output of Chassis Icons, as an icon font under icons/icons/ and as SVG files under icons/svgs/, and the sprite cx-sprite.svg.

TEXT
dist/web/docs/chassis/icons/
├── cx-sprite.svg
├── icons/
│   ├── chassis-icons-min.css
│   ├── chassis-icons.css
│   ├── chassis-icons.json
│   ├── chassis-icons.scss
│   ├── chassis-icons.svg
│   ├── chassis-icons.woff
│   └── chassis-icons.woff2
└── svgs/
    ├── arrow-right-outline.svg
    ├── arrow-right-solid.svg
    └── ...

Chassis Icons owns the icons and their font; the Chassis Icons docs list them. The icons page has what each platform gets.

SVG files

Each icon is one file of icons/svgs/, named <icon>-solid.svg or <icon>-outline.svg. Use an <img> element for an icon that keeps its color:

HTML
<img src="/assets/icons/svgs/arrow-right-solid.svg" alt="" width="24" height="24" />

The files have fill="currentcolor", so an icon takes the color of the text only when its markup is in the page. With Vite, the ?raw query imports the markup of a file as a string:

JavaScript
import arrowRight from '@assets/icons/svgs/arrow-right-solid.svg?raw'

document.querySelector('#next').insertAdjacentHTML('beforeend', arrowRight)

Icon font

The stylesheet icons/icons/chassis-icons.css declares the icon font and one class per icon, cx-<icon>-solid or cx-<icon>-outline. It reads chassis-icons.woff2 and chassis-icons.woff by a relative URL, so keep the files of icons/icons/ together.

HTML
<link rel="stylesheet" href="/assets/icons/icons/chassis-icons.css" />

<i class="cx-arrow-right-solid"></i>

The web build turns a dot inside a name into a hyphen. The minified stylesheet of the icon font is chassis-icons-min.css in the output, not chassis-icons.min.css.

Sprites

A sprite holds icons as <symbol> elements that a page shows with <use>. The output has two: icons/icons/chassis-icons.svg has one symbol per icon of Chassis Icons, with the name of the icon as its id, and icons/cx-sprite.svg has the icons of the home pages of the Chassis sites, with cx-<icon> as the id.

HTML
<svg width="24" height="24" fill="currentColor">
  <use href="/assets/icons/icons/chassis-icons.svg#arrow-right-solid"></use>
</svg>

The Chassis sites put the markup of cx-sprite.svg into the page and refer to a symbol by its id alone, <use href="#cx-rocket"></use>.

Other assets

The build copies any other folder under an app as it is, with the names in kebab-case. In the committed source that is other/, which holds the design tokens of a Figma export:

TEXT
dist/web/docs/chassis/other/
└── default-tokens.json

The source file is source/default/docs/other/default.tokens.json: the dot inside the name became a hyphen. Read a file of such a folder by its output name:

JavaScript
const tokens = await fetch('/assets/other/default-tokens.json').then((response) => response.json())

The other assets page describes these folders.

Brand switching

Every brand of chassis.build.brands has a folder of its own beside the others, dist/web/<app>/<brand>/. A file has the same path in each of them, so an application changes brand by reading another folder, with no change to the paths in its code.

To ship one brand, copy the folder of that brand at build time:

Shell
BRAND=sinefil
mkdir -p public/assets
cp -R vendor/assets/dist/web/docs/$BRAND/. public/assets/

To choose the brand in the browser, copy each brand into a folder of its name and build the path from the brand:

Shell
mkdir -p public/assets
cp -R vendor/assets/dist/web/docs/. public/assets/
JavaScript
export function assetUrl(brand, path) {
  return `/assets/${brand}/${path}`
}

document.querySelector('#logo').src = assetUrl('sinefil', 'images/logo/chassis-logo-brand.svg')

A brand folder of source/ holds only the files that differ, and the build fills the rest from the default brand. The sinefil brand has fonts and logos of its own, and its output has the same files as the output of the default brand, under the same names.

A default file that a brand does not replace stays in the output of the brand. The code family of sinefil has no variable font, so fonts/code.woff2 of its output is the code font of the default brand, and code.css of the brand does not load it. Link the stylesheet of a role and you get the fonts of the brand.

A brand that replaces the font files of a role without replacing the stylesheet of that role gets the stylesheet of the default brand, which declares the family name of the default font. Add text.css, display.css or code.css to fonts/ of the brand in source/, or write own declarations.

Continuous integration

A workflow of your application builds the job before it builds the application, so the output is never committed. This GitHub Actions job checks out the submodule, pulls its Git LFS files, builds the job and copies it:

YAML
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
        with:
          submodules: true

      - uses: actions/setup-node@v7
        with:
          node-version-file: vendor/assets/.nvmrc

      - name: Build the assets
        working-directory: vendor/assets
        run: |
          git lfs pull
          corepack enable
          pnpm install --ignore-workspace
          pnpm assets:site

      - name: Copy the assets
        run: |
          mkdir -p public/assets
          cp -R vendor/assets/dist/web/docs/chassis/. public/assets/

A workflow that uses a release archive needs none of these steps: download the archive of the job with gh release download and extract it.

The commands of this job were run in a project with the submodule; the workflow itself was not run on GitHub Actions.

Best practices

These practices keep your application reading the output as the build wrote it, with the names and the folders of dist/web/.

  • Read a file by its output name: the web names are kebab-case and a dot inside a name is a hyphen, so the output has other/default-tokens.json and icons/icons/chassis-icons-min.css. Find a name with find dist/web/docs/chassis -name '<pattern>'.
  • Keep a stylesheet beside its fonts: text.css, display.css, code.css and chassis-icons.css read their font files by a relative URL. Copy fonts/ and icons/icons/ whole.
  • Ship the licenses: copy <role>-license.txt with the fonts of fonts/.
  • Pin what you build: a submodule is pinned to a commit and an archive to a version, so a renamed file reaches your application only when you move the pin. A change to a name of dist/ is marked **Breaking.** in the changelog.

Avoid the habits that undo this:

  • Do not edit dist/: a full pnpm assets removes it first, and --clean removes the folders of the selected jobs. Change the file in source/ and build again.
  • Do not expect a file the designer did not export: the default build copies. An image without an @2x file in source/ has none in the output, and one without a .webp file has one only in a build with --webp.

Troubleshooting

Each entry gives the message or the symptom, then the cause and the fix.

Build fails on pointers

The build stops with <count> source file(s) are Git LFS pointers, not files: <files>. Run `git lfs pull`, or pass --allow-lfs-pointers to copy the pointers. The checkout has no Git LFS files, so the fonts and the raster images are pointers of a few lines. Install Git LFS, run git lfs pull in the repository or the submodule, and build again.

Font does not load

The browser logs Failed to decode downloaded font for a file of fonts/. The output was built with --allow-lfs-pointers or CHASSIS_ALLOW_LFS_POINTERS=1, so the font is a pointer file. Run git lfs pull and build again without the option.

Font file not found

The browser gets a 404 for a font that a stylesheet of fonts/ or of icons/icons/ names. The stylesheet was copied without the font files beside it, and its URLs are relative. Copy the whole folder.

File not found

The browser gets a 404 for a file that is in source/. The build renamed it, or left it out: the web names are kebab-case with a hyphen for a dot inside the name, and fonts/ keeps only .woff, .woff2, .css, .scss and .txt files. List the output of the type, find dist/web/docs/chassis/fonts, and use the name it prints.

Unknown brand

The build stops with Unknown brand "<name>". Configured: <brands>. The value of --brand is not in chassis.build.brands of package.json. Use a configured brand, or add the brand to the configuration. --app and --platform fail in the same way.

No job selected

The build stops with The filters select no job. Check --brand, --app and --platform together. Each filter value is configured, but no app has that platform: pnpm assets --app demo --platform web fails in the committed configuration, where demo is built for iOS and Android. Read the jobs with pnpm assets --dry-run.

Next steps

Continue with one of these pages: