Extract independent MIT Mortar kit and contribution workflow
Mortar contribution checks / Mortar baseline and browser checks (push) Successful in 3m15s

This commit is contained in:
tony committed 2026-10-13 22:16:21 +01:00
commit 43fe76158c
108 files changed
+10081

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
name: Mortar contribution checks
on:
push:
branches: [main, "codex/**"]
pull_request:
branches: [main]
jobs:
quality:
name: Mortar baseline and browser checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci --ignore-scripts
- run: npm run check
- run: npx playwright install --with-deps chromium
- run: npm run test:browser
# No package-publishing or cloud-governance credentials belong in this workflow.
+7
View File
@@ -0,0 +1,7 @@
node_modules/
dist/
playground-dist/
release/
test-results/
playwright-report/
*.tgz
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
playground-dist
release
test-results
playwright-report
+9
View File
@@ -0,0 +1,9 @@
# Contributing to Mortar
Mortar is a public MIT kit. You can develop, preview and check it without any governance-service account.
Read DESIGN.md before composing UI. Implement reusable components in src, previews in previews, and typed component contracts in manifests. Shells and chrome are shared components, not copies in consumer apps. Product data and actions belong in props and slots.
Run npm ci, npm run catalog, npm run check and npm run test:browser. npm run check:json returns baseline rule IDs, files, lines and corrective messages. npm run dev opens the local playground. Update manifests when changing exports. Include a regression test for a bug fix. Never mark yourself approved, publish a release, or change protected CI rules to make a contribution pass.
Open a PR with rationale, screenshots and actual check results. Maintainer review and protected final-commit CI precede releases. Baseline checks are not proof of visual quality; inspect desktop/mobile, light/dark, keyboard and focus states. Shared design changes require central fixes, not local overrides.
+11
View File
@@ -0,0 +1,11 @@
# Contribute
Clone, run npm ci, npm run catalog, npm run check, npm run dev. Use npm run test:browser for both-theme accessibility/rendering checks. The local playground defaults to port 4340. No service registration or npm token is required.
Use src for central implementations, previews for fixtures and manifests for contracts. Read DESIGN.md and AGENTS.md. Include a reproducible issue and a regression test for fixes. Open a PR with actual check results and visual evidence. Shared shells are versioned alongside controls; applications supply content through props.
Baseline rules are versioned in baseline-policy.json. CI must use the same rules but contributors cannot authorize weakening protected checks; policy, workflow and dependency changes require maintainer review. Branch protection must require successful checks and dismiss approvals when reviewed content changes. Public PR runners must be isolated from release credentials and production networks.
Releases are operator-owned. Verify the final merged commit, run all checks, create an immutable version tag, and prepare the exact package using npm run release:prepare. Upload archives and integrity/provenance records before atomically advancing registry metadata. Never overwrite an existing version. The publisher must verify maintainer approval and successful final-commit CI independently. Publication does not call the paid service; ARC indexes published release manifests afterward.
The cloud service may impose additional organisation adoption policies. Those policies cannot prevent development or installation of the public MIT kit.
+15
View File
@@ -0,0 +1,15 @@
# Mortar design principles
1. **shell-first** — Choose the shared purpose shell before composing a page. Use AppShell for application screens and MarketingHeader for public pages. Document deliberate standalone exceptions.
2. **page-pattern-reuse** — Reuse complete page patterns: navigation, headings, filters, list/detail workspaces, states and action placement. Reusing controls alone is insufficient.
3. **project-agnostic-chrome** — Keep product names, routes, user data, permissions and actions in props and slots. Shared chrome must remain project agnostic.
4. **central-fixes** — Contribute missing patterns centrally. Do not duplicate source, create repeated local wrappers or apply consumer CSS overrides.
5. **semantic-themes** — Use semantic tokens; keep brand separate from appearance and feedback colours. Verify every supported theme.
6. **interaction-quality** — Verify responsive layout, accessible names, keyboard operation, focus and loading/empty/error states. Automated checks alone cannot prove visual quality.
The machine-readable contract is design-principles.json. Both the local contribution workflow and hosted MCP should expose these principles. Detection is split between structural checks and visual maintainer review.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Tony James and Mortar contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# Mortar
A shared design foundation and purpose-built Application UI and Marketing UI kits for React 19 and Tailwind CSS 4. MIT licensed. No account, token, registry connection or subscription is required to use the kit, including use by coding agents.
The package is `@tony-ui/mortar`, distributed from owner-hosted infrastructure. Releases are immutable and publicly downloadable without a service account. First publication is pending protected release verification.
## Install
After public release:
```sh
npm config set @tony-ui:registry https://git.tony.codes/api/packages/tony/npm/ --location=project
npm install @tony-ui/mortar react react-dom tailwindcss
```
No UI service credential is needed.
## Local install
Build and pack from this workspace:
```sh
npm run build
npm pack
```
Install the resulting `tony-ui-mortar-0.1.0.tgz` in the consuming app, with React 19, React DOM 19 and Tailwind 4. Public releases use https://git.tony.codes/api/packages/tony/npm/.
In the app's Tailwind CSS entry:
```css
@import "tailwindcss";
@import "@tony-ui/mortar/styles.css";
```
The package ships its component styles. Standalone use requires no source scanning or hosted account. The separately governed hosted workflow currently also requires `@source '../node_modules/@tony-ui/mortar/src';` in the CSS entry; generated agent starters include that policy declaration.
```tsx
import { ThemeProvider, AppShell, Button } from "@tony-ui/mortar";
<ThemeProvider brand="neutral" density="comfortable" initialAppearance="system">
<AppShell
brand="Example"
navigation={[{ label: "Overview", href: "/", current: true }]}
>
<Button onClick={save}>Save changes</Button>
</AppShell>
</ThemeProvider>;
```
`brand` (`neutral | green`) and `density` (`compact | comfortable | spacious`) are controlled provider props. Appearance (`light | dark | system`) is changed using `AppearanceControl`; system changes are observed. Use one root provider. It restores document attributes on unmount. The provider uses effects: SSR applications should set matching initial document attributes to prevent a first-paint theme flash.
## Purpose kits
The catalogue assigns each export to one purpose. Foundation primitives serve both purpose kits. Application UI provides shells, page headings, metrics, selectable tables, settings and activity. Marketing UI provides headers, heroes, features, social proof, pricing, FAQs, calls to action and footers. Product content and callbacks remain in the consumer. Sample names and prices belong only in the catalogue demonstrations.
Controls use native HTML and Headless UI. Tabs support arrow/Home/End navigation; dialogs and menus use Headless UI focus management. Density has a 44px control floor on coarse pointers. Feedback colours remain separate from the brand.
## Hosted services
Mortar Cloud's registry, agent discovery, approvals, audit and managed release workflow are separate proprietary hosted services. Payment buys service access, not permission to use the MIT components. The kit performs no network requests and contains no licence check or telemetry.
This licence covers this package only. The central service, repository history, design reference documents, candidate fixtures and catalogue application are excluded from the public package.
+11
View File
@@ -0,0 +1,11 @@
{
"schemaVersion": 1,
"version": "1.0.0",
"rules": {
"M001": "Exports require unique manifests and preview fixtures",
"M002": "Source and preview paths stay inside the repository",
"M003": "Dependencies reference known component IDs",
"M004": "Shared components cannot depend on hosted services",
"M005": "Component implementations use semantic colour tokens"
}
}
+1520
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"version": "1.0.0",
"principles": [
{
"id": "shell-first",
"instruction": "Choose the shared purpose shell before composing a page. Use AppShell for application screens and MarketingHeader for public pages. Document deliberate standalone exceptions."
},
{
"id": "page-pattern-reuse",
"instruction": "Reuse complete page patterns: navigation, headings, filters, list/detail workspaces, states and action placement. Reusing controls alone is insufficient."
},
{
"id": "project-agnostic-chrome",
"instruction": "Keep product names, routes, user data, permissions and actions in props and slots. Shared chrome must remain project agnostic."
},
{
"id": "central-fixes",
"instruction": "Contribute missing patterns centrally. Do not duplicate source, create repeated local wrappers or apply consumer CSS overrides."
},
{
"id": "semantic-themes",
"instruction": "Use semantic tokens; keep brand separate from appearance and feedback colours. Verify every supported theme."
},
{
"id": "interaction-quality",
"instruction": "Verify responsive layout, accessible names, keyboard operation, focus and loading/empty/error states. Automated checks alone cannot prove visual quality."
}
]
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-action-link",
"logicalId": "action-link",
"name": "ActionLink",
"description": "Mortar foundation action link, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "ActionLink",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "action", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"href": "string",
"variant": "primary | secondary | ghost | danger"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { ActionLink } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/ActionLinkPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-activity-feed",
"logicalId": "activity-feed",
"name": "ActivityFeed",
"description": "Mortar application activity feed, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "ActivityFeed",
"source": "src/application.tsx",
"capabilities": ["theme", "application", "status"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"label": "string",
"items": "{ id: string; title: string; description?: string; time: string; tone?: Tone }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { ActivityFeed } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/ActivityFeedPreview.tsx"
}
}
+45
View File
@@ -0,0 +1,45 @@
{
"id": "mortar-app-shell",
"logicalId": "app-shell",
"name": "AppShell",
"description": "Mortar application app shell, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "AppShell",
"source": "src/application.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"brand": "string",
"workspace": "string",
"navigation": "NavigationItem[]",
"secondaryNavigation": "{ label: string; items: NavigationItem[] }[]",
"user": "ReactNode",
"header": "ReactNode",
"actions": "ReactNode",
"children": "ReactNode"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { AppShell } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-button"]
},
"preview": {
"source": "previews/AppShellPreview.tsx"
}
}
+36
View File
@@ -0,0 +1,36 @@
{
"id": "mortar-appearance-control",
"logicalId": "appearance-control",
"name": "AppearanceControl",
"description": "Mortar foundation appearance control, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "AppearanceControl",
"source": "src/foundation.tsx",
"capabilities": ["theme", "foundation"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { AppearanceControl } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-theme-provider"]
},
"preview": {
"source": "previews/AppearanceControlPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-badge",
"logicalId": "badge",
"name": "Badge",
"description": "Mortar foundation badge, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Badge",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "status"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"children": "ReactNode",
"tone": "neutral | success | warning | danger"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Badge } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/BadgePreview.tsx"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"id": "mortar-button",
"logicalId": "button",
"name": "Button",
"description": "Mortar foundation button, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Button",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "action", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"children": "ReactNode",
"variant": "primary | secondary | ghost | danger",
"loading": "boolean",
"disabled": "boolean",
"type": "button | submit | reset",
"onClick": "MouseEventHandler<HTMLButtonElement>"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Button } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/ButtonPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-call-to-action",
"logicalId": "call-to-action",
"name": "CallToAction",
"description": "Mortar marketing call to action, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "CallToAction",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"actions": "LinkAction[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { CallToAction } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-action-link"]
},
"preview": {
"source": "previews/CallToActionPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-checkbox",
"logicalId": "checkbox",
"name": "Checkbox",
"description": "Mortar foundation checkbox, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Checkbox",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"checked": "boolean",
"onChange": "ChangeEventHandler<HTMLInputElement>"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Checkbox } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/CheckboxPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-code-block",
"logicalId": "code-block",
"name": "CodeBlock",
"description": "Mortar application code block, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "CodeBlock",
"source": "src/inspection.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"code": "string",
"label": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { CodeBlock } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/CodeBlockPreview.tsx"
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"id": "mortar-data-table",
"logicalId": "data-table",
"name": "DataTable",
"description": "Mortar application data table, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "DataTable",
"source": "src/application.tsx",
"capabilities": ["theme", "application", "table"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"caption": "string",
"columns": "{ key: string; label: string; render: (row: T) => ReactNode; align?: left | right }[]",
"rows": "T[] where T extends { id: string }",
"rowLabel": "(row: T) => string",
"selectedIds": "string[]",
"onSelectionChange": "(ids: string[]) => void",
"toolbar": "ReactNode",
"bulkActions": "ReactNode",
"loading": "boolean",
"error": "string",
"emptyTitle": "string",
"emptyDescription": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { DataTable } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-button", "mortar-empty-state", "mortar-notice"]
},
"preview": {
"source": "previews/DataTablePreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-dropdown",
"logicalId": "dropdown",
"name": "Dropdown",
"description": "Mortar foundation dropdown, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Dropdown",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "action", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"items": "{ label: string; onSelect: () => void; danger?: boolean; disabled?: boolean }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Dropdown } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/DropdownPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-empty-state",
"logicalId": "empty-state",
"name": "EmptyState",
"description": "Mortar foundation empty state, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "EmptyState",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"action": "ReactNode"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { EmptyState } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/EmptyStatePreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-faq",
"logicalId": "faq",
"name": "FAQ",
"description": "Mortar marketing faq, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "FAQ",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"title": "string",
"description": "ReactNode",
"items": "{ question: string; answer: ReactNode }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { FAQ } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/FAQPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-feature-grid",
"logicalId": "feature-grid",
"name": "FeatureGrid",
"description": "Mortar marketing feature grid, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "FeatureGrid",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"title": "string",
"eyebrow": "string",
"description": "string",
"items": "{ title: string; description: string; icon?: ReactNode }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { FeatureGrid } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/FeatureGridPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-feature-section",
"logicalId": "feature-section",
"name": "FeatureSection",
"description": "Mortar marketing feature section, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "FeatureSection",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"children": "ReactNode",
"reverse": "boolean"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { FeatureSection } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/FeatureSectionPreview.tsx"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "mortar-footer",
"logicalId": "footer",
"name": "Footer",
"description": "Mortar marketing footer, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Footer",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"brand": "string",
"description": "string",
"columns": "{ title: string; links: LinkAction[] }[]",
"copyright": "string",
"legal": "LinkAction[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Footer } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/FooterPreview.tsx"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"id": "mortar-hero",
"logicalId": "hero",
"name": "Hero",
"description": "Mortar marketing hero, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Hero",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"eyebrow": "string",
"title": "string",
"description": "string",
"actions": "LinkAction[]",
"children": "ReactNode",
"align": "center | split",
"headingLevel": "1 | 2"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Hero } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-action-link"]
},
"preview": {
"source": "previews/HeroPreview.tsx"
}
}
+44
View File
@@ -0,0 +1,44 @@
{
"id": "mortar-input",
"logicalId": "input",
"name": "Input",
"description": "Mortar foundation input, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Input",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"value": "string | number",
"onChange": "ChangeEventHandler<HTMLInputElement>",
"hint": "string",
"error": "string",
"prefix": "string",
"suffix": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Input } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/InputPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-logo-cloud",
"logicalId": "logo-cloud",
"name": "LogoCloud",
"description": "Mortar marketing logo cloud, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "LogoCloud",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"label": "string",
"items": "{ name: string; image?: string }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { LogoCloud } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/LogoCloudPreview.tsx"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "mortar-marketing-header",
"logicalId": "marketing-header",
"name": "MarketingHeader",
"description": "Mortar marketing marketing header, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "MarketingHeader",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"brand": "string",
"homeHref": "string",
"links": "LinkAction[]",
"action": "LinkAction",
"controls": "ReactNode"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { MarketingHeader } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-button", "mortar-action-link"]
},
"preview": {
"source": "previews/MarketingHeaderPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-metric-group",
"logicalId": "metric-group",
"name": "MetricGroup",
"description": "Mortar application metric group, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "MetricGroup",
"source": "src/application.tsx",
"capabilities": ["theme", "application", "summary"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"label": "string",
"items": "{ label: string; value: string; change?: string; tone?: Tone }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { MetricGroup } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/MetricGroupPreview.tsx"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "mortar-modal",
"logicalId": "modal",
"name": "Modal",
"description": "Mortar foundation modal, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Modal",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"title": "string",
"open": "boolean",
"onClose": "() => void",
"children": "ReactNode",
"actions": "ReactNode"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Modal } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-button"]
},
"preview": {
"source": "previews/ModalPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-notice",
"logicalId": "notice",
"name": "Notice",
"description": "Mortar foundation notice, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Notice",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "status"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"title": "string",
"children": "ReactNode",
"tone": "neutral | success | warning | danger",
"announce": "boolean"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Notice } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/NoticePreview.tsx"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"id": "mortar-page-heading",
"logicalId": "page-heading",
"name": "PageHeading",
"description": "Mortar application page heading, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "PageHeading",
"source": "src/application.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"eyebrow": "string",
"actions": "ReactNode",
"breadcrumbs": "{ label: string; href?: string }[]",
"headingLevel": "1 | 2"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { PageHeading } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/PageHeadingPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-pagination",
"logicalId": "pagination",
"name": "Pagination",
"description": "Mortar application pagination, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Pagination",
"source": "src/application.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"page": "number",
"pageCount": "number",
"onChange": "(page: number) => void"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Pagination } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-button"]
},
"preview": {
"source": "previews/PaginationPreview.tsx"
}
}
+39
View File
@@ -0,0 +1,39 @@
{
"id": "mortar-preview-frame",
"logicalId": "preview-frame",
"name": "PreviewFrame",
"description": "Mortar application preview frame, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "PreviewFrame",
"source": "src/inspection.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"title": "string",
"src": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { PreviewFrame } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/PreviewFramePreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-pricing",
"logicalId": "pricing",
"name": "Pricing",
"description": "Mortar marketing pricing, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Pricing",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"plans": "PricingPlan[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Pricing } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": ["mortar-action-link"]
},
"preview": {
"source": "previews/PricingPreview.tsx"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "mortar-radio-group",
"logicalId": "radio-group",
"name": "RadioGroup",
"description": "Mortar foundation radio group, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "RadioGroup",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"name": "string",
"value": "string",
"onChange": "(value: string) => void",
"options": "{ value: string; label: string; description?: string }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { RadioGroup } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/RadioGroupPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-segmented-control",
"logicalId": "segmented-control",
"name": "SegmentedControl",
"description": "Mortar foundation segmented control, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "SegmentedControl",
"source": "src/inspection.tsx",
"capabilities": ["theme", "foundation", "action", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"value": "string",
"options": "{ value: string; label: string }[]",
"onChange": "(value: string) => void"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { SegmentedControl } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/SegmentedControlPreview.tsx"
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"id": "mortar-select",
"logicalId": "select",
"name": "Select",
"description": "Mortar foundation select, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Select",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"options": "{ value: string; label: string }[]",
"value": "string",
"onChange": "ChangeEventHandler<HTMLSelectElement>",
"hint": "string",
"error": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Select } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/SelectPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-settings-section",
"logicalId": "settings-section",
"name": "SettingsSection",
"description": "Mortar application settings section, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "SettingsSection",
"source": "src/application.tsx",
"capabilities": ["theme", "application"],
"owner": "Mortar maintainers",
"kitId": "application",
"provenance": {
"kitId": "application",
"exampleId": "mortar-application-reference",
"sourceDigest": "9c3a1c251ac5dcf28a566a4a6ee4ae6724296996e47b7eb3ccacad1517668ef3"
},
"contract": {
"props": {
"title": "string",
"description": "string",
"children": "ReactNode"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { SettingsSection } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/SettingsSectionPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-switch",
"logicalId": "switch",
"name": "Switch",
"description": "Mortar foundation switch, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Switch",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"checked": "boolean",
"onChange": "ChangeEventHandler<HTMLInputElement>"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Switch } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/SwitchPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-tabs",
"logicalId": "tabs",
"name": "Tabs",
"description": "Mortar foundation tabs, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Tabs",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "action", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"value": "string",
"onChange": "(value: string) => void",
"items": "{ id: string; label: string; content: ReactNode }[]"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Tabs } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/TabsPreview.tsx"
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"id": "mortar-testimonial",
"logicalId": "testimonial",
"name": "Testimonial",
"description": "Mortar marketing testimonial, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "Testimonial",
"source": "src/marketing.tsx",
"capabilities": ["theme", "marketing"],
"owner": "Mortar maintainers",
"kitId": "marketing",
"provenance": {
"kitId": "marketing",
"exampleId": "mortar-marketing-reference",
"sourceDigest": "4ec7866f2bbb06475a555bfde970c052111c84efde4ce0f38134e7fb400733d2"
},
"contract": {
"props": {
"quote": "string",
"name": "string",
"role": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { Testimonial } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/TestimonialPreview.tsx"
}
}
+42
View File
@@ -0,0 +1,42 @@
{
"id": "mortar-text-area",
"logicalId": "text-area",
"name": "TextArea",
"description": "Mortar foundation text area, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "TextArea",
"source": "src/primitives.tsx",
"capabilities": ["theme", "foundation", "input", "validation", "keyboard"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"label": "string",
"value": "string",
"onChange": "ChangeEventHandler<HTMLTextAreaElement>",
"hint": "string",
"error": "string"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { TextArea } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/TextAreaPreview.tsx"
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"id": "mortar-theme-provider",
"logicalId": "theme-provider",
"name": "ThemeProvider",
"description": "Mortar foundation theme provider, using central semantic tokens.",
"stack": "react-tailwind",
"package": "@tony-ui/mortar",
"version": "0.1.0",
"export": "ThemeProvider",
"source": "src/foundation.tsx",
"capabilities": ["theme", "foundation"],
"owner": "Mortar maintainers",
"kitId": "foundation",
"provenance": {
"kitId": "foundation",
"exampleId": "mortar-foundation-reference",
"sourceDigest": "f132052b4ac52f3909ac437073c076b9f615849effea14dabf7d30759d714c2d"
},
"contract": {
"props": {
"children": "ReactNode",
"initialAppearance": "light | dark | system",
"brand": "neutral | green",
"density": "compact | comfortable | spacious"
},
"states": [
"Light",
"Dark",
"Neutral brand",
"Green brand",
"Keyboard and focus"
],
"usage": "Import { ThemeProvider } from @tony-ui/mortar. Compose with the public typed props.",
"avoid": "Do not copy implementations, override central colours or grant component approval.",
"accessibility": "Verify accessible names, keyboard interaction, visible focus, responsive layout and supported states.",
"dependencies": []
},
"preview": {
"source": "previews/ThemeProviderPreview.tsx"
}
}
+2862
View File
File diff suppressed because it is too large. Load diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "@tony-ui/mortar",
"version": "0.1.0",
"description": "Mortar: a shared design foundation, application and marketing UI for React and Tailwind.",
"type": "module",
"files": [
"dist",
"src",
"styles.css",
"README.md",
"LICENSE",
"catalog.json",
"baseline-policy.json",
"design-principles.json",
"DESIGN.md"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles.css": "./styles.css"
},
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "vite --host 127.0.0.1",
"catalog": "node scripts/check.mjs --write-catalog",
"check": "npm run format:check && npm run build && node scripts/check.mjs && node --test tests/*.test.mjs && npm run build:playground",
"check:json": "node scripts/check.mjs --format json",
"test:browser": "playwright test",
"build:playground": "tsc --noEmit -p tsconfig.playground.json && vite build",
"format:check": "prettier --check .",
"release:prepare": "node scripts/release.mjs"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0"
},
"dependencies": {
"@headlessui/react": "^2.2.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.9.3",
"vite": "^7.1.0",
"@vitejs/plugin-react": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"prettier": "^3.9.9",
"@playwright/test": "^1.63.0",
"@axe-core/playwright": "^4.13.0"
},
"license": "MIT",
"publishConfig": {
"registry": "https://git.tony.codes/api/packages/tony/npm/",
"access": "public"
},
"repository": {
"type": "git",
"url": "https://git.tony.codes/tony/mortar.git"
},
"homepage": "https://ui.tony.codes/"
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mortar component playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/main.tsx"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
import { Suspense, lazy, type ComponentType } from "react";
import { createRoot } from "react-dom/client";
import {
ThemeProvider,
Select,
AppearanceControl,
PageHeading,
Notice,
} from "../src/index.js";
import catalog from "../catalog.json";
import "./styles.css";
const fixtures = import.meta.glob("../previews/*Preview.tsx");
const params = new URLSearchParams(location.search);
const component = params.get("component") || "Button";
const loader = fixtures[`../previews/${component}Preview.tsx`];
const Preview = loader
? lazy(loader as () => Promise<{ default: ComponentType }>)
: null;
createRoot(document.getElementById("root")!).render(
<ThemeProvider
brand={params.get("brand") === "green" ? "green" : "neutral"}
initialAppearance={params.get("theme") === "dark" ? "dark" : "light"}
>
<main className="m-stack p-6 mx-auto max-w-6xl">
<PageHeading
title="Mortar playground"
description="Shared components and shells. No service account required."
/>
<div className="flex flex-wrap gap-4 items-end">
<Select
label="Component"
value={component}
options={catalog.components.map((c) => ({
value: c.export,
label: c.name,
}))}
onChange={(e) => {
params.set("component", e.target.value);
location.search = params.toString();
}}
/>
<AppearanceControl />
</div>
<Suspense fallback={<p role="status">Loading preview…</p>}>
{Preview ? (
<Preview />
) : (
<Notice title="Unknown component" tone="danger">
Choose a component from the catalogue.
</Notice>
)}
</Suspense>
</main>
</ThemeProvider>,
);
+4
View File
@@ -0,0 +1,4 @@
@import "tailwindcss";
@import "../styles.css";
@source '../src';
@source '../previews';
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "tests/browser",
workers: 4,
timeout: 30000,
use: {
baseURL: "http://127.0.0.1:4340",
launchOptions: process.env.UI_TEST_CHROME
? { executablePath: process.env.UI_TEST_CHROME }
: {},
},
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:4340",
reuseExistingServer: false,
},
});
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="ActionLink" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="ActivityFeed" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="AppShell" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="AppearanceControl" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Badge" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Button" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="CallToAction" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Checkbox" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="CodeBlock" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="DataTable" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Dropdown" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="EmptyState" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="FAQ" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="FeatureGrid" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="FeatureSection" />;
}
+476
View File
@@ -0,0 +1,476 @@
import { useState, type ReactNode } from "react";
import * as UI from "../src/index.js";
export function PreviewCase({ name }: { name: string }) {
const [value, setValue] = useState("first"),
[checked, setChecked] = useState(false),
[open, setOpen] = useState(false),
[page, setPage] = useState(1);
const controls = (
<UI.Input
label="Project name"
defaultValue="Northwind"
hint="A name for your workspace"
/>
);
const action = { label: "Explore", href: "#example" };
const items = [
{
title: "Shared foundation",
description: "One theme across every interface.",
},
{
title: "Accessible controls",
description: "Clear labels and keyboard operation.",
},
];
let example: ReactNode;
switch (name) {
case "ThemeProvider":
case "AppearanceControl":
example = (
<>
<UI.AppearanceControl />
<UI.Button>Primary action</UI.Button>
{controls}
</>
);
break;
case "Button":
example = (
<>
<UI.Button>Save changes</UI.Button>
<UI.Button variant="secondary">Cancel</UI.Button>
<UI.Button variant="danger">Delete</UI.Button>
<UI.Button loading>Saving</UI.Button>
</>
);
break;
case "ActionLink":
example = <UI.ActionLink {...action} />;
break;
case "Badge":
example = (
<>
{(["neutral", "success", "warning", "danger"] as const).map(
(tone) => (
<UI.Badge key={tone} tone={tone}>
{tone}
</UI.Badge>
),
)}
</>
);
break;
case "Input":
example = (
<>
{controls}
<UI.Input label="Email" error="Enter a valid email address" />
</>
);
break;
case "Select":
example = (
<>
<UI.Select
label="Environment"
value={value}
onChange={(e) => setValue(e.target.value)}
options={[
{ value: "first", label: "Production" },
{ value: "second", label: "Preview" },
]}
/>
<UI.Select
label="Unavailable environment"
disabled
defaultValue="production"
hint="Your current environment is managed centrally."
options={[{ value: "production", label: "Production" }]}
/>
<UI.Select
label="Required environment"
defaultValue=""
error="Choose an environment."
options={[
{ value: "", label: "Choose an environment" },
{ value: "production", label: "Production" },
]}
/>
</>
);
break;
case "TextArea":
example = (
<UI.TextArea
label="Description"
defaultValue="Shared component preview"
/>
);
break;
case "Checkbox":
example = (
<UI.Checkbox
label="Email notifications"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
);
break;
case "Switch":
example = (
<UI.Switch
label="Automatic updates"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
/>
);
break;
case "RadioGroup":
example = (
<UI.RadioGroup
label="Plan"
name="plan"
value={value}
onChange={setValue}
options={[
{
value: "first",
label: "Standard",
description: "For small teams",
},
{
value: "second",
label: "Advanced",
description: "For growing teams",
},
]}
/>
);
break;
case "Tabs":
example = (
<UI.Tabs
label="Example tabs"
value={value}
onChange={setValue}
items={[
{
id: "first",
label: "Overview",
content: <p>Overview content</p>,
},
{
id: "second",
label: "Activity",
content: <p>Activity content</p>,
},
]}
/>
);
break;
case "Modal":
example = (
<>
<UI.Button onClick={() => setOpen(true)}>Open dialog</UI.Button>
<UI.Modal
open={open}
onClose={() => setOpen(false)}
title="Create project"
>
{controls}
<UI.Button onClick={() => setOpen(false)}>Create project</UI.Button>
</UI.Modal>
</>
);
break;
case "Dropdown":
example = (
<UI.Dropdown
label="Actions"
items={[
{ label: "Duplicate", onSelect: () => setChecked(true) },
{
label: "Delete",
danger: true,
onSelect: () => setChecked(false),
},
]}
/>
);
break;
case "Notice":
example = (
<UI.Notice title="Update available">
Your project can use the latest release.
</UI.Notice>
);
break;
case "EmptyState":
example = (
<UI.EmptyState
title="No projects yet"
description="Create your first project to get started."
action={<UI.Button>Create project</UI.Button>}
/>
);
break;
case "AppShell":
example = (
<UI.AppShell
brand="Northwind"
workspace="Overview"
navigation={[
{ label: "Overview", href: "#example", current: true },
{ label: "Settings", href: "#settings" },
]}
>
<div className="m-stack">
<UI.PageHeading title="Workspace overview" />
{controls}
</div>
</UI.AppShell>
);
break;
case "PageHeading":
example = (
<UI.PageHeading
title="Projects"
description="Your team’s active projects."
actions={<UI.Button>New project</UI.Button>}
/>
);
break;
case "MetricGroup":
example = (
<UI.MetricGroup
label="Summary"
items={[
{
label: "Requests",
value: "1.42M",
change: "+12.4%",
tone: "success",
},
{
label: "Latency",
value: "184ms",
change: "−18ms",
tone: "success",
},
]}
/>
);
break;
case "DataTable":
example = (
<UI.DataTable
caption="Projects"
rowLabel={(row) => row.name}
columns={[
{ key: "name", label: "Name", render: (row) => row.name },
{
key: "status",
label: "Status",
render: (row) => <UI.Badge>{row.status}</UI.Badge>,
},
]}
rows={[{ id: "example", name: "Northwind", status: "Active" }]}
/>
);
break;
case "Pagination":
example = <UI.Pagination page={page} pageCount={5} onChange={setPage} />;
break;
case "SettingsSection":
example = (
<UI.SettingsSection
title="General"
description="Manage project details."
>
{controls}
<UI.Button>Save changes</UI.Button>
</UI.SettingsSection>
);
break;
case "ActivityFeed":
example = (
<UI.ActivityFeed
label="Recent activity"
items={[
{
id: "1",
title: "Deployment completed",
description: "Production is up to date.",
time: "2 minutes ago",
tone: "success",
},
]}
/>
);
break;
case "MarketingHeader":
example = (
<UI.MarketingHeader
brand="Northwind"
links={[
{ label: "Features", href: "#features" },
{ label: "Pricing", href: "#pricing" },
]}
action={action}
/>
);
break;
case "Hero":
example = (
<UI.Hero
title="Build with confidence."
description="One shared foundation for your next product."
actions={[action]}
/>
);
break;
case "FeatureGrid":
example = <UI.FeatureGrid title="Made to work together" items={items} />;
break;
case "FeatureSection":
example = (
<UI.FeatureSection
title="Your brand, centrally managed"
description="Choose a theme in one place."
>
{controls}
</UI.FeatureSection>
);
break;
case "Testimonial":
example = (
<UI.Testimonial
quote="The example shows how a testimonial appears."
name="Example customer"
role="Illustrative content"
/>
);
break;
case "LogoCloud":
example = (
<UI.LogoCloud
label="Example companies"
items={[
{ name: "Northwind" },
{ name: "Contoso" },
{ name: "Fabrikam" },
]}
/>
);
break;
case "Pricing":
example = (
<UI.Pricing
title="Example plans"
description="Illustrative pricing, not a purchase."
plans={[
{
id: "basic",
name: "Basic",
description: "Personal projects",
monthly: "$0",
features: ["One project"],
action,
},
{
id: "team",
name: "Team",
description: "Small teams",
monthly: "$20",
annual: "$16",
features: ["Unlimited projects"],
action,
featured: true,
},
]}
/>
);
break;
case "FAQ":
example = (
<UI.FAQ
title="Questions"
items={[
{
question: "Is the kit open source?",
answer: "Yes. Mortar is MIT licensed.",
},
]}
/>
);
break;
case "CallToAction":
example = (
<UI.CallToAction
title="Start your next project"
description="Use one shared foundation."
actions={[action]}
/>
);
break;
case "Footer":
example = (
<UI.Footer
brand="Northwind"
description="Example product"
columns={[{ title: "Product", links: [action] }]}
copyright="© Example company"
/>
);
break;
case "SegmentedControl":
example = (
<UI.SegmentedControl
label="View"
value={value}
onChange={setValue}
options={[
{ value: "first", label: "Overview" },
{ value: "second", label: "Activity" },
]}
/>
);
break;
case "CodeBlock":
example = (
<UI.CodeBlock
label="Usage"
code="import { Button } from '@tony-ui/mortar';"
/>
);
break;
case "PreviewFrame":
example = <UI.PreviewFrame title="Isolated example" src="about:blank" />;
break;
default:
example = (
<UI.Notice title="Unknown fixture">
This component is not registered.
</UI.Notice>
);
}
return (
<UI.ThemeProvider
brand={
typeof document !== "undefined" &&
document.documentElement.dataset.mortarBrand === "green"
? "green"
: "neutral"
}
initialAppearance={
typeof document !== "undefined" &&
document.documentElement.dataset.mortarAppearance === "dark"
? "dark"
: "light"
}
>
<div className="m-stack">{example}</div>
</UI.ThemeProvider>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Footer" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Hero" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Input" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="LogoCloud" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="MarketingHeader" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="MetricGroup" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Modal" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Notice" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="PageHeading" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Pagination" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="PreviewFrame" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Pricing" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="RadioGroup" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="SegmentedControl" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Select" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="SettingsSection" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Switch" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Tabs" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="Testimonial" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="TextArea" />;
}
+4
View File
@@ -0,0 +1,4 @@
import { PreviewCase } from "./Fixture";
export default function Preview() {
return <PreviewCase name="ThemeProvider" />;
}
+172
View File
@@ -0,0 +1,172 @@
import {
readFile,
readdir,
realpath,
lstat,
writeFile,
} from "node:fs/promises";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { createHash } from "node:crypto";
import ts from "typescript";
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export async function checkKit(root = ROOT) {
const findings = [];
const add = (rule, file, message, line = 1) =>
findings.push({ rule, file, line, severity: "error", message });
const policy = JSON.parse(
await readFile(resolve(root, "baseline-policy.json"), "utf8"),
);
const pkg = JSON.parse(await readFile(resolve(root, "package.json"), "utf8"));
const manifests = [];
for (const file of (await readdir(resolve(root, "manifests"))).sort()) {
if (!file.endsWith(".json")) continue;
try {
manifests.push(
JSON.parse(await readFile(resolve(root, "manifests", file), "utf8")),
);
} catch {
add("M001", `manifests/${file}`, "Manifest must be valid JSON.");
}
}
const actualExports = new Set();
const ast = ts.createSourceFile(
"index.ts",
await readFile(resolve(root, "src/index.ts"), "utf8"),
ts.ScriptTarget.Latest,
true,
);
for (const node of ast.statements)
if (
ts.isExportDeclaration(node) &&
!node.isTypeOnly &&
node.exportClause &&
ts.isNamedExports(node.exportClause)
)
for (const e of node.exportClause.elements)
if (!e.isTypeOnly) actualExports.add(e.name.text);
const ids = new Set();
const names = new Set();
const base = await realpath(root);
for (const c of manifests) {
if (
!c.id ||
ids.has(c.id) ||
names.has(c.export) ||
!actualExports.has(c.export)
)
add(
"M001",
`manifests/${c.id}.json`,
"Use a unique component ID and an existing named export.",
);
ids.add(c.id);
names.add(c.export);
for (const [file, prefix] of [
[c.source, "src/"],
[c.preview?.source, "previews/"],
]) {
try {
if (
typeof file !== "string" ||
!file.startsWith(prefix) ||
file.includes("..") ||
!(await lstat(resolve(root, file))).isFile() ||
!(await realpath(resolve(root, file))).startsWith(base + "/")
)
throw Error();
} catch {
add(
"M002",
String(file),
"Source and explicit preview must be regular files within their canonical repository directories.",
);
}
}
}
for (const name of actualExports)
if (!names.has(name))
add("M001", "src/index.ts", `Add a manifest and preview for ${name}.`);
for (const c of manifests)
for (const id of c.contract?.dependencies || [])
if (!ids.has(id))
add(
"M003",
`manifests/${c.id}.json`,
`Unknown component dependency ${id}.`,
);
async function inspect(directory) {
for (const entry of await readdir(resolve(root, directory), {
withFileTypes: true,
})) {
const file = `${directory}/${entry.name}`;
if (entry.isSymbolicLink()) {
add("M002", file, "Symlinks cannot hide shared source.");
continue;
}
if (entry.isDirectory()) {
await inspect(file);
continue;
}
if (!/\.tsx?$/.test(file)) continue;
const source = await readFile(resolve(root, file), "utf8");
const lines = source.split("\n");
for (let i = 0; i < lines.length; i++) {
if (
/\b(?:fetch|XMLHttpRequest)\b|UI_TOKEN|@tony-ui\/control|ui\.tony\.codes|packages\/control/.test(
lines[i],
)
)
add(
"M004",
file,
"Shared UI must not depend on hosted governance, credentials or network requests.",
i + 1,
);
if (
/(?:bg|text|border|ring)-(?:indigo|blue|green|red|amber|violet|slate|gray)-\d{2,3}\b/.test(
lines[i],
)
)
add(
"M005",
file,
"Use semantic component tokens instead of a fixed Tailwind palette.",
i + 1,
);
}
}
}
await inspect("src");
const catalog = {
schemaVersion: 1,
package: pkg.name,
version: pkg.version,
policyVersion: policy.version,
components: manifests,
};
return {
passed: findings.length === 0,
policyVersion: policy.version,
findings,
catalog,
};
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
) {
const report = await checkKit();
if (process.argv.includes("--write-catalog") && report.passed)
await writeFile(
resolve(ROOT, "catalog.json"),
JSON.stringify(report.catalog, null, 2) + "\n",
);
const { catalog, ...result } = report;
console.log(
process.argv.includes("--format")
? JSON.stringify(result, null, 2)
: `${report.passed ? "PASS" : "FAIL"}: ${catalog.components.length} components; baseline ${report.policyVersion}\n${report.findings.map((f) => `${f.file}:${f.line} ${f.rule} ${f.message}`).join("\n")}`,
);
process.exitCode = report.passed ? 0 : 1;
}
+46
View File
@@ -0,0 +1,46 @@
// Run by a protected release job after maintainer review and exact-commit CI.
import { readFile, mkdir, writeFile } from "node:fs/promises";
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { createHash } from "node:crypto";
import { checkKit, ROOT } from "./check.mjs";
const report = await checkKit();
if (!report.passed)
throw Error("Baseline checks failed; run npm run check:json.");
if (
execFileSync("git", ["status", "--porcelain"], {
cwd: ROOT,
encoding: "utf8",
}).trim()
)
throw Error("Release requires a clean checkout.");
const commit = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: ROOT,
encoding: "utf8",
}).trim();
const pkg = JSON.parse(await readFile(resolve(ROOT, "package.json"), "utf8"));
const out = resolve(ROOT, "release", pkg.version);
await mkdir(resolve(ROOT, "release"), { recursive: true });
await mkdir(out, { recursive: false });
const packed = JSON.parse(
execFileSync("npm", ["pack", "--json", "--pack-destination", out], {
cwd: ROOT,
encoding: "utf8",
}),
)[0];
const bytes = await readFile(resolve(out, packed.filename));
const provenance = {
schemaVersion: 1,
package: pkg.name,
version: pkg.version,
commit,
policyVersion: report.policyVersion,
integrity: "sha512-" + createHash("sha512").update(bytes).digest("base64"),
filename: packed.filename,
};
await writeFile(
resolve(out, "provenance.json"),
JSON.stringify(provenance, null, 2) + "\n",
{ flag: "wx" },
);
console.log(JSON.stringify(provenance, null, 2));
+418
View File
@@ -0,0 +1,418 @@
import { useId, useRef, useEffect, useState, type ReactNode } from "react";
import { Dialog, DialogPanel, DialogTitle } from "@headlessui/react";
import { Button, EmptyState, Notice, type Tone } from "./primitives.js";
export interface NavigationItem {
label: string;
href: string;
current?: boolean;
count?: string;
}
export interface AppShellProps {
brand: string;
workspace?: string;
user?: ReactNode;
navigation: NavigationItem[];
secondaryNavigation?: { label: string; items: NavigationItem[] }[];
header?: ReactNode;
actions?: ReactNode;
children: ReactNode;
}
export function AppShell({
brand,
workspace,
user,
navigation,
secondaryNavigation = [],
header,
actions,
children,
}: AppShellProps) {
const [open, setOpen] = useState(false);
const links = (items: NavigationItem[]) =>
items.map((item) => (
<a
key={item.href}
href={item.href}
aria-current={item.current ? "page" : undefined}
onClick={() => setOpen(false)}
>
<span className="m-nav-marker" aria-hidden="true" />
{item.label}
{item.count && <span className="m-nav-count">{item.count}</span>}
</a>
));
const content = (
<>
<a className="m-workspace" href={navigation[0]?.href || "#"}>
<span className="m-mark" aria-hidden="true" />
<span>
<strong>{brand}</strong>
{workspace && <small>{workspace}</small>}
</span>
</a>
<nav aria-label="Primary">
{links(navigation)}
{secondaryNavigation.map((group) => (
<section key={group.label}>
<h2>{group.label}</h2>
{links(group.items)}
</section>
))}
</nav>
{user && <div className="m-user">{user}</div>}
</>
);
return (
<div className="m-app-shell">
<a className="m-skip" href="#m-content">
Skip to content
</a>
<aside className="m-sidebar">{content}</aside>
<div className="m-app-main">
<header className="m-app-header">
<Button
variant="ghost"
className="m-menu-trigger"
onClick={() => setOpen(true)}
aria-label="Open navigation"
>
Menu
</Button>
<div className="m-header-context">{header || workspace || brand}</div>
<div className="m-actions">{actions}</div>
</header>
<main id="m-content" tabIndex={-1}>
{children}
</main>
</div>
<Dialog open={open} onClose={() => setOpen(false)} className="m-dialog">
<div className="m-backdrop" aria-hidden="true" />
<DialogPanel className="m-mobile-sidebar">
<div className="m-dialog-heading">
<DialogTitle>{brand}</DialogTitle>
<Button variant="ghost" onClick={() => setOpen(false)}>
Close navigation
</Button>
</div>
{content}
</DialogPanel>
</Dialog>
</div>
);
}
export function PageHeading({
title,
description,
eyebrow,
actions,
breadcrumbs,
headingLevel = 1,
}: {
title: string;
description?: string;
eyebrow?: string;
actions?: ReactNode;
breadcrumbs?: { label: string; href?: string }[];
headingLevel?: 1 | 2;
}) {
const Heading = headingLevel === 1 ? "h1" : "h2";
return (
<header className="m-page-heading">
{breadcrumbs && (
<nav aria-label="Breadcrumb">
<ol>
{breadcrumbs.map((b, i) => (
<li key={i}>
{b.href ? (
<a href={b.href}>{b.label}</a>
) : (
<span aria-current="page">{b.label}</span>
)}
</li>
))}
</ol>
</nav>
)}
<div className="m-heading-row">
<div>
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<Heading>{title}</Heading>
{description && <p>{description}</p>}
</div>
{actions && <div className="m-actions">{actions}</div>}
</div>
</header>
);
}
export function MetricGroup({
label,
items,
}: {
label: string;
items: { label: string; value: string; change?: string; tone?: Tone }[];
}) {
return (
<dl className="m-metrics" aria-label={label}>
{items.map((item) => (
<div key={item.label}>
<dt>{item.label}</dt>
<dd>
<strong>{item.value}</strong>
{item.change && (
<span className={`m-tone-${item.tone || "neutral"}`}>
{item.change}
</span>
)}
</dd>
</div>
))}
</dl>
);
}
export interface DataTableProps<T extends { id: string }> {
caption: string;
columns: {
key: string;
label: string;
render: (row: T) => ReactNode;
align?: "left" | "right";
}[];
rows: T[];
selectedIds?: string[];
onSelectionChange?: (ids: string[]) => void;
rowLabel: (row: T) => string;
toolbar?: ReactNode;
bulkActions?: ReactNode;
emptyTitle?: string;
emptyDescription?: string;
loading?: boolean;
error?: string;
}
function SelectAll({
checked,
partial,
onChange,
}: {
checked: boolean;
partial: boolean;
onChange: () => void;
}) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = partial;
}, [partial]);
return (
<input
ref={ref}
type="checkbox"
aria-label="Select all rows"
checked={checked}
onChange={onChange}
/>
);
}
export function DataTable<T extends { id: string }>({
caption,
columns,
rows,
selectedIds = [],
onSelectionChange,
rowLabel,
toolbar,
bulkActions,
emptyTitle = "No records",
emptyDescription = "Records will appear here when they are available.",
loading,
error,
}: DataTableProps<T>) {
const selected = rows.filter((row) => selectedIds.includes(row.id));
return (
<section className="m-data-table" aria-label={caption}>
{(toolbar || selected.length > 0) && (
<div className="m-table-toolbar">
{selected.length > 0 ? (
<>
<strong>{selected.length} selected</strong>
<div className="m-actions">
{bulkActions}
<Button variant="ghost" onClick={() => onSelectionChange?.([])}>
Clear selection
</Button>
</div>
</>
) : (
toolbar
)}
</div>
)}
{loading ? (
<p className="m-table-state" role="status">
Loading records…
</p>
) : error ? (
<Notice title="Unable to load records" tone="danger" announce>
{error}
</Notice>
) : !rows.length ? (
<EmptyState title={emptyTitle} description={emptyDescription} />
) : (
<div
className="m-table-scroll"
role="region"
aria-label={`${caption} scroll area`}
tabIndex={0}
>
<table>
<caption className="m-sr-only">{caption}</caption>
<thead>
<tr>
{onSelectionChange && (
<th scope="col">
<SelectAll
checked={selected.length === rows.length}
partial={
selected.length > 0 && selected.length < rows.length
}
onChange={() =>
onSelectionChange(
selected.length === rows.length
? []
: rows.map((r) => r.id),
)
}
/>
</th>
)}
{columns.map((col) => (
<th key={col.key} scope="col" data-align={col.align}>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
data-selected={selectedIds.includes(row.id) || undefined}
>
{onSelectionChange && (
<td>
<input
type="checkbox"
aria-label={`Select ${rowLabel(row)}`}
checked={selectedIds.includes(row.id)}
onChange={() =>
onSelectionChange(
selectedIds.includes(row.id)
? selectedIds.filter((id) => id !== row.id)
: [...selectedIds, row.id],
)
}
/>
</td>
)}
{columns.map((col, index) =>
index === 0 ? (
<th scope="row" key={col.key} data-align={col.align}>
{col.render(row)}
</th>
) : (
<td key={col.key} data-align={col.align}>
{col.render(row)}
</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function Pagination({
page,
pageCount,
onChange,
}: {
page: number;
pageCount: number;
onChange: (page: number) => void;
}) {
return (
<nav className="m-pagination" aria-label="Pagination">
<span aria-live="polite">
Page {page} of {Math.max(1, pageCount)}
</span>
<div className="m-actions">
<Button
variant="secondary"
disabled={page <= 1}
onClick={() => onChange(page - 1)}
>
Previous
</Button>
<Button
variant="secondary"
disabled={page >= pageCount}
onClick={() => onChange(page + 1)}
>
Next
</Button>
</div>
</nav>
);
}
export function SettingsSection({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
const id = useId();
return (
<section className="m-settings-section" aria-labelledby={id}>
<div>
<h2 id={id}>{title}</h2>
{description && <p>{description}</p>}
</div>
<div className="m-stack">{children}</div>
</section>
);
}
export function ActivityFeed({
label,
items,
}: {
label: string;
items: {
id: string;
title: string;
description?: string;
time: string;
tone?: Tone;
}[];
}) {
return (
<ol className="m-activity" aria-label={label}>
{items.map((item) => (
<li key={item.id}>
<span
aria-hidden="true"
className={`m-activity-dot m-tone-${item.tone || "neutral"}`}
/>
<div>
<strong>{item.title}</strong>
{item.description && <p>{item.description}</p>}
</div>
<span>{item.time}</span>
</li>
))}
</ol>
);
}
+1289
View File
File diff suppressed because it is too large. Load diff
+99
View File
@@ -0,0 +1,99 @@
import {
createContext,
useContext,
useEffect,
useState,
type ReactNode,
} from "react";
export type Appearance = "light" | "dark" | "system";
export type Brand = "neutral" | "green";
export type Density = "compact" | "comfortable" | "spacious";
type Theme = {
appearance: Appearance;
setAppearance: (value: Appearance) => void;
brand: Brand;
density: Density;
};
const ThemeContext = createContext<Theme | null>(null);
export interface ThemeProviderProps {
children: ReactNode;
initialAppearance?: Appearance;
brand?: Brand;
density?: Density;
}
/** One provider per application. Selection never requires a hosted account. */
export function ThemeProvider({
children,
initialAppearance = "system",
brand = "neutral",
density = "comfortable",
}: ThemeProviderProps) {
const [appearance, setAppearance] = useState<Appearance>(initialAppearance);
useEffect(() => {
const element = document.documentElement;
const attributes = {
"data-mortar": "",
"data-mortar-brand": brand,
"data-mortar-density": density,
};
const previous = Object.keys(attributes).map(
(key) => [key, element.getAttribute(key)] as const,
);
for (const [key, value] of Object.entries(attributes))
element.setAttribute(key, value);
return () => {
for (const [key, value] of previous)
value === null
? element.removeAttribute(key)
: element.setAttribute(key, value);
};
}, [brand, density]);
useEffect(() => {
const element = document.documentElement;
const previous = element.getAttribute("data-mortar-appearance");
const media = window.matchMedia("(prefers-color-scheme: dark)");
const update = () =>
element.setAttribute(
"data-mortar-appearance",
appearance === "system"
? media.matches
? "dark"
: "light"
: appearance,
);
update();
media.addEventListener("change", update);
return () => {
media.removeEventListener("change", update);
previous === null
? element.removeAttribute("data-mortar-appearance")
: element.setAttribute("data-mortar-appearance", previous);
};
}, [appearance]);
return (
<ThemeContext.Provider
value={{ appearance, setAppearance, brand, density }}
>
{children}
</ThemeContext.Provider>
);
}
export function AppearanceControl() {
const context = useContext(ThemeContext);
if (!context) throw new Error("AppearanceControl requires ThemeProvider");
return (
<div className="m-segmented" role="group" aria-label="Appearance">
{(["light", "dark", "system"] as const).map((value) => (
<button
key={value}
type="button"
aria-pressed={context.appearance === value}
onClick={() => context.setAppearance(value)}
>
{value[0].toUpperCase() + value.slice(1)}
</button>
))}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
export { ThemeProvider, AppearanceControl } from "./foundation.js";
export type {
ThemeProviderProps,
Appearance,
Brand,
Density,
} from "./foundation.js";
export {
Button,
ActionLink,
Input,
Select,
TextArea,
Checkbox,
RadioGroup,
Switch,
Badge,
Tabs,
Modal,
Dropdown,
Notice,
EmptyState,
} from "./primitives.js";
export type {
ButtonProps,
InputProps,
LinkAction,
Tone,
} from "./primitives.js";
export {
AppShell,
PageHeading,
MetricGroup,
DataTable,
Pagination,
SettingsSection,
ActivityFeed,
} from "./application.js";
export type {
AppShellProps,
NavigationItem,
DataTableProps,
} from "./application.js";
export {
MarketingHeader,
Hero,
FeatureGrid,
FeatureSection,
Testimonial,
LogoCloud,
Pricing,
FAQ,
CallToAction,
Footer,
} from "./marketing.js";
export type { PricingPlan } from "./marketing.js";
export { SegmentedControl, CodeBlock, PreviewFrame } from "./inspection.js";
+59
View File
@@ -0,0 +1,59 @@
import { useId } from "react";
export function SegmentedControl({
label,
value,
options,
onChange,
}: {
label: string;
value: string;
options: { value: string; label: string }[];
onChange: (value: string) => void;
}) {
return (
<div className="m-segmented" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
export function CodeBlock({
code,
label = "Code",
}: {
code: string;
label?: string;
}) {
const id = useId();
return (
<figure className="m-code-block">
<figcaption id={id}>{label}</figcaption>
<pre tabIndex={0} aria-labelledby={id}>
<code>{code}</code>
</pre>
</figure>
);
}
export function PreviewFrame({ title, src }: { title: string; src: string }) {
return (
<section className="m-preview-frame" aria-label={title}>
<a href={src} target="_blank" rel="noopener noreferrer">
Open full preview
</a>
<iframe
title={title}
src={src}
sandbox="allow-scripts"
referrerPolicy="no-referrer"
/>
</section>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState, type ReactNode } from "react";
import { ActionLink, Button, type LinkAction } from "./primitives.js";
export function MarketingHeader({
brand,
homeHref = "/",
links,
action,
controls,
}: {
brand: string;
homeHref?: string;
links: LinkAction[];
action?: LinkAction;
controls?: ReactNode;
}) {
const [open, setOpen] = useState(false);
return (
<header className="m-marketing-header">
<a className="m-brand" href={homeHref}>
<span className="m-mark" aria-hidden="true" />
{brand}
</a>
<Button
variant="ghost"
className="m-menu-trigger"
aria-expanded={open}
aria-controls="m-marketing-nav"
onClick={() => setOpen(!open)}
>
Menu
</Button>
<nav id="m-marketing-nav" aria-label="Primary" data-open={open}>
{links.map((link) => (
<a key={link.href} href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
))}
</nav>
<div className="m-actions">
{controls}
{action && <ActionLink {...action} />}
</div>
</header>
);
}
export function Hero({
eyebrow,
title,
description,
actions = [],
children,
align = "center",
headingLevel = 1,
}: {
eyebrow?: string;
title: string;
description: string;
actions?: LinkAction[];
children?: ReactNode;
align?: "center" | "split";
headingLevel?: 1 | 2;
}) {
const Heading = headingLevel === 1 ? "h1" : "h2";
return (
<section className={`m-hero m-hero-${align}`}>
<div>
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<Heading>{title}</Heading>
<p className="m-hero-description">{description}</p>
<div className="m-actions">
{actions.map((action, i) => (
<ActionLink
key={action.href}
{...action}
variant={i === 0 ? "primary" : "secondary"}
/>
))}
</div>
</div>
{children && <div className="m-hero-preview">{children}</div>}
</section>
);
}
export function FeatureGrid({
eyebrow,
title,
description,
items,
}: {
eyebrow?: string;
title: string;
description?: string;
items: { title: string; description: string; icon?: ReactNode }[];
}) {
return (
<section className="m-marketing-section">
{eyebrow && <p className="m-eyebrow">{eyebrow}</p>}
<h2>{title}</h2>
{description && <p className="m-section-description">{description}</p>}
<div className="m-feature-grid">
{items.map((item, i) => (
<article key={item.title}>
<span className="m-feature-icon" aria-hidden="true">
{item.icon || String(i + 1).padStart(2, "0")}
</span>
<h3>{item.title}</h3>
<p>{item.description}</p>
</article>
))}
</div>
</section>
);
}
export function FeatureSection({
title,
description,
children,
reverse = false,
}: {
title: string;
description: string;
children: ReactNode;
reverse?: boolean;
}) {
return (
<section className="m-feature-section" data-reverse={reverse}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div className="m-feature-preview">{children}</div>
</section>
);
}
export function Testimonial({
quote,
name,
role,
}: {
quote: string;
name: string;
role?: string;
}) {
return (
<figure className="m-testimonial">
<blockquote>{quote}</blockquote>
<figcaption>
<strong>{name}</strong>
{role && <span>{role}</span>}
</figcaption>
</figure>
);
}
export function LogoCloud({
label,
items,
}: {
label: string;
items: { name: string; image?: string }[];
}) {
return (
<section className="m-logo-cloud" aria-label={label}>
<p>{label}</p>
<ul>
{items.map((item) => (
<li key={item.name}>
{item.image ? (
<img src={item.image} alt={item.name} />
) : (
<span>{item.name}</span>
)}
</li>
))}
</ul>
</section>
);
}
export interface PricingPlan {
id: string;
name: string;
description: string;
monthly: string;
annual?: string;
unit?: string;
features: string[];
action: LinkAction;
featured?: boolean;
}
export function Pricing({
title,
description,
plans,
}: {
title: string;
description?: string;
plans: PricingPlan[];
}) {
const [annual, setAnnual] = useState(true),
toggle = plans.some((p) => p.annual);
return (
<section className="m-marketing-section m-pricing">
<h2>{title}</h2>
{description && <p className="m-section-description">{description}</p>}
{toggle && (
<div className="m-segmented" role="group" aria-label="Billing interval">
<button
type="button"
aria-pressed={annual}
onClick={() => setAnnual(true)}
>
Annual
</button>
<button
type="button"
aria-pressed={!annual}
onClick={() => setAnnual(false)}
>
Monthly
</button>
</div>
)}
<div className="m-pricing-grid">
{plans.map((plan) => (
<article key={plan.id} data-featured={plan.featured}>
<h3>{plan.name}</h3>
<p>{plan.description}</p>
<div className="m-price">
<strong>
{annual && plan.annual ? plan.annual : plan.monthly}
</strong>
{plan.unit && <span>{plan.unit}</span>}
</div>
<ul>
{plan.features.map((feature) => (
<li key={feature}>{feature}</li>
))}
</ul>
<ActionLink {...plan.action} variant="secondary" />
</article>
))}
</div>
</section>
);
}
export function FAQ({
title,
description,
items,
}: {
title: string;
description?: ReactNode;
items: { question: string; answer: ReactNode }[];
}) {
return (
<section className="m-faq">
<div>
<h2>{title}</h2>
{description && <div>{description}</div>}
</div>
<div>
{items.map((item) => (
<details key={item.question}>
<summary>
{item.question}
<span aria-hidden="true">+</span>
</summary>
<div>{item.answer}</div>
</details>
))}
</div>
</section>
);
}
export function CallToAction({
title,
description,
actions,
}: {
title: string;
description?: string;
actions: LinkAction[];
}) {
return (
<section className="m-cta">
<h2>{title}</h2>
{description && <p>{description}</p>}
<div className="m-actions">
{actions.map((action, i) => (
<ActionLink
key={action.href}
{...action}
variant={i === 0 ? "primary" : "secondary"}
/>
))}
</div>
</section>
);
}
export function Footer({
brand,
description,
columns,
copyright,
legal = [],
}: {
brand: string;
description: string;
columns: { title: string; links: LinkAction[] }[];
copyright: string;
legal?: LinkAction[];
}) {
return (
<footer className="m-footer">
<div className="m-footer-grid">
<div>
<strong>{brand}</strong>
<p>{description}</p>
</div>
{columns.map((column) => (
<nav key={column.title} aria-label={column.title}>
<h2>{column.title}</h2>
{column.links.map((link) => (
<a href={link.href} key={link.href}>
{link.label}
</a>
))}
</nav>
))}
</div>
<div className="m-footer-bottom">
<span>{copyright}</span>
<div>
{legal.map((link) => (
<a key={link.href} href={link.href}>
{link.label}
</a>
))}
</div>
</div>
</footer>
);
}
+429
View File
@@ -0,0 +1,429 @@
import {
forwardRef,
useId,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
type ReactNode,
} from "react";
import {
Dialog as HeadlessDialog,
DialogPanel,
DialogTitle,
Menu,
MenuButton,
MenuItems,
MenuItem,
} from "@headlessui/react";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
loading = false,
disabled,
children,
className = "",
type = "button",
...props
},
ref,
) {
return (
<button
{...props}
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={`m-button m-button-${variant} ${className}`}
>
{loading && <span className="m-spinner" aria-hidden="true" />}
{children}
</button>
);
},
);
export interface LinkAction {
label: string;
href: string;
}
export function ActionLink({
label,
href,
variant = "primary",
}: LinkAction & { variant?: ButtonProps["variant"] }) {
return (
<a className={`m-button m-button-${variant}`} href={href}>
{label}
</a>
);
}
export type Tone = "neutral" | "success" | "warning" | "danger";
export function Badge({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: Tone;
}) {
return <span className={`m-badge m-tone-${tone}`}>{children}</span>;
}
interface FieldDescription {
label: string;
hint?: string;
error?: string;
}
export interface InputProps
extends InputHTMLAttributes<HTMLInputElement>, FieldDescription {
prefix?: string;
suffix?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ label, hint, error, id, prefix, suffix, className = "", ...props },
ref,
) {
const generated = useId(),
fieldId = id || generated,
messageId = fieldId + "-message";
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-input-group" data-invalid={!!error || undefined}>
{prefix && <span>{prefix}</span>}
<input
{...props}
ref={ref}
id={fieldId}
className={className}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (error || hint) && messageId]
.filter(Boolean)
.join(" ") || undefined
}
/>
{suffix && <span>{suffix}</span>}
</div>
{(error || hint) && (
<p id={messageId} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
});
export function Select({
label,
options,
id,
hint,
error,
...props
}: SelectHTMLAttributes<HTMLSelectElement> &
FieldDescription & { options: { value: string; label: string }[] }) {
const generated = useId(),
fieldId = id || generated,
listbox = !!props.multiple || (props.size || 0) > 1;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<div className="m-select-control" data-listbox={listbox || undefined}>
<select
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{!listbox && (
<svg
viewBox="0 0 20 20"
fill="none"
aria-hidden="true"
focusable="false"
>
<path
d="m6 8 4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function TextArea({
label,
id,
hint,
error,
...props
}: TextareaHTMLAttributes<HTMLTextAreaElement> & FieldDescription) {
const generated = useId(),
fieldId = id || generated;
return (
<div className="m-field">
<label htmlFor={fieldId}>{label}</label>
<textarea
{...props}
id={fieldId}
aria-invalid={!!error || props["aria-invalid"]}
aria-describedby={
[props["aria-describedby"], (hint || error) && fieldId + "-message"]
.filter(Boolean)
.join(" ") || undefined
}
/>
{(hint || error) && (
<p id={fieldId + "-message"} className={error ? "m-error" : ""}>
{error || hint}
</p>
)}
</div>
);
}
export function Checkbox({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string }) {
return (
<label className="m-choice">
<input {...props} type="checkbox" />
{label}
</label>
);
}
export function Switch({
label,
...props
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "role"> & {
label: string;
}) {
return (
<label className="m-choice">
<input {...props} type="checkbox" role="switch" className="m-switch" />
{label}
</label>
);
}
export function RadioGroup({
label,
name,
value,
onChange,
options,
}: {
label: string;
name: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string; description?: string }[];
}) {
return (
<fieldset className="m-radio-group">
<legend>{label}</legend>
{options.map((o) => (
<label className="m-radio-card" key={o.value}>
<input
type="radio"
name={name}
value={o.value}
checked={value === o.value}
onChange={() => onChange(o.value)}
/>
<span>
<strong>{o.label}</strong>
{o.description && <span>{o.description}</span>}
</span>
</label>
))}
</fieldset>
);
}
export function Tabs({
label,
value,
onChange,
items,
}: {
label: string;
value: string;
onChange: (value: string) => void;
items: { id: string; label: string; content: ReactNode }[];
}) {
const base = useId();
return (
<div>
<div
className="m-tabs"
role="tablist"
aria-label={label}
onKeyDown={(e) => {
const i = items.findIndex((item) => item.id === value);
let next = i;
if (e.key === "ArrowRight") next = (i + 1) % items.length;
else if (e.key === "ArrowLeft")
next = (i - 1 + items.length) % items.length;
else if (e.key === "Home") next = 0;
else if (e.key === "End") next = items.length - 1;
else return;
e.preventDefault();
onChange(items[next].id);
document.getElementById(`${base}-${items[next].id}-tab`)?.focus();
}}
>
{items.map((item) => (
<button
key={item.id}
id={`${base}-${item.id}-tab`}
type="button"
role="tab"
aria-selected={item.id === value}
aria-controls={`${base}-${item.id}-panel`}
tabIndex={item.id === value ? 0 : -1}
onClick={() => onChange(item.id)}
>
{item.label}
</button>
))}
</div>
{items.map((item) => (
<div
key={item.id}
hidden={item.id !== value}
role="tabpanel"
id={`${base}-${item.id}-panel`}
aria-labelledby={`${base}-${item.id}-tab`}
tabIndex={0}
>
{item.content}
</div>
))}
</div>
);
}
export function Modal({
open,
onClose,
title,
children,
actions,
}: {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
actions?: ReactNode;
}) {
return (
<HeadlessDialog open={open} onClose={onClose} className="m-dialog">
<div className="m-backdrop" aria-hidden="true" />
<div className="m-dialog-position">
<DialogPanel className="m-dialog-panel">
<div className="m-dialog-heading">
<DialogTitle>{title}</DialogTitle>
<Button variant="ghost" onClick={onClose} aria-label="Close dialog">
Close
</Button>
</div>
{children}
{actions && <div className="m-actions">{actions}</div>}
</DialogPanel>
</div>
</HeadlessDialog>
);
}
export function Dropdown({
label,
items,
}: {
label: string;
items: {
label: string;
onSelect: () => void;
danger?: boolean;
disabled?: boolean;
}[];
}) {
return (
<Menu>
<MenuButton className="m-button m-button-secondary">
{label}
<span aria-hidden="true">⌄</span>
</MenuButton>
<MenuItems anchor="bottom end" className="m-menu">
{items.map((item) => (
<MenuItem key={item.label} disabled={item.disabled}>
<button
type="button"
className={item.danger ? "m-error" : ""}
onClick={item.onSelect}
>
{item.label}
</button>
</MenuItem>
))}
</MenuItems>
</Menu>
);
}
export function Notice({
title,
children,
tone = "neutral",
announce = false,
}: {
title: string;
children?: ReactNode;
tone?: Tone;
announce?: boolean;
}) {
return (
<div
className={`m-notice m-tone-${tone}`}
role={announce ? (tone === "danger" ? "alert" : "status") : undefined}
>
<strong>{title}</strong>
{children && <div>{children}</div>}
</div>
);
}
export function EmptyState({
title,
description,
action,
}: {
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="m-empty">
<h3>{title}</h3>
<p>{description}</p>
{action}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
/* Project-agnostic semantic tokens derived from the supplied Mortar v2 designs.
Status colours deliberately differ from brand colours. No hosted service dependency. */
:root {
--m-font:
-apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue",
sans-serif;
--m-mono: "SF Mono", ui-monospace, Menlo, monospace;
--m-paper: #ffffff;
--m-surface: #ffffff;
--m-sunken: #f5f5f7;
--m-line: #e5e5ea;
--m-line-strong: #8e8e93;
--m-ink: #000000;
--m-secondary: #3a3a3c;
--m-muted: #6e6e73;
--m-primary: #000000;
--m-primary-hover: #29292c;
--m-primary-active: #3a3a3c;
--m-on-primary: #ffffff;
--m-focus: #000000;
--m-link: #000000;
--m-selected: #f2f2f4;
--m-success: #1f7a36;
--m-warning: #945700;
--m-danger: #c70014;
--m-control: 36px;
--m-pad: 14px;
--m-font-size: 14px;
--m-row-pad: 14px;
--m-radius-sm: 4px;
--m-radius: 8px;
--m-radius-lg: 12px;
--m-float: 0 12px 32px -12px rgb(0 0 0 / 0.14);
}
:root[data-mortar] {
color-scheme: light;
}
:root[data-mortar-appearance="dark"] {
color-scheme: dark;
--m-paper: #000000;
--m-surface: #0e0e10;
--m-sunken: #1c1c1e;
--m-line: #29292c;
--m-line-strong: #727278;
--m-ink: #f5f5f7;
--m-secondary: #c7c7cc;
--m-muted: #98989d;
--m-primary: #ffffff;
--m-primary-hover: #e5e5ea;
--m-primary-active: #c7c7cc;
--m-on-primary: #000000;
--m-focus: #ffffff;
--m-link: #f5f5f7;
--m-selected: #1c1c1e;
--m-success: #30d158;
--m-warning: #ffd60a;
--m-danger: #ff6961;
--m-float: none;
}
:root[data-mortar-brand="green"] {
--m-primary: #047857;
--m-primary-hover: #065f46;
--m-primary-active: #064e3b;
--m-on-primary: #ffffff;
--m-focus: #047857;
--m-link: #047857;
--m-selected: #e6f5ed;
}
:root[data-mortar-brand="green"][data-mortar-appearance="dark"] {
--m-focus: #6ee7b7;
--m-link: #6ee7b7;
--m-selected: #062d23;
}
:root[data-mortar-density="compact"] {
--m-control: 30px;
--m-pad: 10px;
--m-font-size: 13px;
--m-row-pad: 10px;
}
:root[data-mortar-density="spacious"] {
--m-control: 44px;
--m-pad: 18px;
--m-font-size: 15px;
--m-row-pad: 18px;
}
@media (pointer: coarse) {
:root[data-mortar] {
--m-control: 44px;
}
}
Loaded 100 of 108 files, more files were not shown because too many files have changed in this diff. Show more