@weft-js/warp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +55 -2
- package/package.json +43 -4
- package/plugin.d.mts +30 -0
- package/plugin.mjs +358 -0
- package/src/Accordion.tsx +58 -0
- package/src/Alert.tsx +25 -0
- package/src/Badge.tsx +17 -0
- package/src/Button.tsx +100 -0
- package/src/Calendar.tsx +162 -0
- package/src/Canvas.tsx +110 -0
- package/src/Card.tsx +29 -0
- package/src/Chart.tsx +133 -0
- package/src/Charts.tsx +69 -0
- package/src/Chip.tsx +27 -0
- package/src/Choice.tsx +105 -0
- package/src/CodeBlock.tsx +65 -0
- package/src/CodeInput.tsx +130 -0
- package/src/Combobox.tsx +113 -0
- package/src/CommandPalette.tsx +348 -0
- package/src/DataGrid.tsx +561 -0
- package/src/DateField.tsx +221 -0
- package/src/Dither.tsx +129 -0
- package/src/Drawer.tsx +41 -0
- package/src/DurationField.tsx +98 -0
- package/src/Feed.tsx +74 -0
- package/src/Field.tsx +147 -0
- package/src/Icon.tsx +8 -0
- package/src/Kbd.tsx +6 -0
- package/src/KeyValueEditor.tsx +114 -0
- package/src/Layout.tsx +109 -0
- package/src/Markdown.tsx +63 -0
- package/src/MentionsInput.tsx +150 -0
- package/src/Menu.tsx +111 -0
- package/src/Modal.tsx +40 -0
- package/src/MonthPicker.tsx +190 -0
- package/src/Nav.tsx +20 -0
- package/src/NumberField.tsx +78 -0
- package/src/Pagination.tsx +122 -0
- package/src/PaymentCard.tsx +63 -0
- package/src/Picker.tsx +120 -0
- package/src/PlaceField.tsx +126 -0
- package/src/Popover.tsx +34 -0
- package/src/Progress.tsx +26 -0
- package/src/RecurrenceField.tsx +126 -0
- package/src/RichSelect.tsx +243 -0
- package/src/RichTextEditor.tsx +207 -0
- package/src/ScrollGrid.tsx +190 -0
- package/src/Segments.tsx +70 -0
- package/src/Select.tsx +192 -0
- package/src/Shape.tsx +20 -0
- package/src/Shell.tsx +308 -0
- package/src/SideNav.tsx +147 -0
- package/src/SignaturePad.tsx +120 -0
- package/src/Skeleton.tsx +14 -0
- package/src/Slider.tsx +42 -0
- package/src/Spinner.tsx +7 -0
- package/src/Table.tsx +528 -0
- package/src/Tabs.tsx +67 -0
- package/src/Tag.tsx +15 -0
- package/src/Ticker.tsx +36 -0
- package/src/TimeField.tsx +237 -0
- package/src/Toast.tsx +56 -0
- package/src/Toggle.tsx +42 -0
- package/src/Tooltip.tsx +32 -0
- package/src/Type.tsx +46 -0
- package/src/VirtualList.tsx +386 -0
- package/src/admin/ApiKeys.tsx +136 -0
- package/src/admin/Audit.tsx +93 -0
- package/src/admin/Billing.tsx +123 -0
- package/src/admin/Cart.tsx +96 -0
- package/src/admin/Checkout.tsx +300 -0
- package/src/admin/CsvImport.tsx +174 -0
- package/src/admin/Danger.tsx +110 -0
- package/src/admin/Integrations.tsx +200 -0
- package/src/admin/InviteDialog.tsx +64 -0
- package/src/admin/Members.tsx +186 -0
- package/src/admin/Order.tsx +63 -0
- package/src/admin/PlainTable.tsx +22 -0
- package/src/admin/Plans.tsx +167 -0
- package/src/admin/SaveBar.tsx +112 -0
- package/src/admin/Secret.tsx +37 -0
- package/src/admin/Settings.tsx +76 -0
- package/src/admin/StepBar.tsx +35 -0
- package/src/admin/TagsInput.tsx +79 -0
- package/src/admin/atoms.tsx +33 -0
- package/src/admin/index.ts +16 -0
- package/src/agent/Actions.tsx +85 -0
- package/src/agent/AgentPlan.tsx +49 -0
- package/src/agent/ArtifactPanel.tsx +80 -0
- package/src/agent/Assistant.tsx +96 -0
- package/src/agent/Checkpoint.tsx +14 -0
- package/src/agent/Cite.tsx +39 -0
- package/src/agent/ContextMeter.tsx +31 -0
- package/src/agent/Conversation.tsx +34 -0
- package/src/agent/Message.tsx +53 -0
- package/src/agent/ModelPicker.tsx +65 -0
- package/src/agent/Prompt.tsx +197 -0
- package/src/agent/Prose.tsx +54 -0
- package/src/agent/Question.tsx +52 -0
- package/src/agent/Reasoning.tsx +19 -0
- package/src/agent/Shimmer.tsx +7 -0
- package/src/agent/Sources.tsx +23 -0
- package/src/agent/SpeechInput.tsx +102 -0
- package/src/agent/Suggestions.tsx +22 -0
- package/src/agent/ToolCall.tsx +91 -0
- package/src/agent/TraceViewer.tsx +55 -0
- package/src/agent/bits.tsx +40 -0
- package/src/agent/index.ts +22 -0
- package/src/bind.ts +42 -0
- package/src/collab/AutoTrack.tsx +42 -0
- package/src/collab/Comments.tsx +94 -0
- package/src/collab/Composer.tsx +173 -0
- package/src/collab/ConversationList.tsx +73 -0
- package/src/collab/Cursors.tsx +35 -0
- package/src/collab/DataIntegrity.tsx +45 -0
- package/src/collab/Kanban.tsx +185 -0
- package/src/collab/Lock.tsx +23 -0
- package/src/collab/PresenceStack.tsx +67 -0
- package/src/collab/ShareDialog.tsx +118 -0
- package/src/collab/Status.tsx +42 -0
- package/src/collab/SyncedForm.tsx +19 -0
- package/src/collab/Thread.tsx +273 -0
- package/src/collab/VersionHistory.tsx +49 -0
- package/src/collab/ViewportRail.tsx +49 -0
- package/src/collab/index.ts +16 -0
- package/src/collab/peer.tsx +16 -0
- package/src/fields.ts +5 -0
- package/src/flow/AuthScreen.tsx +40 -0
- package/src/flow/AuthSteps.tsx +120 -0
- package/src/flow/Checklist.tsx +84 -0
- package/src/flow/ConnBanner.tsx +147 -0
- package/src/flow/Flow.tsx +63 -0
- package/src/flow/MfaSetup.tsx +103 -0
- package/src/flow/Otp.tsx +114 -0
- package/src/flow/ReauthDialog.tsx +47 -0
- package/src/flow/ShortcutsOverlay.tsx +60 -0
- package/src/flow/SignGate.tsx +98 -0
- package/src/flow/SignIn.tsx +80 -0
- package/src/flow/SignUp.tsx +71 -0
- package/src/flow/Sso.tsx +38 -0
- package/src/flow/SystemPage.tsx +247 -0
- package/src/flow/Tour.tsx +108 -0
- package/src/flow/index.ts +17 -0
- package/src/flow/kit.tsx +170 -0
- package/src/index.ts +74 -0
- package/src/layout.css +4 -0
- package/src/links.css +4 -0
- package/src/live/ActivityFeed.tsx +49 -0
- package/src/live/Bell.tsx +62 -0
- package/src/live/Connection.tsx +49 -0
- package/src/live/Inbox.tsx +220 -0
- package/src/live/MeetingRoom.tsx +298 -0
- package/src/live/NotificationPreferences.tsx +193 -0
- package/src/live/Prejoin.tsx +140 -0
- package/src/live/PushSoftAsk.tsx +37 -0
- package/src/live/TransportPicker.tsx +163 -0
- package/src/live/index.ts +12 -0
- package/src/live/notify.tsx +16 -0
- package/src/live/shared.tsx +39 -0
- package/src/parts.tsx +93 -0
- package/src/schedule/Agenda.tsx +173 -0
- package/src/schedule/AvailabilityEditor.tsx +164 -0
- package/src/schedule/BookingCalendar.tsx +132 -0
- package/src/schedule/Calendar.tsx +746 -0
- package/src/schedule/EventEditor.tsx +87 -0
- package/src/schedule/EventPopover.tsx +65 -0
- package/src/schedule/MeetingPlanner.tsx +214 -0
- package/src/schedule/MoveSheet.tsx +78 -0
- package/src/schedule/YearView.tsx +163 -0
- package/src/schedule/bits.tsx +51 -0
- package/src/schedule/index.ts +10 -0
- package/src/skeleton-css.ts +4 -0
- package/src/styles.ts +10 -0
- package/src/tokens.d.ts +2 -0
- package/src/type.css +15 -0
- package/src/ui.ts +26 -0
- package/src/use-ui.ts +10 -0
- package/tailwind.css +38 -0
- package/tokens/color.mjs +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ravonus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,56 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/warp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
warp is weft's UI toolkit: forms, overlays, tables, charts and more, with keyboard and screen reader support built in. The
|
|
6
|
+
components and their look are here. Their behaviour is in [`@weft-js/warp-core`](../warp-core), which works without any
|
|
7
|
+
framework.
|
|
8
|
+
|
|
9
|
+
An app made with `npm create @weft-js` gets warp by default (`--ui warp`). To add it to another app:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @weft-js/warp
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
and add its plugin to `weft.config.ts`:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { defineConfig } from '@weft-js/core/config'
|
|
19
|
+
import { warp } from '@weft-js/warp/plugin'
|
|
20
|
+
|
|
21
|
+
export default defineConfig({ plugins: [warp()] })
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then import what you use. A page pays only for the components it renders.
|
|
25
|
+
|
|
26
|
+
```tsx
|
|
27
|
+
import { state } from '@weft-js/core'
|
|
28
|
+
import { Badge, Button, Card } from '@weft-js/warp'
|
|
29
|
+
|
|
30
|
+
export default function Product() {
|
|
31
|
+
const inCart = state(0)
|
|
32
|
+
return (
|
|
33
|
+
<Card eyebrow="Coffee" title="House blend, 250 g">
|
|
34
|
+
<p>Cart <Badge count>{inCart.value}</Badge></p>
|
|
35
|
+
<Button onClick={() => inCart.value++}>Add to cart</Button>
|
|
36
|
+
</Card>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The look comes from tokens, so it follows the theme, the density and the expression dial (quiet, balanced or vivid) with
|
|
42
|
+
no change to your code. `warp({ seed, fields, dials, radius, fonts, tokens })` sets the app's defaults; `weft docs design`
|
|
43
|
+
has the options.
|
|
44
|
+
|
|
45
|
+
## Docs
|
|
46
|
+
|
|
47
|
+
- The site's toolkit pages list every component with its props, live examples and keyboard table. The words and
|
|
48
|
+
examples are in `site/src/toolkit`.
|
|
49
|
+
- `weft docs design`: the design language and the D-codes `weft check` reports in an app with warp.
|
|
50
|
+
- `weft docs icons`: warp's icons.
|
|
51
|
+
|
|
52
|
+
## Size
|
|
53
|
+
|
|
54
|
+
An app built with warp has a ceiling of 50 KB of brotli JavaScript and CSS. `evals/warp-exit.mjs` builds the shop app
|
|
55
|
+
from the evals with warp and checks it against that.
|
|
56
|
+
`npm run size` in this folder prints what each component costs on its own and on a page with the others.
|
package/package.json
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/warp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "warp, the UI toolkit for weft: components over @weft-js/warp-core",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"*.css",
|
|
9
|
+
"./src/styles.ts",
|
|
10
|
+
"./src/fields.ts",
|
|
11
|
+
"./src/ui.ts"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./skeleton": "./src/skeleton-css.ts",
|
|
16
|
+
"./ui": "./src/ui.ts",
|
|
17
|
+
"./plugin": "./plugin.mjs",
|
|
18
|
+
"./tailwind.css": "./tailwind.css"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@weft-js/warp-icons": "0.1.1",
|
|
22
|
+
"@weft-js/warp-core": "0.1.1"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@weft-js/core": "^0.1.1"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=26"
|
|
32
|
+
},
|
|
33
|
+
"keywords": [
|
|
34
|
+
"weft"
|
|
35
|
+
],
|
|
36
|
+
"files": [
|
|
37
|
+
"src",
|
|
38
|
+
"plugin.mjs",
|
|
39
|
+
"plugin.d.mts",
|
|
40
|
+
"tailwind.css",
|
|
41
|
+
"tokens",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
]
|
|
45
|
+
}
|
package/plugin.d.mts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PluginInstance } from '@weft-js/core/plugin'
|
|
2
|
+
|
|
3
|
+
/** For `site.head`: restores `setDial(…, { persist: true })` before the first paint. */
|
|
4
|
+
export const dialScript: string
|
|
5
|
+
|
|
6
|
+
/** What `warp()` takes in weft.config: the seed, the default dials, radius, fonts and any token. */
|
|
7
|
+
export interface WarpOptions {
|
|
8
|
+
/** The brand accent: `{ accent: '#rrggbb' }`, or a hue and chroma. Checked against every dial stop (D010). */
|
|
9
|
+
seed?: { accent: string } | { hue: number; chroma: number }
|
|
10
|
+
/** `'classic'` makes classic fields the default. */
|
|
11
|
+
fields?: 'floating' | 'classic'
|
|
12
|
+
/** The app's default dials. A `data-*` on `<html>` or any scope still wins. */
|
|
13
|
+
dials?: {
|
|
14
|
+
expression?: 'quiet' | 'balanced' | 'vivid'
|
|
15
|
+
density?: 'compact' | 'cozy' | 'comfortable'
|
|
16
|
+
flair?: 'sweep' | 'none'
|
|
17
|
+
theme?: 'light' | 'dark'
|
|
18
|
+
}
|
|
19
|
+
/** Replaces the radius each expression sets. */
|
|
20
|
+
radius?: 'sharp' | 'soft' | 'round'
|
|
21
|
+
/** A family name, or a full CSS font stack. */
|
|
22
|
+
fonts?: { body?: string; display?: string; mono?: string }
|
|
23
|
+
/** Any spec token, like `{ '--warp-color-page': '#fafafa' }`. Checked at every dial stop (D010). */
|
|
24
|
+
tokens?: Record<`--warp-${string}`, string>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The Vite plugin that serves warp's tokens for an app. */
|
|
28
|
+
export const warp: (options?: WarpOptions) => PluginInstance<unknown>
|
|
29
|
+
/** The plugin, for `import warp from`. */
|
|
30
|
+
export default warp
|
package/plugin.mjs
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// warp() for weft.config.ts: warp's config, tokens, catalog and AGENTS section (PLAN §7c, research 13 §2.13).
|
|
2
|
+
// Components stay ordinary imports; this only contributes data.
|
|
3
|
+
//
|
|
4
|
+
// - seed: the brand accent. The spec tokens (warp-core/styles/warp.css) derive every colour from
|
|
5
|
+
// --warp-seed-accent-h/-c, so a seed is those two values. It is checked with the spec's own contrast checker
|
|
6
|
+
// (warp-core/scripts/contrast-check.mjs: every sys pair, both themes, every dial stop). A hue that fails is fitted (see
|
|
7
|
+
// fitted()), so every hue passes; what can still fail is a token override, which is D010.
|
|
8
|
+
// - fields: 'classic' makes classic fields the default with no attribute in the app: the spec's classic rules
|
|
9
|
+
// (keyed on [data-fields="classic"]) are copied, re-scoped to :root unless an ancestor says data-fields="floating".
|
|
10
|
+
// - dials: the app's default expression, density, flair and theme, the same way. Markup still wins: a data-* on <html>
|
|
11
|
+
// turns the default off, and any element can set its own scope.
|
|
12
|
+
// - radius, fonts, tokens: spec tokens set once for every scope. Each one goes through the contrast check with the seed.
|
|
13
|
+
import { execFileSync } from 'node:child_process'
|
|
14
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { tmpdir } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { definePlugin } from '@weft-js/core/plugin'
|
|
18
|
+
import { fromHex, hex, luminance, toLinear } from './tokens/color.mjs'
|
|
19
|
+
|
|
20
|
+
const core = new URL('../warp-core/', import.meta.url)
|
|
21
|
+
const DEFAULT = { hue: 152, chroma: 0.115 }
|
|
22
|
+
const DIALS = { expression: ['quiet', 'balanced', 'vivid'], density: ['compact', 'cozy', 'comfortable'], flair: ['sweep', 'none'], theme: ['light', 'dark'] }
|
|
23
|
+
const RADII = { sharp: [2, 2, 2], soft: [8, 12, 4], round: [14, 20, 6] }
|
|
24
|
+
const FONTS = { body: ['sans', 'ui-sans-serif, system-ui, sans-serif'], display: ['display', 'ui-sans-serif, system-ui, sans-serif'], mono: ['mono', 'ui-monospace, monospace'] }
|
|
25
|
+
// The spec's scope roots (warp.css §2): every one recomputes the sys tokens, so an override has to land on each.
|
|
26
|
+
const SCOPES = ':root,[data-expression],[data-density],[data-form],[data-pointer],[data-motion],[data-flair],[data-warp]'
|
|
27
|
+
const safe = value => typeof value === 'string' && value !== '' && !/[;{}<]/.test(value)
|
|
28
|
+
const bad = (path, message) => Object.assign(new Error(message), { path })
|
|
29
|
+
|
|
30
|
+
function normalizeSeed(seed) {
|
|
31
|
+
if (seed && typeof seed.accent === 'string') {
|
|
32
|
+
if (!/^#[0-9a-f]{6}$/i.test(seed.accent)) throw new Error('accent must be #rrggbb')
|
|
33
|
+
const { C, H } = fromHex(seed.accent)
|
|
34
|
+
return { hue: Math.round(H * 10) / 10, chroma: Math.round(C * 1000) / 1000 }
|
|
35
|
+
}
|
|
36
|
+
const { hue, chroma } = seed ?? {}
|
|
37
|
+
if (!(hue >= 0 && hue < 360) || !(chroma >= 0 && chroma <= 0.37)) throw new Error('seed is { hue: 0–360, chroma: 0–0.37 } or { accent: "#rrggbb" }') // weft reports it as P115
|
|
38
|
+
return { hue, chroma }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function options(input) {
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const key of Object.keys(input)) if (!['seed', 'fields', 'dials', 'radius', 'fonts', 'tokens'].includes(key)) throw Object.assign(new Error(`unknown option \`${key}\``), { path: key })
|
|
44
|
+
if (input.fields !== undefined && input.fields !== 'floating' && input.fields !== 'classic') throw Object.assign(new Error(`must be 'floating' or 'classic'`), { path: 'fields' })
|
|
45
|
+
if (input.seed !== undefined) {
|
|
46
|
+
try {
|
|
47
|
+
out.seed = normalizeSeed(input.seed)
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw Object.assign(/** @type {Error} */ (error), { path: 'seed' })
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (input.fields) out.fields = input.fields
|
|
53
|
+
for (const [dial, value] of Object.entries(input.dials ?? {})) {
|
|
54
|
+
if (!DIALS[dial]) throw bad(`dials.${dial}`, `unknown dial; use ${Object.keys(DIALS).join(', ')}`)
|
|
55
|
+
if (!DIALS[dial].includes(value)) throw bad(`dials.${dial}`, `must be one of ${DIALS[dial].join(', ')}`)
|
|
56
|
+
}
|
|
57
|
+
if (input.dials) out.dials = input.dials
|
|
58
|
+
if (input.radius !== undefined) {
|
|
59
|
+
if (!RADII[input.radius]) throw bad('radius', `must be one of ${Object.keys(RADII).join(', ')}`)
|
|
60
|
+
out.radius = input.radius
|
|
61
|
+
}
|
|
62
|
+
for (const [font, value] of Object.entries(input.fonts ?? {})) {
|
|
63
|
+
if (!FONTS[font]) throw bad(`fonts.${font}`, `unknown font; use ${Object.keys(FONTS).join(', ')}`)
|
|
64
|
+
if (!safe(value)) throw bad(`fonts.${font}`, 'must be a font family or a CSS font stack')
|
|
65
|
+
}
|
|
66
|
+
if (input.fonts) out.fonts = input.fonts
|
|
67
|
+
for (const [token, value] of Object.entries(input.tokens ?? {})) {
|
|
68
|
+
if (!/^--warp-[a-z0-9-]+$/.test(token)) throw bad(`tokens.${token}`, 'must be a warp token, like --warp-color-page')
|
|
69
|
+
if (!safe(value)) throw bad(`tokens.${token}`, 'must be a CSS value')
|
|
70
|
+
if (token.startsWith('--warp-color-') && !/^(#[0-9a-f]{6}|(oklch|color-mix|light-dark)\(.+\))$/i.test(value)) throw bad(`tokens.${token}`, 'must be #rrggbb or oklch(), so the contrast check can read it')
|
|
71
|
+
}
|
|
72
|
+
if (input.tokens) out.tokens = input.tokens
|
|
73
|
+
return out
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const fontStack = (value, fallback) => (value.includes(',') ? value : `${JSON.stringify(value)}, ${fallback}`)
|
|
77
|
+
|
|
78
|
+
/** The radius, fonts and tokens as declarations, or '' with none set. */
|
|
79
|
+
function overrides({ radius, fonts = {}, tokens = {} }) {
|
|
80
|
+
const out = {}
|
|
81
|
+
if (radius) [out['--warp-radius-control'], out['--warp-radius-surface'], out['--warp-radius-mark']] = RADII[radius].map(px => `${px}px`)
|
|
82
|
+
for (const [font, value] of Object.entries(fonts)) out[`--warp-font-${FONTS[font][0]}`] = fontStack(value, FONTS[font][1])
|
|
83
|
+
Object.assign(out, tokens)
|
|
84
|
+
return Object.entries(out).map(([k, v]) => `${k}:${v}`).join(';')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// In warp.base, a later layer than warp.tokens, so a token set here beats every dial stop and every copied default.
|
|
88
|
+
const overrideCss = declarations => (declarations ? `@layer warp.base{${SCOPES}{${declarations}}}\n` : '')
|
|
89
|
+
|
|
90
|
+
const seedCss = s => `@layer warp.tokens{:root{--warp-seed-accent-h:${s.hue};--warp-seed-accent-c:${s.chroma}}}\n`
|
|
91
|
+
|
|
92
|
+
/** Run the spec checker on warp.css with the seed and any token overrides applied; D010 with every failing pair. */
|
|
93
|
+
export function checkSeed(seed, declarations = '') {
|
|
94
|
+
const dir = mkdtempSync(join(tmpdir(), 'warp-seed-'))
|
|
95
|
+
try {
|
|
96
|
+
const css = join(dir, 'warp.css')
|
|
97
|
+
writeFileSync(css, readFileSync(new URL('styles/warp.css', core), 'utf8') + `\n:root{--warp-seed-accent-h:${seed.hue};--warp-seed-accent-c:${seed.chroma}}\n${declarations && `${SCOPES}{${declarations}}\n`}`)
|
|
98
|
+
let out
|
|
99
|
+
try {
|
|
100
|
+
out = execFileSync(process.execPath, [new URL('scripts/contrast-check.mjs', core).pathname, css, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
101
|
+
} catch (e) {
|
|
102
|
+
out = e.stdout // exit 1 on failures; the JSON is still on stdout
|
|
103
|
+
}
|
|
104
|
+
const { results } = JSON.parse(out)
|
|
105
|
+
const failures = results.filter(r => !r.pass).map(r => `${r.combo}: ${r.fg} on ${r.bg} is ${r.ratio.toFixed(2)}:1, needs ${r.min}:1`)
|
|
106
|
+
if (failures.length) throw Object.assign(new Error(`warp colours fail contrast (${failures.length} pairs): ${failures.slice(0, 5).join('; ')}${failures.length > 5 ? '; …' : ''}`), { code: 'D010', failures })
|
|
107
|
+
return results.length
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(dir, { recursive: true, force: true })
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The resolved tokens as DTCG 2025.10: one token file per theme × expression and a resolver over them. `css`: the app's seed and overrides. */
|
|
114
|
+
export function dtcg(css = '') {
|
|
115
|
+
const dir = mkdtempSync(join(tmpdir(), 'warp-seed-'))
|
|
116
|
+
let resolved
|
|
117
|
+
try {
|
|
118
|
+
const file = join(dir, 'warp.css')
|
|
119
|
+
writeFileSync(file, readFileSync(new URL('styles/warp.css', core), 'utf8') + `\n${css.replace(/@layer warp\.\w+\{([\s\S]*)\}\n?$/gm, '$1')}\n`)
|
|
120
|
+
let out
|
|
121
|
+
try {
|
|
122
|
+
out = execFileSync(process.execPath, [new URL('scripts/contrast-check.mjs', core).pathname, file, '--json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
|
|
123
|
+
} catch (e) {
|
|
124
|
+
out = e.stdout
|
|
125
|
+
}
|
|
126
|
+
resolved = JSON.parse(out).resolved
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(dir, { recursive: true, force: true })
|
|
129
|
+
}
|
|
130
|
+
const px = value => ({ $value: { value, unit: 'px' } })
|
|
131
|
+
const SPACE = { hair: 2, tight: 4, related: 8, group: 16 }
|
|
132
|
+
const X = { quiet: 0, balanced: 0.5, vivid: 1 }
|
|
133
|
+
const files = {}
|
|
134
|
+
for (const [combo, colours] of Object.entries(resolved)) {
|
|
135
|
+
const [theme, expression] = combo.split('/')
|
|
136
|
+
const x = X[expression]
|
|
137
|
+
const color = Object.fromEntries(Object.entries(colours).filter(([, v]) => /^#[0-9a-f]{6}$/i.test(v)).map(([name, code]) => [name, { $value: { colorSpace: 'srgb', components: [1, 3, 5].map(i => parseInt(code.slice(i, i + 2), 16) / 255), hex: code } }]))
|
|
138
|
+
files[`warp.${theme}.${expression}.tokens.json`] = {
|
|
139
|
+
color: { $type: 'color', ...color },
|
|
140
|
+
space: { $type: 'dimension', ...Object.fromEntries(Object.entries(SPACE).map(([k, v]) => [k, px(v)])) },
|
|
141
|
+
radius: { $type: 'dimension', mark: px(Math.round(4 - 2 * x)), control: px(Math.round(8 - 6 * x)), surface: px(Math.round(12 - 10 * x)) },
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Each file is whole, so one modifier picks one file: the dials don't layer in DTCG terms.
|
|
145
|
+
const contexts = Object.fromEntries(Object.keys(files).map(f => [f.split('.').slice(1, 3).join('-'), [{ $ref: f }]]))
|
|
146
|
+
files['warp.resolver.json'] = { name: 'warp', version: '2025.10', modifiers: { mode: { contexts, default: 'light-balanced' } }, resolutionOrder: [{ $ref: '#/modifiers/mode' }] }
|
|
147
|
+
return files
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A token's value in the spec, with `from` (its lightness for one theme) replaced by `to`.
|
|
151
|
+
function relit(token, from, to) {
|
|
152
|
+
const value = readFileSync(new URL('styles/warp.css', core), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').match(new RegExp(`${token}:\\s*([^;]+);`))[1]
|
|
153
|
+
return value.replace(/\s+/g, ' ').replace(`oklch(${from} `, `oklch(${to} `)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The lightness at which `chroma` and `hue` have luminance `Y`: contrast only sees luminance, so this keeps the default's contrast. */
|
|
157
|
+
function lightnessFor(Y, chroma, hue) {
|
|
158
|
+
let lo = 0, hi = 1
|
|
159
|
+
for (let k = 0; k < 24; k++) {
|
|
160
|
+
const mid = (lo + hi) / 2
|
|
161
|
+
if (luminance(toLinear({ L: mid, C: chroma, H: hue })) < Y) lo = mid
|
|
162
|
+
else hi = mid
|
|
163
|
+
}
|
|
164
|
+
return Math.ceil(hi * 1000) / 1000
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The spec's lightnesses are tuned for the default hue, and equal lightness is not equal luminance: other hues land just
|
|
168
|
+
// outside the two tightest pairs (light sweep against the ink, dark accent text on its pressed fill). Those two tokens
|
|
169
|
+
// are relit to the default's luminance, keeping the hue and chroma. Accent text takes a 3% margin: at these hues the soft
|
|
170
|
+
// fill behind it is brighter too, and text only ever gets lighter.
|
|
171
|
+
function fitted(seed) {
|
|
172
|
+
const sweep = lightnessFor(luminance(toLinear({ L: 0.515, C: 0.13, H: DEFAULT.hue })), Math.min(Math.max(seed.chroma * 1.25, 0.12), 0.13), seed.hue)
|
|
173
|
+
const text = lightnessFor(luminance(toLinear({ L: 0.8, C: DEFAULT.chroma, H: DEFAULT.hue })) * 1.03, seed.chroma, seed.hue)
|
|
174
|
+
return { '--warp-color-sweep': relit('--warp-color-sweep', 0.515, sweep), '--warp-color-accent-text': relit('--warp-color-accent-text', 0.8, Math.max(text, 0.8)) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `declarations` plus whatever a custom seed needs to pass, or D010. A token set in the config beats a fitted one. */
|
|
178
|
+
function fitSeed(seed, declarations) {
|
|
179
|
+
try {
|
|
180
|
+
checkSeed(seed, declarations)
|
|
181
|
+
return declarations
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error.code !== 'D010') throw error
|
|
184
|
+
}
|
|
185
|
+
const fit = Object.entries(fitted(seed)).filter(([token]) => !declarations.includes(`${token}:`)).map(([k, v]) => `${k}:${v}`)
|
|
186
|
+
const merged = [...fit, ...(declarations ? [declarations] : [])].join(';')
|
|
187
|
+
checkSeed(seed, merged)
|
|
188
|
+
return merged
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Every spec rule keyed on `key`, re-scoped to `scope`, inside its original layer. */
|
|
192
|
+
function rescoped(key, scope) {
|
|
193
|
+
const files = [new URL('styles/warp.css', core), ...['inputs', 'components'].flatMap(dir => readdirSync(new URL(`styles/${dir}/`, core)).map(f => new URL(`styles/${dir}/${f}`, core)))]
|
|
194
|
+
let out = ''
|
|
195
|
+
for (const file of files) {
|
|
196
|
+
const css = readFileSync(file, 'utf8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
197
|
+
const stack = []
|
|
198
|
+
let start = 0
|
|
199
|
+
for (let i = 0; i < css.length; i++) {
|
|
200
|
+
if (css[i] === '{') { stack.push({ prelude: css.slice(start, i).trim(), at: i }); start = i + 1 }
|
|
201
|
+
else if (css[i] === '}') {
|
|
202
|
+
const open = stack.pop()
|
|
203
|
+
if (open && open.prelude.includes(key) && !open.prelude.startsWith('@')) {
|
|
204
|
+
const layers = stack.map(s => s.prelude).filter(p => p.startsWith('@'))
|
|
205
|
+
const rule = `${open.prelude.replaceAll(key, scope)}{${css.slice(open.at + 1, i).trim()}}`
|
|
206
|
+
out += layers.reduceRight((inner, p) => `${p}{${inner}}`, rule) + '\n'
|
|
207
|
+
}
|
|
208
|
+
start = i + 1
|
|
209
|
+
} else if (css[i] === ';' && !stack.length) start = i + 1
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* For `site.head`: applies the dials `setDial(…, { persist: true })` kept, before the first paint, so a prerendered page
|
|
217
|
+
* doesn't show the OS theme first.
|
|
218
|
+
*/
|
|
219
|
+
export const dialScript = `<script>try{Object.assign(document.documentElement.dataset,JSON.parse(localStorage.getItem('warp:dials')||'{}'))}catch(e){}</script>`
|
|
220
|
+
|
|
221
|
+
export const classicCss = () => rescoped('[data-fields="classic"]', ':root:not([data-fields="floating"])')
|
|
222
|
+
export const dialCss = (dial, value) => rescoped(`[data-${dial}="${value}"]`, `:root:not([data-${dial}])`)
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The app's colours for its generated share cards (`site.card`, research 20 §10): the page, text and accent tokens of
|
|
226
|
+
* warp.css as sRGB, since a card is an image and has no stylesheet.
|
|
227
|
+
*/
|
|
228
|
+
function cardColors(seed, dials) {
|
|
229
|
+
const color = (L, C, H) => hex(toLinear({ L, C, H }))
|
|
230
|
+
const [h, c] = [85, 0.012] // --warp-ref-neutral: warm paper and ink
|
|
231
|
+
return {
|
|
232
|
+
scheme: dials?.theme ?? 'dark',
|
|
233
|
+
dark: { background: color(0.155, c, h), text: color(0.935, c, h), subtle: color(0.735, c, h), accent: color(0.76, seed.chroma * 1.15, seed.hue) },
|
|
234
|
+
light: { background: color(0.972, c / 2, h), text: color(0.235, c, h), subtle: color(0.475, c, h), accent: color(0.5, Math.min(seed.chroma, 0.13), seed.hue) },
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const agents = `Import components from \`@weft-js/warp\`; each brings its own CSS (the spec's, split per component). Layout: \`Stack Row Grid Split Center Cover Frame Spacer Inset\` (\`gap\`: hair tight related group section page; \`as\`: the element). Text: \`Heading\` (\`level\`, \`role\`), \`Text\` (\`role\`, \`tone\`, \`subtle\`). No raw values (D001–D007). Intent props only: \`tone\` (neutral accent positive caution critical info), \`emphasis\` (solid soft outline ghost frame link), \`size\` (sm md lg). No style props: the look comes from the dials, \`data-expression="quiet|balanced|vivid"\`, \`data-theme\`, \`data-density\`, \`data-motion\`, \`data-flair\` on any element (scopes nest; \`setDial()\` from \`@weft-js/warp-core\` writes them). Fields take \`label\` (required), \`hint\`, \`error\`, native \`value\`/\`onInput\`; \`variant="classic"\` puts the label above. \`Modal\`/\`Drawer\` take \`open\` as a box. App-wide look: \`warp({ seed: { accent: '#0a7d4f' }, dials, radius, fonts, tokens })\`; \`weft check\` fails with D010 if any pair can't pass.`
|
|
239
|
+
|
|
240
|
+
const catalog = [
|
|
241
|
+
['Stack', 'Vertical flow; `gap` is the relationship'],
|
|
242
|
+
['Row', 'Horizontal cluster that wraps; `justify`, `reflow` to stack below a step'],
|
|
243
|
+
['Grid', 'Intrinsic columns by `min`, capped by `cols`'],
|
|
244
|
+
['Split', 'Fixed side and fluid main, stacked below its step'],
|
|
245
|
+
['Center', 'Measured column, centred; `max`, `gutter`'],
|
|
246
|
+
['Cover', 'Fills the screen with one centred child (auth, errors)'],
|
|
247
|
+
['Frame', 'Fixed-ratio media box'],
|
|
248
|
+
['Spacer', 'Takes the free space in a Row or Stack'],
|
|
249
|
+
['Inset', 'Sunken padded region'],
|
|
250
|
+
['Heading', 'Real heading: `level` is the outline and picks the look; `role` overrides it'],
|
|
251
|
+
['Text', 'Text in a role: `role`, `tone`, `subtle`, `faint`'],
|
|
252
|
+
['Link', 'A link: `href`, `subtle`, `external`; a bare `<a>` still gets warp\'s colour'],
|
|
253
|
+
['VirtualList', 'A long list with exact text heights: rows render only in view'],
|
|
254
|
+
['Thread', 'A chat log on a window: stick to end, older messages, unread divider'],
|
|
255
|
+
['Feed', 'APG feed on a window with Page Up/Down between articles'],
|
|
256
|
+
['Kanban', 'A board whose columns are windows; drag, menu and Alt + arrows'],
|
|
257
|
+
['ScrollGrid', 'Read-only two-axis grid, both axes virtual, wrapped columns optional'],
|
|
258
|
+
['DateField', 'Typed date segments with a calendar; ISO value'],
|
|
259
|
+
['DateRangeField', 'Two date segments, range calendar, presets, compare'],
|
|
260
|
+
['TimeField', 'Typed time with a slot list or wheels'],
|
|
261
|
+
['DateTimeField', 'Date and time in one field; value is a plain date-time'],
|
|
262
|
+
['InlineCalendar', 'The calendar grid in the page: single, multiple, range'],
|
|
263
|
+
['MonthPicker', 'Month grid in a field'],
|
|
264
|
+
['WeekPicker', 'Week selection with week numbers'],
|
|
265
|
+
['PeriodPicker', 'Year, quarter, month or week in one trigger'],
|
|
266
|
+
['TimeZoneSelect', 'Searchable IANA zones with offsets'],
|
|
267
|
+
['CountrySelect', 'Searchable countries, names from Intl'],
|
|
268
|
+
['LanguageSelect', 'Searchable languages with endonyms'],
|
|
269
|
+
['CurrencySelect', 'Searchable currencies with symbols'],
|
|
270
|
+
['PhoneField', 'Country button and tel input; E.164 value'],
|
|
271
|
+
['DurationField', 'Spinbuttons per unit; ISO duration, balanced'],
|
|
272
|
+
['RecurrenceField', 'RRULE builder in plain language with next dates'],
|
|
273
|
+
['PlaceLookupInput', 'Place combobox over your search()'],
|
|
274
|
+
['AddressField', 'Country-first address, optional provider, manual fallback'],
|
|
275
|
+
['PaymentCardInput', "Field shell around the provider's hosted card field"],
|
|
276
|
+
['CardNumberText', 'Saved card display'],
|
|
277
|
+
['SignaturePad', 'Draw or type a signature'],
|
|
278
|
+
['Button', 'Native button: tone, emphasis (solid soft outline ghost frame link), size, loading, icon-only; `collapse` shows only the icon on a phone or tablet and keeps the text as its name'],
|
|
279
|
+
['Toggle', 'Button with aria-pressed'],
|
|
280
|
+
['Input', 'Text field: floating border label (or classic), hint, error, prefix, suffix, clear'],
|
|
281
|
+
['Search', 'Search field with icon and clear'],
|
|
282
|
+
['Textarea', 'Multi-line field that grows 3 to 10 lines; Cmd/Ctrl+Enter submits'],
|
|
283
|
+
['Select', 'Native select in the field shell'],
|
|
284
|
+
['Checkbox', 'Native checkbox; indeterminate'],
|
|
285
|
+
['Switch', 'Checkbox with role switch; the rail fills with the sweep'],
|
|
286
|
+
['Segmented', 'Radio group with a sliding face'],
|
|
287
|
+
['Chip', 'Filter chip (aria-pressed)'],
|
|
288
|
+
['Tabs', 'Tablist (line, pill, enclosed) with panels'],
|
|
289
|
+
['Card', 'Surface: eyebrow, title, body; interactive cards sweep'],
|
|
290
|
+
['Badge', 'Status or count; status dot'],
|
|
291
|
+
['Tag', 'User label, removable'],
|
|
292
|
+
['Kbd', 'One keyboard key'],
|
|
293
|
+
['Alert', 'Inline message with icon, title, description, actions'],
|
|
294
|
+
['Progress', 'Linear or circular progress; indeterminate'],
|
|
295
|
+
['Spinner', 'Busy indicator'],
|
|
296
|
+
['Skeleton', 'Loading placeholder: text, circle, block'],
|
|
297
|
+
['Tooltip', 'Hint on hover and keyboard focus (never essential)'],
|
|
298
|
+
['Popover', 'Button that opens a non-modal dialog panel'],
|
|
299
|
+
['Modal', 'Native modal dialog; alert dialog'],
|
|
300
|
+
['Drawer', 'Sheet: side on wide screens, bottom on phones'],
|
|
301
|
+
['Toaster', 'Toast region; toast.show() from anywhere'],
|
|
302
|
+
['Table', 'Data table: sort, filter, Columns, resize, pin, selection, bulk bar, details, inline edit, live cells, phone cards'],
|
|
303
|
+
['LineChart', 'Line or area chart: multi-series, crosshair, annotations, reference lines, live tween'],
|
|
304
|
+
['BarChart', 'Bars: grouped, stacked or percent (`layout`)'],
|
|
305
|
+
['ComboChart', 'Bars plus a moving average on one axis'],
|
|
306
|
+
['DonutChart', 'Donut or `pie`, centre readout'],
|
|
307
|
+
['RadialChart', '270° radial bars'],
|
|
308
|
+
['FunnelChart', 'Step bars with % of previous'],
|
|
309
|
+
['ScatterChart', 'Points, shape per group'],
|
|
310
|
+
['BubbleChart', 'Points sized by value'],
|
|
311
|
+
['Histogram', 'Bins with p50 and p95'],
|
|
312
|
+
['HeatGrid', 'Week by day heatmap; calendar at 52 weeks'],
|
|
313
|
+
['RadarChart', 'Polygons on up to 3 series'],
|
|
314
|
+
['Treemap', 'Squarified areas'],
|
|
315
|
+
['Sunburst', 'Two-ring part to whole'],
|
|
316
|
+
['SankeyChart', 'Flows between columns'],
|
|
317
|
+
['CandlestickChart', 'Candles or `variant="ohlc"`'],
|
|
318
|
+
['BoxPlot', 'Quartiles, whiskers, outliers'],
|
|
319
|
+
['WaterfallChart', 'Bridge with totals'],
|
|
320
|
+
['StreamChart', 'Rolling window on a fixed domain, Pause'],
|
|
321
|
+
['BrushChart', 'Overview and brush window'],
|
|
322
|
+
['TileMap', 'Tile-grid choropleth'],
|
|
323
|
+
['GaugeChart', 'Banded gauge with a needle'],
|
|
324
|
+
['Sparkline', 'Inline trend, aria-hidden inside a labelled parent'],
|
|
325
|
+
['Shell', 'App shell: bottom bar, rail or sidebar by its own width; header, account menu, More sheet, focus variant'],
|
|
326
|
+
['SideNav', 'Docs sidebar: a left panel of folding sections, remembered; a collapse bar at its edge folds it when docked and opens it over the page on phones; filter'],
|
|
327
|
+
].map(([name, summary]) => ({ name, import: '@weft-js/warp', summary }))
|
|
328
|
+
|
|
329
|
+
export const warp = definePlugin({
|
|
330
|
+
name: '@weft-js/warp',
|
|
331
|
+
version: '0.1.0',
|
|
332
|
+
weft: '*',
|
|
333
|
+
namespace: 'D',
|
|
334
|
+
options,
|
|
335
|
+
// A custom seed runs warp-core's contrast checker over a temporary copy of its stylesheet (checkSeed).
|
|
336
|
+
permissions: { fs: { read: ['node_modules/@weft-js/warp-core/**'], write: ['$TMPDIR/warp-seed-*'] }, spawn: ['node'] },
|
|
337
|
+
contribute(o) {
|
|
338
|
+
const seed = o.seed ?? DEFAULT
|
|
339
|
+
const custom = seed.hue !== DEFAULT.hue || seed.chroma !== DEFAULT.chroma
|
|
340
|
+
let declarations = overrides(o)
|
|
341
|
+
if (custom) declarations = fitSeed(seed, declarations) // throws { code: 'D010' }
|
|
342
|
+
else if (declarations) checkSeed(seed, declarations)
|
|
343
|
+
const dials = Object.entries(o.dials ?? {}).map(([dial, value]) => dialCss(dial, value)).join('')
|
|
344
|
+
const css = (custom ? seedCss(seed) : '') + (o.fields === 'classic' ? classicCss() : '') + dials + overrideCss(declarations)
|
|
345
|
+
return {
|
|
346
|
+
tokens: { namespace: 'warp', ...(css && { css }), values: { seed, fields: o.fields ?? 'floating', ...(o.dials && { dials: o.dials }) } },
|
|
347
|
+
agents: { title: 'warp (UI kit)', text: agents },
|
|
348
|
+
card: cardColors(seed, o.dials),
|
|
349
|
+
catalog,
|
|
350
|
+
map: [{ want: 'UI components', call: 'Import from `@weft-js/warp`; `weft plugins` has the catalog', have: `${catalog.length} components` }],
|
|
351
|
+
diagnostics: [
|
|
352
|
+
{ code: 'D010', catches: 'A token override, alone or with the seed, that leaves text, a control boundary or the focus ring below WCAG contrast at some dial stop', fix: 'Change the token or seed; the message lists each failing pair', severity: 'error' },
|
|
353
|
+
],
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
export default warp
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { DownIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { nextIndex, uid } from '@weft-js/warp-core'
|
|
3
|
+
import { state, untrack } from '@weft-js/core'
|
|
4
|
+
import './styles'
|
|
5
|
+
import '@weft-js/warp-core/styles/components/accordion.css'
|
|
6
|
+
|
|
7
|
+
/** One disclosure: its header, its panel and the value that names it. */
|
|
8
|
+
export type AccordionItem = { value: string; title: unknown; content: unknown; disabled?: boolean | undefined }
|
|
9
|
+
/** Props for Accordion. */
|
|
10
|
+
export interface AccordionProps {
|
|
11
|
+
items: AccordionItem[]
|
|
12
|
+
variant?: 'boxed' | 'line' | undefined
|
|
13
|
+
/** More than one open at once. */
|
|
14
|
+
multiple?: boolean
|
|
15
|
+
/** What starts open. */
|
|
16
|
+
defaultOpen?: string[]
|
|
17
|
+
onChange?: ((open: string[]) => void) | undefined
|
|
18
|
+
/** The heading level of each title: one below the heading the accordion sits under. Default 3, in a page section. */
|
|
19
|
+
level?: 2 | 3 | 4 | 5 | 6
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* An accordion (05 §4; APG): each header a real button in a heading, ↑/↓/Home/End between headers. The sweep runs on
|
|
24
|
+
* hover and holds while an item is open; the height moves with grid rows, so nothing is measured.
|
|
25
|
+
*/
|
|
26
|
+
export function Accordion({ items, variant = 'boxed', multiple, defaultOpen = [], onChange, level = 3 }: AccordionProps) {
|
|
27
|
+
const base = uid('acc')
|
|
28
|
+
const open = state<string[]>(untrack(() => defaultOpen))
|
|
29
|
+
const toggle = (v: string) => {
|
|
30
|
+
const on = open.value.includes(v)
|
|
31
|
+
open.value = on ? open.value.filter(x => x !== v) : multiple ? [...open.value, v] : [v]
|
|
32
|
+
onChange?.(open.value)
|
|
33
|
+
}
|
|
34
|
+
const onKey = (e: KeyboardEvent) => {
|
|
35
|
+
const list = [...(e.currentTarget as HTMLElement).querySelectorAll<HTMLElement>('[data-part="trigger"]:not(:disabled)')]
|
|
36
|
+
const at = list.indexOf(e.target as HTMLElement)
|
|
37
|
+
if (at < 0) return
|
|
38
|
+
const to = nextIndex(e.key, at, list.length, { orientation: 'vertical' })
|
|
39
|
+
if (to < 0) return
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
list[to]!.focus()
|
|
42
|
+
}
|
|
43
|
+
return (
|
|
44
|
+
<div data-scope="accordion" data-part="root" data-variant={variant === 'line' ? 'line' : undefined} onKeyDown={onKey}>
|
|
45
|
+
{items.map((it, i) => (
|
|
46
|
+
<div key={it.value} data-part="item" data-sweep="">
|
|
47
|
+
<div data-part="heading" role="heading" aria-level={level}>
|
|
48
|
+
<button type="button" data-part="trigger" id={`${base}-h${i}`} aria-expanded={open.value.includes(it.value) ? 'true' : 'false'} aria-controls={`${base}-c${i}`} disabled={it.disabled} onClick={() => toggle(it.value)}>
|
|
49
|
+
{it.title}<DownIcon data-part="chevron" />
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
<div data-part="content" role="region" id={`${base}-c${i}`} aria-labelledby={`${base}-h${i}`} data-state={open.value.includes(it.value) ? 'open' : undefined}><div><div>{it.content}</div></div></div>
|
|
53
|
+
<span data-part="sweep" aria-hidden="true"></span>
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
package/src/Alert.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { XIcon } from '@weft-js/warp-icons'
|
|
2
|
+
import { derived } from '@weft-js/core'
|
|
3
|
+
import type { Tone } from './Button'
|
|
4
|
+
import { Icon, toneIcon } from './Icon'
|
|
5
|
+
import './styles'
|
|
6
|
+
import '@weft-js/warp-core/styles/components/button.css' // the close button's markup
|
|
7
|
+
import '@weft-js/warp-core/styles/components/alert.css'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* An inline alert (05 §5): icon, title, description, actions, close. Status is never colour alone: the icon and the
|
|
11
|
+
* words carry it. `live` makes it role="alert" (only for a NEW error the user must hear).
|
|
12
|
+
*/
|
|
13
|
+
export function Alert({ tone = 'info', title, live, actions, onDismiss, children }: { tone?: Tone | undefined; title?: unknown; live?: boolean | undefined; actions?: unknown; onDismiss?: () => void; children?: unknown }) {
|
|
14
|
+
const icon = derived(() => toneIcon[tone])
|
|
15
|
+
return (
|
|
16
|
+
<div data-scope="alert" data-tone={tone} role={live ? 'alert' : undefined}>
|
|
17
|
+
<Icon name={icon.value} data-part="icon" />
|
|
18
|
+
{title !== undefined && <div data-part="title">{title}</div>}
|
|
19
|
+
{children !== undefined && <div data-part="description">{children}</div>}
|
|
20
|
+
{actions !== undefined && <div data-part="actions">{actions}</div>}
|
|
21
|
+
{/* <Button emphasis="ghost" tone="neutral" size="sm" icon={XIcon} label="Dismiss"> as its markup: a ghost button needs no JS */}
|
|
22
|
+
{onDismiss && <button type="button" data-scope="button" data-emphasis="ghost" data-tone="neutral" data-size="sm" data-icon-only="" aria-label="Dismiss" data-part="close" data-dismiss="" onClick={onDismiss}><XIcon /></button>}
|
|
23
|
+
</div>
|
|
24
|
+
)
|
|
25
|
+
}
|
package/src/Badge.tsx
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Tone } from './Button'
|
|
2
|
+
import './styles'
|
|
3
|
+
import '@weft-js/warp-core/styles/components/badge.css'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A status or count badge (05 §5). `dot` adds a status dot; a count badge says its unit to screen readers in
|
|
7
|
+
* `srText` (never an aria-label on a span, review 03 m2).
|
|
8
|
+
*/
|
|
9
|
+
export function Badge({ tone, emphasis, count, dot, srText, children, ...rest }: { tone?: Tone | undefined; emphasis?: 'soft' | 'solid' | 'outline'; count?: boolean | undefined; dot?: boolean; srText?: string; children?: unknown; [attr: string]: unknown }) {
|
|
10
|
+
return (
|
|
11
|
+
<span data-scope="badge" data-tone={tone} data-emphasis={emphasis === 'soft' ? undefined : emphasis} data-count={count ? '' : undefined} {...rest}>
|
|
12
|
+
{dot && <span data-scope="status-dot" data-tone={tone}></span>}
|
|
13
|
+
{children}
|
|
14
|
+
{srText && <span data-part="sr-only"> {srText}</span>}
|
|
15
|
+
</span>
|
|
16
|
+
)
|
|
17
|
+
}
|