The Chucao design system (colors, typography, spacing, layout — see
DESIGN.md and chucao-tokens.json)
is packaged as framework-agnostic Web Components built with
StencilJS and published to npm as
@devschile/chucao.
Stencil components are standard Web Components, so they work in React, Vue,
Angular, Svelte, or without any framework. See
using-the-library.md for how to consume them.
pnpm install
pnpm startpnpm start runs stencil build --dev --watch --serve.
To build the components for production:
pnpm run buildpnpm run build runs the Stencil build plus the React and Vue wrapper builds
(build:react and build:vue).
To run the component tests (including coverage):
pnpm test
pnpm run test:coverageSee testing.md for details on the testing setup and how the
coverage report works.
Need help with Stencil itself? Check out the docs.
src/
├── components/ Stencil components (one folder per component)
├── global/ Global stylesheet (utility classes & CSS variables), bundled into every build
├── tokens/ Design tokens generated by toki — do not edit by hand
└── index.ts Public entry point (re-exports tokens)
The files under src/tokens (tokens.ts, tokens.css,
tokens.d.ts, types.ts) are generated from
chucao-tokens.json by
toki — do not edit them by hand.
src/global/chucao.css (the file bundled into every
build via globalStyle) imports tokens.css directly, so there's a single
source of truth for the generated :root custom properties. See
src/tokens/README.md for their naming conventions,
CSS custom properties and TypeScript usage.
The token schema covers color (background, surface, border, accent, status,
text, brand), typography (families, size, weight, tracking), a spacing
scale (2xs–6xl plus shift/elevate), radius (sm/md/pill),
border (width, accent bar), shadow, duration, easing, focus,
opacity, z-index, effect (blur) and layout. Component stylesheets
reference these via var(--token-name) — no hardcoded colors or sizes. The
DESIGN.md-style short aliases (--bg, --accent, --radius, …) live in
chucao.css and map onto the generated tokens.
To change tokens, edit chucao-tokens.json and run pnpm run tokens. The
script regenerates the stencil output with toki build (reading
toki.config.ts), copies the generated files into src/tokens/, and formats
them with Prettier. Run pnpm run tokens:check (→ toki validate) to lint the
token file.
The component gallery on the docs site is generated, not hand-maintained.
pnpm run generate:gallery reads the demo specs in
scripts/gallery-data.mjs (which must cover
every component folder under src/components/) and rewrites two files:
docs-site/index.html— the.compdemo blocks inside<section id="componentes">, between the<!-- GALLERY:START -->/<!-- GALLERY:END -->markers.docs-site/assets/js/app.js— onecustomElements.whenDefined(...)block per component, containing the event-logbind(...)wiring and the seeding derived from each entry'sbindings/init. The gate is deliberately per-component:whenDefinednever resolves for an element the loaded library does not define, so a single shared gate would let one unpublished component silently stop every other demo from being seeded.
The GitHub Pages workflow runs pnpm run generate:gallery before assembling the
publish directory, and check-pr runs it too and then fails if the result
differs from what is committed — running the generators without checking their
output let stale generated files sit in main unnoticed. Regenerate and commit
before pushing:
pnpm build && pnpm run generate:galleryWhen adding a
component, add a matching entry to scripts/gallery-data.mjs; the generator
fails if a component folder lacks a gallery entry (or vice versa).
Adding or changing a component means releasing it. The gallery ships to the
live site on the next main push (.github/workflows/docs.yml), but the site
loads the library from the CDN chucao/latest/ prefix, which only updates on a
release (pnpm run release). The docs publish is gated on that:
pnpm run check:released (run by docs.yml) diffs the local build's component
manifest — tags and props — against the released CDN bundle, and fails the
deploy if the gallery references a component, prop or attribute the release
does not carry yet. This applies to a new component and to an existing one
whose API or behavior changes. Merge a component change and cut its release
together.
The sidebar's component list is a second generated region, delimited by
<!-- SIDEBAR:START --> / <!-- SIDEBAR:END -->, emitted from the same
gallery array in the same pass as the .comp blocks. A component therefore
cannot appear in the gallery without appearing in the navigation.
Each .comp block carries id="<tag>" so components are linkable
(#ch-select) and tabindex="-1" so following such a link moves focus to the
block rather than only scrolling. The generator fails the build if a tag is
not usable as a URL fragment, alongside the existing coverage checks.
The sidebar is emitted as real markup rather than assembled at runtime, so navigation still works with JavaScript disabled. The four section links (Colores, Tipografía, Marca, Documentación) are hand-written — they do not grow with the component count, so generating them would be pointless.
docs-site/assets/js/nav.js holds the interaction layer: search filtering,
active-item tracking via aria-current, and the mobile drawer. It is
hand-written and the generator never touches it — app.js is rewritten
wholesale on every run, so interaction code there would be deleted by the next
pnpm run generate:gallery.
The search field and the drawer toggle are created by nav.js, not present
in the HTML. Without JavaScript there is no dead control to confuse anyone; the
complete fallback is the full static list.
The search query round-trips through the URL as ?q=, so a filtered view is
shareable and Back steps through the queries actually searched for. It is
written on commit — on blur or after a pause — never on each keystroke:
mixing replaceState while typing with pushState on commit corrupts history,
because the replace overwrites the entry the previous push created.
docs-site/index.html loads the library from the CDN
(static.devschile.cl/chucao/latest/). That is correct for the published site,
but it means the site always shows the last release, never the working tree —
a component you just added stays invisible there until a release ships.
pnpm run docs:serveThat builds, regenerates the gallery, and serves the site against the local
Stencil output on http://localhost:4173 (set PORT to change it). It assembles
a throwaway .docs-preview/ directory rather than editing docs-site/, so the
committed HTML keeps pointing at the CDN and there is no dev-only variant to
commit by accident. The CDN URLs for the stylesheet, the module and the
self-hosted fonts are rewritten to the local copy, so the preview also works
offline, and a badge in the corner marks it as a local build.
It is a snapshot, not a watcher: re-run it after changing a component.
When creating new component tags, avoid using chucao or stencil alone as
the tag name; instead prefix component tags with ch- (e.g. ch-input) so
they don't collide with other libraries on the page.
Beyond tag naming, follow these conventions — already used consistently
across ch-accordion, ch-alert, ch-badge, ch-button, ch-card, ch-checkbox,
ch-divider, ch-input, ch-link, ch-modal, ch-radio, ch-select,
ch-spinner, ch-switch, ch-tabs, ch-textarea, and ch-tooltip — so future
components stay consistent as the library grows. See
v1-readiness.md for the full readiness assessment that
identified and documented these conventions.
- Custom events: prefix with
chand use camelCase (chClick,chChange,chInput), mirroring the native event they wrap (click,change,input). - Variant props: type them as a string union (e.g.
'primary' | 'secondary') and export the union as a named type alongside the component class (e.g.ChButtonVariant,ChBadgeVariant), so consumers can import and reuse the type. - Standard interactive-component props: any component that wraps a
native form control should expose
disabled(@Prop() disabled = false;),required(@Prop() required = false;),name(@Prop() name?: string;), and, when it holds a value,valueas@Prop({ mutable: true }) value = ''— reflect each prop straight onto the native element (e.g.<button disabled={this.disabled}>,<input disabled={this.disabled} required={this.required} />). This is the patternch-inputandch-selectalready follow, and thatch-buttonwas updated to match. The form controls declarename,disabledandrequiredas@Prop({ reflect: true })so the host also carries them: the platform reads those attributes on the host for form association (see the form participation bullet below). - Form participation & native validation: the six form controls
(
ch-checkbox,ch-input,ch-radio,ch-select,ch-switch,ch-textarea) are form-associated custom elements (@Component({ formAssociated: true })). Inside a<form>they behave like native controls: their value/checked state is submitted throughElementInternals.setFormValue(), they appear inform.elements, andform.checkValidity()/FormDatasee them.requiredwith an empty value is invalid (valueMissing), blocking submit, and theinvalid/errorMessageprops are mirrored intosetValidity({ customError }), so the host matches:invalid/:valid. The constraint-validation API for form-associated custom elements lives onelement.internals(not on the element):checkValidity(),validationMessage,validity,willValidate.formResetCallbackrestoresvalue/checkedto the initial state on form reset. This is a 2.0 breaking change: controls inside a form that previously contributed nothing now participate — consumers who compensated by hand (e.g. hidden inputs feedingFormData) must remove that workaround to avoid duplicate values. - Accessibility: prefer native, keyboard-accessible elements
(
<button>,<input>,<select>,<textarea>) inside the shadow root instead of reimplementing their semantics — this is what givesch-button,ch-input,ch-select, andch-textareaworking Tab/Space/Enter navigation for free. Becausearia-*attributes set on the custom element host aren't automatically forwarded into the shadow DOM, components that need a label expose it explicitly:ch-input/ch-select/ch-textareaaccept alabelprop rendered as a<label>associated via a generatedid/forpair,ch-checkbox/ch-switchwrap their native control in a<label>(implicit labeling),ch-radiorenders arole="radiogroup"labelled viaaria-labelledby, andch-buttonaccepts alabelprop applied asaria-labelon the native<button>(for icon-only buttons). Interactive elements must also keep a visible:focus-visiblestyle (seech-button.css) so keyboard users can see where focus is. - Composite widgets (keyboard + panels):
ch-tabsandch-accordionimplement keyboard-accessible, non-form composites.ch-tabsfollows the WAI-ARIA tablist pattern: arrow keys move and activate (Left/Right wrap around, Home/End jump to first/last, disabled tabs are skipped), the active tab carriesaria-selectedand a rovingtabindex(0/-1), and each tab is wired to itsrole="tabpanel"viaaria-controls/aria-labelledby.ch-accordionfollows the WAI-ARIA accordion pattern with native<button>toggles (Enter/Space for free), single-open semantics, andaria-expanded/aria-controls/role="region"wiring. Both keep panel content in the consumer's light DOM, distributed into the shadow root via a named slot per panel:panel-<value>(ch-tabs),panel-<value>(ch-accordion). Only the active panel is rendered visible. - Overlays:
ch-modalis built on the native<dialog>opened withshowModal(), so the browser owns the hard parts — moving focus in and returning it on close, marking the rest of the page inert,Escape, the top layer,::backdrop, and the implicitdialogrole witharia-modal="true". Deliberately absent is a focus trap: the W3C APA group concluded a modal should still let keyboard users reach browser UI, so hand-rolling one would be less correct. Two things the platform does not cover are added here — locking the page behind the dialog so it cannot scroll (reference-counted, so nested modals and unmounts cannot unlock it early), and closing on a click outside the dialog, which isclosedby="any"in the platform but is implemented in JavaScript for every browser alike while Safari lacks it. Because a slotted heading cannot be referenced witharia-labelledbyacross the shadow boundary, the accessible name comes through thelabelprop: always pass it, with the same text as the heading. Native<dialog>events are wired withaddEventListenerrather than JSX, since Stencil derives a listener's event name from whetheron<name>exists onwindowand that differs between the browser and the test environment for pointer events. - Cross-boundary ARIA:
ch-tooltiptakes both its trigger and its text from the consumer's light DOM through thetriggerandcontentslots. That is not a stylistic choice — anaria-describedbyIDREF only resolves within a single tree, and slotted nodes stay in the document tree even though they render inside the shadow root, so putting both there is what makes the association work at all. Rendering the text inside the shadow root would need a reference pointing into a shadow tree: ARIA element reflection (ariaDescribedByElements) only points outwards, and assigning it inwards fails silently — the assignment is accepted and reads back empty. The platform's real fix,referenceTarget, has not shipped. The component generates anidon the text (respecting one the consumer already set), marks itrole="tooltip", and points the trigger at it, re-running onslotchangesince a framework re-render can replace the slotted nodes. The trigger must be a natively focusable element:aria-*set on a custom element host does not reach the native element inside it, so ach-buttontrigger would not be described — tracked in the library-wide discussion on accessible names. Hover and focus are tracked as two independent flags rather than one open boolean, so a pointer passing over and away cannot hide a tooltip whose trigger still holds keyboard focus, and a third flag records anEscapedismissal.Escapeis handled on thedocument, not the host: a tooltip opened by hovering leaves focus wherever it was, so a host-scoped handler would never see the key. The empty.bubble::beforein the stylesheet is load-bearing — it spans the offset between trigger and bubble so the pointer stays inside the component while it crosses, which is what keeps the bubble reachable. Without it the pointer passes over the page behind the gap, the host getsmouseleave, and the bubble disappears before it can be read. Together these cover the three parts of WCAG 1.4.13 (hoverable, dismissible, persistent). - Links & dividers:
ch-linkrenders a native<a>withdefault/mutedvariants; it auto-addsrel="noopener noreferrer"fortarget="_blank"and, whendisabled, renders a non-interactive element witharia-disabled="true". The underline is an opt-out: set--ch-link-text-decorationtononeon the host (e.g. for button-like CTAs), or style the internal element viach-link::part(link).ch-dividerrenders a native<hr>withhorizontal(default) /verticalorientations andaria-orientation.