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.
| Type | What 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 folder | Every 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.
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:
mkdir -p public/assets
unzip "chassis-assets-web-docs-chassis-<version>.zip" -d public/assetsAn 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.
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:sitepnpm 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:
pnpm assets --brand sinefil --app docs --platform webWhile 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:
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.
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/:
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.jsonCopy 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:
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:
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:
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:
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.
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:
<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:
@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.
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:
<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.
<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.
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:
<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:
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.
<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.
<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:
dist/web/docs/chassis/other/
└── default-tokens.jsonThe 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:
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:
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:
mkdir -p public/assets
cp -R vendor/assets/dist/web/docs/. public/assets/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:
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.jsonandicons/icons/chassis-icons-min.css. Find a name withfind dist/web/docs/chassis -name '<pattern>'. - Keep a stylesheet beside its fonts:
text.css,display.css,code.cssandchassis-icons.cssread their font files by a relative URL. Copyfonts/andicons/icons/whole. - Ship the licenses: copy
<role>-license.txtwith the fonts offonts/. - 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 fullpnpm assetsremoves it first, and--cleanremoves the folders of the selected jobs. Change the file insource/and build again. - Do not expect a file the designer did not export: the default build copies. An image without an
@2xfile insource/has none in the output, and one without a.webpfile 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:
- Fonts, images, icons and other assets: the source layout and the rules of each type.
- Build system: the commands, the options and the configuration.
- iOS applications and Android applications: the same assets on the other platforms.