# Installation > Install @9klabs/design, import its one stylesheet, load the brand fonts, and render your first component. ## Install the package `@9klabs/design` is a Vue 3 library. It needs Vue 3.5 or later as a peer dependency. ```bash npm install @9klabs/design ``` ## Import the stylesheet once Import the stylesheet at your application entry, before anything else renders: ```ts // main.ts import '@9klabs/design/style.css'; ``` It is the only CSS the library needs. It carries the design tokens, the light and dark themes, branded element defaults and accessibility utilities. Every component brings its own scoped styles, so nothing else has to be imported per component. The stylesheet declares its cascade layers up front, in this order: ```css @layer normalize, fonts, tokens, theme, base, primitives, utilities; ``` Your own CSS, which is not in a layer, always wins over all of them. Override a token or restyle an element without fighting specificity. ## Load the brand fonts The design uses IBM Plex Sans and IBM Plex Serif for English, and Thmanyah Sans and Thmanyah Serif Display for Arabic. The main stylesheet names those families but does not bundle the font files, because bundling them would add more than a megabyte to every app. Choose one of the following: - **Bundled copies.** Import them next to the stylesheet: ```ts import '@9klabs/design/style.css'; import '@9klabs/design/font-faces.css'; ``` - **Your own loader.** Provide the families yourself, for example with `@nuxt/fonts`, which resolves them from the `--font-sans`, `--font-serif`, `--font-arabic-sans` and `--font-arabic-display` tokens. ## Use a component Import components by name from the package root: ```vue ``` That renders: ```vue Save changes Cancel ``` Types are exported from the same entry point, for example `I9kComponentSize`, `I9kTabItem` and `I9kBreadcrumbItem`: ```ts import type { I9kTabItem } from '@9klabs/design'; ``` ## Next steps - [Vue Router and Nuxt](https://design.the9klabs.com/getting-started/frameworks.md): render links through your router and keep server rendering safe. - [Theming and dark mode](https://design.the9klabs.com/foundations/theming.md): switch between light and dark. - [Components](https://design.the9klabs.com/components.md): browse all of them. # Vue Router and Nuxt > Route links through Vue Router or Nuxt, install the toaster per app, and keep server rendering safe. ## Links through your router Components that render navigation take a `linkComponent` prop. Without it, a link is a plain `` and following it reloads the page. With it, the component renders yours instead and passes the destination as `to`. These components take `linkComponent`: | Component | Renders links for | | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | [`I9kButton`](https://design.the9klabs.com/components/button.md), [`I9kIconButton`](https://design.the9klabs.com/components/icon-button.md) | `to` | | [`I9kNavigation`](https://design.the9klabs.com/components/navigation.md), [`I9kNavMenu`](https://design.the9klabs.com/components/nav-menu.md) | the site links | | [`I9kSideNav`](https://design.the9klabs.com/components/side-nav.md) | the section links | | [`I9kBreadcrumb`](https://design.the9klabs.com/components/breadcrumb.md) | every item but the last | | [`I9kFooter`](https://design.the9klabs.com/components/footer.md) | site-relative links in its columns | | [`I9kAnnouncementBar`](https://design.the9klabs.com/components/announcement-bar.md), [`I9kProfileMenu`](https://design.the9klabs.com/components/profile-menu.md), [`I9kLanguageSwitcher`](https://design.the9klabs.com/components/language-switcher.md) | their links | Pass the imported component, not its name, so it resolves wherever the component renders: ```vue ``` `I9kButton` and `I9kIconButton` also accept the globally registered name, which is the shortest way to write a one-off link: ```vue See pricing ``` In Nuxt, pass `NuxtLink` the same way. It is auto-imported in templates, so `:link-component="NuxtLink"` needs no import in a page or component. ::: tip Decide the current page in the caller `I9kSideNav` and `I9kBreadcrumb` never read the router. Set `current` on a side-nav link, or put the current page last in the breadcrumb, from your route. That way a nested page, such as an editor under Courses, can still mark its section. ::: ## Notifications, once per app Notifications go through one store per app. Install it where you create the app, and render one `` at the root, before the page content: ```ts // main.ts import { createI9kToaster } from '@9klabs/design'; app.use(createI9kToaster({ labels: { region: 'Notifications', dismiss: 'Dismiss' } })); ``` ```ts // plugins/toaster.ts in Nuxt import { createI9kToaster } from '@9klabs/design'; export default defineNuxtPlugin((nuxtApp) => { nuxtApp.vueApp.use(createI9kToaster()); }); ``` The store is created per app, never as a module singleton, so two server-rendered requests never share notifications. [Notifications and dialogs](https://design.the9klabs.com/guides/notifications-and-dialogs.md) covers the rest. ## Server rendering Every component renders on the server. Anything that needs the browser waits until the component is mounted: `matchMedia`, timers, the dialog API, and Nino's maze seed when none is given. In practice: - **No hydration surprises from viewport checks.** `I9kSidebarLayout` renders its wide layout on the server and switches to the drawer once mounted on a narrow screen. `I9kNavigation` lays out with CSS alone. - **Teleports go to ``.** `I9kNavMenu` teleports its panel there, which Nuxt and VitePress place correctly. A hand-rolled server renderer must put body teleports at the very start of ``. - **The theme class is yours.** The components follow the `dark` or `light` class on ``. Set it before the first paint so a dark page never flashes light. See [Theming and dark mode](https://design.the9klabs.com/foundations/theming.md). # Working with AI agents > Point Claude or any coding agent at these docs. Every page is Markdown at its URL plus .md, llms.txt indexes them, and each component has a self-contained prompt. ## Every page is Markdown Add `.md` to any page's URL to get the same content as Markdown, with nothing to scrape: | Page | Markdown | | ---------------------------------------------- | ------------------------------------------------------------------------------------------ | | [`/components/button`](https://design.the9klabs.com/components/button.md) | /components/button.md | | [`/foundations/theming`](https://design.the9klabs.com/foundations/theming.md) | /foundations/theming.md | A component's Markdown holds everything its page shows: the import line, the code of every example, the gotchas, the full props, emits and slots tables, the types they reference, and the agent prompt. Each HTML page also points to its twin with ``. The **Copy page as Markdown** button at the top of each page puts that text on your clipboard, ready to paste into a chat. ## The index and the full text | URL | What it holds | | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | /llms.txt | The index, in the [llms.txt](https://llmstxt.org) format: the rules every component follows, then a link and a one-line summary for every page's Markdown. Start here. | | /llms-full.txt | Every page's Markdown in one file, for agents that would rather read everything at once. | | /components.json | Every component's API as JSON: props with resolved types and defaults, emits with payloads, slots, gotchas and prompts. | ## Prompts Two kinds of prompt come with the docs: - **Using a component.** The end of every component page has an agent prompt. It names the import, every prop with its type and default, the events, the slots, the behavior that surprises people, and a usage example. Paste it when you ask an agent to build with that component. - **Building a new component.** [Building a component](https://design.the9klabs.com/guides/building-a-component.md) ends with a prompt that teaches an agent this repository's conventions: scoped styles, the size scale, logical properties, the registry entry and the tests. ## Set up Claude Code in a consuming app To have Claude Code use the design system in an app, add a short note to the app's `CLAUDE.md` (or `AGENTS.md`), so every session knows where to look: ```md ## UI components Build UI with @9klabs/design. Before using a component, read its Markdown page, for example https://design.the9klabs.com/components/button.md. The index of every page is https://design.the9klabs.com/llms.txt. Follow the rules listed there. ``` With that note, a request like "add a sign-in form" goes from the index to the [`I9kField`](https://design.the9klabs.com/components/field.md), [`I9kInput`](https://design.the9klabs.com/components/input.md) and [`I9kButton`](https://design.the9klabs.com/components/button.md) pages, and from there to code that uses their real props. ## Rules every component follows The constraints a props table cannot express are on one page: [Rules for agents](https://design.the9klabs.com/guides/rules-for-agents.md). They are also at the top of `llms.txt`. # Design tokens > The colors, spacing, radii, control sizes and page measures every component is built from, read straight from tokens.css. ## How to use them Read tokens with `var()` in your own styles, and prefer the role over the raw value. A surface should use `--surface-color`, not `--white-color`, so it turns dark with the theme: ```css .my-card { padding: var(--spacing-8); border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--surface-color); color: var(--theme-text-color); } ``` To change a value for the whole app, redefine it after the stylesheet. Your CSS sits outside the library's cascade layers, so it wins: ```css :root { --radius-md: 0.5rem; } ``` ## Color roles Green marks primary actions and selection. Orange is a brand accent for details such as link underlines and featured borders. Errors, warnings and successes each have their own foreground and background, tuned per theme. | Role | Tokens | Use for | | -------------- | --------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | | Canvas | `--theme-bg-color` | The page background | | Surface | `--surface-color` | Panels, cards and fields | | Raised surface | `--surface-raised-color` | Featured panels and banners, with `--shadow-sm` | | Interaction | `--surface-hover-color`, `--surface-sunken-color` | Neutral hover and pressed states | | Selection | `--selected-bg-color`, `--primary-text-color` | Selected filters, page numbers, radio cards | | Feedback | `--error-color`, `--error-bg-color`, `--success-color`, `--success-bg-color`, `--warning-color`, `--warning-bg-color` | Validation and status messages | | Boundaries | `--control-border-color`, `--focus-color`, `--border-color` | Control borders, keyboard focus, and subtle dividers | Glass tokens (`--glass-bg`, `--glass-blur`) are for overlays such as the navigation bar. Content surfaces stay opaque, so their contrast holds over images and decorative backgrounds. ## Every token ### Brand colors | Token | HSL | Hex | | --- | --- | --- | | `--primary-color` | hsl(143 64% 24%) | #166434 | | `--primary-color-alpha-12` | hsl(143 64% 24% / 0.12) | #1664341F | | `--on-primary-color` | hsl(0 0% 100%) | #FFFFFF | | `--accent-color` | hsl(23 98% 46%) | #E85A02 | | `--on-accent-color` | hsl(0 0% 0%) | #000000 | | `--dark-color` | hsl(214 45% 7%) | #0A111A | | `--dark-color-alpha-05` | hsl(214 45% 7% / 0.05) | #0A111A0D | | `--dark-color-alpha-10` | hsl(214 45% 7% / 0.1) | #0A111A1A | | `--dark-color-alpha-20` | hsl(214 45% 7% / 0.2) | #0A111A33 | | `--dark-color-lighten-30` | hsl(214 45% 30%) | #2A486F | | `--gray-color` | hsl(0 0% 80%) | #CCCCCC | | `--dark-gray-color` | hsl(0 0% 46%) | #757575 | | `--white-color` | hsl(0 0% 100%) | #FFFFFF | | `--white-color-alpha-05` | hsl(0 0% 100% / 0.05) | #FFFFFF0D | | `--white-color-alpha-15` | hsl(0 0% 100% / 0.15) | #FFFFFF26 | | `--white-color-alpha-20` | hsl(0 0% 100% / 0.2) | #FFFFFF33 | | `--white-color-darken-30` | hsl(0 0% 70%) | #B3B3B3 | ### Theme colors | Token | Light | Dark | | --- | --- | --- | | `--theme-bg-color` | hsl(0 0% 98%) (#FAFAFA) | hsl(0 0% 6%) (#0F0F0F) | | `--theme-text-color` | hsl(0 0% 10%) (#1A1A1A) | hsl(0 0% 96%) (#F5F5F5) | | `--text-color` | hsl(0 0% 10%) (#1A1A1A) | hsl(0 0% 96%) (#F5F5F5) | | `--primary-text-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 44%) (#2FB161) | | `--text-color-light` | hsl(0 0% 40%) (#666666) | hsl(0 0% 60%) (#999999) | | `--code-bg` | hsl(0 0% 90%) (#E6E6E6) | hsl(0 0% 10%) (#1A1A1A) | | `--border-color` | hsl(0 0% 84%) (#D6D6D6) | hsl(0 0% 25%) (#404040) | | `--surface-color` | hsl(0 0% 100%) (#FFFFFF) | hsl(0 0% 10%) (#1A1A1A) | | `--surface-raised-color` | hsl(0 0% 100%) (#FFFFFF) | hsl(0 0% 14%) (#242424) | | `--surface-sunken-color` | hsl(0 0% 96%) (#F5F5F5) | hsl(0 0% 8%) (#141414) | | `--surface-hover-color` | hsl(0 0% 94%) (#F0F0F0) | hsl(0 0% 18%) (#2E2E2E) | | `--control-border-color` | hsl(0 0% 46%) (#757575) | hsl(0 0% 55%) (#8C8C8C) | | `--focus-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 60%) (#5ED48B) | | `--primary-hover-color` | hsl(143 64% 20%) (#12542B) | hsl(143 64% 28%) (#1A753D) | | `--primary-pressed-color` | hsl(143 64% 16%) (#0F4323) | hsl(143 64% 20%) (#12542B) | | `--selected-bg-color` | hsl(143 35% 94%) (#EAF5EE) | hsl(143 24% 16%) (#1F3327) | | `--selected-hover-bg-color` | hsl(143 35% 90%) (#DDEEE3) | hsl(143 24% 13%) (#19291F) | | `--selected-pressed-bg-color` | hsl(143 35% 86%) (#CFE8D8) | hsl(143 24% 10%) (#132018) | | `--success-color` | hsl(143 64% 24%) (#166434) | hsl(143 58% 65%) (#72DA9A) | | `--success-bg-color` | hsl(143 35% 94%) (#EAF5EE) | hsl(143 24% 13%) (#19291F) | | `--error-color` | hsl(0 65% 38%) (#A02222) | hsl(0 80% 76%) (#F39191) | | `--error-bg-color` | hsl(0 70% 97%) (#FDF2F2) | hsl(0 30% 13%) (#2B1717) | | `--warning-color` | hsl(35 90% 28%) (#885207) | hsl(40 85% 70%) (#F4C871) | | `--warning-bg-color` | hsl(42 90% 94%) (#FDF5E2) | hsl(35 35% 13%) (#2D2316) | | `--glass-bg` | hsl(0 0% 100% / 0.2) (#FFFFFF33) | hsl(0 0% 0% / 0.2) (#00000033) | | `--glass-shadow` | hsl(0 0% 0% / 0.1) (#0000001A) | hsl(0 0% 0% / 0.1) (#0000001A) | # Sizes > One sm, md and lg scale for every control, so buttons, inputs and badges of the same size line up. ## The scale ```vue Small Medium Large ``` Each size sets three things, from these tokens: | Size | Height | Font size | Gap | | ---- | ------------------------------ | ----------------------------------- | -------------------- | | `sm` | `--control-height-sm` (2rem) | `--control-font-size-sm` (0.875rem) | `--component-gap-sm` | | `md` | `--control-height-md` (2.5rem) | `--control-font-size-md` (1rem) | `--component-gap-md` | | `lg` | `--control-height-lg` (3rem) | `--control-font-size-lg` (1.125rem) | `--component-gap-lg` | ## Mixing controls Because heights come from the same tokens, controls of one size sit on one line: ```vue Search Beta ``` ::: warning I9kInput uses `uiSize` `I9kInput` names its scale `uiSize` (`ui-size` in templates), not `size`. The native HTML `size` attribute, a width in characters, stays available on the input. ::: ## Inside a field `I9kField` passes its size down to the control it wraps, so the label, hint and control match: ```vue ``` ## Typing a size The scale is one type, `I9kComponentSize`, exported from the package. Use it for your own props rather than writing the union again: ```ts import type { I9kComponentSize } from '@9klabs/design'; defineProps<{ size?: I9kComponentSize }>(); ``` The package also exports `I9kTone` (`neutral`, `primary`, `success`, `warning`, `danger`) for components that will take a status color. No component reads it yet. # Theming and dark mode > Switch between light and dark with one class on the html element, remember the reader's choice, and make your own components follow. ## Set the class ```html ``` With no class, the page is light. The stylesheet also sets `color-scheme`, so native scrollbars and form controls match. ## Let the reader choose [`I9kThemeSwitcher`](https://design.the9klabs.com/components/theme-switcher.md) is the control. It holds no state and never touches the document itself: it shows the value you give it and emits the flipped one. Apply the class and remember the choice yourself: ```vue ``` ```vue ``` ## Avoid a flash of the wrong theme The server cannot know the reader's choice. Read it in a small inline script in ``, so the class is set before the first paint: ```html ``` Then start the switch at `false` on the server and read the real state once mounted, as above, so hydration matches. ## Make your own components follow Use the theme tokens rather than fixed colors, and your component turns dark with everything else: ```css .my-panel { background: var(--surface-color); color: var(--theme-text-color); border: 1px solid var(--border-color); } ``` When a value has no token, reach the dark theme from scoped CSS with `:global`: ```css .my-panel__glow { opacity: 0.4; } :global(.dark) .my-panel__glow { opacity: 0.2; } ``` The [tokens page](https://design.the9klabs.com/foundations/tokens.md#every-token) lists each theme token with its light and dark value. ## Inside the green section [`I9kSection`](https://design.the9klabs.com/components/section.md) with `variant="primary"` fills itself with the brand green and re-maps the theme tokens for its contents: `--primary-color` becomes white, `--on-primary-color` green, and so on. Components inside it stay legible without props of their own. ```vue Applications close on Friday. Apply now ``` ::: warning What to keep out of the green Status colors (error, warning, success) are not re-mapped and disappear on the green. An `I9kModal` placed inside the section inherits the re-map and turns translucent. Render both outside it. ::: # Arabic and right-to-left > Mark Arabic content with lang and dir. The components mirror on their own and take the Arabic typefaces from the language. ## Mark the language and the direction Set both on the element that holds the content, usually ``: ```html ``` They do different jobs, so set both: - **`lang="ar"`** selects the typography. `:lang(ar)` switches body text to Thmanyah Sans and headings to Thmanyah Serif Display. - **`dir="rtl"`** mirrors the layout. Components are built with logical properties (`margin-inline-start`, `inset-inline-end`), so start and end follow the reading direction without a prop. ```vue
ابدأ الآن اعرف المزيد جديد
``` Every demo on this site can be flipped to right-to-left with the **文A** button in the top bar, so you can check any component in both directions. ## Mixed content A page in one language often quotes the other. Mark the quoted part on its own element: ```html

