Add Astro agent skills and color/vertical props to Steps (#2908)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-08-28 18:39:00 +02:00
1 parent 5baf073a6c
commit d7cbe87705
14 files changed
+1047 -7

No files matched your search

+32
View File
@@ -18,6 +18,28 @@ Shared instructions for all AI agents (Claude Code, Cursor, etc.). Canonical age
- `docs/components/` — docs-only Astro components (`Example`, menu, …) via `@components`.
- Package manager: pnpm workspaces + turbo. Node.js >= 22.12.
## Skills
Task-specific instructions live in `.agents/skills/<name>/SKILL.md` — read the matching one before starting, it is the source of truth for that area.
| Skill | Use it when |
| --- | --- |
| `ui-component` | creating or extending a component in `shared/ui/` or `shared/components/` |
| `astro-scripts` | a component or page needs client-side JavaScript |
| `page-layouts` | choosing or changing the page chrome (`shared/layouts/`) |
| `shared-lib` | moving logic into `shared/lib/` and testing it |
| `core-scss` | writing or changing styles in `core/scss/` |
| `core-js` | working on the framework JavaScript in `core/js/` |
| `demo-pages` | adding or updating a page in `preview/pages/` |
| `write-docs` | writing or updating a page in `docs/content/` |
| `class-reference` | a docs page needs its `classnames` table added or refreshed |
| `navigation` | menus, page URLs and docs redirects |
| `astro-dev` | starting a dev server and verifying a change in the browser |
| `html-diff` | proving a refactor changes no rendered HTML |
| `build-pipeline` | assets missing, stale or growing; editing `.build/` or the asset manifests |
| `generate-changeset` | writing a changeset (always — never hand-write the file) |
| `mr-description` | drafting an MR/PR title and description |
## Language
- Write all repository content in English: code, comments, documentation, commit messages, PR titles and descriptions.
@@ -28,6 +50,10 @@ Shared instructions for all AI agents (Claude Code, Cursor, etc.). Canonical age
### Astro pages and components
- Pages are Astro components; reusable markup lives in `shared/components/`.
- Building or changing a shared component: follow the `ui-component` skill (Props interface, prop names, class composition, slots).
- Client-side JavaScript: follow the `astro-scripts` skill. Always `<script is:inline>` inside `<CaptureScript>` — never a bundled script.
- Page chrome (header, navbar, sidebar, wrappers) comes from a layout in `shared/layouts/` — see the `page-layouts` skill. Pages never hand-roll it.
- Logic beyond assembling classes belongs in `shared/lib/*.ts` with a sibling `*.test.ts` — see the `shared-lib` skill.
- Icons: use the shared component — `<Icon name="home" />` (import from `@ui/Icon.astro`).
- Links between preview pages are relative: `./job-listing.html` (root-level pages).
- Boolean HTML attributes in Astro: `selected={true}` renders a bare attribute, but some attributes (e.g. `multiple`) render `="true"` — use `multiple ? '' : undefined` when a bare attribute is required.
@@ -48,8 +74,14 @@ Shared instructions for all AI agents (Claude Code, Cursor, etc.). Canonical age
- Use semantic HTML elements and proper heading hierarchy.
- Include ARIA labels (e.g. `aria-label` for icon-only buttons) and alt text for images.
### Verifying changes
- Start dev servers and check pages in the browser with the `astro-dev` skill (preview 3000, docs 3010); never build while a dev server is running.
- For a change that should not alter rendered HTML, prove it with the `html-diff` skill.
## SCSS guidelines
- Follow the `core-scss` skill for anything under `core/scss/`: custom-property pattern, the build-time `--tblr-` prefix, dark mode, RTL, sass-true tests and the lint/size gates.
- Use semantic class names that describe purpose, not appearance.
- Keep Bootstrap-compatible class naming conventions.
- Group related styles together with clear comments; keep consistent spacing and indentation.
+80
View File
@@ -0,0 +1,80 @@
---
name: astro-dev
description: >-
Start a Tabler Astro dev server and verify a change in the browser. Use
whenever a change to `preview/`, `docs/`, `shared/` or `core/` has to be
seen running — before handing a page or component back — and whenever the
dev server behaves oddly: stale CSS/JS, assets that vanish, a page that will
not reload. Covers which server to start on which port, background mode, the
rule against building while a dev server runs, and the verification loop.
---
# Run and verify the dev servers
Never ask the user to check a page manually. Start the server, look at the page, and show what you found.
## 1. Which server
| Package | Command | Port | Serves |
| --- | --- | --- | --- |
| `@tabler/preview` | `pnpm --dir preview run dev` | 3000 | demo pages (`preview/pages/*.astro`) |
| `@tabler/docs` | `pnpm --dir docs run dev` | 3010 | documentation (`docs/content/**/*.mdx`) |
| `@tabler/screenshots` | `pnpm --dir screenshots run dev` | 3020 | screenshot capture pages |
`preview_start` returns a `tabId` — pass it to every later `navigate` / `read_page` / `computer` call so they act on that tab and not on whatever is fronted. The same entries exist in `.claude/launch.json` (`preview`, `docs`, `screenshots`) — start them with `preview_start` so the browser pane opens on the right port, and use `preview-attach` when a server is already running. `pnpm dev` at the repo root starts everything through turbo; only do that when the change spans packages, because the persistent tasks make the output hard to read.
A `shared/` change shows up in both preview and docs. Verify in the package the user asked about, and open the other one too when the component is used there.
## 2. Full dev vs. astro-only
Each package's `dev` script is `astro dev` plus its asset watchers (`concurrently`), and turbo runs `dev-prepare` first so `tmp-assets/` and `public/` exist:
- **SCSS or `core/` touched → use the full `dev` script.** `astro dev` alone will not rebuild the stylesheets.
- **Only `.astro` / `.mdx` / `shared/lib` touched → `astro dev --background` inside the package is enough** and faster, provided the assets have been built before. Manage it with `astro dev status`, `astro dev logs`, `astro dev stop`; the log file is `.astro/dev.log` in the package. Add `--force` to replace a server that is already running.
## 3. Do not build while a dev server is running
`pnpm build` (or a per-package build) run next to a live dev server fights the watchers: the build's asset copy overwrites what the watcher just wrote into `public/`, and the two file watchers together can exhaust file handles (`EMFILE`). Verify through the running server and HMR instead. If a build is genuinely needed, stop the dev server first.
## 4. The verification loop
1. `preview_start` with the package's config, then open the page (`/badges` in dev; the built file is `/badges.html`).
2. `read_console_messages` — errors first, then warnings.
3. `read_page` or `curl` for structure; assert instead of eyeballing when the page is generated from data:
```bash
curl -s http://localhost:3000/badges | grep -o '<h[1-6]' | sort | uniq -c
```
4. `computer` clicks/typing for anything interactive, then read the state back.
5. `resize_window` for responsive or dark-mode work; `colorScheme` for `data-bs-theme`.
6. Screenshot the result for the user when the change is visual.
## 5. When the page looks stale or unstyled
In order:
- **CSS did not change** — the SCSS watcher is not running. Restart with the full `dev` script rather than `astro dev`.
- **Assets 404 after a restart** — `public/preview` (or `docs/public`) was wiped by a build or a `clean`. Run `pnpm --dir <pkg> run dev-prepare`, then start dev again.
- **A `public/` file changed but the browser did not reload** — Astro does not watch `public/`; the `copyAssets` integration reloads the listed dirs, everything else needs a manual refresh.
- **`shared/` change not picked up** — the file is aliased, not watched through `node_modules`; confirm the import uses `@shared`/`@ui`, not a path into `node_modules`.
- Still wrong: `pnpm --dir <pkg> run clean`, then `dev-prepare`, then `dev`.
## 6. Gates before handing work back
Run at the repo root and read the **full** output — a tail hides TypeScript errors:
```bash
pnpm run type-check && pnpm run format-prettier && pnpm run lint
```
`type-check` is `astro check` per package, so it covers `.astro` frontmatter and templates. For a markup refactor that should not change output at all, also run the `html-diff` skill.
## 7. Checklist
- [ ] Right package started, on its own port
- [ ] Full `dev` script when SCSS or `core/` is involved
- [ ] No build started while a dev server was running
- [ ] Page opened, console clean, interaction exercised
- [ ] Screenshot for visual changes
- [ ] Repo-level `type-check` / `format-prettier` / `lint` clean, full output read
+122
View File
@@ -0,0 +1,122 @@
---
name: astro-scripts
description: >-
Add or fix client-side JavaScript in a Tabler Astro component or page —
plugin initialisation, Bootstrap wiring, anything that needs a `<script>` in
the rendered HTML. Use whenever a component needs browser behaviour, a
third-party library has to be initialised, an inline script does not run, or
a script has to be moved out of a page. Covers `CaptureScript` /
`PageScripts`, `script is:inline define:vars`, the IIFE trap, init timing,
per-instance registries, and when a script must be assembled as a string.
---
# Client-side scripts in Astro components
The preview HTML is the product: users copy a page out of `preview/dist` and expect the scripts in it to be readable and self-contained. That single fact drives every rule below.
## 1. Never let Astro bundle a script
Always `<script is:inline>`. A plain `<script>` is bundled, hashed, and moved into a module chunk — invisible in the copied HTML — and a plain `<style>` makes Astro stamp `data-astro-cid-*` on every element of the component.
## 2. Where the script ends up
Scripts render at the **end of `<body>`**, not where the component sits, so they run after their markup and after the vendor libraries in `<head>`. The route:
```text
<CaptureScript> → addPageScript() → page-scripts registry → <PageScripts />
```
- `shared/components/CaptureScript.astro` captures its slot and registers it.
- `shared/layouts/BaseLayout.astro` and `docs/layouts/DocsLayout.astro` drain the registry through `<PageScripts />`.
- Registration is **synchronous** in `CaptureScript`'s frontmatter on purpose: Astro renders siblings concurrently, so a script registered after an `await` can miss the drain. Do not make `CaptureScript` async.
- The registry does not dedupe. Two instances of a component emit two scripts — write them so that is harmless (see section 6).
## 3. The standard snippet
```astro
---
import CaptureScript from '../components/CaptureScript.astro'
interface Props {
id: string
}
const { id } = Astro.props
const selector = `#wysiwyg-${id}`
---
<textarea id={`wysiwyg-${id}`}></textarea>
<CaptureScript>
<!-- BEGIN WYSIWYG -->
<script is:inline define:vars={{ selector }}>
function initWysiwyg() {
hugeRTE.init({ selector, height: 300 })
}
document.readyState !== 'loading' ? initWysiwyg() : document.addEventListener('DOMContentLoaded', initWysiwyg, { once: true })
</script>
<!-- END WYSIWYG -->
</CaptureScript>
```
- `BEGIN … / END …` comments mark the block in the copied HTML. Use the component name in caps.
- A named `init…` function plus the `readyState` guard, never a bare call: the script may be parsed after `DOMContentLoaded` has already fired.
- The component takes a required `id` prop and derives ids and selectors from it in the frontmatter. Never query by class or by tag — a page renders many instances.
## 4. Passing data with `define:vars`
`define:vars` JSON-serialises the values and declares them as consts at the top of the script. It carries data only: no functions, no DOM nodes, no closures over frontmatter helpers. Pass config objects whole (`chartOptions`, `markers`, `sortKeys`) instead of interpolating them into a template string.
Two known snags:
- **Astro wraps a `define:vars` script in an IIFE.** Anything the markup calls (`onclick="setPageListItems(this)"`) must be assigned to `window` explicitly — see `shared/components/demo/AdvancedTable.astro`. If several instances define the same global, make it stateless and resolve the instance from the clicked element.
- The type checker does not see `define:vars` bindings used inside template literals. `shared/components/marketing/hero/Side.astro` carries a `// @ts-nocheck` for exactly that; copy the comment with its explanation rather than restructuring the script.
## 5. When the script has to be a string
If caller-supplied JS must land *inside* a function body, `define:vars` cannot do it — it injects variables, not code. Then build the script as a template string in the frontmatter and render it with `<Fragment set:html={script} />` inside `<CaptureScript>` (see `shared/ui/Signature.astro`). Serialise every interpolated value with `JSON.stringify` and keep raw-JS props documented as an escape hatch.
## 6. Instance registries
Plugin instances go into a namespaced global keyed by id, so demo pages, docs examples, and the browser console can reach them:
```js
window.tabler_rating ??= {}
window.tabler_rating[ratingKey] = new StarRating(ratingSelector, { … })
```
`window.tabler_list`, `window.tabler_rating`, … follow the same shape. Never store an instance in a bare global.
## 7. Loading the library
The library itself is not imported — it is a global loaded from `libs.json`:
1. Check `core/libs.json` for the plugin (`npm`, `js`, `css`, `head`); add an entry if it is missing.
2. Preview pages declare it on the layout: `pageLibs={['nouislider', 'tom-select']}` on `DefaultLayout` / `BaseLayout`.
3. Docs pages declare it in front matter: `docs-libs: apexcharts` (and `css-plugins:` for extra `tabler-*.css`).
A component that silently assumes a global is a broken page whenever someone forgets the prop — say which lib it needs in a frontmatter comment and in the docs page.
## 8. Prettier caveat
`prettier-plugin-astro` cannot parse HTML comments or `<script>` tags inside a JSX expression (`{cond && (<Fragment><!-- … --><script>…)}`). Files that do this on purpose are listed at the bottom of `.prettierignore`. Prefer a plain (non-conditional) `<CaptureScript>` block so the file stays formattable; only add a new entry to `.prettierignore` when the conditional really is required, and keep it in the commented group.
## 9. Verify
An inline script that throws leaves the page looking fine but dead. Never hand one over unchecked:
1. Start the dev server and open the page (`astro-dev` skill).
2. `read_console_messages` — zero errors.
3. Interact with the component (click, type) and confirm the behaviour, or read the instance back from `window.tabler_*` with `javascript_tool`.
## 10. Checklist
- [ ] `<script is:inline>` inside `<CaptureScript>`, with `BEGIN`/`END` comments
- [ ] Required `id` prop; selectors derived from it, no class-wide queries
- [ ] Data passed via `define:vars`, not string interpolation
- [ ] `init…()` + `readyState` guard
- [ ] Globals the markup needs assigned to `window` (IIFE trap)
- [ ] Instance stored in a `window.tabler_*` registry keyed by id
- [ ] Library present in `core/libs.json` and declared via `pageLibs` / `docs-libs`
- [ ] Two instances on one page still work
- [ ] Console clean in the browser; `pnpm run format-prettier` and `pnpm run type-check` clean
+80
View File
@@ -0,0 +1,80 @@
---
name: build-pipeline
description: >-
Understand or repair the Tabler asset pipeline — `core/dist`, `tmp-assets/`,
the generated `public/` directories, the `copy-assets` Astro integration,
`build-css.ts`, the vite/terser JS builds and the turbo dev graph. Use when
assets are missing, stale, duplicated or growing between builds, when a
watcher's output disappears after a restart, when a page 404s on
`/dist/css/tabler.css`, and before editing anything under `.build/`, a
package's asset manifest in `astro.config.mjs`, or the dev/build scripts.
---
# The asset pipeline
Astro only renders pages. Every stylesheet, script, font and image on a page arrives through a separate pipeline, and almost every "weird assets" bug is a directory being written by the wrong step.
## 1. Who writes what
| Directory | Written by | Notes |
| --- | --- | --- |
| `core/dist/` | `@tabler/core` (`css-build`, `js-build`, `copy`) | the framework: css, js, fonts, img, vendored libs |
| `preview/tmp-assets/` | `preview` `assets` (`build-css.ts` + vite/terser) | demo css/js, isolated from Astro's output |
| `docs/tmp-assets/css/` | `docs` `css` / `watch-css` (sass) | docs stylesheets |
| `<pkg>/public/` | **`copy-assets` only** | fully generated, wiped and rebuilt on every Astro start |
| `<pkg>/dist/` | `astro build` | the shipped site |
`public/`, `tmp-assets/` and `dist/` are all git-ignored per package. Never hand-edit a file in any of them, and never commit one.
## 2. `copy-assets` (`.build/copy-assets.ts`)
An Astro integration, configured per package in `astro.config.mjs`:
- On `astro:config:done` it **deletes `public/`** and rebuilds it from the `copies` manifest (core dist, the package's `tmp-assets`, `shared/static`, favicons). Running it twice must never accumulate content — that is why it starts from a clean directory.
- It is skipped when the Astro command is `sync`, so `astro check` (and CI's type-check job) does not need built workspace assets.
- In dev it watches `syncDirs` and copies changed files into `public/`, then sends one coalesced `full-reload` (250 ms window, `.map` files ride along with their source file). `reloadDirs` are directories a watcher already writes into directly — those only get the reload.
- `allowDestinationFallback` keeps the existing copy when the source vanishes mid-copy — `@tabler/core` cleaning `dist/` while `turbo dev` starts the dependents.
**Rule for watchers:** because `public/` is wiped at startup, a watcher writing straight into it (preview's `watch-css` / `watch-js`) only works when `dev-prepare` has produced the same output into `tmp-assets` first, so the startup copy seeds it. The safer arrangement is docs': the watcher writes into `tmp-assets/css` and `copy-assets` syncs it to `public/css`. Prefer that for anything new.
## 3. Ordering
`turbo.json` encodes the graph: `@tabler/core#dev-prepare` → `@tabler/preview#dev-prepare` → the `dev` tasks (docs depends on both). `dev-prepare` is what guarantees `core/dist` and `preview/tmp-assets` exist before a dependent package's `copy-assets` runs. If you add a package or an asset dependency, add the edge here too — inside a package, ordering comes from Astro's own lifecycle, not from a pre-script.
## 4. Traps already paid for
Do not undo these; each has a comment at the site:
- **Build output must not be an Astro output dir.** `preview/.build/vite.config.mts` writes to `tmp-assets/js`, not inside `dist/`: `astro build` copies `public/` into `dist/`, so an `outDir` under `dist/` gets re-seeded and re-copied, growing without bound across builds.
- **No leading-dot output path.** terser's CLI `--source-map` parser rejects a path segment starting with a dot (`.build/out/…`) with a bogus "not a supported option".
- **One write per output file.** `build-css.ts` runs sass + postcss + clean-css in-process so each file is written once, fully processed; it also skips writes when the content is unchanged, to keep watchers quiet. Do not reintroduce a step that rewrites a finished file in place — that is what shifted every source-map mapping when the banner was added afterwards (#2766).
- **Vendor copies are not pages.** `preview`'s `prettify-html` integration formats built HTML on `astro:build:done` but excludes `dist/preview/` and `dist/dist/`, which are asset copies and contain third-party HTML that breaks the parser.
## 5. Diagnosing
Work down this list; each step is cheap:
1. **404 on `/dist/css/tabler.css` or `/preview/css/demo.css`** — the source was never built. `pnpm --dir core run dev-prepare`, then `pnpm --dir preview run dev-prepare`.
2. **Assets vanished after restarting dev** — something wrote into `public/` that `copy-assets` does not know about; it was wiped at startup. Add it to the manifest, or write it into `tmp-assets` instead.
3. **CSS/JS edits do not appear** — the watcher is not running (started `astro dev` alone instead of the package's `dev` script), or its output dir is not in `syncDirs`/`reloadDirs`.
4. **Stale content that survives a rebuild** — `pnpm --dir <pkg> run clean`, then `dev-prepare`.
5. **`dist/` grows between identical builds** — an output dir is nested inside another copy step (see the first trap).
6. **CI type-check fails on missing assets** — something made `copy-assets` run for the `sync` command; keep the early return.
Read the dev server's own log first: `copy-assets` logs `public/ rebuilt from workspace assets`, each fallback warning, and every reload with the file that caused it.
## 6. When you change the pipeline
- Verify **both** paths: `pnpm --dir <pkg> run dev` and a clean `pnpm --dir <pkg> run build`.
- Verify **both** packages when the change is in `.build/` — preview and docs share those files, with different manifests.
- Run the build twice in a row and compare `du -sh <pkg>/dist` — equal sizes are the accumulation check.
- Confirm `git status` stays clean: nothing generated may become tracked.
- Do not start a build while a dev server is running (`astro-dev` skill).
## 7. Checklist
- [ ] Nothing writes into `public/` except `copy-assets`
- [ ] New generated output lands in `tmp-assets/`, and is listed in the package's `copies` (and `syncDirs` when it changes during dev)
- [ ] Cross-package ordering added to `turbo.json` if a new dependency appeared
- [ ] Dev and clean build both verified, build run twice with stable output size
- [ ] `git status` clean; `pnpm run type-check` clean without prebuilt assets
+99
View File
@@ -0,0 +1,99 @@
---
name: core-js
description: >-
Work on the framework's own JavaScript in `core/js/` — the `tabler.js` and
`tabler-theme.js` bundles, the vendored Bootstrap component port, and their
browser tests. Use when a Tabler behaviour needs adding or fixing (a
`data-bs-toggle` handler, the theme switcher, a plugin initialiser), when a
Bootstrap component's JS misbehaves, and before changing anything under
`core/js/src/`. Not for demo-page scripts — that is the `astro-scripts`
skill.
---
# The framework JavaScript
`core/js` builds two shipped bundles. Everything here is public API and is bound by a size budget.
## 1. Two entry points
| Entry | Ships as | Holds |
| --- | --- | --- |
| `js/tabler.ts` | `tabler.js` / `.esm.js` (+ `.min`) | plugin initialisers, the Bootstrap components, the `tabler` helper namespace |
| `js/tabler-theme.ts` | `tabler-theme.js` (+ variants) | the colour-mode/theme switcher only |
`tabler-theme.js` is loaded right after `<body>` and **not deferred**, so the chosen theme applies before the first paint. It stays tiny on purpose (bundlewatch: 1 kB raw, 800 B minified) — do not add anything to it that is not needed before paint.
## 2. `js/src/bootstrap/**` is a port, not our code
That directory is Bootstrap's JavaScript rewritten in TypeScript, MIT headers kept, `VERSION = '5.3.8'`. Treat it as vendored:
- Fix bugs the way upstream did, and keep the file's structure recognisable against `twbs/bootstrap`. Gratuitous restructuring makes the next upstream sync expensive.
- `js/src/bootstrap.ts` is the single source of truth for what is exported and for the `bootstrap` namespace object.
- Coverage is configured to measure exactly this directory (`js/src/bootstrap/**`), and `js/tests/unit/*.spec.ts` mirrors upstream's suite — a change here is expected to come with its test.
## 3. Tabler's own modules
Modules directly in `js/src/*.ts` (`autosize`, `countup`, `dropdown`, `input-mask`, `popover`, `sortable`, `switch-icon`, `tab`, `toast`, `tooltip`) are **side-effect initialisers**: importing them from `tabler.ts` wires the behaviour. The pattern is a `data-bs-toggle` query at import time:
```ts
// js-docs-start switch-icon-init
const switchesTriggerList: HTMLElement[] = [].slice.call(document.querySelectorAll<HTMLElement>('[data-bs-toggle="switch-icon"]'))
switchesTriggerList.map(function (switchTriggerEl: HTMLElement) {
switchTriggerEl.addEventListener('click', (e: MouseEvent) => {
const active = switchTriggerEl.classList.toggle('active')
switchTriggerEl.setAttribute('aria-pressed', active ? 'true' : 'false')
})
})
// js-docs-end switch-icon-init
```
- The attribute API stays `data-bs-*`. Renaming it to `data-tblr-*` is a breaking change held for 2.0 — do not start it here.
- Keep the accessible state in sync with the class (`aria-pressed`, `aria-expanded`), as above.
- A new module must be imported from `js/tabler.ts`, or it never ships.
- Shared helpers go to `js/src/tabler.ts` (`prefix`, `hexToRgba`, `getColor`), which reads theme values through `--tblr-*` custom properties.
## 4. Docs markers
`js-docs-start <name>` / `js-docs-end <name>` mark the snippet a documentation page shows for that behaviour (`docs/components/CodeDocs.astro`, `docs/lib/llms.ts`). Keep the marked region self-contained and copy-pasteable, and check who references the name before renaming it.
## 5. Tests
Tests run in a **real browser** — vitest browser mode with Playwright Chromium, not jsdom:
```bash
pnpm --filter @tabler/core test:js
pnpm --filter @tabler/core test:js:watch
pnpm --filter @tabler/core test:js:coverage
```
- `js/tests/unit/**/*.spec.ts` — behaviour, using `js/tests/helpers/fixture.ts` to mount markup.
- `js/tests/visual/*.html` — pages for looking at a component by hand.
- The first run needs Playwright's browser installed; if it fails on a missing binary, install it rather than switching the config to jsdom.
## 6. Build and budget
`vite` builds each entry as a library in `es` + `umd` (`BASE_NAME` selects the entry), then terser produces the `.min` variants with source maps. Sizes are enforced:
| File | Limit |
| --- | --- |
| `dist/js/tabler.js` | 64 kB |
| `dist/js/tabler.min.js` | 48 kB |
| `dist/js/tabler-theme.js` | 1 kB |
```bash
pnpm --filter @tabler/core build
pnpm run bundlewatch
```
Prefer a small dependency-free implementation over pulling a package in: every kilobyte here lands on every Tabler page.
## 7. Checklist
- [ ] New module imported from `js/tabler.ts` (or deliberately kept out of the bundle)
- [ ] `data-bs-*` attribute API unchanged; accessible state updated with the class
- [ ] Bootstrap-port changes stay close to upstream and come with a `js/tests/unit` spec
- [ ] `js-docs-*` markers intact and self-contained
- [ ] `pnpm --filter @tabler/core test` (js + scss) clean
- [ ] `pnpm run type-check` and `pnpm run lint-prettier` clean
- [ ] `pnpm run bundlewatch` within budget, or the new limit justified in the PR
- [ ] Docs page updated and changeset written
+121
View File
@@ -0,0 +1,121 @@
---
name: core-scss
description: >-
Write or change styles in `core/scss/` — the framework itself. Use whenever a
component needs a new class, modifier, size or colour variant, when a Sass or
CSS custom property is added or renamed, when dark mode or RTL behaviour is
involved, and before touching `_variables.scss`, `_props.scss` or anything
under `core/scss/ui/`. Covers where a style goes, the custom-property
pattern and its build-time `--tblr-` prefix, dark mode, RTL, the docs
markers, the SCSS unit tests and the lint/size gates.
---
# Styles in `core/scss`
`core/scss` is the product: a class that ships here is public API for every Tabler user. Components in `shared/ui` only assemble the class names defined here.
## 1. Where a style goes
| Path | Holds |
| --- | --- |
| `ui/_*.scss` | components — `.badge`, `.card`, `.steps`, one file per component |
| `layout/_*.scss` | page chrome — `root`, `core`, `navbar`, `page`, `footer`, `dark`, `animations`, `accessibility` |
| `utils/_*.scss` | utility classes — colors, background, text, sizing, shadow, opacity, scroll, hover |
| `mixins/`, `helpers/` | mixins, functions, the utilities API |
| `bootstrap/` | Tabler's managed copies of Bootstrap's own partials |
| `vendor/` | overrides for third-party plugin CSS |
| `tests/` | sass-true unit tests (see section 7) |
Entry points: `tabler.scss` (which forwards `_core.scss`, then `_extends.scss` last), plus the standalone bundles `tabler-flags`, `tabler-marketing`, `tabler-payments`, `tabler-props`, `tabler-socials`, `tabler-themes`, `tabler-vendors`. A new partial is not compiled until it is `@forward`ed from `_core.scss` (or the bundle it belongs to).
The module graph uses `@use` / `@forward`: a partial starts with `@use '../config' as *`, which is the hub forwarding `settings`, `variables`, `variables-dark`, `maps`, `mixins` and `utilities`. Cross-module `@extend` rules must stay in `_extends.scss`, which loads last.
## 2. The component pattern
```scss
@use '../config' as *;
.badge {
--badge-padding-x: #{$badge-padding-x};
--badge-font-size: #{$badge-font-size};
--badge-line-height: 1;
display: inline-flex;
padding: var(--badge-padding-y) var(--badge-padding-x);
font-size: var(--badge-font-size);
@include border-radius(var(--badge-border-radius));
}
```
- Every themeable value becomes a **custom property declared at the top of the component's root rule**, seeded from a Sass variable (`#{$badge-font-size}`). Declarations below read `var(--badge-*)`, never the Sass variable directly — that is what lets users retheme without recompiling.
- A value with no reason to be overridden can be a literal (`--badge-line-height: 1`).
- Modifiers set custom properties rather than redeclaring properties: `.badge-sm { --badge-font-size: … }`.
- Sass variables go to `_variables.scss` with `!default`, dark-mode counterparts to `_variables-dark.scss`.
## 3. Custom properties are authored bare
Write `--badge-bg`, not `--tblr-badge-bg`. The public `--tblr-` prefix is added at build time by `.build/css-var-prefix.ts` (a postcss pass in `build-css.ts`).
The consequence to remember: **names owned by third-party libraries must not be prefixed.** `cssVarIgnore` lists them (`--bs-`, `--fc-`, `--gl-`, `--litepicker-`, `--plyr-`, `--ts-`, …). Prefixing one detaches the theming with no error anywhere — the library keeps reading its own name and simply never sees the value. When a vendor override introduces a new foreign name, add it to `cssVarIgnore`; `core/scss/tests/css-var-prefix.test.mjs` snapshots every custom property of `tabler-vendors.scss`, so a missing entry shows up as a `--tblr-`-prefixed foreign name in the snapshot diff.
Global properties (`--dir`, colours, fonts, spacing) live in `_props.scss`, which emits them on `:root, :host`.
## 4. Dark mode
- Colour pairs are expressed with `light-dark()` where possible (`_variables.scss`, `layout/_root.scss`), so one declaration covers both modes.
- What cannot be expressed that way goes to `_variables-dark.scss`, or to `layout/_dark.scss` for the visibility helpers.
- Dark mode is keyed on `.theme-dark`, `[data-bs-theme='dark']` and `[data-theme='dark']` — match all three when you add a selector, and keep the whole block behind `@if $enable-dark-mode`.
## 5. RTL
RTL stylesheets are generated by `rtlcss` in `build-css.ts` (`--rtl`), so **do not hand-write RTL overrides**. Two rules:
- Prefer logical properties (`padding-inline-start`, `inset-inline-end`) — rtlcss then needs no help.
- A physical transform that must flip uses the `--dir` multiplier (`translateX(calc(var(--dir) * -50%))`), and the declaration is marked `/* rtl:ignore */` so rtlcss does not negate an already-correct calc. Both patterns are in `_utilities.scss` and `ui/_steps.scss`.
## 6. Docs markers
Snippets shown on documentation pages are pulled from the source with markers, so the docs cannot drift:
```scss
// scss-docs-start alert-variables
$alert-padding-y: … !default;
// scss-docs-end alert-variables
```
`docs/components/CodeDocs.astro` and `docs/lib/llms.ts` read these. When you rename or move a marked block, check who references the marker name before deleting it.
## 7. Unit tests (sass-true)
`core/scss/tests/*.test.scss` are real unit tests over mixins and functions, auto-discovered by `core/scss/tests/scss.test.mjs` and run through vitest:
```bash
pnpm --filter @tabler/core test:scss
pnpm --filter @tabler/core test # js + scss
```
Add a test when you write a mixin whose output is easy to break silently — the `_cards.test.scss` case (a `0%` that must keep its unit or the whole `color-mix()` drops) is the model. Note that stylelint deliberately ignores `core/scss/tests/**`: autofix there would rewrite the assertions.
## 8. Gates
```bash
pnpm run lint:scss # stylelint (twbs config; prettier owns formatting)
pnpm --filter @tabler/core css-lint # + find-unused-sass-variables
pnpm run generate-tokens:check # shared/lib/tokens.ts must match the Sass maps
pnpm run lint-prettier # formatting
pnpm run bundlewatch # size budgets (tabler.css 80 kB, tabler.min.css 75 kB)
```
- A new entry in `$theme-colors`, `$avatar-sizes` and friends must be regenerated into `shared/lib/tokens.ts` with `pnpm run generate-tokens` — the check gate fails otherwise.
- An unused Sass variable fails `css-lint`; delete it or use it.
- Growth past a bundlewatch limit is a decision, not an accident: raise the number in `core/package.json` deliberately and say so in the PR.
## 9. Checklist
- [ ] Partial in the right directory and `@forward`ed from `_core.scss` or its bundle
- [ ] Themeable values as custom properties at the top of the root rule, seeded from `!default` Sass variables
- [ ] Custom properties written bare; new foreign names added to `cssVarIgnore`
- [ ] Dark mode via `light-dark()` or all three dark selectors, behind `$enable-dark-mode`
- [ ] Logical properties, or `--dir` + `/* rtl:ignore */`; no hand-written RTL
- [ ] sass-true test for a mixin that can break silently
- [ ] `lint:scss`, `css-lint`, `generate-tokens:check`, `lint-prettier`, `test:scss` clean
- [ ] Docs page and class table updated (`write-docs`, `class-reference`), changeset written
+91
View File
@@ -0,0 +1,91 @@
---
name: html-diff
description: >-
Prove that a refactor changes no rendered HTML, using the `html-diff`
harness over every preview page. Use before and after any change that is
meant to be output-neutral — extracting or renaming a component, replacing
hand-written markup with a `shared/ui` component, reordering props,
restructuring a layout, migrating SCSS class emission — and whenever the
user asks whether a change is safe or byte-identical. Covers capturing the
baseline, reading the diff, and what counts as an acceptable difference.
---
# Prove a refactor changes no HTML
`.build/html-diff.ts` renders every preview page from a running dev server and compares it to a stored baseline. It is the only fast way to refactor shared components with confidence: one run covers every page that uses them.
## 1. When to use it
Use it for any change that **should not** alter output: component extraction, replacing repeated markup with a component, renaming props, moving code between `shared/` files, layout restructuring, prettier/format passes.
Do not use it for a change that intentionally alters markup — the whole run will be red and tell you nothing. There, review the diff of the pages you meant to change instead.
## 2. Capture the baseline first
The baseline must come from the tree **before** the change. If you have already started editing, stash first.
```bash
git stash # only if the working tree already has the change
pnpm --dir preview run dev # or preview_start with the `preview` config
pnpm run html-diff:baseline
git stash pop
```
Before editing, list the pages that render what you are about to change:
```bash
grep -rl "Steps.astro" preview/pages docs/content
```
Green on everything is not the only result worth checking — knowing that `all-elements.html` **must** stay identical while `steps.html` is allowed to differ is what turns the run into a proof.
Snapshots land in `.cache/html-diff/` (one HTML file per page) and the directory is wiped on every baseline run. The dev server must be reachable at `http://localhost:3000`; override with `HTML_DIFF_BASE` if you run it elsewhere.
## 3. Check after the change
Keep the same dev server running — it recompiles pages on demand — and run:
```bash
pnpm run html-diff
```
Every page byte-identical:
```
✓ All 128 pages are byte-identical to the baseline.
```
Anything else exits non-zero and prints one line per differing page.
## 4. Reading the output
| Line | Meaning |
| --- | --- |
| `± page: differs at line N` with `-`/`+` | real difference — the first differing line of that page |
| `± page: WHITESPACE-ONLY difference` | identical after collapsing inter-tag whitespace |
| `± page: new page (no baseline snapshot)` | page added since the baseline |
| `± page: page removed` | page deleted, or it now fails to render |
| `✖ N page(s) failed to render` | the dev server is down, or a page throws — fix that before reading anything else |
A whitespace-only difference is usually inert (flex containers, `<select>` internals) but is **not** automatically fine: inside inline contexts whitespace is a rendered space. Open such a page and compare visually before waving it through.
For a full diff of one page, compare the snapshot to the live render:
```bash
diff <(cat .cache/html-diff/badges.html) <(curl -s http://localhost:3000/badges)
```
## 5. What the harness does not cover
- **Preview pages only.** Docs pages and dynamic routes (`[...]` in the filename) are skipped.
- **Dev output, not built output.** The build additionally prettifies HTML; a formatting-only difference can still appear in `dist`.
- The `Generated <date>` footer is normalised away; nothing else is.
- Pages are compared as text, so an attribute reorder counts as a difference even when the DOM is equivalent. That is the point — Tabler ships the HTML.
## 6. Checklist
- [ ] Baseline captured from the pre-change tree, with the dev server running
- [ ] Same dev server used for baseline and check
- [ ] `pnpm run html-diff` green, or every remaining difference explained
- [ ] Whitespace-only pages opened in the browser before accepting them
- [ ] New/removed pages are intentional
+86
View File
@@ -0,0 +1,86 @@
---
name: navigation
description: >-
Make a page reachable and keep its links valid — the preview sidebar/navbar
menu in `shared/data/menu.json`, the docs menu in `shared/data/docs.json`,
and the redirects in `docs/astro.config.mjs`. Use whenever a page is added,
renamed, moved or removed in `preview/pages/` or `docs/content/`, whenever a
`pageMenu` key does not highlight the right entry, and whenever a docs URL
changes. Covers both menu formats, the `pageMenu` key rules and the link
gate.
---
# Menus, URLs and redirects
A new page that no menu points at is invisible, and a renamed docs URL breaks every external link to it. Both menus are hand-maintained JSON; nothing discovers pages automatically.
## 1. Preview menu — `shared/data/menu.json`
An object of top-level groups, each with nested `children`:
```json
"base": {
"title": "Interface",
"icon": "package",
"columns": 2,
"children": {
"badges": { "url": "badges.html", "title": "Badges", "badge": "New" }
}
}
```
| Key | Meaning |
| --- | --- |
| `title` / `title-long` | label; `title-long` is used where the navbar has room |
| `icon` | icon name for the group (top level) |
| `url` | **with the `.html` extension** — `badges.html`, matching `build.format: 'file'` |
| `badge` | small label on the entry, e.g. `"New"` |
| `columns` | split a long child list into N columns |
| `active`, `disabled`, `right` | force highlight, grey out, push to the right side |
| `children` | nested entries (a third level is allowed) |
**The `pageMenu` prop is the path of keys, dot-separated**: `base.badges` matches the `badges` child of the `base` group. `NavbarMenu.astro` splits on `.` and highlights by key, so a mismatch between the page prop and the JSON key silently highlights nothing. `menu-sample.json` is a separate, smaller menu used by sample layouts — leave it alone unless the task is about those.
## 2. Docs menu — `shared/data/docs.json`
An array-based tree (`menu`) plus a `links` list for the external links in the docs header:
```json
{ "title": "Getting started", "url": "/ui/getting-started",
"children": [ { "title": "Installation", "url": "/ui/getting-started/installation" } ] }
```
- URLs are **absolute paths without an extension and without a trailing slash** (`/ui/components/badge`) — `vercel.json` sets `trailingSlash: false`, so a trailing slash costs a 308.
- The path mirrors the file: `docs/content/ui/components/badge.mdx` → `/ui/components/badge`; an `index.mdx` drops the last segment.
- A page in `docs/content/**` that is missing here still renders — it is simply unreachable by navigation, which is easy to miss because no build step complains.
## 3. Renamed or removed docs URLs
Add an entry to `redirects` in `docs/astro.config.mjs`, next to the existing ones:
```js
'/ui/base/markdown': { status: 301, destination: '/ui/base/prose' },
```
The plural component slugs kept there (`alerts` → `alert`, `cards` → `card`, …) are the pre-Astro URLs still present in Google's index and in external links — that is the standard this repo holds itself to: an old URL keeps working. The Vercel adapter turns these into real HTTP redirects; without an adapter they would only be meta-refresh pages.
Update in the same pass: `shared/data/docs.json`, any `related:` front matter pointing at the old path, and `<DocsLink path="…">` on the matching preview page.
## 4. Verify with the link gate
```bash
pnpm run check-docs-links
```
It resolves, from source and without a build: markdown links and `related:` in `docs/content/**/*.mdx`, menu and link URLs in `shared/data/docs.json`, redirect destinations from `docs/astro.config.mjs`, `href` literals in docs components, `<DocsLink path="…">` and `getDocsUrl('…')` in the demo site, plus asset paths that really render. Anchors are checked against real heading slugs.
It does **not** check `menu.json`: a wrong preview URL or a `pageMenu` key that matches nothing shows up only in the browser. Open the page and confirm the menu entry is highlighted (`astro-dev` skill).
## 5. Checklist
- [ ] New preview page added to `menu.json` with a `.html` url, and its `pageMenu` prop equals the dot-path of the keys
- [ ] New docs page added to `docs.json` at the right depth, url absolute and extension-free
- [ ] Renamed or removed docs URL has a 301 in `docs/astro.config.mjs`
- [ ] `related:` entries and `<DocsLink path="…">` updated to the new path
- [ ] `pnpm run check-docs-links` clean
- [ ] Menu highlighting checked in the browser
+92
View File
@@ -0,0 +1,92 @@
---
name: page-layouts
description: >-
Pick and configure the right layout for a Tabler preview page, and change or
add a layout in `shared/layouts/`. Use whenever a page needs different page
chrome — sidebar, navbar variant, boxed/fluid body, centered card, error or
marketing page — whenever a page hand-rolls a page header or wrapper markup,
and before adding a new layout file. Covers the layout map, the
`DefaultLayout` props, the page-header slot, and the rules for a new layout.
---
# Page layouts
Layouts own the page chrome: `<head>`, assets, body classes, navbar/sidebar, page header, footer. A page supplies content and a handful of props — never its own header block, wrapper divs, or `<html>` scaffolding.
## 1. Which layout
| Layout | Use for |
| --- | --- |
| `DefaultLayout` | the normal app page — navbar/sidebar, page header, footer (98 of the preview pages) |
| `SingleLayout` | centered card on an empty page: sign-in, sign-up, error-ish flows (`containerSize`, `hideLogo`) |
| `SettingsLayout` | settings pages — wraps the body in a card with the settings nav (`active`) |
| `ProseLayout` | long-form text in a `card-lg` + `.prose` body (terms, changelog) |
| `ErrorLayout` | error pages, content driven by `pageError` keyed into `errors.json` |
| `MarketingLayout` | marketing/landing pages with the marketing navbar and footer |
| `PayLayout` | minimal checkout chrome: small logo + close button |
| `RedirectLayout` | bare meta-refresh document (`url`, `base`) |
| `BaseLayout` | only when a page needs no chrome at all (screenshot/playground pages). Everything above wraps it. |
Docs pages do not choose a layout — `docs/pages/[...slug].astro` renders the collection through `DocsLayout`, and MDX front matter has no `layout:` key.
## 2. `DefaultLayout` — content props
| Prop | Effect |
| --- | --- |
| `title` | `<title>` **and**, by default, the page header title |
| `pageHeader` | header title when it differs from `title`; `pageHeader={false}` removes the header |
| `pretitle` | small line above the title (`page-pretitle`) |
| `description` | line under the title *and* `<meta name="description">` |
| `pageMenu` | menu entry to highlight, e.g. `base.badges` — must match a key in `shared/data/menu.json` |
| `pageLibs` | third-party libs to load, keyed into `core/libs.json` (`['apexcharts']`) |
| `pageHeaderFile` | swaps the title block for a prebuilt header (`"profile"`, `"uptime"`) |
Do not pass `pageHeader` when it repeats `title` — the fallback already covers it.
## 3. `DefaultLayout` — chrome flags
Each flag maps to one documented body/layout class, so reach for the prop instead of `bodyClass`:
| Prop | Class |
| --- | --- |
| `sidebar`, `sidebarDark`, `sidebarEnd` | `layout-sidebar`, `layout-sidebar-dark`, `layout-sidebar-end` |
| `hideTopbar` | `layout-hide-topbar` |
| `navbarTransparent`, `navbarCondensed`, `navbarDark`, `navbarOverlap`, `navbarSticky`, `navbarHideBrand` | the matching `layout-navbar-*` classes |
| `navbarClass` | extra classes on the navbar |
| `wrapperFull` | `page-wrapper-full`, drops the `.container-xl` around the body |
| `containerCentered`, `containerClass` | `my-auto` / extra classes on the `.container-xl` |
| `bodyClass` | free-form body classes — `layout-boxed`, `layout-fluid` |
| `rtl` | `dir="rtl"` plus the RTL stylesheets |
## 4. The page-header slot
Anything to the right of the title goes in the `page-header-actions` slot, as a direct child of the layout:
```astro
<DocsLink slot="page-header-actions" path="/ui/components/badge" />
<HeaderActionsButtons slot="page-header-actions" />
```
Prebuilt groups live in `shared/components/layout/` (`HeaderActionsButtons`, `HeaderActionsPrint`, `HeaderActionsPhotos`, `HeaderActionsBreadcrumb`, …). The header renders the actions column only when the slot has content.
## 5. Never hand-roll chrome
If a page contains `<div class="page-header">`, `<h2 class="page-title">`, `page-wrapper`, `page-body`, or its own `<head>` markup, that is a layout prop that was missed. Add or extend the prop instead of duplicating the markup — the same header exists on 100+ pages and drifts the moment one copy is edited.
## 6. Adding or changing a layout
- A new layout wraps `BaseLayout` (or `DefaultLayout` when it only reshapes the body, like `SettingsLayout` and `ProseLayout`). It never re-emits `<html>`, assets, or theme settings.
- Every prop gets a doc comment naming the class or front-matter key it maps to — follow the existing style in `DefaultLayout`.
- Booleans default to off; content props are optional with a sensible fallback.
- Keep `<!-- BEGIN … -->` / `<!-- END … -->` markers around structural blocks: users copy the rendered HTML.
- Pass `pageLibs` through to `BaseLayout` so pages using the new layout can still load plugins.
- Changing a shared layout touches every page that uses it — prove it with the `html-diff` skill.
## 7. Checklist
- [ ] Layout chosen from the table, not `BaseLayout` plus hand-written chrome
- [ ] `title`, `description`, `pageMenu` set; no `pageHeader` repeating `title`
- [ ] Chrome via named flags, `bodyClass` only for `layout-boxed` / `layout-fluid`
- [ ] Header extras in the `page-header-actions` slot
- [ ] `pageLibs` listed for every plugin the page initialises
- [ ] Page opened in the browser (`astro-dev` skill); `html-diff` run if a shared layout changed
+86
View File
@@ -0,0 +1,86 @@
---
name: shared-lib
description: >-
Move logic out of Astro frontmatter into `shared/lib/*.ts` and cover it with
a vitest test. Use when a component's or page's frontmatter grows past
assembling classes, when demo data needs shaping or formatting, when the
same helper appears in two components, or when the user asks for a helper,
utility or unit test under `shared/`. Covers what belongs there, module
conventions, the sibling `*.test.ts` rule, deterministic demo data, and the
strict-TypeScript patterns the repo uses.
---
# Helpers in `shared/lib`
`shared/lib/*.ts` is the only place in `shared/` where real logic lives. Components stay markup; anything with a branch worth testing moves here, gets a name, and gets a test.
## 1. What belongs here
Move it to `shared/lib` when it is:
- **formatting** — dates, numbers, strings (`date-format.ts`, `string-format.ts`)
- **data access** — turning a JSON file into a typed export (`people.ts`, `site.ts`)
- **generation** — deterministic demo values (`pseudo-random.ts`), SVG/icon handling (`svg.ts`), markdown rendering (`render-markdown.ts`)
- **anything duplicated** in two components, or longer than a few lines in frontmatter
Keep it in the component when it is class assembly, a ternary over props, or a single `.map()` over a prop — pushing that into `lib` only adds an import.
Do not put in `shared/lib`: DOM code (that is an inline script — see the `astro-scripts` skill), Astro-specific rendering (that is a component), or build-time tooling (that is `.build/`).
## 2. Module conventions
```ts
// Demo data date formatting quirks:
// - unix seconds are shifted by local timezone offset (not the true epoch)
const MONTHS = ['January', 'February', /* … */]
/** Formats a date as "Month DD, YYYY" using its UTC components. */
export function formatLongDate(date: Date): string {
return `${MONTHS[date.getUTCMonth()]} ${String(date.getUTCDate()).padStart(2, '0')}, ${date.getUTCFullYear()}`
}
```
- Named exports only, one topic per file, filename in kebab-case matching the topic.
- Explicit return types; pure functions with no module-level mutable state (`page-scripts.ts` is the deliberate exception — a render-scoped registry).
- A file-header comment when the module encodes a quirk (why the epoch is shifted, why the RNG is fake). A `/** … */` line on each exported function.
- Export the types consumers need (`export interface Person`), so call sites do not re-cast.
- Imports inside `shared/` are relative (`./array`, `../data/people.json`). Any bare npm import must be declared in `shared/package.json`.
## 3. Every module has a sibling test
`shared/vitest.config.mts` collects `lib/**/*.test.ts`, so `foo.ts` gets `foo.test.ts` next to it. This is not optional — every module in the directory has one.
```bash
pnpm --filter @tabler/shared test # one run
pnpm --filter @tabler/shared test:watch # while writing
pnpm run test # all packages, via turbo
```
Test the branches, not the happy path only: the fallback when a regex does not match, the empty range, the thrown error. `globals: true` is set, but the existing files still import `describe`/`it`/`expect` from `vitest` — keep that style.
## 4. Demo data must be deterministic
Pages are rebuilt constantly and the rendered HTML is compared byte-for-byte (`html-diff` skill), so a helper that produces different output on each run breaks the harness and pollutes diffs.
- Use `pseudo-random.ts` (`randomNumber`, `randomDate`, `randomItem`, `timeagoLabel`), seeded by the loop index — never `Math.random()`.
- Never seed demo data from `Date.now()` / `new Date()` without arguments. Fixed dates only.
## 5. Strict TypeScript patterns
Every package extends `astro/tsconfigs/strictest`, so indexing an array yields `T | undefined`:
- Use `requireIndex(items, i)` from `array.ts` when the demo data is trusted to have the entry — it throws with a useful message instead of rendering `undefined`.
- Cast a JSON import to its type **once**, in the lib module, and export the typed value (`export const people = peopleData as Person[]`). Call sites import the typed export; they never re-cast the JSON.
- Give such interfaces an index signature (`[key: string]: unknown`) when the JSON has more fields than the components read.
`pnpm run type-check` runs `astro check` per package and covers these files.
## 6. Checklist
- [ ] Logic is out of the frontmatter and named after what it does
- [ ] Named exports, explicit return types, no hidden state
- [ ] Header comment for quirks, doc comment per exported function
- [ ] Sibling `*.test.ts` covering the branches, not just the happy path
- [ ] Deterministic: `pseudo-random.ts`, no `Math.random()`, no unseeded dates
- [ ] JSON cast once in the lib module, `requireIndex` for trusted indexing
- [ ] `pnpm --filter @tabler/shared test`, repo-level `type-check` and `format-prettier` clean
+145
View File
@@ -0,0 +1,145 @@
---
name: ui-component
description: >-
Build or extend a shared Astro component in `shared/ui/*.astro` (or
`shared/components/**`) — the library that both the preview and the docs
render. Use whenever the user asks for a new component, for a new prop,
variant or size on an existing one, or for a refactor of a shared component.
Covers the Props interface, the prop-name dictionary, class composition,
slots, the `as` pitfall, and the follow-ups (demo page, docs, changeset) a
new component needs.
---
# Build a shared UI component
`shared/ui/*.astro` is the component library. `@tabler/preview` and `@tabler/docs` render the same files, so every change here lands on demo pages and in documentation examples at once. Components are markup: the design lives in `core/scss/`, and logic longer than a few lines lives in `shared/lib/*.ts`.
## 1. Before creating a file
- **Extend, do not fork.** Read the `Props` interface of the closest existing component first. A new look is a prop on the existing component (`variant`, `size`, `light`), never a parallel `ButtonAlt.astro`.
- **Check the demo page before the component.** If `preview/pages/<name>.astro` hand-writes variants the component cannot express, those are the missing props, and the component has drifted from the markup it is supposed to own. Close that gap instead of adding one more hand-written block.
- **Find the CSS first.** A component wraps classes that exist in `core/scss/ui/_*.scss`. If the class is missing, writing it is part of the task — never invent class names in the component.
- Where it goes: generic building blocks in `shared/ui/`; fixed demo content (a dashboard card with its own data) in `shared/components/cards/`; docs-only components in `docs/components/`.
- Imports inside `shared/` are relative (`./Icon.astro`, `../components/CaptureScript.astro`). `@shared` / `@ui` / `@data` are for the site packages. Any bare npm import used in `shared/` must be declared in `shared/package.json`.
## 2. Skeleton
```astro
---
import Icon from './Icon.astro'
/** core/scss/ui/_badges.scss `.badge-{size}` modifier classes */
type BadgeSize = 'sm' | 'lg'
interface Props {
text?: string
color?: string
size?: BadgeSize | undefined
/** -lt colour variant + text-{color}-lt-fg */
light?: boolean
class?: string
icon?: string
/** remaining attributes (id, data-*, aria-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { text, color, size, light, class: className, icon, ...rest } = Astro.props
const classes = ['badge', size && `badge-${size}`, color && `bg-${color}${light ? '-lt' : ''}`, className]
---
<span class:list={classes} {...rest}>{icon && <Icon name={icon} />}{text}</span>
```
Rules the file above follows, and every component should:
- `interface Props` + destructuring with defaults. No untyped `Astro.props`.
- The `class` prop keeps its HTML name and is destructured as `class: className`.
- A union type for each modifier set (`'sm' | 'lg'`), with a doc comment naming the SCSS file the classes come from. Export the type (`export type IconSize`) when a sibling component reuses it.
- `[key: string]: unknown` + `...rest` spread on the root element, so callers can pass `id`, `data-*`, `aria-*` without a prop for each. Add it unless the component deliberately controls its own attributes.
- One doc comment per non-obvious prop, saying which class or attribute it emits.
## 3. Prop-name dictionary
Names are shared across the library so the API stays predictable (and stays portable to React/Vue later). Reuse these before inventing a name:
| Prop | Meaning |
| --- | --- |
| `class` | extra classes, merged last so callers can override |
| `id` | element id; **required** for components that a script initialises |
| `color` | palette colour (`primary`, `azure`, …), emitted as `-{color}` |
| `size` | `sm` / `md` / `lg` (…), where `md` emits no class |
| `variant` | style variant of the same component (`outline`, `ghost`, `important`) |
| `text`, `title`, `description`, `label` | content passed as a prop instead of a slot |
| `icon`, `iconEnd` | icon names, rendered through `Icon.astro` |
| `href`, `external` | link target; `external` adds `target="_blank" rel="noreferrer"` |
| `disabled`, `active`, `required`, `invalid` | state booleans, named after the HTML/CSS state |
| `show…` (`showClose`, `showValue`) | opt-in parts of the markup |
| `ariaLabel` | accessible name when the visible content is an icon or a colour |
Props are camelCase. Booleans default to `false` — never `showClose = true`.
## 4. Class composition
- Use `class:list={[...]}` with falsy entries for conditionals; it drops them and merges the caller's `className` for you. `.filter(Boolean).join(' ')` is only for a string you have to pass to a helper (`iconSvg`).
- Put `className` last so the caller wins.
- Do not emit a class for a default (`size !== 'md' && \`btn-${size}\``).
- No `<style>` blocks and no CSS imports in the frontmatter. Styling is `core/scss/`.
## 5. Slots and containers
Container components (`Card`, `CardBody`, `ButtonList`, …) take a `<slot />` rather than a content prop. Use `Astro.slots.has('name')` when the markup differs depending on whether a named slot was filled.
Polymorphic containers take an `as` prop — with one trap:
```astro
---
import type { HTMLTag } from 'astro/types'
// `as` must stay out of the Props body: Astro's frontmatter scanner bails on a member
// literally named `as` and silently falls back to untyped props.
type PolymorphicProps = { as?: HTMLTag }
interface Props extends PolymorphicProps {
class?: string
}
const { as: Element = 'div', class: className }: Props = Astro.props
---
<Element class:list={['card', className]}><slot /></Element>
```
Heading components pick their own level (`CardTitle` renders `h2`, `as="h3"` for a nested section) so pages cannot skip levels.
## 6. Escape hatches and Astro traps
- Content that may contain markup or entities (`&hellip;`) → `set:html` / `<Fragment set:html={…} />`. Entities in attribute strings must be passed as an expression (`title={"…&hellip;"}`), because JSX decodes them inside string literals.
- Boolean attributes are inconsistent: `selected={true}` renders bare, but some (e.g. `multiple`) render `="true"`. Use `multiple ? '' : undefined` when a bare attribute is required, and check the rendered HTML.
- Client-side behaviour (plugin init, event wiring) does **not** go in an ad-hoc `<script>` — see the `astro-scripts` skill.
- Icons always through `<Icon name="…" />`, never inline SVG.
## 7. Logic belongs in `shared/lib`
Anything beyond assembling classes and simple ternaries (formatting, data shaping, SVG handling, deterministic demo data) goes to `shared/lib/*.ts` and is imported by the component. Every module there has a sibling `*.test.ts` run by vitest (`pnpm --filter @tabler/shared test`) — add one for the function you introduce, and keep it pure so it is testable without a DOM.
Use `pseudo-random.ts` instead of `Math.random()` for demo data: pages are rebuilt constantly and random output would change the HTML on every build.
## 8. After the component exists
A new component is not done until it is visible and documented:
1. **Demo page** — a page in `preview/pages/` showing its variants (`demo-pages` skill).
2. **Docs page** — `docs/content/**` (`write-docs` skill), plus the class table (`class-reference` skill) when the component introduces classes.
3. **Changeset** — `generate-changeset` skill, `minor` for a new component, `patch` for a fix.
## 9. Checklist
- [ ] Extends an existing component instead of duplicating one
- [ ] Classes exist in `core/scss/`; no invented class names
- [ ] `interface Props` typed, prop names taken from the dictionary in section 3
- [ ] `class: className` merged last, `...rest` forwarded
- [ ] Accessible: semantic element, `ariaLabel` where the content is an icon
- [ ] Logic in `shared/lib` with a test, not in the frontmatter
- [ ] `pnpm run format-prettier` and `pnpm run type-check` clean at the repo root (full output, not a tail)
- [ ] Demo page + docs page + changeset
+5
View File
@@ -0,0 +1,5 @@
---
"@tabler/preview": minor
---
Added `color` and `vertical` props to the `Steps` component and used it on the steps preview page.
+2 -5
View File
@@ -5,6 +5,7 @@ import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import DocsLink from '@ui/DocsLink.astro'
import Steps from '@ui/Steps.astro'
---
<DefaultLayout title="Steps" pageMenu="base.steps" description="Steps show progress through a numbered or labeled sequence, as a horizontal bar, breadcrumb, or vertical timeline.">
@@ -29,11 +30,7 @@ import DocsLink from '@ui/DocsLink.astro'
<CardBody>
<CardTitle>Horizontal counter</CardTitle>
<CardSubtitle>The same steps without numbers, for a more minimal look.</CardSubtitle>
<ul class="steps steps-green steps-counter my-4">
<li class="step-item"></li>
<li class="step-item active"></li>
<li class="step-item"></li>
</ul>
<Steps count={3} active={2} numbers color="green" class="my-4" />
</CardBody>
</Card>
</div>
+6 -2
View File
@@ -5,14 +5,18 @@ interface Props {
count: number
active: number
numbers?: boolean
/** steps-{color} — recolors the dots and connectors */
color?: string
/** steps-vertical — stacks the steps into a column */
vertical?: boolean
class?: string
/** remaining attributes (id, data-*, aria-*, …) are forwarded to the root element */
[key: string]: unknown
}
const { count, active, numbers, class: className, ...rest } = Astro.props
const { count, active, numbers, color, vertical, class: className, ...rest } = Astro.props
const classes = ['steps', numbers && 'steps-counter', className]
const classes = ['steps', color && `steps-${color}`, numbers && 'steps-counter', vertical && 'steps-vertical', className]
const steps = range(1, count)
---