@tonycodes-ui/mortar 0.11.0 → 0.13.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 +1 -1
- package/CHANGELOG.md +16 -47
- package/CONTRIBUTING.md +14 -2
- package/DESIGN.md +1 -1
- package/README.md +1 -1
- package/catalog.json +610 -377
- package/cli/audit.mjs +152 -0
- package/dist/agent-data.json +1 -1
- package/dist/application.d.ts +30 -5
- package/dist/application.js +20 -8
- package/dist/illustration.d.ts +47 -0
- package/dist/illustration.js +47 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/inspection.js +17 -1
- package/dist/internal.d.ts +39 -3
- package/dist/internal.js +74 -4
- package/dist/marketing.d.ts +33 -9
- package/dist/marketing.js +23 -14
- package/dist/primitives.js +20 -11
- package/docs/agents.md +21 -18
- package/docs/theming.md +72 -1
- package/package.json +1 -1
- package/skills/mortar/SKILL.md +1 -1
- package/src/application.css +95 -1
- package/src/application.tsx +88 -19
- package/src/data.css +58 -2
- package/src/foundation.css +10 -0
- package/src/illustration.tsx +169 -0
- package/src/index.ts +6 -0
- package/src/inspection.tsx +15 -0
- package/src/internal.tsx +180 -3
- package/src/marketing.css +181 -2
- package/src/marketing.tsx +103 -94
- package/src/primitives.tsx +38 -19
package/dist/marketing.js
CHANGED
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { IconLinks, KitLink, } from "./navigation.js";
|
|
3
3
|
import { useId, useState } from "react";
|
|
4
|
-
import {
|
|
4
|
+
import { DialogPanel, DialogTitle } from "@headlessui/react";
|
|
5
5
|
import { ActionLink, Button } from "./primitives.js";
|
|
6
6
|
import { BrandMark, Prose } from "./layout.js";
|
|
7
7
|
import { SegmentedControl } from "./inspection.js";
|
|
8
8
|
import { ChevronDown } from "./icons.js";
|
|
9
9
|
import { StatGroup } from "./marketing-sections.js";
|
|
10
10
|
import { childLevel, headingTag } from "./heading.js";
|
|
11
|
-
import { ActionLinks, DialogFrame, TermList, TitledIntro, cx, componentRoot, titledIntroSlots, sectionTone, useDomId, } from "./internal.js";
|
|
12
|
-
function
|
|
13
|
-
const dataLayout = layout === "list" ? undefined : layout;
|
|
14
|
-
return (_jsxs(Popover, { className: "m-flyout", "data-layout": dataLayout, children: [_jsxs(PopoverButton, { className: "m-flyout-trigger", children: [label, _jsx(ChevronDown, {})] }), _jsxs(PopoverPanel, { anchor: "bottom", className: "m-flyout-panel", "data-layout": dataLayout, children: [_jsx("ul", { children: items.map((item, index) => (_jsx("li", { children: _jsxs(CloseButton, { as: KitLink, href: item.href, onClick: onNavigate, children: [item.icon && (_jsx("span", { className: "m-flyout-icon", "aria-hidden": "true", children: item.icon })), _jsxs("span", { className: "m-flyout-text", children: [_jsx("strong", { children: item.label }), item.description && _jsx("span", { children: item.description })] })] }) }, `${item.label}:${item.href}:${index}`))) }), footer.length > 0 && (_jsx("div", { className: "m-flyout-footer", children: footer.map((link, index) => (_jsx(CloseButton, { as: KitLink, href: link.href, onClick: onNavigate, children: link.label }, `${link.label}:${link.href}:${index}`))) }))] })] }));
|
|
15
|
-
}
|
|
16
|
-
export function MarketingHeader({ brand, homeHref = "/", logo, links, action, controls, layout = "inline", mobileMenu = "inline", className, id, }) {
|
|
11
|
+
import { ActionLinks, DialogFrame, Flyout, TermList, TitledIntro, cx, componentRoot, titledIntroSlots, sectionTone, useDomId, } from "./internal.js";
|
|
12
|
+
export function MarketingHeader({ brand, tagline, homeHref = "/", logo, links, action, controls, layout = "inline", mobileMenu = "inline", className, id, }) {
|
|
17
13
|
const [open, setOpen] = useState(false);
|
|
18
14
|
// Generated so two headers on one page keep distinct, valid references.
|
|
19
15
|
const navId = useDomId("m-marketing-nav");
|
|
20
16
|
const panel = mobileMenu === "panel";
|
|
21
17
|
const close = () => setOpen(false);
|
|
22
|
-
const
|
|
18
|
+
const homeLink = (_jsxs(KitLink, { className: "m-brand", href: homeHref, children: [_jsx(BrandMark, { logo: logo }), brand] }));
|
|
19
|
+
// The tagline sits outside the link so the link's name stays the brand.
|
|
20
|
+
const brandLink = tagline ? (_jsxs("div", { className: "m-brand-lockup", children: [homeLink, _jsx("span", { className: "m-brand-tagline", children: tagline })] })) : (homeLink);
|
|
23
21
|
const menuButton = (_jsx(Button, { variant: "ghost", className: "m-menu-trigger", "aria-haspopup": panel ? "dialog" : undefined, "aria-expanded": open, "aria-controls": panel ? undefined : navId, onClick: () => setOpen(!open), children: "Menu" }));
|
|
24
22
|
const nav = (_jsx("nav", { id: navId, "aria-label": "Primary", "data-open": (open && !panel) || undefined, children: links.map((link, index) => "items" in link ? (_jsx(Flyout, { label: link.label, items: link.items, footer: link.footer, layout: link.layout, onNavigate: close }, `${link.label}:flyout:${index}`)) : (_jsx(KitLink, { href: link.href, onClick: close, children: link.label }, `${link.label}:${link.href}:${index}`))) }));
|
|
25
23
|
const actions = (controls || action) && (_jsxs(_Fragment, { children: [controls, action && _jsx(ActionLink, { ...action })] }));
|
|
@@ -29,8 +27,15 @@ export function MarketingHeader({ brand, homeHref = "/", logo, links, action, co
|
|
|
29
27
|
function PanelLinks({ links, onNavigate, }) {
|
|
30
28
|
return (_jsx("nav", { "aria-label": "Primary", className: "m-marketing-panel-nav", children: links.map((link, index) => "items" in link ? (_jsxs("details", { children: [_jsxs("summary", { children: [link.label, _jsx(ChevronDown, {})] }), [...link.items, ...(link.footer ?? [])].map((item, i) => (_jsx(KitLink, { href: item.href, onClick: onNavigate, children: item.label }, `${item.label}:${item.href}:${i}`)))] }, `${link.label}:group:${index}`)) : (_jsx(KitLink, { href: link.href, onClick: onNavigate, children: link.label }, `${link.label}:${link.href}:${index}`))) }));
|
|
31
29
|
}
|
|
32
|
-
|
|
33
|
-
|
|
30
|
+
/**
|
|
31
|
+
* A public page: the header, a main region that grows to fill the viewport, and
|
|
32
|
+
* the footer. Content is never clipped; a longer page simply scrolls.
|
|
33
|
+
*/
|
|
34
|
+
export function MarketingPage({ header, footer, children, align = "start", id, className, }) {
|
|
35
|
+
return (_jsxs("div", { id: id, className: cx("m-marketing-page", className), ...componentRoot("MarketingPage"), "data-align": align === "center" ? "center" : undefined, children: [header, _jsx("main", { className: "m-marketing-main", children: children }), footer] }));
|
|
36
|
+
}
|
|
37
|
+
export function Hero({ id, className, eyebrow, title, emphasis, description, actions = [], note, children, layout = "center", wide, headingLevel = 1, background, images = [], tone = "default", }) {
|
|
38
|
+
return (_jsxs("section", { id: id, className: cx("m-hero", `m-hero-${layout}`, className), ...componentRoot("Hero"), "data-wide": layout === "split" ? wide : undefined, "data-background": background ? true : undefined, "data-tone": background ? "inverse" : sectionTone(tone), children: [background && (_jsx("div", { className: "m-hero-background", "aria-hidden": "true", children: background })), _jsxs("div", { children: [_jsx(TitledIntro, { eyebrow: eyebrow, title: title, emphasis: emphasis, headingLevel: headingLevel }), _jsx("p", { className: "m-hero-description", children: description }), _jsx(ActionLinks, { actions: actions }), note && _jsx("div", { className: "m-hero-note", children: note })] }), images.length > 0 && (_jsx("div", { className: "m-hero-tiles", children: images.map((image, index) => (_jsx("div", { children: image }, index))) })), children && _jsx("div", { className: "m-hero-preview", children: children })] }));
|
|
34
39
|
}
|
|
35
40
|
export function FeatureGrid({ id, className, eyebrow, title, description, items, marker = "auto", columns, tone = "default", headingLevel = 2, }) {
|
|
36
41
|
const ItemHeading = headingTag(childLevel(headingLevel));
|
|
@@ -115,13 +120,17 @@ export function CallToAction({ id, className, title, description, actions, tone
|
|
|
115
120
|
const copy = (_jsxs(_Fragment, { children: [_jsx(TitledIntro, { title: title, description: description, descriptionClassName: null, headingLevel: headingLevel }), _jsx(ActionLinks, { actions: actions })] }));
|
|
116
121
|
return (_jsx("section", { id: id, className: cx("m-cta", className), ...componentRoot("CallToAction"), "data-tone": sectionTone(tone), "data-layout": media ? "split" : undefined, children: media ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "m-cta-copy", children: copy }), _jsx("div", { className: "m-cta-media", children: media })] })) : (copy) }));
|
|
117
122
|
}
|
|
118
|
-
export function Footer({ id, className, brand, logo, description, note, columns, copyright, legal = [], itemHeadingLevel = 2, social = [], layout = "columns", newsletter, }) {
|
|
123
|
+
export function Footer({ id, className, brand, logo, description, note, columns = [], copyright, legal = [], itemHeadingLevel = 2, social = [], layout = "columns", newsletter, }) {
|
|
119
124
|
const Heading = headingTag(itemHeadingLevel);
|
|
120
125
|
const center = layout === "center";
|
|
121
|
-
const
|
|
126
|
+
const inline = layout === "inline";
|
|
127
|
+
const identity = (_jsxs(_Fragment, { children: [_jsxs("strong", { className: "m-brand", children: [_jsx(BrandMark, { logo: logo }), brand] }), description && _jsx("p", { children: description }), note && _jsx("p", { children: note })] }));
|
|
122
128
|
const socialLinks = social.length > 0 && (_jsx(IconLinks, { label: "Social media", links: social }));
|
|
123
|
-
const navs = columns.map((column) => (_jsxs("nav", { "aria-label": column.label, children: [!center && _jsx(Heading, { children: column.label }), column.items.map((link, index) => (_jsx(KitLink, { href: link.href, children: link.label }, `${link.label}:${link.href}:${index}`)))] }, column.label)));
|
|
124
|
-
|
|
129
|
+
const navs = columns.map((column) => (_jsxs("nav", { "aria-label": column.label, children: [!center && !inline && _jsx(Heading, { children: column.label }), column.items.map((link, index) => (_jsx(KitLink, { href: link.href, children: link.label }, `${link.label}:${link.href}:${index}`)))] }, column.label)));
|
|
130
|
+
const legalLinks = (_jsx("div", { children: legal.map((link, index) => (_jsx(KitLink, { href: link.href, children: link.label }, `${link.label}:${link.href}:${index}`))) }));
|
|
131
|
+
if (inline)
|
|
132
|
+
return (_jsxs("footer", { id: id, className: cx("m-footer", className), ...componentRoot("Footer"), "data-layout": "inline", children: [_jsxs("div", { className: "m-footer-inline", children: [identity, navs, socialLinks, _jsx("span", { children: copyright }), legal.length > 0 && legalLinks] }), newsletter && _jsx("div", { className: "m-footer-newsletter", children: newsletter })] }));
|
|
133
|
+
return (_jsxs("footer", { id: id, className: cx("m-footer", className), ...componentRoot("Footer"), "data-layout": center ? "center" : undefined, children: [center ? (_jsxs("div", { className: "m-footer-center", children: [identity, navs, socialLinks] })) : (_jsxs("div", { className: "m-footer-grid", children: [_jsxs("div", { children: [identity, socialLinks] }), navs] })), newsletter && _jsx("div", { className: "m-footer-newsletter", children: newsletter }), _jsxs("div", { className: "m-footer-bottom", children: [_jsx("span", { children: copyright }), legalLinks] })] }));
|
|
125
134
|
}
|
|
126
135
|
/** Intro band for inner pages (about, pricing, careers), with optional links and stats. */
|
|
127
136
|
export function HeaderSection({ id, className, eyebrow, title, description, links = [], stats = [], tone = "default", align = "start", headingLevel = 1, }) {
|
package/dist/primitives.js
CHANGED
|
@@ -80,14 +80,16 @@ function DescribedChoice({ component, input, id, label, description, error, requ
|
|
|
80
80
|
.join(" ") || undefined;
|
|
81
81
|
return (_jsxs("div", { ...componentRoot(component), className: cx("m-choice", "m-choice-described", className), children: [input({ id: fieldId, "aria-describedby": ids }), _jsxs("div", { className: "m-choice-text", children: [_jsxs("label", { htmlFor: fieldId, children: [label, required && _jsx(RequiredMark, {})] }), description && _jsx("p", { id: descriptionId, children: description }), _jsx(FieldFeedback, { id: messageId, error: error })] })] }));
|
|
82
82
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Keeps a choice in the described layout once it has had a description or
|
|
85
|
+
* error. Switching back to the plain label would remount the input and drop
|
|
86
|
+
* focus mid-interaction (for example a description that changes with the
|
|
87
|
+
* value). If the layout changes while the input has focus, focus follows it.
|
|
88
|
+
*/
|
|
89
|
+
function useDescribedLayout(active, input) {
|
|
88
90
|
const [described, setDescribed] = useState(false);
|
|
89
91
|
const refocus = useRef(false);
|
|
90
|
-
if (
|
|
92
|
+
if (active && !described) {
|
|
91
93
|
refocus.current =
|
|
92
94
|
typeof document !== "undefined" &&
|
|
93
95
|
!!input.current &&
|
|
@@ -98,15 +100,22 @@ export function Checkbox({ label, description, error, className, ...props }) {
|
|
|
98
100
|
if (refocus.current)
|
|
99
101
|
input.current?.focus();
|
|
100
102
|
refocus.current = false;
|
|
101
|
-
}, [described]);
|
|
102
|
-
|
|
103
|
+
}, [described, input]);
|
|
104
|
+
return described;
|
|
105
|
+
}
|
|
106
|
+
export function Checkbox({ label, description, error, className, ...props }) {
|
|
107
|
+
const input = useRef(null);
|
|
108
|
+
const described = useDescribedLayout(!!(description || error), input);
|
|
109
|
+
if (described)
|
|
103
110
|
return (_jsx(DescribedChoice, { component: "Checkbox", id: props.id, label: label, description: description, error: error, required: props.required, describedBy: props["aria-describedby"], className: className, input: (attrs) => (_jsx("input", { ...props, ...attrs, ref: input, "aria-invalid": error ? true : props["aria-invalid"], type: "checkbox" })) }));
|
|
104
111
|
return (_jsxs("label", { ...componentRoot("Checkbox"), className: cx("m-choice", className), children: [_jsx("input", { ...props, ref: input, type: "checkbox" }), label, props.required && _jsx(RequiredMark, {})] }));
|
|
105
112
|
}
|
|
106
113
|
export function Switch({ label, description, className, ...props }) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
const input = useRef(null);
|
|
115
|
+
const described = useDescribedLayout(!!description, input);
|
|
116
|
+
if (described)
|
|
117
|
+
return (_jsx(DescribedChoice, { component: "Switch", id: props.id, label: label, description: description, className: className, input: (attrs) => (_jsx("input", { ...props, ...attrs, ref: input, type: "checkbox", role: "switch", className: "m-switch" })) }));
|
|
118
|
+
return (_jsxs("label", { ...componentRoot("Switch"), className: cx("m-choice", className), children: [_jsx("input", { ...props, ref: input, type: "checkbox", role: "switch", className: "m-switch" }), label] }));
|
|
110
119
|
}
|
|
111
120
|
export function RadioGroup({ label, name, value, onChange, options, layout = "list", hint, error, required, id, className, }) {
|
|
112
121
|
const { messageId, describedBy } = useField({ id, hint, error });
|
package/docs/agents.md
CHANGED
|
@@ -94,27 +94,30 @@ Add `--json` for machine-readable output. `mortar audit` exits with status 1 whe
|
|
|
94
94
|
|
|
95
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
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
|
-
| `
|
|
110
|
-
| `styles
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
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
|
+
| `raw-element` | error | A raw `<table>`, `<select>`, `<textarea>`, `<dialog>`, `<progress>` or `<input>` (by type; not `hidden`, `file`, `range`, `color` or `image`) anywhere in an app that uses Mortar, naming the component to use instead. A raw `<button>` is a warning. The mapping comes from each contract's `replaces` field, so new components extend it |
|
|
110
|
+
| `missing-styles` | error | No import of `@tonycodes-ui/mortar/styles.css` |
|
|
111
|
+
| `styles-order` | warning | Mortar's styles imported before Tailwind |
|
|
112
|
+
| `missing-theme-provider` | warning | Mortar components rendered without a `ThemeProvider` |
|
|
113
|
+
| `kit-class` | warning | Mortar-internal class names on the app's own markup |
|
|
114
|
+
| `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) |
|
|
115
|
+
| `limited-data` | info | The installed release has no agent data, so a newer catalogue was used |
|
|
115
116
|
|
|
116
117
|
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
|
|
|
119
|
+
A clean audit means the audit found none of the problems above. It is not proof that a page is built only from Mortar components: hand-written layout, typography or animation in the app's own markup is invisible to it unless it touches Mortar's classes or colours. Do not report a passing audit as that proof. To show a page is composed from the kit, point to the components it renders (each root carries `data-m-component`) and to [what you can extend](theming.md#what-you-can-extend).
|
|
120
|
+
|
|
118
121
|
## Agent skill
|
|
119
122
|
|
|
120
123
|
[`skills/mortar/SKILL.md`](../skills/mortar/SKILL.md) is a ready-made skill with these workflow defaults. For Claude Code, install it once:
|
package/docs/theming.md
CHANGED
|
@@ -107,7 +107,7 @@ tenant.theme = {
|
|
|
107
107
|
};
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
ThemeProvider renders the brand as
|
|
110
|
+
ThemeProvider renders the brand as an inline `<style>` element beside its content, so it needs a Content Security Policy that allows inline styles (see [Under a strict Content Security Policy](#under-a-strict-content-security-policy)). 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
111
|
|
|
112
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
113
|
- **`accentGradient`** is a gradient whose stops follow the colour rules (see [Accent gradients](#accent-gradients)).
|
|
@@ -132,10 +132,81 @@ brandToStyle(theme); // inline custom properties for one element
|
|
|
132
132
|
|
|
133
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
134
|
|
|
135
|
+
### Under a strict Content Security Policy
|
|
136
|
+
|
|
137
|
+
`brandTokens` and `registerBrand` add an inline `<style>` element. Under a policy of `style-src 'self'` without `'unsafe-inline'`, the browser blocks it. The page still renders and `data-mortar-brand` is still set, but no `--m-brand-*` property applies, so the brand silently falls back to Mortar's defaults. The console shows a `style-src` violation. A server-rendered `style` attribute, for example from `brandToStyle` on the server, is blocked in the same way (`style-src-attr`).
|
|
138
|
+
|
|
139
|
+
**For brands you know at build time, ship the brand as a stylesheet.** `brandToCss` returns the same rule `brandTokens` injects. Write it into your CSS at build time, import it after Mortar's styles, and give ThemeProvider the brand name only:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// scripts/brand-css.mjs, run before your build (for example in "prebuild")
|
|
143
|
+
import { writeFileSync } from "node:fs";
|
|
144
|
+
import { brandToCss } from "@tonycodes-ui/mortar/brand";
|
|
145
|
+
import { acme } from "../src/brand.js"; // a BrandDefinition
|
|
146
|
+
|
|
147
|
+
writeFileSync("src/brand.generated.css", `${brandToCss("acme", acme)}\n`);
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```css
|
|
151
|
+
@import "@tonycodes-ui/mortar/styles.css";
|
|
152
|
+
@import "./brand.generated.css";
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
<ThemeProvider brand="acme">…</ThemeProvider> {/* no brandTokens */}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A same-origin stylesheet satisfies `style-src 'self'`, and the rule is identical to the injected one, so overlays, inverse bands and container-scoped providers behave the same. `brandToCss` validates the definition exactly as `brandTokens` does, so a bad value fails the build instead of the page. To keep a checked-in stylesheet in step with the definition, compare them in a test: `expect(readFileSync("src/brand.generated.css", "utf8").trim()).toBe(brandToCss("acme", acme))`.
|
|
160
|
+
|
|
161
|
+
**For tenant brands loaded at runtime** (from a database or CMS), `brandTokens` needs the policy to allow the inline style:
|
|
162
|
+
|
|
163
|
+
- **`'unsafe-inline'` in `style-src`.** This is simplest, but it allows any inline style.
|
|
164
|
+
- **A hash of the exact rule** (`'sha256-…'`). This is brittle: any change to the palette changes the hash.
|
|
165
|
+
- **Serve each tenant's rule as a stylesheet instead**, for example from a route that returns `brandToCss(tenant.slug, tenant.theme)` as `text/css`, linked with `<link rel="stylesheet">`. Tenant data is validated by `brandToCss`, so it cannot inject other CSS.
|
|
166
|
+
|
|
167
|
+
Mortar's own styles and the no-JavaScript appearance fallback are ordinary stylesheet rules and need nothing beyond `style-src 'self'`.
|
|
168
|
+
|
|
135
169
|
### With the brand builder
|
|
136
170
|
|
|
137
171
|
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
172
|
|
|
173
|
+
## Marketing surfaces and illustrations
|
|
174
|
+
|
|
175
|
+
A branded landing page needs no CSS of its own. MarketingPage, MarketingHeader, Hero, Footer and NetworkIllustration read the same semantic tokens as every other component, so one brand, in CSS or from `brandTokens` at runtime, restyles all of them in both appearances. The [split landing template](../playground/template-pages.tsx) (`?recipe=split-landing`) is composed this way.
|
|
176
|
+
|
|
177
|
+
| Role | Token | Brand contract | Used by |
|
|
178
|
+
| ------------------- | ---------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
179
|
+
| Canvas and surfaces | `--m-paper`, `--m-surface`, `--m-sunken` | `paper`, `surface`, `sunken` | The page; illustration nodes and the pause control on `surface` |
|
|
180
|
+
| Text | `--m-ink`, `--m-secondary`, `--m-muted` | (fixed by Mortar) | Titles; the hero description and illustration labels; the tagline, hero note and inline footer |
|
|
181
|
+
| Borders | `--m-line`, `--m-line-strong` | (fixed by Mortar) | Header and inline-footer rules and the tagline divider; illustration connections and node outlines on `line-strong`, which keeps 3:1 |
|
|
182
|
+
| Emphasis | `--m-link` | `link` | Hero `emphasis`. On an accent or background hero it takes the band's text colour instead |
|
|
183
|
+
| Accent | `--m-accent`, `--m-accent-fill` | `accent`, `accentGradient` | Accent nodes and travelling signals; `tone="accent"` on Hero, CallToAction and Banner; `variant="accent"` buttons |
|
|
184
|
+
| Focus | `--m-focus` | `focus` | Every link and button, including the illustration's pause control |
|
|
185
|
+
|
|
186
|
+
Emphasis uses the brand's `link` colour because link is the brand's text-safe colour. Accent is a fill and may be too light or too dark to read as text. In the neutral brand, link is ink, so an emphasised phrase looks like the rest of the title and keeps only its `<em>` meaning. Set `link` to give it a colour.
|
|
187
|
+
|
|
188
|
+
No new tokens were needed for these components: each role above already had one. If your brand needs a role that is missing, raise it as an issue rather than styling around it.
|
|
189
|
+
|
|
190
|
+
### Your own SVG
|
|
191
|
+
|
|
192
|
+
Draw your own illustration with the same tokens so it follows the brand and appearance: `fill="var(--m-accent)"`, `stroke="var(--m-line-strong)"`, or Tailwind's `fill-accent` and `stroke-line` on your own elements. Give decorative SVG `aria-hidden="true"`, and give informative SVG `role="img"` and a `<title>`. Pause any motion under `prefers-reduced-motion`, and let people pause it.
|
|
193
|
+
|
|
194
|
+
### What you can extend
|
|
195
|
+
|
|
196
|
+
Supported extension points:
|
|
197
|
+
|
|
198
|
+
- **The brand contract.** CSS `--m-brand-*` values, or `brandTokens` and `ThemeProvider` (including `scope="container"` for one branded region).
|
|
199
|
+
- **Props and slots.** Logos stay your own assets, passed through `logo`. Also `controls`, `action`, `tagline`, Hero `children`, `images`, `background` and `note`, MarketingPage `header`, `footer` and children, and NetworkIllustration's `nodes` and `connections`.
|
|
200
|
+
- **`className` for layout only.** Margins, grid placement and a maximum width on a component's root.
|
|
201
|
+
- **The scales and Tailwind utilities** in your own markup (above and below).
|
|
202
|
+
|
|
203
|
+
Not supported, and flagged by `mortar audit` where it can see them:
|
|
204
|
+
|
|
205
|
+
- Selectors on `.m-*` classes or `[data-m-component]` (`css-override`)
|
|
206
|
+
- Tailwind arbitrary variants into the kit (`class-override`)
|
|
207
|
+
- Raw colours on Mortar components (`raw-colour`)
|
|
208
|
+
- Styling by state attributes such as `data-paused`, which are for tests and scripts
|
|
209
|
+
|
|
139
210
|
## Appearance before JavaScript runs
|
|
140
211
|
|
|
141
212
|
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>`.
|
package/package.json
CHANGED
package/skills/mortar/SKILL.md
CHANGED
|
@@ -31,6 +31,6 @@ If a shared pattern is missing or a component is wrong, fix it centrally instead
|
|
|
31
31
|
|
|
32
32
|
## Completion
|
|
33
33
|
|
|
34
|
-
Required evidence: the app's build, typecheck, a clean `mortar audit`, and browser checks of responsive layout, light and dark, and keyboard and accessibility. Automated checks cannot prove visual quality or complete accessibility; report what you verified and any remaining blockers. Do not declare completion with failing required checks.
|
|
34
|
+
Required evidence: the app's build, typecheck, a clean `mortar audit`, and browser checks of responsive layout, light and dark, and keyboard and accessibility. Automated checks cannot prove visual quality or complete accessibility; report what you verified and any remaining blockers. A clean audit is not proof that a page uses Mortar components exclusively; point to the rendered components instead. Do not declare completion with failing required checks.
|
|
35
35
|
|
|
36
36
|
Provide a local preview by default. Deploy only with the user's authorisation; existing explicit authorisation counts. A passing audit is not deployment permission.
|
package/src/application.css
CHANGED
|
@@ -264,6 +264,20 @@
|
|
|
264
264
|
.m-stacked-shell > main:focus {
|
|
265
265
|
outline: none;
|
|
266
266
|
}
|
|
267
|
+
/* A second row of page tabs under the bar; the row draws the full-width rule. */
|
|
268
|
+
.m-stacked-tabs {
|
|
269
|
+
padding: 0 24px;
|
|
270
|
+
border-bottom: 1px solid var(--m-line);
|
|
271
|
+
background: var(--m-surface);
|
|
272
|
+
}
|
|
273
|
+
.m-stacked-tabs .m-tab-nav ul {
|
|
274
|
+
border-bottom: 0;
|
|
275
|
+
}
|
|
276
|
+
@media (max-width: 900px) {
|
|
277
|
+
.m-stacked-tabs {
|
|
278
|
+
padding: 0 16px;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
267
281
|
|
|
268
282
|
/* == Navbar == */
|
|
269
283
|
.m-navbar {
|
|
@@ -274,6 +288,39 @@
|
|
|
274
288
|
padding: 0 24px;
|
|
275
289
|
border-bottom: 1px solid var(--m-line);
|
|
276
290
|
background: var(--m-surface);
|
|
291
|
+
color: var(--m-ink);
|
|
292
|
+
}
|
|
293
|
+
/* Accent bar: the brand's accent fill, remapped like the accent bands
|
|
294
|
+
(marketing.css): on-accent text, links and focus, inverted primary
|
|
295
|
+
actions and outlined secondary ones. --m-sunken uses --m-accent-wash, the
|
|
296
|
+
same direction as an accent button's hover, so a selected row or hover
|
|
297
|
+
moves the fill away from the on-accent text. A mix towards --m-on-accent
|
|
298
|
+
drops that text below 4.5:1. Panels open in a portal and keep the page's
|
|
299
|
+
colours. Inverse needs nothing here: [data-tone="inverse"] remaps every
|
|
300
|
+
token (tokens.css). */
|
|
301
|
+
.m-navbar[data-tone="accent"] {
|
|
302
|
+
background-color: var(--m-accent);
|
|
303
|
+
background-image: var(--m-accent-fill);
|
|
304
|
+
--m-ink: var(--m-on-accent);
|
|
305
|
+
--m-secondary: var(--m-on-accent);
|
|
306
|
+
--m-muted: var(--m-on-accent);
|
|
307
|
+
--m-link: var(--m-on-accent);
|
|
308
|
+
--m-focus: var(--m-on-accent);
|
|
309
|
+
--m-line: color-mix(in oklab, var(--m-on-accent) 35%, transparent);
|
|
310
|
+
--m-line-strong: var(--m-on-accent);
|
|
311
|
+
--m-primary: var(--m-on-accent);
|
|
312
|
+
--m-primary-hover: var(--m-on-accent);
|
|
313
|
+
--m-primary-active: var(--m-on-accent);
|
|
314
|
+
--m-on-primary: var(--m-accent);
|
|
315
|
+
--m-surface: transparent;
|
|
316
|
+
--m-sunken: color-mix(
|
|
317
|
+
in oklab,
|
|
318
|
+
var(--m-accent-wash, transparent) 14%,
|
|
319
|
+
transparent
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
.m-navbar[data-tone="accent"] .m-button-primary:hover {
|
|
323
|
+
box-shadow: inset 0 0 0 2px var(--m-accent);
|
|
277
324
|
}
|
|
278
325
|
.m-navbar > .m-brand {
|
|
279
326
|
display: inline-flex;
|
|
@@ -316,6 +363,27 @@
|
|
|
316
363
|
font-weight: var(--m-weight-semibold);
|
|
317
364
|
box-shadow: inset 0 -2px 0 var(--m-primary);
|
|
318
365
|
}
|
|
366
|
+
/* A group's trigger looks like the links beside it; its panel is the
|
|
367
|
+
shared flyout (marketing.css). */
|
|
368
|
+
.m-navbar > nav > .m-flyout {
|
|
369
|
+
display: flex;
|
|
370
|
+
}
|
|
371
|
+
.m-navbar .m-flyout-trigger {
|
|
372
|
+
padding: 0 10px;
|
|
373
|
+
color: var(--m-secondary);
|
|
374
|
+
font-size: var(--m-font-size);
|
|
375
|
+
font-weight: var(--m-weight-medium);
|
|
376
|
+
box-shadow: inset 0 -2px 0 transparent;
|
|
377
|
+
}
|
|
378
|
+
.m-navbar .m-flyout-trigger:hover {
|
|
379
|
+
color: var(--m-ink);
|
|
380
|
+
box-shadow: inset 0 -2px 0 var(--m-line-strong);
|
|
381
|
+
}
|
|
382
|
+
.m-navbar .m-flyout[data-current] > .m-flyout-trigger {
|
|
383
|
+
color: var(--m-ink);
|
|
384
|
+
font-weight: var(--m-weight-semibold);
|
|
385
|
+
box-shadow: inset 0 -2px 0 var(--m-primary);
|
|
386
|
+
}
|
|
319
387
|
@media (max-width: 900px) {
|
|
320
388
|
.m-navbar {
|
|
321
389
|
flex-wrap: wrap;
|
|
@@ -362,6 +430,25 @@
|
|
|
362
430
|
box-shadow: inset 3px 0 0 var(--m-primary);
|
|
363
431
|
background: var(--m-sunken);
|
|
364
432
|
}
|
|
433
|
+
.m-navbar > nav > .m-flyout {
|
|
434
|
+
display: block;
|
|
435
|
+
}
|
|
436
|
+
.m-navbar .m-flyout-trigger {
|
|
437
|
+
width: 100%;
|
|
438
|
+
min-height: 44px;
|
|
439
|
+
justify-content: space-between;
|
|
440
|
+
padding: 0 12px;
|
|
441
|
+
border-radius: var(--m-radius);
|
|
442
|
+
box-shadow: none;
|
|
443
|
+
}
|
|
444
|
+
.m-navbar .m-flyout-trigger:hover {
|
|
445
|
+
box-shadow: none;
|
|
446
|
+
background: var(--m-sunken);
|
|
447
|
+
}
|
|
448
|
+
.m-navbar .m-flyout[data-current] > .m-flyout-trigger {
|
|
449
|
+
box-shadow: inset 3px 0 0 var(--m-primary);
|
|
450
|
+
background: var(--m-sunken);
|
|
451
|
+
}
|
|
365
452
|
}
|
|
366
453
|
|
|
367
454
|
/* == Breadcrumbs == */
|
|
@@ -549,14 +636,21 @@
|
|
|
549
636
|
--m-tone: var(--m-secondary);
|
|
550
637
|
}
|
|
551
638
|
@media (max-width: 560px) {
|
|
639
|
+
/* Two metrics a row, but a value too wide for half a row takes a row of
|
|
640
|
+
its own instead of being clipped; a value wider than the whole row
|
|
641
|
+
wraps. */
|
|
552
642
|
.m-metrics {
|
|
553
|
-
|
|
643
|
+
display: flex;
|
|
644
|
+
flex-wrap: wrap;
|
|
554
645
|
}
|
|
555
646
|
.m-metrics > div {
|
|
647
|
+
flex: 1 1 40%;
|
|
648
|
+
max-width: 100%;
|
|
556
649
|
border-bottom: 1px solid var(--m-line);
|
|
557
650
|
}
|
|
558
651
|
.m-metrics dd {
|
|
559
652
|
font-size: 24px;
|
|
653
|
+
overflow-wrap: break-word;
|
|
560
654
|
}
|
|
561
655
|
}
|
|
562
656
|
|