The course is called الذكاء الاصطناعي.

``` Some components take the language of a part of their content as a prop, because they render that part themselves. A toast's `detail`, for example, accepts `detailLang` and `detailDir` for a server message in Arabic on an English page. ## When the language is unknown If the markup cannot carry `lang`, for example text from a CMS without language metadata, apply the typography by class instead: | Class | Applies | | --------------------- | ------------------------------------------------------------------- | | `.i9k-arabic` | Thmanyah Sans, the Arabic body family | | `.i9k-arabic-display` | Thmanyah Serif Display, the Arabic heading family | | `.i9k-root` | IBM Plex Sans, to set the English family on an app shell explicitly | ## Directions that do not mirror Some things keep their direction in both languages: code stays left to right, inside an Arabic page as on this site. Where a component offers a direction-aware option, it is named logically rather than left and right. `I9kNino`'s `look` takes `start` and `end`, so he glances toward the same side of a form in both languages, and `I9kGlow`'s `position` takes `top-start` and `top-end`. ## Checking your work Any visual change needs checking in both directions, as well as in light and dark. A layout that uses `left`, `right`, `margin-left` or `text-align: left` will not mirror. Use the logical equivalents (`inset-inline-start`, `margin-inline-start`, `text-align: start`). # Page layout > One page column shared by the navigation, every section and the footer, plus the grid, cluster and panel that arrange content inside it. ## The page column [`I9kContainer`](https://design.the9klabs.com/components/container.md) is the column. It sets only a width and centres it, so padding, backgrounds and minimum heights stay yours. | Size | Token | Width | Use for | | ---- | ---------------------- | ----- | ------------------------------- | | `sm` | `--container-width-sm` | 42rem | A reading column for long text | | `md` | `--container-width-md` | 75rem | The site column, the default | | `lg` | `--container-width-lg` | 90rem | A wide, desktop-first work area | On a viewport narrower than the column, it keeps a gutter on each side: `--container-gutter` (2rem), or `--container-gutter-sm` (1rem) at 768px and below. `I9kNavigation` and `I9kFooter` render their content in a container of their own, so anything you put in one lines up with the brand in the bar and the columns in the footer. ```vue
…
``` ### Three rules 1. **A full-bleed background goes around the container, not on it.** The `
` carries the fill, and the container inside holds the content. 2. **Never nest one container in another.** The inner one would take the gutter off a second time, so a phone loses twice the margin. A narrower block inside a column is a plain element with `max-width` and `margin-inline: auto`. 3. **Widen an area, not every container.** For an admin area that needs the wide column throughout, chrome included, redefine `--container-width-md: var(--container-width-lg)` on the area's root instead of passing `size="lg"` to each container. ## The page wrapper [`I9kPageContainer`](https://design.the9klabs.com/components/page-container.md) is the page-level wrapper: a flex column with a minimum height, deriving its width from the same token. Its `size` is the gutter, so its content lines up with the chrome for `md`, and for `sm` and `lg` once the viewport is wide enough. Anything that must share the bar's edge on every viewport belongs in an `I9kContainer`. ## Arranging content Layout components style only their own layout. They never change their children. [`I9kGrid`](https://design.the9klabs.com/components/grid.md) lays out equal columns, collapsing on narrow screens: ```vue One Two Three ``` [`I9kCluster`](https://design.the9klabs.com/components/cluster.md) spaces a row of items that wraps, such as actions or tags: ```vue Vue TypeScript Accessibility ``` [`I9kPanel`](https://design.the9klabs.com/components/panel.md) is the bordered surface for grouped content, and [`I9kSection`](https://design.the9klabs.com/components/section.md) gives a block of the page its heading, actions and, when asked, a collapsible body. ## Semantic roots Surface and layout components accept `as` when the element needs stronger meaning, for example an `I9kPanel` that is an `
`, or an `I9kContainer` that is the page's `
`: ```vue … … ``` ## A frame of its own For a page that is its own frame, such as a course player or this documentation, use [`I9kSidebarLayout`](https://design.the9klabs.com/components/sidebar-layout.md). It renders the bar, a sticky sidebar that becomes a drawer on narrow screens, and the `
` landmark itself, so do not wrap it in another `
`. # Notifications and dialogs > Raise toasts from one per-app toaster, interrupt with a modal, and let the two work together without lost or repeated announcements. ## Pick the right component | You want to… | Use | | ------------------------------------------------------------------- | ---------------------------------------------------- | | Report the result of an action: saved, sent, failed | A toast, through [`I9kToaster`](https://design.the9klabs.com/components/toaster.md) | | Show standing status in the page, such as "This course is archived" | [`I9kToast`](https://design.the9klabs.com/components/toast.md), in the page flow | | Interrupt for a focused task, such as an edit form | [`I9kModal`](https://design.the9klabs.com/components/modal.md) | | Explain a control on hover or focus | [`I9kTooltip`](https://design.the9klabs.com/components/tooltip.md) | | Announce something site-wide above the navigation | [`I9kAnnouncementBar`](https://design.the9klabs.com/components/announcement-bar.md) | Never show the same message both ways. A toast replaces an inline banner for an action's result. ## Set up the toaster Install one store per app and render one toaster at the root, **before** the page content in the tree. A toaster never announces a toast raised before its own setup ran, so one rendered below the page would show, but not announce, a toast the page raised while setting up. ```ts // main.ts import { createI9kToaster } from '@9klabs/design'; app.use(createI9kToaster({ labels: { region: 'Notifications', dismiss: 'Dismiss' } })); ``` ```vue ``` Translate through the store, not through props. Its `labels` object is reactive; assign its fields when the locale changes: ```ts const toaster = useI9kToaster(); Object.assign(toaster.labels, { region: t('notifications.region'), dismiss: t('dismiss') }); ``` ## Raise a toast **From state you already have.** `useI9kToastSource` follows a getter: the toast shows while it returns options, updates when they change, and goes away when it returns `null` or the component unmounts. ```ts const error = ref(null); useI9kToastSource(() => error.value ? { id: 'login', variant: 'error', message: error.value } : null, ); async function signIn() { error.value = null; // clear first, so the same failure twice shows twice try { await login(); } catch (failure) { error.value = 'Those details did not match.'; } } ``` **Imperatively.** `useI9kToaster()` returns the store: ```ts const toaster = useI9kToaster(); const id = toaster.show({ variant: 'success', message: 'Changes saved.' }); toaster.dismiss(id); ``` A toast is plain text. `info` and `success` dismiss themselves after five seconds. `warning` and `error` stay until dismissed, since a reader may need time to act on them. Showing a toast with an `id` already on screen replaces it rather than stacking a duplicate. ## Open a modal The parent owns `open`. The close button and Escape only ask to close, by emitting `update:open(false)`. The modal closes when you set `open` to `false`, and focus returns to whatever opened it. ```vue ``` ::: tip Three behaviors to rely on - Clicking the backdrop never closes a modal, so a stray click cannot lose an unsaved form. - The body and footer render only while open, so a form inside starts fresh every time. Put `autofocus` on its first field. - Set `dismissible` to `false` while a save is in flight, so it cannot be closed halfway. ::: ## Toasts and modals together An open `I9kModal` renders a toaster of its own inside the dialog, one layer above the page's. Only the deepest one shows toasts, so a toast raised while the modal is open is readable, announced and dismissable above the inert page. `I9kSidebarLayout`'s drawer does the same on narrow screens. There is nothing to wire up. Raise the result of a modal's task in the same tick you close it, in either order: ```ts async function save() { saving.value = true; await updateModule(title.value); saving.value = false; open.value = false; toaster.show({ variant: 'success', message: 'Module saved.' }); } ``` The page's toaster shows that toast and announces it once the dialog has closed. A toast the modal already announced is never announced again by the page. ::: warning Keep modals out of the green section An `I9kModal` placed inside an `I9kSection variant="primary"` inherits its re-mapped colors and turns translucent. Render the modal outside the section. ::: # Building a component > The conventions a new component in @9klabs/design follows, from scoped styles and the size scale to its registry entry and tests, with a prompt that teaches them to an agent. ## The files a component touches | File | What goes in it | | ------------------------------- | ----------------------------------------------------------------------------------------- | | `src/components/I9kThing.vue` | The component, with `