# About Source: https://teasim.com/about.md Canonical: https://teasim.com/about Markdown: https://teasim.com/about.md Language: en ## Why we exist Most component libraries make two decisions on your behalf. They decide how your site looks, by shipping a stylesheet. And they decide how your site is built, by rendering your content for you. Both are borrowed decisions, and both are expensive to undo two years later when you want something they did not anticipate. Teasim tries to make neither. ## Shared foundations, independent platforms StyleSheet provides the web styling foundation through TeaCSS. Astro brings components, page blocks and content tools together for content-driven websites. React is in progress; Three and the native platforms are planned. Each product page shows its current status and available packages. ## How the Astro packages fit together `@teasim/astra` renders markup and carries behaviour with it. It ships no stylesheet at all; your own TeaCSS build generates exactly the classes your markup used. `@teasim/aster` arranges those elements into the page-level chrome a site repeats — the shell, the header, the navigation, the table of contents. It owns arrangement and never restates a surface. `@teasim/astro` computes navigation, validates content, and serves the machine-readable routes. It renders nothing. No package knows what the others look like, and a project can take any of them on its own. ## What we optimise for **The markup you would have written.** An accordion is a `details` element. If you inspect the DOM and find something you would not have written by hand, that is a defect. **Behaviour without JavaScript first.** The open state belongs to the browser. A runtime binds on top when there is one, and the page works when there is not. **Decisions that are written down.** Where something looks like removable duplication and is not, the reason lives beside it, so the next reader does not have to rediscover it by breaking something. --- # Approach Source: https://teasim.com/approach.md Canonical: https://teasim.com/approach Markdown: https://teasim.com/approach.md Language: en ## Start from the markup Design the DOM before the API. If the element you would have written by hand is a `details`, the component is a `details`. Props exist to arrange markup, not to hide it. ## Measure instead of reasoning Where a question has a number attached, get the number. Whether a rebuild is redundant is a hash comparison, not an argument. Whether a role belongs on an element is a count of how many appear on a page. Whether a colour passes is a pixel read back off a canvas with the whole ancestor stack composited onto it. Reasoning is what you do when measurement is impossible, not when it is inconvenient. ## Verify the thing that ships A green build proves the code compiles. It proves nothing about what a browser does with the output. After a component change, open the page. The corollary is that verification has to be *hostile*: run the check against a case whose answer you already know, and if it agrees, then trust it on the cases you do not. ## Let the gate do the blocking A check you read with your eyes is a report. If it is meant to block, the exit code has to be the condition. ## Write down what you decided not to do A decision that produces no diff leaves no trace. The next review will reach the same question, spend the same time, and may answer it differently. Where it matters, a test pins the current shape and names the reason in its title. ## Prefer the boring failure Given two designs, prefer the one whose failure is loud. A class name assembled from a template literal produces no CSS and no error — the markup looks correct and the element is simply unstyled. A literal string that is wrong produces a rule you can see in the stylesheet. --- # Brand Source: https://teasim.com/brand.md Canonical: https://teasim.com/brand Markdown: https://teasim.com/brand.md Language: en ## The frame Every band on this site sits inside a rail on each edge. The rail is `clamp(1rem, 18.75vw - 6rem, 3rem)` wide, so it grows with the viewport and stops at 48 pixels, and it always draws the line at its inner edge. Above 1440 pixels a gutter opens between the rail and the content column and draws a second line at its own inner edge — 1440 because that is the first width where the gutter is wide enough for the two lines not to read as one thickened rule. The content column is capped at 80rem. Everything else is margin. The gutter absorbs whatever is left over, which is what keeps the two sides symmetric rather than piling the slack against one edge. ## Corner nodes Where two bands meet, a six-pixel square rotated 45 degrees marks the corner, offset so its centre sits on the seam. That only holds because of one invariant: every frame row draws the boundary as a border on its own bottom edge, and the last row on the page draws none. A border moves the row's padding box by the same pixel the node is offset against, so the node lands on the line without knowing anything about the row on the other side. A line drawn by anything else — an absolutely positioned hairline, a border on content nested inside the row — misses by one pixel, every time. They are decoration, hidden from assistive technology, and they are the one flourish the system allows itself. ## Lines carry the structure There are three kinds of line and they are not interchangeable: - **Rails and seams** are full-strength `gray-500`, painted as real borders. They are structure, and they must survive forced colours — which a border does, and which the seam names explicitly anyway. - **Hatching** is a 135-degree repeating gradient, one pixel of ink every five. It marks a major division between sections. - **Wisps** are alpha tints at around 28%. They are ornament. They are allowed to disappear under forced colours, because a deliberate whisper repainted at full contrast is not the same design. ## Type IBM Plex Serif sets every heading, at weights between 340 and 400 — light enough that a large heading does not shout. IBM Plex Sans sets body text. The mono face carries eyebrows, metadata and code, always uppercase with wide tracking when it is a label. ## Motion One transition, 150 milliseconds, ease-out, on colour and small translations. The page frame scales fractionally when a sheet opens over it. Everything stops under `prefers-reduced-motion`. --- # Documentation Source: https://teasim.com/docs/index.md Canonical: https://teasim.com/docs Markdown: https://teasim.com/docs/index.md Language: en ## Choose your starting point - [Astro elements](https://teasim.com/packages/astra): accessible interface components. - [Astro blocks](https://teasim.com/packages/aster): page-level compositions. - [Astro content](https://teasim.com/packages/astro): content schemas, navigation and machine routes. - [TeaCSS](https://teasim.com/packages/teacss): styling foundations. - [React](https://teasim.com/products/react): the application packages currently in progress. See [Project structure](https://teasim.com/docs/project-structure) for how the layers compose. --- # Project structure Source: https://teasim.com/docs/project-structure.md Canonical: https://teasim.com/docs/project-structure Markdown: https://teasim.com/docs/project-structure.md Language: en A scaffolded Teasim project is an ordinary Astro project. Everything you will edit is a real file in your repository; all three packages stay dependencies and are never copied in. ## What lands in your repository ```text my-docs ├── astro.config.mjs ├── index.css ├── package.json ├── tsconfig.json └── src ├── content.config.ts ├── content │ └── docs │ ├── index.mdx │ └── brewing.md ├── helpers │ └── docs.ts └── pages └── [...slug].astro ``` | Path | What it is | | --- | --- | | `index.css` | The TeaCSS entry — presets, `@source` globs, `@teacss;`. This file **is** the configuration; there is no `teacss.config.*` | | `astro.config.mjs` | `teacss()` and `teasim()`, plus `markdown.shikiConfig`, `prefetch`, and `vite.resolve.noExternal` | | `src/content.config.ts` | The `docs` collection, from astro's `contentCollection()` | | `src/content/docs/` | Your pages. The tree here is the site | | `src/helpers/docs.ts` | The glue: collection entries in, navigation data out | | `src/pages/[...slug].astro` | One route for every page, and the landing page too — there is no separate `index.astro` | Ten files, two of them seed content. One `.md` and one `.mdx` on purpose: the pair proves both parse paths the moment the dev server starts. ## The tree is the structure The directory layouts under `src/content/docs/` and `src/content/package-docs/` define the URL structure and the sidebar. **The path is the structure** — `guides/installing` nests under `guides` whether or not `guides` is a page in its own right. ```text src/content ├── docs │ ├── index.mdx → /docs │ └── project-structure.mdx → /docs/project-structure └── package-docs ├── astra.mdx → /packages/astra ├── astra │ └── accordion.mdx → /packages/astra/accordion ├── teacss.mdx → /packages/teacss └── teacss ├── concepts.mdx → /packages/teacss/concepts ├── standard.mdx → /packages/teacss/standard ├── official.mdx → /packages/teacss/official └── icons.mdx → /packages/teacss/icons ``` A directory with no entry of its own becomes a heading in the sidebar: its `slug` and `href` are `null`, it is not navigable, and it takes its label from the path segment, title-cased. An entry alongside a same-named directory makes that group navigable, as with TeaCSS Concepts, Standard, Official, and Icons. Package pages define the navigable roots; folders below them group their references. Move a file and its route and its sidebar entry move with it. Frontmatter handles the fine grain — `sidebar.order`, `sidebar.badge`, `draft` — but the shape of the site comes from the filesystem. There is no second navigation config to keep in sync, so the site cannot drift from its own contents. ## What is yours and what is not The three packages divide by what they produce: | Package | Ships | Knows about | | --- | --- | --- | | `@teasim/astra` | elements — Button, Dialog, Prose | behaviour and accessibility | | `@teasim/aster` | blocks — Shell01, Sidenav01, Pricing01 | arrangement | | `@teasim/astro` | data helpers, the Astro integration, the CLI | your content | The direction is one-way: aster composes astra, your page composes both, and astro imports neither. **astro computes, astra draws, aster arranges, the page glues** — which is why `PackageManagers` takes commands already translated rather than importing astro's `translatePackageCommand` itself. Nothing is vendored. There is no upstream theme to override and no copy in your repository to diff against, so upgrading the chrome is `npm update`. ## What the integration adds `teasim()` in your Astro config injects three routes you never write: | Route | Contents | | --- | --- | | `/llms.txt` | The sidebar flattened to links and descriptions | | `/llms-full.txt` | Every page body as markdown, slug-ordered | | `/[...slug].md` | One page body as markdown | They are wired together on purpose: an agent reads `/llms.txt`, follows a link, and gets markdown rather than HTML it has to strip. Try [`/llms.txt`](https://teasim.com/llms.txt) or the markdown twin of this page, [`/docs/project-structure.md`](https://teasim.com/docs/project-structure.md). To take one over, create the file — `src/pages/llms.txt.ts` and that route is yours, no option required. ## What is not in the tree `.astro/` is generated: content-collection types and the resolved config. It is gitignored, and nothing in it is yours to edit. There is no config file of astro's own. Everything it needs is an option on the `teasim()` call, and nothing else in the project reads one — including `site`, which stays on Astro's `defineConfig` because the sitemap, the canonical URL, and `Astro.site` all read it from there. --- # Teasim Source: https://teasim.com/index.md Canonical: https://teasim.com/ Markdown: https://teasim.com/index.md Language: en Teasim connects interface standards, platform products, content infrastructure, and the practices that keep them aligned. Adopt one layer, or compose the whole project. ## Why Teasim exists Product teams often accumulate separate answers for design, implementation, content, documentation, and delivery. Each answer can work on its own while the product around them becomes harder to understand and change. Teasim keeps those responsibilities distinct without letting the project feel fragmented. Shared standards establish the quality bar; focused products express them for each platform; project tooling keeps the result legible to people and machines. ## What guides the project - **Standards without sameness.** Products share a quality bar, not a forced implementation. - **Boundaries preserve choice.** Foundations, platform surfaces, and project tooling change at their own pace. - **The output is the proof.** Accessibility, documentation, built pages, and browser behaviour matter more than internal abstractions. - **Decisions stay written down.** Specs, proposals, guides, and notes keep the reasoning beside the work. Read [About](https://teasim.com/about) for why the system is split this way, [Approach](https://teasim.com/approach) for how changes are measured and verified, and [Products](https://teasim.com/products) for the focused implementations. ## Built for people and agents Every page here has a markdown twin. Append `.md` to any address, or start from the index the integration generates: - [`/llms.txt`](https://teasim.com/llms.txt) — every page as a link with its description - [`/llms-full.txt`](https://teasim.com/llms-full.txt) — the whole corpus as one document - [`/index.md`](https://teasim.com/index.md) — this page, as markdown None of it is hand-maintained. `teasim()` derives all three from the same entries the sidebar is built from, so the corpus can never list a page the site does not serve. --- # Native platform Source: https://teasim.com/native.md Canonical: https://teasim.com/native Markdown: https://teasim.com/native.md Language: en ## Shared standards, native foundations Teasim's native direction carries the same quality bar as the web: accessible interfaces, clear documentation, and product behaviour that holds up in use. Each platform uses its own language and interface toolkit. The native platforms are planned. Their product pages describe the intended scope; published packages and documentation will follow as the implementations become available. ## Built around each platform **Apple** uses Swift and SwiftUI for products in the Apple ecosystem. **Android** uses Kotlin and Jetpack Compose for native Android interfaces. **Desktop** uses GPUI for applications that need responsive, high-performance interfaces. --- # A class the extractor never saw Source: https://teasim.com/notes/a-class-the-extractor-never-saw.md Canonical: https://teasim.com/notes/a-class-the-extractor-never-saw Markdown: https://teasim.com/notes/a-class-the-extractor-never-saw.md Language: en The frame puts small rotated squares at the corners of each band. They all rendered at roughly `x = 0`, stacked on the left edge instead of sitting at the four corners. Nothing was wrong with the geometry. The component built its classes like this: ```ts const corners = ["t", "t", "b", "b"]; const x = index < 2 ? "i-s" : "i-e"; const cls = `${x}:[var(--site-node-offset)]`; ``` ## Why it produced no CSS at all TeaCSS is a scanner, not a runtime. It reads your source **as text**, finds whole class tokens, and generates a rule for each one it recognises. It never executes the file. So `` `${x}:[var(--site-node-offset)]` `` is not a class token as far as the scanner is concerned. There is no `i-s:[...]` in the source text — only a fragment starting at `:`. No rule is generated. The class still lands in the HTML at runtime, matches nothing, and `inset-inline-start` falls back to `auto`. The failure mode is the dangerous one: **no error**. A token that matches no rule emits no CSS and no warning. The markup looks right, the class attribute looks right, and the element is simply unstyled. ## The fix Write the tokens out whole and pick between them: ```ts const corners = [ "i-t:[-3.5px] i-s:[var(--site-node-offset)]", "i-t:[-3.5px] i-e:[var(--site-node-offset)]", "i-b:[-3.5px] i-s:[var(--site-node-offset)]", "i-b:[-3.5px] i-e:[var(--site-node-offset)]", ]; ``` Verbose, and correct. A test now asserts the literal strings are present and that no `i-*:${` appears in the file, because the next person to tidy this into a loop will reintroduce exactly the same bug. ## The general rule Any class token that cannot appear literally in your source — because it is composed at runtime, or comes from a CMS — has to be declared with `@safelist` instead. Interpolation and utility scanners do not mix. --- # A hairline that vanished in high contrast Source: https://teasim.com/notes/a-hairline-that-vanished-in-high-contrast.md Canonical: https://teasim.com/notes/a-hairline-that-vanished-in-high-contrast Markdown: https://teasim.com/notes/a-hairline-that-vanished-in-high-contrast.md Language: en The site's whole visual identity is hairlines: rails down both edges, a seam between bands, small rotated squares at the corners, a hatched divider. In Windows High Contrast Mode, one of them disappeared and the rest were fine. ## Why only one Forced colours replaces a fixed set of properties with system colours: `color`, `background-color`, `border-color`, `outline-color`, and a few more. A border survives because `border-color` is replaced with `CanvasText`. A CSS gradient survives because `background-image` is not in the list. Every line in the frame was a border or a gradient — except the seam between bands, which was a one-pixel `div` whose only paint was `background-color`. Forced colours replaced it with `Canvas`, the same colour as the page behind it. It did not fade; it became the background. ## The fix, and the thing that nearly made it useless Naming a system colour is the whole fix: ```html ``` But media queries carry **no specificity**. Both rules are a single class, so whichever the stylesheet emits last wins. Had `bg-color:gray-500` come after the forced-colors block, the fix would have been a no-op that still passed every test — the class would be present, the CSS would exist, and the line would still vanish. It was worth checking rather than assuming. In the built stylesheet the base utility sits at byte 102188 and the forced-colors rule at 126668, so the system colour wins. ## What we did not fix The footer carries a small decorative flourish, also painted with a background, also erased in forced colours. We left it. The rule that decided it: **match the design intent, not the pixel.** The seam is full strength and meant to be seen, so its erasure is a regression. The flourish is a deliberate 28% wisp, and repainting it `CanvasText` would not restore the intent — it would invert it, turning a whisper into a crisp rule that reads as structure the design never claimed. ## Since then The background-painted seam is gone. Aligning the corner nodes forced a stricter rule — every frame row draws the boundary as a border on its own bottom edge — and a border was the only shape that put the line and the node on the same pixel. So the fix described above is now redundant twice over: a border is already replaced with `CanvasText`, and the seam names it anyway. The reasoning survives the mechanism. A line's paint decides whether it exists in forced colours, and the property you reach for is not a free choice. --- # Duplication worth keeping Source: https://teasim.com/notes/duplication-worth-keeping.md Canonical: https://teasim.com/notes/duplication-worth-keeping Markdown: https://teasim.com/notes/duplication-worth-keeping.md Language: en A long cleanup removed redundancy across the packages. Nine candidates survived on purpose. Each had been proposed by an independent review and rejected with a reason — and without the reason written down, each would be proposed again. ## When merging would change behaviour Two menu families have typeahead buffers that look identical. They are not: one treats a single character as a fresh search, the other counts it as a repeat. Merge them and the first keypress lands on a different item. Nineteen accessible-name resolutions across the packages use three spellings but only **two** behaviours: eleven pass `aria-labelledby` through a blank check, eight do not. Unifying them decides what a blank string means, which is an API choice wearing a refactor's clothing. ## When merging would hide a defect Two files call a twenty-millisecond timer `waitForRaf`. It is not a frame. Folding it into the real frame helper does not fix the misnomer — it buries it somewhere the next reader will trust it. A keyboard table looks like it is missing right-to-left handling. It is not: a capture-phase listener intercepts the arrows first when direction is RTL and stops propagation, so the table below never sees them. Substituting one for the other is behaviour-identical and costs a reader the reason two layers exist. ## When the abstraction costs more than the copies Around forty presentational wrappers are the irreducible shape of a component library under these conventions. Eight teardown blocks share two lines. An icon size ramp repeats across recipes. ## The habit The output of a cleanup is not only the diff. It is the **list of things you decided not to change, and why** — otherwise the same review runs again in six months, reaches the same conclusions, and this time nobody remembers the reason. A decision that produces no diff leaves no trace in the repository. Where it matters, pin it with a test that asserts the current shape and names the reason in its title. --- # Measure before you believe the audit Source: https://teasim.com/notes/measure-before-you-believe-the-audit.md Canonical: https://teasim.com/notes/measure-before-you-believe-the-audit Markdown: https://teasim.com/notes/measure-before-you-believe-the-audit.md Language: en Colours in this project are `oklch()` and `oklab()` with alpha, so contrast cannot be checked by parsing strings. The obvious approach is to paint them: fill a one-pixel canvas with the element's background, then the text colour, and read the pixel back. Done that way it reports badges failing at 3:1 that actually pass at 10:1. ## Why A tinted badge's own background is something like an 8% wash. Fill only that onto a transparent canvas and you get an 8% wash over nothing, which reads back as near-black. Every light-on-tint pairing then looks broken. The element's real background is the **whole ancestor stack** — the page colour, then each non-transparent ancestor from outermost inward, then the element's own tint. Composite all of it, and the numbers become real. ## What it caught once it was honest The corrected audit found one thing the eye never would: every code comment on the site at **3.92:1** in dark mode, and eleven light-mode tokens under 4.5, straight from the default syntax theme. Swapping to the high-contrast pair put the worst token at 4.91 light and 8.90 dark across 130 measured tokens. ## Two rules that came with it Skip anything under an `aria-hidden` ancestor, not just the leaf — a decorative wordmark at 9% opacity is not a contrast failure, and it will otherwise dominate the report. Measure in a separate step from the click that changed the theme. Computed styles are stale within the same task, so a same-step measurement reports the *previous* theme's colours with total confidence. ## The general point An audit that reports failures is not evidence of failures. It is evidence about the audit. The first thing to check when a tool says everything is broken is whether the tool is measuring what you think it is — and the cheapest way to find out is to run it against a case whose answer you already know. --- # Name the thing focus lands on Source: https://teasim.com/notes/name-the-thing-focus-lands-on.md Canonical: https://teasim.com/notes/name-the-thing-focus-lands-on Markdown: https://teasim.com/notes/name-the-thing-focus-lands-on.md Language: en A code block that scrolls sideways needs a tab stop, or a keyboard user cannot reach the rest of the line. Both of ours had one. Neither said what it was. ## Finding it by class, not by instance The interesting move was not fixing the code block. It was asking what *else* had the same shape. Every element in the package that renders `tabindex="0"`: | Component | Role | Named | | --- | --- | --- | | `scroll-area/root` | `region` | yes, required | | `table/container` | `region` | yes, required | | `tabs/content` | `tabpanel` | yes | | `context-menu/trigger` | `group` | yes | | `code-block/body` | none | **no** | | `carousel/content` | `group` | **no** | Two gaps, not one. The carousel was the better find: its root names itself and carries `aria-roledescription="carousel"`, each slide names itself and carries `slide`, and the focusable scroll viewport between them announced a group and nothing else. ## The worst case is the empty one For the code block, the case that turns this from polish into a real gap is the one with no title, no language, and copying switched off. The header is omitted entirely, so the `figure` has no `figcaption` and therefore no name either. The name on the body is then the only name anywhere in the subtree. ## The rule that came out of it **The outer element carries the consumer's name; the inner scroll container says what is inside it.** A code block's figure is named by its title, and the body is named "Code" or "bash code". A carousel is named "Featured products" by whoever placed it, and the viewport is named "Slides". That is why the inner name is derived rather than required. Ask the consumer for a second name and there are only two things they can write: the outer name again, which is a duplicate announcement, or the default spelled out by hand. Requiring it buys redundancy or boilerplate, never information. --- # Region or group — let the count decide Source: https://teasim.com/notes/region-or-group-let-the-count-decide.md Canonical: https://teasim.com/notes/region-or-group-let-the-count-decide Markdown: https://teasim.com/notes/region-or-group-let-the-count-decide.md Language: en Having decided a scrollable code block needs a role and a name, the obvious move was to match its siblings. `scroll-area/root` and `table/container` both use `role="region"`. So: `region`. That was wrong, and the way to find out was to count. ## What the pages actually contain ```txt /components/code 13 code blocks, 1 with a title, 2 with a language /index 4 code blocks, 0 with a title, 0 with a language ``` A `region` is a landmark. It appears in the landmark list that screen-reader users navigate by. Ten of the thirteen blocks on one page would have been landmarks named "Code" — the same name, ten times, in the list meant to help you skip around the page. That is not a smaller version of a good outcome. It is a worse outcome than having no landmark at all. ## The axis that actually matters The siblings are regions for a reason that does not generalise: a data table or a scroll panel is **one prominent thing**, deliberately placed, and its name is a required prop the consumer must supply. A code block **names itself** and a documentation page renders one per fence. Same runtime, same tab stop, opposite frequency — and frequency is the axis that picks `region` from `group`. `group` names the tab stop and stays out of the landmark list. "The other two are regions" is consistency of implementation, not consistency of situation. ## Two passes, one answer A second reviewer reached `group` independently, before seeing the count, on the required-prop-versus-self-naming argument. Two routes to the same call is a better signal than either one alone — and it is the kind of check worth setting up deliberately when a decision has no test that can fail. --- # The build that ran twice Source: https://teasim.com/notes/the-build-that-ran-twice.md Canonical: https://teasim.com/notes/the-build-that-ran-twice Markdown: https://teasim.com/notes/the-build-that-ran-twice.md Language: en The repository has one gate: `bun run verify`. It runs formatting, types, audits, a build, both examples, the tests, and a static Storybook. It took about two minutes, and roughly fifteen seconds of that was pure waste. ## Finding it The script chain read: ```txt check → typecheck → audit → build → example → test → docs:build ``` and `docs:build` was itself defined as `bun run build && storybook build`. So `build` ran, five steps happened, and then `build` ran again. ## Proving it was safe to remove The interesting question is not whether the second build is redundant but whether anything between the two writes into `dist`. Rather than reason about it, hash it: ```bash find packages/*/dist -type f -exec shasum -a 256 {} \; | sort -k2 | shasum -a 256 ``` Take that digest, run `example` and `test`, take it again. Identical. Nothing in between touches the build output, so the second build could only reproduce what was already there. ## The fix Split the Storybook step out under its own name and let the gate call that, leaving `docs:build` to keep its documented contract for anyone running it alone. Two minutes became one minute forty. ## The part worth keeping The digest is the point. "Nothing else writes there" is the kind of claim that is obviously true right up until a build step gains a side effect, and it costs one command to stop guessing. --- # The dev server that died on every build Source: https://teasim.com/notes/the-dev-server-that-died-on-every-build.md Canonical: https://teasim.com/notes/the-dev-server-that-died-on-every-build Markdown: https://teasim.com/notes/the-dev-server-that-died-on-every-build.md Language: en For weeks the workflow had a superstition in it. Rebuild a package, and the running dev server would start returning 500s — `Could not import ./CodeBlock.astro` — and stay broken until you killed it, deleted `node_modules/.vite`, and started again. ## What was actually happening The build tool cleans before it writes, and its clean is a glob: ```txt Cleaning 4311 files ``` So a rebuild **deletes every `.js`, `.d.ts`, `.astro` and `.css` in `dist`** and puts them back a second later. The dev server watches that directory through the workspace symlink. It sees four thousand deletions, tries to re-resolve a module that no longer exists, fails — and **caches the failure**. The files returning a second later change nothing, because nothing invalidates the cached failure. That is why deleting the cache was the only thing that ever worked. ## The fix Stop watching the directory that is guaranteed to churn: ```js vite: { server: { watch: { ignored: ["**/packages/*/dist/**"] } }, } ``` Verified by firing twenty-five requests at the previously-failing module *during* a rebuild. All 200. ## What did not work `resolve.preserveSymlinks: true` would have avoided the out-of-root path entirely, and it breaks the framework's own module resolution under this package manager's store layout. Changing the build's clean step was the other candidate, and it is worse than it looks: without the glob clean, a renamed output lingers in `dist` forever. ## The trade-off, stated plainly A package change now needs the dev server restarted before it appears. Before, it needed a restart *and* a cache deletion *and* it crashed first. The site's own files still hot-reload. --- # The gate has to actually gate Source: https://teasim.com/notes/the-gate-has-to-actually-gate.md Canonical: https://teasim.com/notes/the-gate-has-to-actually-gate Markdown: https://teasim.com/notes/the-gate-has-to-actually-gate.md Language: en The repository has one rule before merging: `bun run verify` must pass. It has been defeated twice, in two different ways, and neither involved the checks themselves being wrong. ## Once through a pipe ```bash bun run verify | grep -E "error|fail" ``` The exit code of a pipeline is the exit code of its **last** command. `grep` found nothing, exited 0, and the `&&` chain sailed on to the merge. A failing test had been sitting in the output the whole time. ## Once through a semicolon ```bash cat "$OUTPUT" ; git merge --no-ff branch && git push ``` Reading the result and acting on the result are not the same thing. `cat` succeeded, so the merge ran — against a branch whose verify had exited 1 in zero seconds because the formatter rejected a quote style. Both times the check worked perfectly. Both times nothing was listening. ## The shape that cannot fail this way Make the exit code the condition, in the same command: ```bash bun run verify > verify.log 2>&1 E=$? if [ $E -eq 0 ]; then git merge --no-ff -q branch && git push -q else echo "NOT MERGED"; tail -25 verify.log fi ``` ## The part that generalises A verification step you read with your eyes is not a gate; it is a report. The difference only shows up on the day the report says something you were not expecting, which is exactly the day you are moving quickly and skim it. If a check is meant to block, something other than your attention has to be doing the blocking. --- # Two packages, one line Source: https://teasim.com/notes/two-packages-one-line.md Canonical: https://teasim.com/notes/two-packages-one-line Markdown: https://teasim.com/notes/two-packages-one-line.md Language: en Most component libraries make two decisions for you. They decide how your site **looks**, by shipping a stylesheet. And they decide how your site is **built**, by rendering your content. Both feel like conveniences on day one. Both are expensive to undo in year two, when you want something the library did not anticipate and the only paths open are to fight it, fork it, or leave. ## Where the line falls `@teasim/astra` renders markup and carries behaviour with it. It ships no CSS. `@teasim/astro` computes navigation, validates content and serves the machine-readable routes. It renders nothing. Neither knows what the other looks like. A project can take either half alone — the elements without the content layer, or the content layer under a completely different set of components. ## What the split costs us Two packages is more surface than one. Two changelogs, two dependency graphs, and a boundary that has to be defended every time something would be *slightly* easier if the content layer knew about the components. There is a rule for that: the packages take external dependencies on different terms — the element package takes none at all — and where a feature needs one, the need decides which package it belongs to, not convenience. ## What it buys A stylesheet you own. An accordion that is a `details` element, so it works before the JavaScript arrives and keeps working if it never does. And an exit: because nothing here is a wrapper around a widget, replacing one family with your own is a local change, not a migration. The measure of the split is not how much it helps on day one. It is how little it costs on the day you want to leave. ## A postscript Written when there were two. The rendering half has since been cut once more, along the same kind of line: `@teasim/astra` is the elements, and `@teasim/aster` is the page-level chrome composed from them — so a project that wants a Button never installs a documentation shell. The argument is unchanged; it simply applied a second time. --- # @teasim/aster Source: https://teasim.com/packages/aster.md Canonical: https://teasim.com/packages/aster Markdown: https://teasim.com/packages/aster.md Language: en `@teasim/aster` arranges pages from Astra elements. It owns grid, gaps, content measure, and responsive composition while Astra owns element appearance and browser behavior. It provides documentation shells, headers, footers, navigation trees, pagers, and marketing sections. This guide targets **Aster 0.5.0**, **Astra 0.5.0**, and **TeaCSS 0.7.1**. ## Install After configuring GitHub Packages as shown in [Astra](https://teasim.com/packages/astra#install): ```sh bun add @teasim/aster@0.5.0 @teasim/astra@0.5.0 @teacss/classes@0.7.1 bun add -d teacss@0.7.1 @teacss/astro@0.7.1 ``` Aster requires Node.js **24.2.0 or later**, Astro `>=7.2.0`, Astra **^0.5.0**, and exact TeaCSS / Classes peers **0.7.1**. The application installs Classes directly so its recipes and both libraries share one module. Aster has no stylesheet entry of its own. Its recipes use Standard, Official, and Astra's declarations. At the project-root `index.css`: ```css @import "@teasim/astra/index.shortcuts.css"; @presets "standard,icons,official"; @source "./src/**/*.{astro,ts,tsx,js,jsx,md,mdx,css}"; @source "./node_modules/@teasim/astra/dist/**/*.{js,astro,css}"; @source "./node_modules/@teasim/aster/dist/**/*.{js,astro,css}"; @teacss; ``` Keep both packages in the SSR build: ```ts import { defineConfig } from "astro/config"; import { teacss } from "@teacss/astro"; export default defineConfig({ integrations: [teacss()], vite: { resolve: { noExternal: ["@teasim/astra", "@teasim/aster"] } }, }); ``` See [TeaCSS installation](https://teasim.com/packages/teacss/installation) for entry-relative source paths and adapter requirements. ## Import by family Aster has no public package root. Import components, public helpers, and types from the documented family subpath: ```ts import { Shell01 } from "@teasim/aster/shell-01"; import { Header01 } from "@teasim/aster/header-01"; import { Footer01 } from "@teasim/aster/footer-01"; ``` Numbered families use two digits. `Header01`, `header01Recipe`, and the `header-01/root` part belong to one family; old site-prefixed aliases and source deep imports are not supported. Use documented props and slots rather than treating one numbered design as interchangeable with another. Recipes are internal; customize rendered parts through documented `data-slot` identities, or use the package's editable source registry for local composition. Header appearance controls use `appearanceLabel` and related appearance options. The page layout installs Astra's `AppearanceScript` once, as shown in [Shared appearance](https://teasim.com/packages/astra#shared-appearance). ## The block boundary A block can render sibling parts and named slots because page composition needs regions. It composes Astra's elements instead of duplicating their surfaces, semantics, or interactive controllers. Aster itself ships no client scripts; interactive behavior comes from the Astra elements it composes. The site owns its brand, navigation data, content, and decorative frame. Neither Aster nor Astra loads content collections. Compute navigation through [Astro](https://teasim.com/packages/astro), then pass the resulting data into the relevant block. Every block remains ordinary Astro and can be replaced locally without changing the content model. ## Continue reading See [Project structure](https://teasim.com/docs/project-structure) for how the content, element, and block layers compose into a complete site. --- # @teasim/astra Source: https://teasim.com/packages/astra.md Canonical: https://teasim.com/packages/astra Markdown: https://teasim.com/packages/astra.md Language: en `@teasim/astra` provides the element layer for Astro. Each family renders semantic HTML and adds browser behavior where the platform does not provide it. An accordion uses `details`; modal content uses `dialog`. Public props, parts, recipes, and native behavior form the component contract. This guide targets **Astra 0.5.0** with **TeaCSS 0.7.1**. ## Install Configure GitHub Packages and supply a package-read credential through the environment: ```ini @teasim:registry=https://npm.pkg.github.com //npm.pkg.github.com/:_authToken=${GIT_TOKEN} ``` ```sh bun add @teasim/astra@0.5.0 @teacss/classes@0.7.1 bun add -d teacss@0.7.1 @teacss/astro@0.7.1 ``` Astra requires Node.js **24.2.0 or later**, Astro `>=7.2.0`, and exact TeaCSS / Classes peers **0.7.1**. Install Classes directly so application and package recipes share one runtime module. Keep Astro and the TeaCSS adapter's host requirements satisfied as well. ## Generate the styles Astra ships its TeaCSS declarations, not a compiled stylesheet. At the project-root `index.css`, import the declarations and scan the distributed components: ```css @import "@teasim/astra/index.shortcuts.css"; @presets "standard,icons,official"; @source "./src/**/*.{astro,ts,tsx,js,jsx,md,mdx,css}"; @source "./node_modules/@teasim/astra/dist/**/*.{js,astro,css}"; @teacss; ``` The CSS import registers shared shortcuts and safelists. Scanning finds the classes actually used by recipes and Astro markup; both steps are required. Keep the package in Astro's SSR build: ```ts import { defineConfig } from "astro/config"; import { teacss } from "@teacss/astro"; export default defineConfig({ integrations: [teacss()], vite: { resolve: { noExternal: ["@teasim/astra"] } }, }); ``` [Installation](https://teasim.com/packages/teacss/installation) explains entry-relative globs and adding Aster's sources. Components use TeaCSS semantic roles; select palettes on `` as described in [Palette](https://teasim.com/packages/teacss/concepts/palette). ## Public imports and styling ```ts import { Accordion, AccordionItem, AccordionSummary, AccordionContent } from "@teasim/astra"; import { accordionRecipe } from "@teasim/astra/recipes"; import { cn, recipe } from "@teacss/classes"; ``` Components, public family helpers, and types come from the package root. Component subpaths and source deep imports are private. Recipes come from `@teasim/astra/recipes`; prop-authoring helpers have their own `@teasim/astra/helpers` entry. `@teasim/astra/runtime` exposes curated client utilities, not family installers, and importing it installs no listeners. Each documented part owns its namespaced `data-slot`; callers must not replace it. Components take `class`, while recipe resolvers take `className` and merge caller classes last. Define new application recipes with `defaults`, as shown in [Classes](https://teasim.com/packages/teacss/concepts/classes). Native and ARIA forwarding is part-specific: a component's owned attributes cannot be overridden. ## Shared appearance Place one initializer in `` before styles. It resolves the stored or system preference before the first paint: ```astro --- import { AppearanceScript, AppearanceSwitch } from "@teasim/astra"; --- ``` `AppearanceScript` owns the resolved `` and CSS `color-scheme`. Its default storage key is `teasim-appearance`; `defaultAppearance` accepts `system`, `light`, or `dark`. The switcher changes light/dark preference, while palette selection remains the application's responsibility. Use one appearance manager instead of writing a competing initializer. The document emits `teasim:appearance-change` when the state changes. Style against `@dark` / `@light` or `data-css-appearance`, never a separate `data-theme` attribute or implicit `.dark` class. ## Continue reading The [Accordion](https://teasim.com/packages/astra/accordion) guide covers native disclosure, controlled events, and keyboard behavior. [Code](https://teasim.com/packages/astra/code) covers syntax highlighting, annotations, scrolling, and code copying. Page composition belongs to [Aster](https://teasim.com/packages/aster); source validation and navigation data belong to [Astro](https://teasim.com/packages/astro). --- # Accordion Source: https://teasim.com/packages/astra/accordion.md Canonical: https://teasim.com/packages/astra/accordion Markdown: https://teasim.com/packages/astra/accordion.md Language: en ## Installation Configure the CSS entry, source scan, and Astro SSR settings from the [Astra setup](https://teasim.com/packages/astra#generate-the-styles). This component uses the application's TeaCSS `0.7.1` build. The open state is `
` and ``, so a row opens before any JavaScript runs and keeps working if none ever does. A delegated runtime binds on top to add what the element has no answer for — arrow-key roving focus, `multiple`, `defaultValue`, and an `accordion:change` event. There is no client directive to write; it binds itself and survives a view transition. ## Preview

Does this need JavaScript?

No. The disclosure is the browser's, and so is the exclusive-group behaviour when items share a `name`.

Where do the styles come from?

Your application's TeaCSS build. Import Astra's CSS declarations and scan the package; the build generates the stylesheet.

Can I restyle it?

Every part takes `class`, and caller classes merge last. ```astro --- import { Accordion, AccordionContent, AccordionItem, AccordionSummary } from "@teasim/astra"; ---

