@tonycodes-ui/mortar 0.0.0-stage → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +10 -0
- package/CHANGELOG.md +268 -0
- package/CONTRIBUTING.md +60 -0
- package/DESIGN.md +51 -0
- package/LICENSE +21 -0
- package/README.md +155 -2
- package/TESTING.md +83 -0
- package/baseline-policy.json +16 -0
- package/bin/mortar.mjs +258 -0
- package/catalog.json +9900 -0
- package/cli/audit.mjs +943 -0
- package/cli/data.mjs +176 -0
- package/cli/mcp.mjs +216 -0
- package/cli/migrations.mjs +128 -0
- package/cli/packet.mjs +267 -0
- package/cli/search.mjs +246 -0
- package/cli/tools.mjs +229 -0
- package/design-principles.json +30 -0
- package/dist/agent-data.json +1 -0
- package/dist/application.d.ts +340 -0
- package/dist/application.js +240 -0
- package/dist/auth.d.ts +121 -0
- package/dist/auth.js +97 -0
- package/dist/brand.d.ts +69 -0
- package/dist/brand.js +266 -0
- package/dist/calendar.d.ts +75 -0
- package/dist/calendar.js +310 -0
- package/dist/choice.d.ts +36 -0
- package/dist/choice.js +36 -0
- package/dist/composer.d.ts +28 -0
- package/dist/composer.js +47 -0
- package/dist/display.d.ts +136 -0
- package/dist/display.js +75 -0
- package/dist/docs.d.ts +145 -0
- package/dist/docs.js +141 -0
- package/dist/ecommerce.d.ts +261 -0
- package/dist/ecommerce.js +177 -0
- package/dist/feedback.d.ts +80 -0
- package/dist/feedback.js +98 -0
- package/dist/foundation.d.ts +42 -0
- package/dist/foundation.js +121 -0
- package/dist/heading.d.ts +5 -0
- package/dist/heading.js +9 -0
- package/dist/icons.d.ts +50 -0
- package/dist/icons.js +70 -0
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +18 -0
- package/dist/inspection.d.ts +24 -0
- package/dist/inspection.js +14 -0
- package/dist/internal.d.ts +514 -0
- package/dist/internal.js +332 -0
- package/dist/layout.d.ts +107 -0
- package/dist/layout.js +66 -0
- package/dist/marketing-sections.d.ts +161 -0
- package/dist/marketing-sections.js +58 -0
- package/dist/marketing.d.ts +277 -0
- package/dist/marketing.js +163 -0
- package/dist/navigation.d.ts +35 -0
- package/dist/navigation.js +44 -0
- package/dist/primitives.d.ts +258 -0
- package/dist/primitives.js +244 -0
- package/dist/showcase.d.ts +29 -0
- package/dist/showcase.js +10 -0
- package/dist/workspace.d.ts +80 -0
- package/dist/workspace.js +48 -0
- package/docs/agents.md +127 -0
- package/docs/auth-flows.md +71 -0
- package/docs/migrating-to-0.6.md +94 -0
- package/docs/theming.md +170 -0
- package/package.json +94 -4
- package/skills/mortar/SKILL.md +36 -0
- package/src/application.css +1487 -0
- package/src/application.tsx +1413 -0
- package/src/auth.tsx +452 -0
- package/src/brand.ts +335 -0
- package/src/calendar.css +468 -0
- package/src/calendar.tsx +868 -0
- package/src/choice.tsx +240 -0
- package/src/composer.tsx +158 -0
- package/src/data.css +690 -0
- package/src/display.tsx +452 -0
- package/src/docs.css +704 -0
- package/src/docs.tsx +737 -0
- package/src/ecommerce.css +810 -0
- package/src/ecommerce.tsx +1033 -0
- package/src/feedback.tsx +388 -0
- package/src/foundation.css +1867 -0
- package/src/foundation.tsx +227 -0
- package/src/heading.ts +13 -0
- package/src/icons.tsx +237 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +233 -0
- package/src/inspection.tsx +91 -0
- package/src/internal.tsx +700 -0
- package/src/layout.tsx +342 -0
- package/src/marketing-sections.tsx +621 -0
- package/src/marketing.css +2579 -0
- package/src/marketing.tsx +1235 -0
- package/src/navigation.tsx +118 -0
- package/src/overlays.css +350 -0
- package/src/primitives.tsx +1135 -0
- package/src/showcase.tsx +120 -0
- package/src/tokens.css +449 -0
- package/src/workspace.tsx +333 -0
- package/styles.css +56 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { KitLink } from "./navigation.js";
|
|
3
|
+
import { Fragment, useId } from "react";
|
|
4
|
+
import { Combobox, ComboboxInput, ComboboxOption, ComboboxOptions, Description, DialogPanel, DialogTitle, } from "@headlessui/react";
|
|
5
|
+
import { Button } from "./primitives.js";
|
|
6
|
+
import { Kbd } from "./display.js";
|
|
7
|
+
import { ChevronRight, Close } from "./icons.js";
|
|
8
|
+
import { childLevel, headingTag } from "./heading.js";
|
|
9
|
+
import { DialogFrame, componentRoot, cx, useComponentName, } from "./internal.js";
|
|
10
|
+
export function SlideOver({ open, onClose, title, description, children, actions, size = "md", className, }) {
|
|
11
|
+
return (_jsx(DialogFrame, { open: open, onClose: onClose, component: "SlideOver", children: _jsxs(DialogPanel, { className: cx("m-slide-over", className), "data-size": size === "md" ? undefined : size, children: [_jsxs("header", { children: [_jsxs("div", { children: [_jsx(DialogTitle, { children: title }), description && _jsx(Description, { children: description })] }), _jsx(Button, { variant: "ghost", onClick: onClose, "aria-label": "Close panel", children: _jsx(Close, {}) })] }), _jsx("div", { className: "m-slide-over-body", children: children }), actions && _jsx("footer", { className: "m-actions", children: actions })] }) }));
|
|
12
|
+
}
|
|
13
|
+
function Highlight({ label, match, }) {
|
|
14
|
+
if (!match)
|
|
15
|
+
return _jsx(_Fragment, { children: label });
|
|
16
|
+
const [a, b] = match;
|
|
17
|
+
return (_jsxs(_Fragment, { children: [label.slice(0, a), _jsx("mark", { children: label.slice(a, b) }), label.slice(b)] }));
|
|
18
|
+
}
|
|
19
|
+
/** Controlled ⌘K palette. Filtering and ranking stay with the consumer. */
|
|
20
|
+
export function CommandPalette({ open, onClose, query, onQueryChange, groups, label = "Command menu", placeholder = "Type a command or search", empty, footer, className, }) {
|
|
21
|
+
const total = groups.reduce((n, g) => n + g.items.length, 0);
|
|
22
|
+
const component = useComponentName("CommandPalette");
|
|
23
|
+
return (_jsx(DialogFrame, { open: open, onClose: onClose, component: component, children: _jsx("div", { className: "m-dialog-position m-palette-position", children: _jsxs(DialogPanel, { className: cx("m-palette", className), children: [_jsx(DialogTitle, { className: "m-sr-only", children: label }), _jsxs(Combobox, { value: null, onChange: (item) => {
|
|
24
|
+
if (!item)
|
|
25
|
+
return;
|
|
26
|
+
item.onSelect();
|
|
27
|
+
onClose();
|
|
28
|
+
}, children: [_jsxs("div", { className: "m-palette-input", children: [_jsx(ComboboxInput, { autoFocus: true, "aria-label": label, placeholder: placeholder, value: query, onChange: (e) => onQueryChange(e.target.value), onKeyDownCapture: (e) => {
|
|
29
|
+
if (e.key === "Escape") {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
e.stopPropagation();
|
|
32
|
+
onClose();
|
|
33
|
+
}
|
|
34
|
+
} }), _jsx(Kbd, { keys: ["esc"] })] }), _jsx(ComboboxOptions, { static: true, modal: false, className: "m-palette-results", children: groups
|
|
35
|
+
.filter((g) => g.items.length)
|
|
36
|
+
.map((group) => (_jsxs("div", { role: "group", "aria-label": group.label, children: [_jsx("div", { className: "m-palette-group", "aria-hidden": "true", children: group.label }), group.items.map((item) => (_jsx(ComboboxOption, { value: item, as: Fragment, children: _jsxs("div", { className: "m-palette-item", children: [item.icon && (_jsx("span", { className: "m-palette-icon", "aria-hidden": "true", children: item.icon })), _jsxs("span", { children: [_jsx("span", { className: "m-palette-label", children: _jsx(Highlight, { label: item.label, match: item.match }) }), item.description && (_jsx("small", { children: item.description }))] }), item.shortcut ? (_jsx(Kbd, { keys: item.shortcut })) : (item.meta && (_jsx("span", { className: "m-palette-meta", children: item.meta })))] }) }, item.id)))] }, group.label))) })] }), _jsx("p", { className: total ? "m-sr-only" : "m-palette-empty", role: "status", children: total ? ("") : empty ? (_jsxs(_Fragment, { children: [empty.title, empty.description && _jsxs("span", { children: [" ", empty.description] })] })) : (`No results for “${query}”.`) }), footer && _jsx("div", { className: "m-palette-footer", children: footer })] }) }) }));
|
|
37
|
+
}
|
|
38
|
+
export function StackedList({ label, title, headingLevel = 2, action, items = [], groups = [], chevrons = false, id, className, }) {
|
|
39
|
+
const base = useId();
|
|
40
|
+
const hasTitle = title !== undefined && title !== null && title !== "";
|
|
41
|
+
const Title = headingTag(headingLevel);
|
|
42
|
+
const GroupHeading = headingTag(hasTitle ? childLevel(headingLevel) : headingLevel);
|
|
43
|
+
const row = (item) => {
|
|
44
|
+
const body = (_jsxs(_Fragment, { children: [item.leading, _jsxs("span", { className: "m-stacked-text", children: [_jsx("strong", { children: item.title }), item.description && _jsx("span", { children: item.description })] }), item.meta && _jsx("span", { className: "m-stacked-meta", children: item.meta }), chevrons && item.href && (_jsx("span", { className: "m-stacked-chevron", "aria-hidden": "true", children: _jsx(ChevronRight, {}) }))] }));
|
|
45
|
+
return (_jsxs("li", { children: [item.href ? (_jsx(KitLink, { href: item.href, children: body })) : (_jsx("div", { children: body })), item.trailing && (_jsx("div", { className: "m-stacked-trailing", children: item.trailing }))] }, item.id));
|
|
46
|
+
};
|
|
47
|
+
return (_jsxs("section", { id: id, ...componentRoot("StackedList"), className: cx("m-stacked-list", className), "aria-label": label, "aria-labelledby": label || !hasTitle ? undefined : `${base}-title`, children: [(hasTitle || action) && (_jsxs("header", { children: [hasTitle && (_jsx(Title, { id: `${base}-title`, className: "m-stacked-title", children: title })), action && _jsx("div", { className: "m-stacked-action", children: action })] })), (items.length > 0 || !groups.length) && _jsx("ul", { children: items.map(row) }), groups.map((group, index) => (_jsxs("div", { className: "m-stacked-group", children: [_jsx(GroupHeading, { id: `${base}-${index}`, className: "m-stacked-group-heading", children: group.label }), _jsx("ul", { "aria-labelledby": `${base}-${index}`, children: group.items.map(row) })] }, group.label)))] }));
|
|
48
|
+
}
|
package/docs/agents.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Mortar for coding agents
|
|
2
|
+
|
|
3
|
+
Mortar ships its own agent tooling: an MCP server and a `mortar` command-line tool inside the `@tonycodes-ui/mortar` package. Both read the installed package's catalogue (`catalog.json`), contracts, examples and guides. They run locally and work offline. They need no account, token or hosted service, and they never send anything anywhere. Everything is MIT licensed, like the kit.
|
|
4
|
+
|
|
5
|
+
The tooling needs Node 22 or newer. It adds no runtime dependencies to your app.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
In the application:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @tonycodes-ui/mortar react react-dom tailwindcss
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
In the app's Tailwind CSS entry, after Tailwind:
|
|
16
|
+
|
|
17
|
+
```css
|
|
18
|
+
@import "tailwindcss";
|
|
19
|
+
@import "@tonycodes-ui/mortar/styles.css";
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Render one `ThemeProvider` at the root:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { ThemeProvider } from "@tonycodes-ui/mortar";
|
|
26
|
+
|
|
27
|
+
<ThemeProvider brand="neutral" density="comfortable" initialAppearance="system">
|
|
28
|
+
<App />
|
|
29
|
+
</ThemeProvider>;
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Connect the MCP server
|
|
33
|
+
|
|
34
|
+
The server speaks the Model Context Protocol over stdio. Inside an app that has Mortar installed, `npx` runs the installed version, so the tools always describe the components you actually import.
|
|
35
|
+
|
|
36
|
+
**Claude Code:**
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
claude mcp add mortar -- npx -y @tonycodes-ui/mortar mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Add `--scope project` to share it with the repository through `.mcp.json`.
|
|
43
|
+
|
|
44
|
+
**Cursor, Windsurf, VS Code and other clients** that read an `mcpServers` JSON file (for example `.cursor/mcp.json`):
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"mortar": {
|
|
50
|
+
"command": "npx",
|
|
51
|
+
"args": ["-y", "@tonycodes-ui/mortar", "mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
In a project without Mortar installed, `npx -y @tonycodes-ui/mortar mcp` downloads the latest release first. To run a checkout of this repository instead, use `node /path/to/mortar/bin/mortar.mjs mcp` after `npm run build`.
|
|
58
|
+
|
|
59
|
+
## Tools
|
|
60
|
+
|
|
61
|
+
| Tool | Arguments | Returns |
|
|
62
|
+
| ------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
63
|
+
| `search_components` | `query`, optional `kit`, `category`, `limit` | Ranked components: name, kit, category, description, capabilities. |
|
|
64
|
+
| `get_component` | `name` | The full contract: props with types, required flags and defaults; usage, avoid and accessibility notes; the components it renders; its `data-m-component` marker; tested states; page templates; a JSX example; the import line. |
|
|
65
|
+
| `get_build_packet` | `components` and/or `brief` | The chosen components (for a brief, with the reason for each, starting from the purpose shell), their dependency closure, install, CSS and ThemeProvider setup, the import line, the design principles and API conventions, and matching page templates. |
|
|
66
|
+
| `get_guide` | `topic` | A guide as markdown: `design`, `conventions`, `theming`, `auth-flows`, `migrating-0.6`, `contributing`, `testing` or `agents`. |
|
|
67
|
+
| `list_kits` | none | Kits → categories → components, and the page templates. |
|
|
68
|
+
| `audit_app` | `path` | A read-only audit of an application: findings with file, line, rule, message and a suggested fix. |
|
|
69
|
+
|
|
70
|
+
## Command line
|
|
71
|
+
|
|
72
|
+
The same data is available without an MCP client:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
npx mortar search "sign in"
|
|
76
|
+
npx mortar component DataTable
|
|
77
|
+
npx mortar packet AppShell DataTable
|
|
78
|
+
npx mortar packet --brief "an admin screen listing invoices with filters"
|
|
79
|
+
npx mortar audit .
|
|
80
|
+
npx mortar guide theming
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Add `--json` for machine-readable output. `mortar audit` exits with status 1 when it finds errors, so it can run in CI. `mortar --help` lists every command.
|
|
84
|
+
|
|
85
|
+
## Workflow
|
|
86
|
+
|
|
87
|
+
1. **Search.** Before writing UI, find what the kit already has: `search_components`, or `list_kits` to browse. Start from the purpose shell: `AppShell` for application screens, `MarketingHeader` (with `Footer`) for public pages, `DocsShell` for documentation and `AuthLayout` for sign-in and registration.
|
|
88
|
+
2. **Packet.** Call `get_build_packet` with the components you picked, or with the brief. Compose from it: the contracts list every prop, and the page templates show complete page patterns to follow.
|
|
89
|
+
3. **Compose.** Use named imports from `@tonycodes-ui/mortar` and the exact props and values in each contract. Keep product names, data, routes, permissions and actions in props and slots. Theme with `ThemeProvider` and tokens (`get_guide theming`). Never copy a component's source, wrap it repeatedly, or override `.m-*` classes or `[data-m-component]` in your CSS.
|
|
90
|
+
4. **Audit.** Run `audit_app` (or `npx mortar audit`) and fix what it finds, then run the app's own typecheck and build. Verify in a browser: mobile and desktop, light and dark, keyboard and focus, and loading, empty and error states.
|
|
91
|
+
5. **Contribute.** If a shared pattern is missing or a component is wrong, fix it in the kit, not in the app. Open a pull request at [github.com/tonycodes/mortar](https://github.com/tonycodes/mortar) following [CONTRIBUTING.md](../CONTRIBUTING.md) and [AGENTS.md](../AGENTS.md) (`get_guide contributing`). Maintainer review and CI decide what merges; agents never approve, merge or publish their own changes.
|
|
92
|
+
|
|
93
|
+
## The audit
|
|
94
|
+
|
|
95
|
+
`audit_app` reads the application's files and never changes them. It checks against the contracts of the Mortar version installed in the app (or, for releases older than this tooling, against the tool's own catalogue, and says so).
|
|
96
|
+
|
|
97
|
+
| Rule | Severity | Finds |
|
|
98
|
+
| ------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
99
|
+
| `not-declared` | error | Mortar imported but missing from package.json |
|
|
100
|
+
| `not-installed` | error | Mortar imported but not in node_modules |
|
|
101
|
+
| `version-mismatch` | error | The installed version does not satisfy the package.json range |
|
|
102
|
+
| `unknown-export` | error | A named import the installed version does not export, with the replacement for names removed in 0.6 |
|
|
103
|
+
| `deep-import` | error | Imports or aliases into the package's `src` or `dist` |
|
|
104
|
+
| `unknown-prop` | error | A prop the component's contract does not declare, with the 0.6 rename where there is one |
|
|
105
|
+
| `invalid-value` | error | A literal outside the prop's declared union, such as `layout="centered"` (now `center`) |
|
|
106
|
+
| `css-override` | error | Consumer CSS selecting Mortar's `.m-*` classes or `[data-m-component]` |
|
|
107
|
+
| `class-override` | error | A Tailwind arbitrary variant that targets `.m-*` classes, such as `[&_.m-button]:rounded-none` |
|
|
108
|
+
| `local-copy` | error | A copy of a Mortar source file, or code rendering Mortar's component marker |
|
|
109
|
+
| `missing-styles` | error | No import of `@tonycodes-ui/mortar/styles.css` |
|
|
110
|
+
| `styles-order` | warning | Mortar's styles imported before Tailwind |
|
|
111
|
+
| `missing-theme-provider` | warning | Mortar components rendered without a `ThemeProvider` |
|
|
112
|
+
| `kit-class` | warning | Mortar-internal class names on the app's own markup |
|
|
113
|
+
| `raw-colour` | warning | Hex, `rgb()`/`oklch()` colours or Tailwind palette classes in files that render Mortar components, except where they define a brand (`brandTokens`, `brandToCss`/`brandToStyle`/`registerBrand` arguments, `BrandDefinition` objects, `--m-brand-*` values) |
|
|
114
|
+
| `limited-data` | info | The installed release has no agent data, so a newer catalogue was used |
|
|
115
|
+
|
|
116
|
+
The audit is a fast scanner, not a type checker. It does not see props passed through a spread (`{...props}`), components re-exported or wrapped under another name, class names built at runtime, or markup in `.vue`, `.svelte`, `.astro` or `.html` files. Run the app's typecheck and build as well.
|
|
117
|
+
|
|
118
|
+
## Agent skill
|
|
119
|
+
|
|
120
|
+
[`skills/mortar/SKILL.md`](../skills/mortar/SKILL.md) is a ready-made skill with these workflow defaults. For Claude Code, install it once:
|
|
121
|
+
|
|
122
|
+
```sh
|
|
123
|
+
mkdir -p ~/.claude/skills/mortar
|
|
124
|
+
npx mortar skill > ~/.claude/skills/mortar/SKILL.md
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Or put it in a project's `.claude/skills/mortar/` to share it with everyone working there.
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Sign-in and registration flows
|
|
2
|
+
|
|
3
|
+
Every auth screen is a page of its own, built from the same parts:
|
|
4
|
+
|
|
5
|
+
- **AuthLayout** is the page. It renders the `main` landmark and the page's `h1`, with an optional logo, description and footer link. `layout="center"` sets the form on the page, `layout="cards"` on a raised card, and `layout="split"` beside an `aside` (a quote or image) on wide screens.
|
|
6
|
+
- **ProviderButtons** lists sign-in providers (GitHub, Google, SSO). Separate them from the email form with `<Divider label="or continue with" soft spacing="none" />`.
|
|
7
|
+
- **PasswordInput** is every password field: a show/hide toggle, the right `autocomplete`, an optional requirement checklist and a strength meter.
|
|
8
|
+
- **CodeInput** is every one-time code: one input drawn as a cell per character.
|
|
9
|
+
- **ErrorSummary** lists the problems after a failed submit, above the form, with a link to each field.
|
|
10
|
+
- **Input**, **Checkbox**, **Button** and **Notice** do the rest. Submit buttons use `loading` while the request runs.
|
|
11
|
+
|
|
12
|
+
AuthLayout is a standalone page: do not put it inside AppShell or MarketingHeader. For a passwordless sign-in section inside another page (an emailed link plus providers), use **SignInPanel** with `headingLevel={2}`.
|
|
13
|
+
|
|
14
|
+
A `<form>` placed directly in AuthLayout stacks its fields, and its direct Button children fill the width. Give each form an accessible name (`aria-label="Sign in"`), and set `noValidate` when you show your own errors.
|
|
15
|
+
|
|
16
|
+
## Steps
|
|
17
|
+
|
|
18
|
+
The playground has a page template for each step (`?recipe=<id>`).
|
|
19
|
+
|
|
20
|
+
| Step | Template | Components | Fields and `autocomplete` |
|
|
21
|
+
| ---------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
22
|
+
| Sign in | `sign-in`, `sign-in-split` | AuthLayout, Input, PasswordInput, Checkbox ("Remember me"), a "Forgot password?" link, Divider, ProviderButtons; footer links to sign-up | Email: `type="email" autoComplete="username"`. Password: `PasswordInput` (default `purpose="current"`, `current-password`) |
|
|
23
|
+
| Sign up | `sign-up` | AuthLayout, Input, PasswordInput with `requirements` and `strength`, Checkbox (`required`, with `error`) for the terms with links, ProviderButtons; footer links to sign-in | Name: `autoComplete="name"`. Email: `type="email" autoComplete="email"`. Password: `purpose="new"` (`new-password`) |
|
|
24
|
+
| Forgot password | `forgot-password` | AuthLayout, Input, Button, a success Notice | Email: `type="email" autoComplete="email"` |
|
|
25
|
+
| Check your email | `check-email` | AuthLayout, Text, a resend Button with a cooldown | none |
|
|
26
|
+
| Reset password | `reset-password` | AuthLayout, two PasswordInputs, Button | Both `purpose="new"`. Add a hidden `<input type="email" autoComplete="username" value={email} readOnly hidden>` so password managers save the new password against the account |
|
|
27
|
+
| Verify email | `verify-email` | AuthLayout, CodeInput, Button, a resend Button with a countdown, a "Change your email" link | `CodeInput` (`one-time-code`, `inputmode="numeric"`) |
|
|
28
|
+
| Two-factor | `two-factor` | AuthLayout, CodeInput, Button, a ghost Button that switches to a recovery code | Authenticator: `CodeInput`. Recovery: `CodeInput alphanumeric length={8}` |
|
|
29
|
+
|
|
30
|
+
## Validation and errors
|
|
31
|
+
|
|
32
|
+
- Validate on submit, not while the person is still typing. Clear a field's error as soon as they change it.
|
|
33
|
+
- Show each error under its field (`error` on Input, PasswordInput, CodeInput and Checkbox, and on RadioGroup for a group: the field is marked `aria-invalid` and the message is announced).
|
|
34
|
+
- When a submit fails, also show an `ErrorSummary` above the form: pass the same messages, each with its field's id as `target`, and the count of failed submits as `submitCount`. It is an alert titled "There is a problem" and takes focus after each failed submit, so keyboard and screen reader users start from the list; it does not take focus while a fixed field's error clears. Each link focuses its field (the checked or first radio of a RadioGroup) and scrolls its label into view without changing the URL. The templates show the pattern.
|
|
35
|
+
- Messages say what to do: "Enter your email address", "Enter an email address in the correct format, like name@example.com", "The passwords don't match".
|
|
36
|
+
- Errors from the server (wrong password, expired code) go in the same places: a form-level Notice for credentials, the field's `error` for a code.
|
|
37
|
+
- Confirm success with a Notice whose title changes from empty (`announce`, kept mounted), or by moving to the next step.
|
|
38
|
+
|
|
39
|
+
## Passwords
|
|
40
|
+
|
|
41
|
+
- **Show every rule up front.** Pass every rule the server enforces as `requirements`, before the person types. Never reveal a rule only after a failed submit.
|
|
42
|
+
- Requirements update on each keystroke visually, but are announced only when the whole set becomes met (or stops being met), not on every key.
|
|
43
|
+
- Use a real strength estimator (for example zxcvbn) for `strength`; the playground's scoring is a stand-in.
|
|
44
|
+
- **Never block paste**, and never disable autocomplete on password fields. Password managers and pasting from them are the safest way to use long, unique passwords.
|
|
45
|
+
- Do not cap password length below 64 characters, or forbid characters such as spaces.
|
|
46
|
+
- For a confirm field, use a second `PasswordInput purpose="new"` and check that the values match on submit.
|
|
47
|
+
|
|
48
|
+
## One-time codes
|
|
49
|
+
|
|
50
|
+
- Use one `CodeInput`, never one input per digit: separate inputs break screen readers, paste and SMS or password-manager autofill.
|
|
51
|
+
- Pasting a formatted code (`123 456`, `123-456`) works: `onChange` receives only the allowed characters.
|
|
52
|
+
- **Do not submit automatically unless you mean to.** `onComplete` is opt-in: pass it only when a full code should submit at once (the two-factor template does, and says so in the hint). Otherwise the person presses the button.
|
|
53
|
+
- Say where the code went ("We sent a 6-digit code to ana@example.com") and how long it lasts. Offer resend, a way to change the address, and a fallback such as a recovery code.
|
|
54
|
+
- **Never log codes, or store them in analytics, URLs or local storage.** Treat a code like a password.
|
|
55
|
+
|
|
56
|
+
## Security notes
|
|
57
|
+
|
|
58
|
+
- **Do not reveal whether an account exists.** "If an account exists for that address, you'll get a link" — on forgot-password, sign-up conflicts and resend.
|
|
59
|
+
- **Rate limits come from the server.** Show its message in the page ("You've asked for a lot of emails. Try again in 10 minutes.") with a Notice, a field `error` or the resend status, and keep the action `aria-disabled` while it applies, so focus stays on it. Mortar has no built-in limit: pass whatever your auth service returns.
|
|
60
|
+
- Resend buttons count down before they can be used again (the templates use 30 seconds). Use `aria-disabled` rather than `disabled`, so a focused button keeps focus.
|
|
61
|
+
- After a password reset, sign out other sessions and say so.
|
|
62
|
+
- Provider buttons call your auth SDK through `onSelect`; Mortar renders no redirects and holds no credentials.
|
|
63
|
+
|
|
64
|
+
## Accessibility checklist
|
|
65
|
+
|
|
66
|
+
- One `h1` per page (AuthLayout's `title`) and one `main`.
|
|
67
|
+
- Every field has a visible label; hints and errors are linked with `aria-describedby`.
|
|
68
|
+
- The password toggle is a button named "Show password" with `aria-pressed`; it never steals focus from the field.
|
|
69
|
+
- Links in AuthLayout's text are underlined, so they do not rely on colour.
|
|
70
|
+
- Focus moves only in response to the person: to the error summary after a failed submit, to the new field when switching to a recovery code. Do not autofocus on page load.
|
|
71
|
+
- On narrow screens, `layout="split"` puts a text aside after the form and hides an aside that is only an image or video.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Migrating to 0.6
|
|
2
|
+
|
|
3
|
+
Mortar 0.6.0 applies one set of API conventions across every component. The rules are in [DESIGN.md](../DESIGN.md#api-conventions). Old names were removed rather than aliased, so TypeScript reports every call site that needs a change. Work through the compiler errors with the tables below.
|
|
4
|
+
|
|
5
|
+
## Patterns that apply everywhere
|
|
6
|
+
|
|
7
|
+
| Before | After |
|
|
8
|
+
| ----------------------------------------------------------------------------- | ------------------------------------------------ |
|
|
9
|
+
| Busy props: `saving`, `submitting`, `adding` | `loading` |
|
|
10
|
+
| `emptyTitle`, `emptyDescription`, `emptyAction`, `emptyMessage`, `emptyLabel` | `empty={{ title, description?, action? }}` |
|
|
11
|
+
| Item or action callbacks: `onClick` | `onSelect` |
|
|
12
|
+
| Column `align: "left" \| "right"` | `align: "start" \| "end"` |
|
|
13
|
+
| `headingLevel` that set item headings (GridList, ProductList, Steps, Footer) | `itemHeadingLevel` |
|
|
14
|
+
| Visible heading props named `heading` | `title` |
|
|
15
|
+
| Group shapes `{ title, items }` or `{ title, links }` | `{ label, items }` (`ItemGroup`) |
|
|
16
|
+
| Single-word values `centered`, `card` | `center`, `cards` |
|
|
17
|
+
| Section tone `surface` | `default` |
|
|
18
|
+
| `className` on an inner control (Input, Select, TextArea, Checkbox, Switch) | `className` goes on the component's root element |
|
|
19
|
+
|
|
20
|
+
New everywhere, with no change needed:
|
|
21
|
+
|
|
22
|
+
- every component accepts `className` on its root;
|
|
23
|
+
- section-level components accept `id`;
|
|
24
|
+
- headings accept `headingLevel` (`1`–`6`).
|
|
25
|
+
|
|
26
|
+
## By component
|
|
27
|
+
|
|
28
|
+
### Controls and feedback
|
|
29
|
+
|
|
30
|
+
| Component | Before | After |
|
|
31
|
+
| -------------------- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| Button | `loading` sets native `disabled` | `loading` sets `aria-disabled` and `aria-busy`, and the button keeps focus. Don't rely on `:disabled` styling while loading. |
|
|
33
|
+
| ButtonGroup | item `onClick` | item `onSelect` |
|
|
34
|
+
| Toast | `action` / `secondaryAction` `{ label, onClick }` | `{ label, onSelect }` (`ToastAction`) |
|
|
35
|
+
| SaveBar | `saving` | `loading` |
|
|
36
|
+
| CommentComposer | `submitting`, `onValueChange` | `loading`, `onChange` |
|
|
37
|
+
| ConfirmDialog | `destructive` (boolean, default `true`) | `tone: "default" \| "danger"` (default `"danger"`) |
|
|
38
|
+
| Combobox | `emptyLabel` | `empty={{ title }}` |
|
|
39
|
+
| Banner | `tone="neutral"` | `tone="default"` |
|
|
40
|
+
| PreviewFrame | `title` | `label` |
|
|
41
|
+
| Badge, Notice, Toast | `m-tone-*` classes | `data-tone`, omitted for neutral |
|
|
42
|
+
| Modal | `data-size="md"` | omitted for the default size |
|
|
43
|
+
|
|
44
|
+
### Application
|
|
45
|
+
|
|
46
|
+
| Component | Before | After |
|
|
47
|
+
| -------------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
48
|
+
| AppShell | `user`, `secondaryNavigation` | `footer`, `sections` |
|
|
49
|
+
| AppShell | main element `id="m-content"` | a generated id; use the skip link instead of a hard-coded `#m-content` |
|
|
50
|
+
| AppShell, DocsShell | Menu button `aria-label="Open navigation"` | named by its visible text "Menu", with `aria-haspopup="dialog"` and `aria-expanded` |
|
|
51
|
+
| StackedList | `heading` | `title`, plus `headingLevel`. Group headings sit one level below the title; without a title they use `headingLevel`. `items` and `groups` both render. |
|
|
52
|
+
| DataTable | `emptyTitle`, `emptyDescription`; column `align: "left" \| "right"` | `empty`; `align: "start" \| "end"` |
|
|
53
|
+
| Table | column `align: "left" \| "right"` | `align: "start" \| "end"` |
|
|
54
|
+
| GridList | `headingLevel` (item headings) | `itemHeadingLevel` |
|
|
55
|
+
| Card | `tone="sunken"` | `tone="muted"` |
|
|
56
|
+
| Text | `tone` | `color` (`data-color`) |
|
|
57
|
+
| ProgressBar | `tone: "primary" \| "warning" \| "danger"` | `tone: Tone`; omit it for the accent colour |
|
|
58
|
+
| CommandPalette | `emptyMessage` | `empty={{ title, description? }}` |
|
|
59
|
+
| Navbar, StackedShell | `NavigationItem[]` (children silently dropped) | `LinkItem[]` |
|
|
60
|
+
| Sidebar | group labels are `<h2>` | `<p class="m-nav-group-title">` in a labelled group; a parent with children lists its own page as the first link |
|
|
61
|
+
|
|
62
|
+
### Marketing and docs
|
|
63
|
+
|
|
64
|
+
| Component | Before | After |
|
|
65
|
+
| --------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| Hero | `align="center" \| "split"` | `layout="center" \| "split"` |
|
|
67
|
+
| FAQ | `layout="centered"` | `layout="center"` |
|
|
68
|
+
| CallToAction, Pricing | `tone: MarketingTone` (`surface \| inverse`) | `tone: SectionTone` (`default \| muted \| inverse`); `MarketingTone` is removed |
|
|
69
|
+
| BentoGrid | item `inverse: boolean` | item `tone: SectionTone` |
|
|
70
|
+
| Footer | `columns: { title, links }[]`; `headingLevel` | `columns: { label, items }[]`; `itemHeadingLevel` |
|
|
71
|
+
| Steps | `headingLevel` | `itemHeadingLevel` |
|
|
72
|
+
| DocsShell | `navigation: { title, items }[]` | `navigation: { label, items }[]`; icons, counts and nested items now render |
|
|
73
|
+
| Callout | `type: "note" \| "tip" \| "warning" \| "breaking"` | `tone: Tone`: `tip` becomes `success`, and `breaking` becomes `tone="danger" title="Breaking"`. It renders `role="note"` instead of `<aside>`. |
|
|
74
|
+
| LinkCards | item `title` | item `label` |
|
|
75
|
+
| PagerLinks | landmark named "Pagination" | `label`, default "Previous and next page" |
|
|
76
|
+
| Pricing | availability text in `role="status"` | plain text with class `m-pricing-availability` |
|
|
77
|
+
|
|
78
|
+
### Ecommerce and calendar
|
|
79
|
+
|
|
80
|
+
| Component | Before | After |
|
|
81
|
+
| --------------------------------- | ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
82
|
+
| ProductList | `headingLevel` (item headings); `variant="card"` | `itemHeadingLevel`; `variant="cards"` |
|
|
83
|
+
| ProductOverview, ProductQuickView | `adding`; the add button was `type="button"` | `loading`; the add button submits the form, so `onAdd` fires once on submit |
|
|
84
|
+
| ProductFilters | `onChange(facet, values)` | `onChange(selected)` with the complete next selection |
|
|
85
|
+
| ShoppingCart | `emptyTitle`, `emptyDescription`, `emptyAction` | `empty`, plus `headingLevel` |
|
|
86
|
+
| OrderHistory | `emptyTitle`, `emptyDescription` | `empty` |
|
|
87
|
+
| Calendar | `onDateChange` fired only from the week and day views | it fires from every view, and so does `onMonthChange`; a new controlled `date` prop |
|
|
88
|
+
|
|
89
|
+
## Markup and styling
|
|
90
|
+
|
|
91
|
+
Read this section if your own CSS or tests select on Mortar markup.
|
|
92
|
+
|
|
93
|
+
- **Data attributes follow one rule.** A boolean attribute is present only when true (`data-x="true"`). An enum attribute is omitted for its default value. Selectors such as `[data-divider="false"]` or `[data-variant="compact"]` no longer match. Use `:not([data-divider])` or the component's own props instead.
|
|
94
|
+
- **Section and component titles carry classes** such as `m-section-title`, `m-card-title` and `m-product-name`. Styles no longer depend on the title being an `h2`.
|
package/docs/theming.md
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# Theming
|
|
2
|
+
|
|
3
|
+
Mortar's look comes from tokens in [`src/tokens.css`](../src/tokens.css), in three layers:
|
|
4
|
+
|
|
5
|
+
1. **Brand contract.** A brand declares `--m-brand-l-*` (light), `--m-brand-d-*` (dark) and the brand fonts (`--m-brand-font`, `--m-brand-font-heading`), and nothing else. Every value is optional.
|
|
6
|
+
2. **Appearance.** Light and dark resolve the contract into the live tokens (`--m-primary`, `--m-link`, …) and into the `--m-inv-*` tokens that inverse sections use. So dark mode and `tone="inverse"` never need brand-specific CSS.
|
|
7
|
+
3. **Scales.** Type, space, radius, sizing, density and stacking. These are appearance-independent.
|
|
8
|
+
|
|
9
|
+
Components only read the live `--m-*` tokens. Never style components directly; change tokens.
|
|
10
|
+
|
|
11
|
+
## Choosing a brand, appearance and density
|
|
12
|
+
|
|
13
|
+
```tsx
|
|
14
|
+
<ThemeProvider brand="green" initialAppearance="system" density="compact">
|
|
15
|
+
<App />
|
|
16
|
+
</ThemeProvider>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
ThemeProvider writes `data-mortar-brand`, `data-mortar-appearance` and `data-mortar-density` on the document root, or on a wrapper with `scope="container"`. Menus, listboxes and dialogs opened inside a container keep its theme.
|
|
20
|
+
|
|
21
|
+
## Adding a brand
|
|
22
|
+
|
|
23
|
+
### In CSS
|
|
24
|
+
|
|
25
|
+
A brand is one rule. This is enough; everything else derives from it:
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
[data-mortar-brand="acme"] {
|
|
29
|
+
--m-brand-l-primary: #1d4ed8;
|
|
30
|
+
--m-brand-l-on-primary: #ffffff;
|
|
31
|
+
--m-brand-d-primary: #93c5fd;
|
|
32
|
+
--m-brand-d-on-primary: #0b1f4d;
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
<ThemeProvider brand="acme">…</ThemeProvider>
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
The full contract, for each of `l` (light) and `d` (dark):
|
|
41
|
+
|
|
42
|
+
| Token | Default when omitted |
|
|
43
|
+
| ----------------------------------- | --------------------------------------------------- |
|
|
44
|
+
| `--m-brand-*-primary` | Neutral (black in light, white in dark) |
|
|
45
|
+
| `--m-brand-*-primary-hover` | `primary` mixed 85% with ink |
|
|
46
|
+
| `--m-brand-*-primary-active` | `primary` mixed 72% with ink |
|
|
47
|
+
| `--m-brand-*-on-primary` | Neutral (white in light, black in dark) |
|
|
48
|
+
| `--m-brand-*-accent` / `-on-accent` | `primary` / `on-primary` |
|
|
49
|
+
| `--m-brand-*-focus` | `primary` |
|
|
50
|
+
| `--m-brand-*-link` | `primary` in light, ink in dark |
|
|
51
|
+
| `--m-brand-*-selected` | `primary` tinted into the page (8% light, 10% dark) |
|
|
52
|
+
| `--m-brand-*-paper` | `#ffffff` in light, `#000000` in dark |
|
|
53
|
+
| `--m-brand-*-surface` | `#ffffff` in light, `#0e0e10` in dark |
|
|
54
|
+
| `--m-brand-*-sunken` | `#f5f5f7` in light, `#1c1c1e` in dark |
|
|
55
|
+
| `--m-brand-*-accent-gradient` | None: accent fills are the flat `accent` |
|
|
56
|
+
|
|
57
|
+
And once for both appearances:
|
|
58
|
+
|
|
59
|
+
| Token | Default when omitted |
|
|
60
|
+
| ------------------------ | -------------------------------------- |
|
|
61
|
+
| `--m-brand-font` | `--m-font` (the system stack) |
|
|
62
|
+
| `--m-brand-font-heading` | `--m-font-heading`, else the body font |
|
|
63
|
+
|
|
64
|
+
Hover and active mix towards **ink** (darker in light, lighter in dark), so text on them keeps at least the contrast it has on `primary`. Check `on-primary` against `primary` yourself (4.5:1 or more); Mortar's own brands are tested in `tests/tone-contrast.test.mjs`.
|
|
65
|
+
|
|
66
|
+
A brand never inherits values from an outer brand: every `[data-mortar-brand]` starts from an empty contract.
|
|
67
|
+
|
|
68
|
+
### Surfaces
|
|
69
|
+
|
|
70
|
+
`paper` is the page, `surface` is cards, panels and raised controls, and `sunken` is wells, muted bands and hover fills. Inverse bands use the other appearance's surfaces: a brand with a navy dark page (`--m-brand-d-paper: #0b1f3a`) gets navy inverse bands on light pages too. Without a brand surface, an inverse band on a light page keeps `#000000` / `#1c1c1e` / `#1c1c1e`, and on a dark page `#f5f5f7` / `#ffffff` / `#ffffff`. `selected` is tinted into the brand's page.
|
|
71
|
+
|
|
72
|
+
Text, line and status colours stay Mortar's, so a tinted surface is the brand's contrast responsibility. Muted text and danger are the lowest text, and strong lines (control borders, 3:1) the lowest overall: on a navy dark page, `#0b1f3a` (paper), `#0d2341` (surface) and `#0f2848` (sunken) keep every text and status colour at 4.5:1 and strong lines at 3:1, but a lighter sunken such as `#143156` drops strong lines to 2.7:1, and `#16365f` drops muted text to 4.2:1. The brand builder's contrast table checks text, muted text, links and strong lines on all three surfaces.
|
|
73
|
+
|
|
74
|
+
### Accent gradients
|
|
75
|
+
|
|
76
|
+
`accentGradient` (`--m-brand-*-accent-gradient`) is an optional fill for components that opt in: Button and ActionLink `variant="accent"`, and `tone="accent"` on CallToAction, Hero and Banner. Everything else, including the derived hover, active and selected colours, keeps reading the flat `accent`, which should be a colour close to the gradient.
|
|
77
|
+
|
|
78
|
+
```css
|
|
79
|
+
[data-mortar-brand="sunset"] {
|
|
80
|
+
--m-brand-l-accent: #c2410c;
|
|
81
|
+
--m-brand-l-on-accent: #ffffff;
|
|
82
|
+
--m-brand-l-accent-gradient: linear-gradient(90deg, #c2410c, #9a3412);
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- The value is a `linear-gradient()`, `radial-gradient()` or `conic-gradient()`, or a `repeating-` form, with at least two colour stops. Angles, positions, percentages and keywords such as `to right` are allowed; `url()` and other images are not.
|
|
87
|
+
- The appearance exposes it as `--m-accent-fill`, an image. Without a gradient it is a flat gradient of `--m-accent`, so paint it as `background-image: var(--m-accent-fill)` over `background-color: var(--m-accent)`. It is not a colour and cannot be used where one is expected. Inverse bands use `--m-inv-accent-fill`, from the other appearance.
|
|
88
|
+
- Text on it is `on-accent`, which must reach 4.5:1 against **every** stop. `tests/tone-contrast.test.mjs` checks a sample gradient brand, and the brand builder checks yours stop by stop.
|
|
89
|
+
- In an accent band, copy and links use `on-accent`, primary actions invert (an `on-accent` button with accent text, the same pair) and secondary actions are outlined. Media and Hero previews keep the page's colours.
|
|
90
|
+
|
|
91
|
+
### Fonts
|
|
92
|
+
|
|
93
|
+
`fonts: { body, heading }` (`--m-brand-font`, `--m-brand-font-heading`) are font-family lists, the same in light and dark. They apply wherever the brand does, including a container-scoped ThemeProvider. Headings (`h1`–`h6` inside Mortar) and component titles use the heading font; without one they keep the body font. Mortar loads no font files: load them in your app (for example with `@font-face` or your framework's font loader) and name them here. An entry may be a bare `var(--name)`, as framework font loaders such as `next/font` hand out (`var(--font-inter), system-ui, sans-serif`); a `var()` with a fallback, `url()` and `local()` are rejected.
|
|
94
|
+
|
|
95
|
+
### At runtime, from data
|
|
96
|
+
|
|
97
|
+
For tenant-specific colours from a database or CMS, pass `brandTokens`:
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
<ThemeProvider brand={tenant.slug} brandTokens={tenant.theme}>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
tenant.theme = {
|
|
105
|
+
light: { primary: "#1d4ed8", onPrimary: "#ffffff" },
|
|
106
|
+
dark: { primary: "#93c5fd", onPrimary: "#0b1f4d" },
|
|
107
|
+
};
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
ThemeProvider renders the brand as a `<style>` element beside its content. It is included in server-rendered HTML, so there is no unstyled flash; it updates when the tokens change and is removed with the provider. Overlays opened inside the provider share it, because the rule matches the brand attribute they carry. Brand names may contain only letters, digits and hyphens. Every value is checked, and anything else throws an error that names the key (such as `dark.accent`), so tenant data cannot inject CSS and a value of the wrong kind cannot fail silently:
|
|
111
|
+
|
|
112
|
+
- **Colours** are hex, a colour name (including `transparent` and `currentcolor`), or `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `lab()`, `lch()`, `oklab()`, `oklch()`, `color()`, `color-mix()`, `light-dark()` or `var(--name)`. Arguments may nest those functions and `calc()`, `min()`, `max()` or `clamp()`. A gradient, `url()`, top-level `calc()`, `attr()` or `image-set()` is rejected.
|
|
113
|
+
- **`accentGradient`** is a gradient whose stops follow the colour rules (see [Accent gradients](#accent-gradients)).
|
|
114
|
+
- **Fonts** are family names, quoted names and generic families separated by commas, such as `"Inter", sans-serif`. Functions and `url()` are rejected.
|
|
115
|
+
- `;`, `{`, `}`, `<`, `>`, comments and, outside font names, quotes are always rejected.
|
|
116
|
+
|
|
117
|
+
`isBrandColour`, `isBrandGradient` and `isBrandFont` from `@tonycodes-ui/mortar/brand` run the same checks, for validating a form before you save a tenant's theme.
|
|
118
|
+
|
|
119
|
+
Outside ThemeProvider, `@tonycodes-ui/mortar/brand` has the same building blocks:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import {
|
|
123
|
+
brandToCss,
|
|
124
|
+
brandToStyle,
|
|
125
|
+
registerBrand,
|
|
126
|
+
} from "@tonycodes-ui/mortar/brand";
|
|
127
|
+
|
|
128
|
+
brandToCss("acme", theme); // a CSS rule, e.g. for a build step
|
|
129
|
+
const dispose = registerBrand("acme", theme); // adds a <style>; reference-counted
|
|
130
|
+
brandToStyle(theme); // inline custom properties for one element
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Inline custom properties apply only to that element's subtree. Menus and dialogs portal outside it, so prefer `brandTokens` or `registerBrand` for anything with overlays.
|
|
134
|
+
|
|
135
|
+
### With the brand builder
|
|
136
|
+
|
|
137
|
+
The playground's [brand builder](https://mortar.tony.codes/playground/?page=brand) (`/playground/?page=brand`) designs a brand against this contract. Start from a preset, set the light and dark primary pairs, and open Advanced for the optional tokens, surfaces and accent gradient; empty fields show the value they derive. Set heading and body fonts below. Light and dark previews, each with an inverse band, an accent band, an accent button, a heading sample and overlays, update as you type, and a table checks contrast: 4.5:1 for text on primary, hover, active and accent, for links, text and muted text on page, surface and sunken, and for on-accent text on the accent fill (every gradient stop) at rest, hover and active; 3:1 for strong lines on page, surface and sunken, and for the focus ring. Copy the result as the CSS rule (from `brandToCss`) or as the `brandTokens` object. The brand is kept in the URL, so a link shares it.
|
|
138
|
+
|
|
139
|
+
## Appearance before JavaScript runs
|
|
140
|
+
|
|
141
|
+
Until ThemeProvider sets `data-mortar-appearance` (during server rendering, or with JavaScript off), Mortar follows the operating system's colour scheme, so dark-mode users don't see a light flash. To always start light, render `data-mortar-appearance="light"` on `<html>`.
|
|
142
|
+
|
|
143
|
+
## Scales
|
|
144
|
+
|
|
145
|
+
Override these on `:root` or on any container to retheme shape and rhythm:
|
|
146
|
+
|
|
147
|
+
- **Type:** `--m-font`, `--m-font-heading` (headings and component titles; unset by default, so they keep the body font), `--m-mono`, `--m-weight-regular|medium|semibold|bold`, `--m-tracking-display|heading|body|caps`, `--m-leading-display|heading|body`.
|
|
148
|
+
- **Space:** `--m-space-1|2|3|4|6|8|10|12` (4px to 48px).
|
|
149
|
+
- **Radius:** `--m-radius-xs|sm`, `--m-radius`, `--m-radius-lg|xl|full`.
|
|
150
|
+
- **Sizing and density:** `--m-control`, `--m-pad`, `--m-font-size`, `--m-row-pad`, `--m-icon`. `data-mortar-density="compact" | "spacious"` adjusts these.
|
|
151
|
+
- **Stacking:** `--m-z-sticky|overlay|popover|toast|skip`.
|
|
152
|
+
|
|
153
|
+
What follows the scales, and what does not:
|
|
154
|
+
|
|
155
|
+
- **Weight:** every component font weight reads `--m-weight-*`.
|
|
156
|
+
- **Radius:** every component corner that is 2, 4, 8, 12, 16 or 999px reads the matching `--m-radius-*` token. Circles (`50%`) and a few in-between sizes (5, 6, 7, 10 and 44px, and `0.5rem` in CodeBlock and PreviewFrame) stay fixed.
|
|
157
|
+
- **Tracking:** display titles and big numbers read `--m-tracking-display`, headings `--m-tracking-heading`, uppercase labels `--m-tracking-caps`, and text that resets to normal reads `--m-tracking-body`. Titles tuned to other values, such as the hero's `-0.045em` or section titles' `-0.035em`, stay fixed.
|
|
158
|
+
- **Leading:** display titles read `--m-leading-display`, testimonial quotes `--m-leading-heading`, and long-form text `--m-leading-body`. Other line heights stay fixed, including compact table and calendar text at 1.3, which does not follow `--m-leading-heading`.
|
|
159
|
+
- **Space:** component padding, gaps and margins do not read `--m-space-*` yet. Use the space scale in your own layouts.
|
|
160
|
+
|
|
161
|
+
## Tailwind utilities
|
|
162
|
+
|
|
163
|
+
`@tonycodes-ui/mortar/styles.css` maps tokens into Tailwind's theme, so utilities follow a retheme:
|
|
164
|
+
|
|
165
|
+
- **Colours:** `paper`, `surface`, `sunken`, `ink`, `secondary`, `muted`, `line`, `primary`, `on-primary`, `accent`, `on-accent`, `focus`, `link`, `selected`, `success`, `warning`, `danger`, `info`. For example `bg-primary` or `text-muted`.
|
|
166
|
+
- **Radius:** `rounded-m-xs`, `rounded-m-sm`, `rounded-m`, `rounded-m-lg`, `rounded-m-xl`, `rounded-m-full`. Tailwind's own `rounded-sm/md/lg` keep their usual sizes.
|
|
167
|
+
- **Shadow:** `shadow-float`.
|
|
168
|
+
- **Tracking:** `tracking-display`, `tracking-heading`, `tracking-caps`.
|
|
169
|
+
- **Fonts:** `font-sans` (a brand's body font, else `--m-font`), `font-heading` (the brand's heading font, else `--m-font-heading`, else the body font) and `font-mono`.
|
|
170
|
+
- **Weights:** `font-medium`, `font-semibold` and `font-bold` follow `--m-weight-*`.
|