@ohmycorp/sarah-ui
v0.11.0
Published
Angular form and window primitives, themed entirely by CSS custom properties
Readme
sarah-ui
Angular form and window primitives, extracted from Sarah. Every component carries its own appearance and is themed entirely through CSS custom properties, so it drops into any Angular 22 app and looks right with no stylesheet to import and nothing to configure.
Install
npm install @ohmycorp/sarah-uiPeer dependencies are @angular/core, @angular/common and @angular/forms. Nothing else — no @angular/localize, because the library ships no translatable strings, and no rxjs.
Use
Every component is standalone, takes signal inputs, and uses the app- selector prefix.
import { Component } from '@angular/core';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { TextFieldComponent, ToggleComponent } from '@ohmycorp/sarah-ui';
@Component({
selector: 'app-account-form',
imports: [ReactiveFormsModule, TextFieldComponent, ToggleComponent],
template: `
<app-text-field label="Email" type="email" [formControl]="email" hint="We never share it" />
<app-toggle label="Weekly digest" [formControl]="digest" />
`,
})
export class AccountFormComponent {
email = new FormControl('');
digest = new FormControl(false);
}TextFieldComponent, SelectFieldComponent and ToggleComponent are ControlValueAccessors, so they bind with formControl, formControlName or ngModel.
FloatingWindowComponent renders inside a FloatingWindowLayerComponent, which provides WindowStackService: a window outside a layer throws NG0201. The layer is pointer-events: none so clicks pass through to the page, which means anything else you render inside it must set pointer-events: auto itself.
Every icon-only button takes its label as a required input, so the library never owns user-visible text: FloatingWindowComponent's closeLabel, maximizeLabel and restoreLabel, and ModalHeaderComponent's closeLabel. Pass your own, translated however your app translates.
Theming
The library reads 21 custom properties and declares none of them. Each is read as var(--ui-name, <default>), where the default is the value Sarah uses today, so an app that defines nothing gets Sarah's light palette in its own font. Define any of them anywhere that inherits down and the components follow.
:root {
--ui-primary: #0f766e;
--ui-radius-control: 10px;
--ui-field-size: 1rem;
}Three granularities all work, because the library declares nothing to out-specify:
- Document —
:root { --ui-primary: … } - Per component kind —
app-text-field, app-select-field { --ui-bg: #fff } - Per instance — a wrapper class, or
[style.--ui-primary]
The contract
src/styles/_tokens.sass is the source of truth; token() fails the build on a name that is not in it. Defaults below.
| Token | Default | Affects |
| --------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------ |
| --ui-primary | #6366f1 | field focus border, toggle track when checked, active chip, window hover |
| --ui-bg | #f1f5f9 | field background, chip group background, modal header close hover |
| --ui-surface | #ffffff | active chip, skeleton card, window surface, modal header |
| --ui-border | #cbd5e1 | field border, modal header rule |
| --ui-text | #1e293b | field text, window text and bar, modal header title |
| --ui-text-muted | #64748b | captions, placeholders, idle chips, hints, toggle track, modal header close |
| --ui-danger | #ef4444 | invalid field border, error text, window close hover |
| --ui-success | #22c55e | success text |
| --ui-radius-control | 4px | fields |
| --ui-radius-group | 8px | chip group and chips, skeleton logo, modal header close |
| --ui-radius-panel | 14px | floating window |
| --ui-radius-card | 20px | skeleton card |
| --ui-shadow-raised | two-layer 7%/4% | toggle track and knob, chip group, active chip |
| --ui-shadow-card | two-layer 8%/4% | skeleton card |
| --ui-shadow-overlay | two-layer 12%/6% | floating window |
| --ui-field-size | 15px | input, textarea and select text |
| --ui-caption-size | 13px | the label above a field |
| --ui-field-chevron | inline SVG data URI | the select arrow |
| --ui-placeholder-fade | #ffffff | what the placeholder colour mixes toward, 30% |
| --ui-hover-shade | #000000 | what chip and button hovers mix toward, 6% |
| --ui-knob | #ffffff | the toggle switch knob |
| --ui-series-1 … --ui-series-8 | blue, orange, aqua, yellow, magenta, green, violet, red | the categorical chart palette, in fixed slot order |
| --ui-heat-0 … --ui-heat-4 | #eef2f7 → #184f95 | the sequential ramp, lightest meaning none |
| --ui-grid | #e2e8f0 | chart gridlines and table rules |
Two of these want flipping in a dark-first app, because they mix toward a colour rather than being one: set --ui-placeholder-fade and --ui-hover-shade to #000000 and #ffffff respectively, or set --ui-hover-shade: var(--ui-text) to make it follow the palette in one line.
--ui-field-size takes a length, never inherit — on a custom property inherit is the CSS-wide keyword and inherits the parent's custom property, not its font size. An app on a 16px scale sets 1rem.
Dark mode
The library has no dark mode, deliberately. It reads tokens and never names a theme selector, so it is correct under any trigger you choose — a class, a data attribute, prefers-color-scheme, a cookie-driven attribute, or a class on one panel:
[data-theme='dark'] {
--ui-primary: #818cf8;
--ui-bg: #0f172a;
--ui-surface: #1e293b;
--ui-border: #334155;
--ui-text: #e2e8f0;
--ui-text-muted: #94a3b8;
--ui-danger: #f87171;
--ui-success: #4ade80;
}If you use sass, the dark palette ships ready to include under whatever selector you like:
@use "pkg:@ohmycorp/sarah-ui/styles/tokens"
[data-theme="dark"]
@include tokens.dark@mixin light emits all 21 as declarations, for an app that wants them defined rather than falling back.
Only the eight colours and the three shadows change in dark mode. The radii and the two type sizes do not.
Already have a theme?
Alias your own names once and every component follows, including through your existing dark mode, because var() substitutes the computed value on the same element:
:root {
--ui-primary: var(--brand);
--ui-text: var(--ink);
}Charts
Eight dependency-free SVG components, no d3 and no charting library: app-line-chart, app-bar-chart, app-pie-chart, app-radar-chart, app-candlestick-chart, app-tree-chart, app-contributions-chart, plus the shared app-chart-legend, app-chart-tooltip and app-chart-table they compose. Every one takes a required caption, ships a hover and keyboard readout, and renders a collapsed table of its own data wherever the page passes tableView.
import { LineChartComponent } from '@ohmycorp/sarah-ui'
@Component({
imports: [LineChartComponent],
template: `
<app-line-chart
caption="Documents per month"
[labels]="months"
[series]="[{ name: 'Invoices', values: invoices }]"
/>
`,
})Rules the components enforce for you
These are not style preferences; they are the reasons the charts stay readable, and several are enforced in code rather than left to the caller.
- Series colours are assigned in fixed slot order and never cycled. A ninth series takes a muted grey rather than a generated hue, which under colour-vision deficiency would be indistinguishable from an existing slot. Fold a long tail into one "Other" series, or facet it.
- Colour follows the entity, not its rank. The pie sorts slices by value for layout but keys each colour to the slice's position in the array you passed, so a slice does not change colour when the numbers move.
- The pie folds past six segments. A pie is only legible at a glance below about six slices, so it keeps the five largest and sums the rest into one
otherLabelslice. - The radar is an all-pairs form. Every polygon overlaps every other, and the palette only clears the stricter all-pairs separation gates for its first three slots. Past three series, facet instead.
- There is no dual-axis chart and there never will be. Two y-scales on one plot invent a correlation the data does not contain. Use two charts, or index both series to a common base.
- The table view is the relief for a palette slot below 3:1, and it is opt-in.
tableViewis off by default and[tableView]="true"renders the collapsed table of the chart's own data. Series slots 3, 4 and 5 sit below 3:1 against the light surface and slot 6 below it against the dark one, so a chart that reaches a fourth series must either turn the table view on or label its marks directly. The first three slots, the heat steps and any chart whose marks carry visible values stand without it.
The palette was validated against this library's own surfaces — #ffffff light and #1e293b dark — not against a reference surface. Both modes pass the lightness band, chroma floor, adjacent-pair colour-vision separation and normal-vision floor. If you substitute your own --ui-series-* hues, re-run that validation rather than eyeballing it.
Charts in dark mode
The eight series slots and the five heat steps are re-stepped for a dark surface in @mixin dark, not flipped. If your app themes dark and uses charts, include that mixin or copy its values — the light defaults are not legible on a dark surface.
Known limits
- Hit targets are sized in viewBox units, so they meet the 24px minimum only while the chart renders at its natural width or wider. A chart squeezed into a narrow column scales them down with everything else.
- The SVG carries
role="img", which prunes its focusable marks from the accessibility tree in strict implementations. Keyboard users get the focus ring and the readout; a screen reader reaches the same readout through each mark'saria-label, and the table view when the page turns it on. - Axis text is sized in viewBox units at about a sixtieth of the chart width, so it scales with the chart rather than with the theme's
--ui-caption-size.
Components
ChipGroupComponent, FloatingWindowComponent, FloatingWindowLayerComponent, IconComponent, ModalHeaderComponent, SelectFieldComponent, SkeletonCardsComponent, StatusTextComponent, TextFieldComponent, ToggleComponent, plus WindowStackService and the ChipOption, IconName and StatusTextVariant types.
IconComponent draws every stroke icon with stroke="currentColor", so it takes its colour from wherever you put it: <app-icon name="trash" />, [size] optional.
Develop
pnpm install # the pnpm version is pinned by packageManager; `corepack enable pnpm` once provides it
pnpm build # ng-packagr -> dist/
pnpm test # vitest through the Angular unit-test builder
pnpm format:check # Prettier over .ts and .html; .sass is not coveredpnpm-workspace.yaml denies every dependency's install script; each of those packages ships a prebuilt binary, and pnpm refuses to install while a new one is unlisted.
To publish, bump the version, commit and push first, then build and publish:
pnpm build
pnpm publish ./dist # a bare `dist` would name a registry package instead of the folderpnpm publish refuses an uncommitted tree, a branch other than main, or one behind origin, so every published version matches a commit on GitHub; dist/ is gitignored, so building after the commit keeps the tree clean. With two-factor authentication it asks for the one-time code in the terminal, or prints a login URL and QR code. The package is public, so a consumer needs no registry authentication — which is what lets a Docker build run a plain install against it.