Does this need JavaScript?

No. The disclosure is the browser's.

Where do the styles come from?

Your application's TeaCSS build.
``` ## Examples ### One at a time Give sibling Items the same non-empty `name` and the browser closes the open one when another opens. The group name is **document-wide**, so a page with two independent accordions needs two names.

Green

Two minutes, water just off the boil.

Black

Three minutes, water at a rolling boil. Open at most one member of a named group initially — two Items sharing a name and both carrying `open` is a state the browser has to resolve, and it closes one of them for you. ### Several at once Dropping `name` is not enough. `multiple` defaults to `false`, and on bind the runtime keeps the first open Item and closes the rest — so two Items that both carry `open` render as one open row. Pass `multiple` on the **root** to keep them all.

Application-generated CSS

The application's TeaCSS build generates every rule.

Native disclosure first

For this family the open state is native; the runtime only adds focus and events. ```astro … … ``` The two mechanisms are independent and both apply. `name` is the browser's, and it works with scripting off; `multiple` is the runtime's, and it wins on bind. Setting `multiple` while siblings share a `name` gets you the browser's exclusivity anyway — use one or the other, not both. ## API reference Four components, each rendering one native element. | Component | Renders | Own props | | --- | --- | --- | | `Accordion` | `
` | `multiple`, `defaultValue`, `collapsible`, `orientation`, `loopFocus` | | `AccordionItem` | `
` | `value` — plus native `open` and `name` | | `AccordionSummary` | `` | none | | `AccordionContent` | `
` | none | ### Accordion | Prop | Type | Default | Notes | | --- | --- | --- | --- | | `multiple` | `boolean` | `false` | Several Items open at once | | `defaultValue` | `string \| string[]` | — | Which `value` starts open; an array joins with commas | | `collapsible` | `boolean` | `true` | The last open Item may close | | `orientation` | `"horizontal" \| "vertical"` | `"vertical"` | Which arrow keys move focus | | `loopFocus` | `boolean` | `true` | Focus wraps at either end | ### AccordionItem | Prop | Type | Default | Notes | | --- | --- | --- | --- | | `value` | `string` | — | Identifies the Item to `defaultValue` | | `open` | `boolean` | `false` | Native `
` attribute | | `name` | `string` | — | Native group name; siblings sharing one are mutually exclusive | `AccordionSummary` must be the **first direct element child** of its Item — that is a `
` requirement, not this component's. `AccordionContent` renders a plain `
` and does not force a region landmark. `AccordionItem` and `AccordionSummary` reserve `aria-controls`, `aria-disabled`, `aria-expanded`, and `role`; Summary reserves `tabindex` as well. Passing those attributes is ignored rather than merged. Root and Content otherwise forward their native `
` attributes. Every part reserves `data-slot` and class composition hooks, so the component retains its own identity. ### How the props reach the runtime `Accordion` serializes the root options into its owned `data-*` attributes: `data-multiple`, `data-default-value`, `data-collapsible`, `data-orientation`, and `data-loop-focus`. Some default values are omitted. The runtime reads the resulting DOM on bind; applications should pass the public props instead of writing those private attributes themselves. Family installers are **not public API**. Import all four components from `@teasim/astra`; the component's own `