@weft-js/warp-core 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 +135 -2
- package/bindings/weft.ts +23 -0
- package/package.json +44 -4
- package/scripts/contrast-check.mjs +348 -0
- package/src/admin.ts +280 -0
- package/src/agent.ts +460 -0
- package/src/calendar.ts +131 -0
- package/src/canvas.ts +223 -0
- package/src/charts/cartesian.ts +454 -0
- package/src/charts/engine.ts +324 -0
- package/src/charts/index.ts +10 -0
- package/src/charts/marks.ts +37 -0
- package/src/charts/math.ts +106 -0
- package/src/charts/scene.ts +88 -0
- package/src/charts/shapes.ts +274 -0
- package/src/charts/spark.ts +14 -0
- package/src/charts/types.ts +52 -0
- package/src/code.ts +175 -0
- package/src/collab.ts +727 -0
- package/src/copy.ts +77 -0
- package/src/dial.ts +77 -0
- package/src/dither/field.ts +204 -0
- package/src/dither/fields.ts +66 -0
- package/src/dither/image.ts +114 -0
- package/src/dither/index.ts +11 -0
- package/src/dither/mask.ts +74 -0
- package/src/dither/pointer.ts +94 -0
- package/src/dither/quantize.ts +64 -0
- package/src/dither/ramp.ts +67 -0
- package/src/dither/stage.ts +368 -0
- package/src/dither/tiles.ts +72 -0
- package/src/editor.ts +413 -0
- package/src/floating-label.ts +238 -0
- package/src/flow-pack.ts +658 -0
- package/src/flow.ts +62 -0
- package/src/focus.ts +79 -0
- package/src/form.ts +74 -0
- package/src/grid.ts +381 -0
- package/src/identity.ts +127 -0
- package/src/index.ts +48 -0
- package/src/indicator.ts +110 -0
- package/src/interact.ts +52 -0
- package/src/layers.ts +114 -0
- package/src/listbox.ts +345 -0
- package/src/live.ts +815 -0
- package/src/mention.ts +80 -0
- package/src/menu.ts +77 -0
- package/src/motion.ts +54 -0
- package/src/nav.ts +149 -0
- package/src/overlays.ts +203 -0
- package/src/palette.ts +66 -0
- package/src/picker.ts +126 -0
- package/src/press-origin.ts +29 -0
- package/src/recurrence.ts +128 -0
- package/src/schedule.ts +663 -0
- package/src/search.ts +167 -0
- package/src/segments.ts +162 -0
- package/src/shape.ts +81 -0
- package/src/shell.ts +100 -0
- package/src/shuttle.ts +10 -0
- package/src/sidenav.ts +176 -0
- package/src/signature.ts +43 -0
- package/src/slider.ts +100 -0
- package/src/stage-select.ts +137 -0
- package/src/stage.ts +319 -0
- package/src/store.ts +50 -0
- package/src/sweep.ts +41 -0
- package/src/table.ts +164 -0
- package/src/temporal.ts +216 -0
- package/src/toast.ts +221 -0
- package/src/type.ts +65 -0
- package/src/typeahead.ts +22 -0
- package/src/virtual.ts +378 -0
- package/styles/app/activity-feed.css +5 -0
- package/styles/app/activity.css +9 -0
- package/styles/app/agenda.css +76 -0
- package/styles/app/artifact.css +12 -0
- package/styles/app/assistant.css +51 -0
- package/styles/app/auto-track.css +6 -0
- package/styles/app/availability.css +25 -0
- package/styles/app/avatar-stack.css +13 -0
- package/styles/app/avatar.css +17 -0
- package/styles/app/badge.css +16 -0
- package/styles/app/base.css +109 -0
- package/styles/app/bell.css +14 -0
- package/styles/app/booking.css +26 -0
- package/styles/app/branch.css +9 -0
- package/styles/app/calendar.css +297 -0
- package/styles/app/call-controls.css +38 -0
- package/styles/app/checkpoint.css +6 -0
- package/styles/app/cite.css +25 -0
- package/styles/app/click-ripple.css +5 -0
- package/styles/app/comment-anchor.css +6 -0
- package/styles/app/comment-thread.css +15 -0
- package/styles/app/commit.css +8 -0
- package/styles/app/composer.css +42 -0
- package/styles/app/conflict.css +9 -0
- package/styles/app/context-meter.css +10 -0
- package/styles/app/conversations.css +16 -0
- package/styles/app/cursor.css +15 -0
- package/styles/app/cursors.css +4 -0
- package/styles/app/event-card.css +11 -0
- package/styles/app/field.css +6 -0
- package/styles/app/follow-flag.css +4 -0
- package/styles/app/inbox.css +33 -0
- package/styles/app/integrity.css +14 -0
- package/styles/app/kanban.css +12 -0
- package/styles/app/lock.css +9 -0
- package/styles/app/message.css +76 -0
- package/styles/app/model-menu.css +10 -0
- package/styles/app/notification.css +39 -0
- package/styles/app/pip.css +12 -0
- package/styles/app/plan.css +31 -0
- package/styles/app/prefs-matrix.css +61 -0
- package/styles/app/prejoin.css +16 -0
- package/styles/app/prompt.css +12 -0
- package/styles/app/push-ask.css +10 -0
- package/styles/app/question.css +17 -0
- package/styles/app/reactions.css +27 -0
- package/styles/app/reasoning.css +18 -0
- package/styles/app/receipts.css +5 -0
- package/styles/app/room.css +46 -0
- package/styles/app/run.css +46 -0
- package/styles/app/section-peers.css +6 -0
- package/styles/app/share.css +7 -0
- package/styles/app/shimmer.css +13 -0
- package/styles/app/sources.css +12 -0
- package/styles/app/speech.css +26 -0
- package/styles/app/suggest.css +15 -0
- package/styles/app/suggestions.css +15 -0
- package/styles/app/sync.css +7 -0
- package/styles/app/synced-form.css +12 -0
- package/styles/app/system-page.css +52 -0
- package/styles/app/thread-replies.css +7 -0
- package/styles/app/thread.css +17 -0
- package/styles/app/tile.css +48 -0
- package/styles/app/toast.css +5 -0
- package/styles/app/tokens.css +18 -0
- package/styles/app/tool-call.css +53 -0
- package/styles/app/trace.css +10 -0
- package/styles/app/transport.css +63 -0
- package/styles/app/typing.css +14 -0
- package/styles/app/versions.css +28 -0
- package/styles/app/viewport-rail.css +7 -0
- package/styles/app/year.css +71 -0
- package/styles/canvas/canvas.css +17 -0
- package/styles/canvas/dither.css +55 -0
- package/styles/canvas/stage.css +300 -0
- package/styles/components/accordion.css +50 -0
- package/styles/components/ai-label.css +31 -0
- package/styles/components/alert.css +35 -0
- package/styles/components/anchor.css +35 -0
- package/styles/components/avatar.css +30 -0
- package/styles/components/badge.css +26 -0
- package/styles/components/base.css +128 -0
- package/styles/components/breadcrumbs.css +29 -0
- package/styles/components/button-group.css +17 -0
- package/styles/components/button.css +224 -0
- package/styles/components/chip.css +17 -0
- package/styles/components/command-palette.css +63 -0
- package/styles/components/copy.css +44 -0
- package/styles/components/countdown.css +23 -0
- package/styles/components/dialog.css +174 -0
- package/styles/components/empty-state.css +21 -0
- package/styles/components/fab.css +23 -0
- package/styles/components/hamburger.css +17 -0
- package/styles/components/highlight.css +8 -0
- package/styles/components/icon.css +11 -0
- package/styles/components/image.css +19 -0
- package/styles/components/item-row.css +20 -0
- package/styles/components/link.css +40 -0
- package/styles/components/listbox.css +51 -0
- package/styles/components/live-status.css +20 -0
- package/styles/components/logo.css +28 -0
- package/styles/components/menu.css +69 -0
- package/styles/components/menubar.css +30 -0
- package/styles/components/meta-list.css +12 -0
- package/styles/components/meter.css +16 -0
- package/styles/components/nav.css +22 -0
- package/styles/components/number.css +29 -0
- package/styles/components/pagination.css +32 -0
- package/styles/components/palette-motion.css +7 -0
- package/styles/components/persona.css +12 -0
- package/styles/components/pill.css +23 -0
- package/styles/components/popconfirm.css +6 -0
- package/styles/components/popover.css +18 -0
- package/styles/components/progress.css +50 -0
- package/styles/components/qr-code.css +38 -0
- package/styles/components/quote.css +12 -0
- package/styles/components/section-message.css +11 -0
- package/styles/components/selection-toolbar.css +13 -0
- package/styles/components/separator.css +12 -0
- package/styles/components/share.css +11 -0
- package/styles/components/sheet.css +104 -0
- package/styles/components/skeleton.css +33 -0
- package/styles/components/speed-dial.css +20 -0
- package/styles/components/spinner.css +13 -0
- package/styles/components/status-light.css +5 -0
- package/styles/components/steps.css +46 -0
- package/styles/components/surface.css +26 -0
- package/styles/components/tabs.css +76 -0
- package/styles/components/tag.css +19 -0
- package/styles/components/theme-panel.css +13 -0
- package/styles/components/theme-toggle.css +10 -0
- package/styles/components/ticker.css +16 -0
- package/styles/components/toast.css +59 -0
- package/styles/components/toggle-button.css +78 -0
- package/styles/components/toggletip.css +11 -0
- package/styles/components/toolbar.css +13 -0
- package/styles/components/tooltip.css +36 -0
- package/styles/components/tour.css +8 -0
- package/styles/components/truncate.css +34 -0
- package/styles/components/watermark.css +9 -0
- package/styles/data/avatar-group.css +7 -0
- package/styles/data/avatar.css +19 -0
- package/styles/data/bar-list.css +15 -0
- package/styles/data/base.css +45 -0
- package/styles/data/card-grid.css +5 -0
- package/styles/data/card.css +26 -0
- package/styles/data/chart.css +114 -0
- package/styles/data/chip.css +15 -0
- package/styles/data/chips.css +6 -0
- package/styles/data/comparison.css +31 -0
- package/styles/data/count-up.css +5 -0
- package/styles/data/delta.css +21 -0
- package/styles/data/desc.css +14 -0
- package/styles/data/distribution.css +7 -0
- package/styles/data/filter-panel.css +23 -0
- package/styles/data/flow.css +43 -0
- package/styles/data/kanban.css +80 -0
- package/styles/data/layers.css +13 -0
- package/styles/data/legend.css +17 -0
- package/styles/data/list.css +29 -0
- package/styles/data/load-more.css +4 -0
- package/styles/data/map.css +42 -0
- package/styles/data/meter.css +25 -0
- package/styles/data/pagination.css +42 -0
- package/styles/data/panel.css +24 -0
- package/styles/data/query.css +34 -0
- package/styles/data/results.css +7 -0
- package/styles/data/ring.css +20 -0
- package/styles/data/scroll-grid.css +37 -0
- package/styles/data/sortable.css +21 -0
- package/styles/data/stat-row.css +19 -0
- package/styles/data/stat.css +49 -0
- package/styles/data/table.css +352 -0
- package/styles/data/timeline.css +24 -0
- package/styles/data/tokens.css +86 -0
- package/styles/data/tooltip.css +17 -0
- package/styles/data/tracker.css +22 -0
- package/styles/data/tree.css +22 -0
- package/styles/data/virtual.css +15 -0
- package/styles/flows/api-key.css +7 -0
- package/styles/flows/auth.css +50 -0
- package/styles/flows/base.css +52 -0
- package/styles/flows/bulk-bar.css +13 -0
- package/styles/flows/checklist.css +25 -0
- package/styles/flows/checkout.css +25 -0
- package/styles/flows/choice.css +8 -0
- package/styles/flows/confirm.css +9 -0
- package/styles/flows/confirmation.css +5 -0
- package/styles/flows/conn.css +21 -0
- package/styles/flows/flow.css +19 -0
- package/styles/flows/gate-badge.css +6 -0
- package/styles/flows/gate.css +21 -0
- package/styles/flows/import-map.css +4 -0
- package/styles/flows/import-preview.css +12 -0
- package/styles/flows/integration.css +14 -0
- package/styles/flows/integrations.css +5 -0
- package/styles/flows/line-item.css +10 -0
- package/styles/flows/lines.css +10 -0
- package/styles/flows/meter.css +29 -0
- package/styles/flows/otp.css +22 -0
- package/styles/flows/pay-frame.css +17 -0
- package/styles/flows/plan-card.css +26 -0
- package/styles/flows/plans.css +9 -0
- package/styles/flows/qty.css +13 -0
- package/styles/flows/roles-matrix.css +7 -0
- package/styles/flows/save-bar.css +16 -0
- package/styles/flows/setting-row.css +16 -0
- package/styles/flows/settings-section.css +14 -0
- package/styles/flows/settings.css +28 -0
- package/styles/flows/shortcuts.css +11 -0
- package/styles/flows/sso.css +8 -0
- package/styles/flows/system-page.css +15 -0
- package/styles/flows/tokens.css +11 -0
- package/styles/flows/tour.css +31 -0
- package/styles/flows/tracker.css +12 -0
- package/styles/flows/usage.css +5 -0
- package/styles/inputs/base.css +5 -0
- package/styles/inputs/choice.css +275 -0
- package/styles/inputs/field.css +179 -0
- package/styles/inputs/file.css +64 -0
- package/styles/inputs/form.css +74 -0
- package/styles/inputs/keyframes.css +14 -0
- package/styles/inputs/listbox.css +74 -0
- package/styles/inputs/pickers.css +156 -0
- package/styles/inputs/range.css +109 -0
- package/styles/inputs/text.css +117 -0
- package/styles/layout/anchor-nav.css +46 -0
- package/styles/layout/announcement.css +28 -0
- package/styles/layout/back-to-top.css +17 -0
- package/styles/layout/banner.css +23 -0
- package/styles/layout/base.css +84 -0
- package/styles/layout/board.css +42 -0
- package/styles/layout/breadcrumb.css +19 -0
- package/styles/layout/canvas.css +26 -0
- package/styles/layout/center.css +16 -0
- package/styles/layout/consent.css +31 -0
- package/styles/layout/cover.css +11 -0
- package/styles/layout/doc-tabs.css +35 -0
- package/styles/layout/docs.css +29 -0
- package/styles/layout/edges.css +20 -0
- package/styles/layout/float-panel.css +44 -0
- package/styles/layout/frame.css +18 -0
- package/styles/layout/grid.css +19 -0
- package/styles/layout/inset.css +5 -0
- package/styles/layout/inspector.css +35 -0
- package/styles/layout/locale.css +5 -0
- package/styles/layout/masonry.css +20 -0
- package/styles/layout/master-detail.css +51 -0
- package/styles/layout/navlist.css +31 -0
- package/styles/layout/page-header.css +23 -0
- package/styles/layout/pager.css +12 -0
- package/styles/layout/pull-refresh.css +18 -0
- package/styles/layout/route-progress.css +18 -0
- package/styles/layout/row.css +23 -0
- package/styles/layout/scroll-area.css +35 -0
- package/styles/layout/section.css +15 -0
- package/styles/layout/shell.css +414 -0
- package/styles/layout/sidenav.css +125 -0
- package/styles/layout/spacer.css +7 -0
- package/styles/layout/split-pane.css +36 -0
- package/styles/layout/split.css +45 -0
- package/styles/layout/stack.css +15 -0
- package/styles/layout/status-bar.css +17 -0
- package/styles/layout/sticky-bar.css +33 -0
- package/styles/layout/surface.css +8 -0
- package/styles/layout/tabnav.css +36 -0
- package/styles/layout/tokens.css +43 -0
- package/styles/layout/topnav.css +63 -0
- package/styles/manifest.json +1051 -0
- package/styles/rich/address.css +7 -0
- package/styles/rich/body.css +31 -0
- package/styles/rich/calendar-grid.css +68 -0
- package/styles/rich/code-input.css +21 -0
- package/styles/rich/code-tabs.css +17 -0
- package/styles/rich/codeblock.css +25 -0
- package/styles/rich/datetime.css +13 -0
- package/styles/rich/diff.css +20 -0
- package/styles/rich/filter-bar.css +27 -0
- package/styles/rich/gap-rows.css +44 -0
- package/styles/rich/helpers.css +27 -0
- package/styles/rich/json-tree.css +20 -0
- package/styles/rich/key-value.css +18 -0
- package/styles/rich/listbox.css +24 -0
- package/styles/rich/log.css +39 -0
- package/styles/rich/markdown.css +25 -0
- package/styles/rich/md-editor.css +16 -0
- package/styles/rich/monthgrid.css +15 -0
- package/styles/rich/payment.css +14 -0
- package/styles/rich/phone.css +21 -0
- package/styles/rich/pick.css +12 -0
- package/styles/rich/popover.css +36 -0
- package/styles/rich/props.css +40 -0
- package/styles/rich/prose.css +35 -0
- package/styles/rich/recurrence.css +13 -0
- package/styles/rich/rich-text.css +52 -0
- package/styles/rich/segments.css +25 -0
- package/styles/rich/signature.css +16 -0
- package/styles/rich/sortable.css +33 -0
- package/styles/rich/timezone.css +12 -0
- package/styles/warp.css +1563 -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,136 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @weft-js/warp-core
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> **Early release (0.1.1).** APIs will change between minor versions.
|
|
4
|
+
|
|
5
|
+
warp's behaviour, tokens and CSS, with no components: plain TypeScript over the DOM. `@weft-js/warp` builds its components
|
|
6
|
+
on it. Nothing in the main entry imports a framework, so it works under weft, React, Solid or no framework at all.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install @weft-js/warp-core
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## When to skip it
|
|
13
|
+
|
|
14
|
+
Most weft apps should skip it.
|
|
15
|
+
|
|
16
|
+
- **You want finished components:** install `@weft-js/warp`. It brings this package with it.
|
|
17
|
+
- **You want no warp at all:** you need neither package. `npm create @weft-js -- --ui none` and `--ui tailwind` install
|
|
18
|
+
neither.
|
|
19
|
+
- **You want your own markup and warp's keyboard, focus and motion:** this package is for you. `npm create @weft-js -- --ui headless`
|
|
20
|
+
sets it up.
|
|
21
|
+
|
|
22
|
+
## What's in it
|
|
23
|
+
|
|
24
|
+
- **Behaviour:** `menu`, `dialog`, `popover`, `tooltip`, `indicator`, `roving`, `slider` and more.
|
|
25
|
+
Each is an action, `(el, options) => { update, destroy }`.
|
|
26
|
+
- **Helpers:** pure functions such as `nextIndex`, `place` and `uid`.
|
|
27
|
+
- **CSS:** `@weft-js/warp-core/styles/warp.css` has the tokens. Each component has its own file, such as
|
|
28
|
+
`@weft-js/warp-core/styles/components/tabs.css`. Skip them and bring your own CSS if you like.
|
|
29
|
+
- **Other entries:** `/motion`, `/charts`, `/canvas`, `/dither` and `/search`.
|
|
30
|
+
|
|
31
|
+
## `use`: an action in a weft component
|
|
32
|
+
|
|
33
|
+
`use` comes from `@weft-js/warp-core/weft`, the one entry that needs `@weft-js/core`. It starts the action once the element is in the
|
|
34
|
+
document, never on the server. It calls `update` when the options change and `destroy` with the component.
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
use(action, () => element, () => options)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`update` runs only when the value from the options getter changes. An option the action doesn't read still counts, so
|
|
41
|
+
read a box there to re-run it, like `at` in the tabs below.
|
|
42
|
+
|
|
43
|
+
Outside weft, make the action yourself:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const bar = indicator(list, { selected: '[aria-selected="true"]' })
|
|
47
|
+
bar.update() // after the selection changes
|
|
48
|
+
bar.destroy() // when the element goes
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Tabs
|
|
52
|
+
|
|
53
|
+
`nextIndex` turns a key into the tab to move to: arrows wrap, and Home and End jump. It returns -1 for any other key.
|
|
54
|
+
`indicator` slides the `data-part="indicator"` element to the selected tab.
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
import { indicator, nextIndex } from '@weft-js/warp-core'
|
|
58
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
59
|
+
import '@weft-js/warp-core/styles/warp.css'
|
|
60
|
+
import '@weft-js/warp-core/styles/components/tabs.css'
|
|
61
|
+
import { state } from '@weft-js/core'
|
|
62
|
+
|
|
63
|
+
const names = ['Plan', 'Billing', 'Team']
|
|
64
|
+
|
|
65
|
+
export function Settings() {
|
|
66
|
+
const at = state(0)
|
|
67
|
+
let list: HTMLDivElement | undefined
|
|
68
|
+
use(indicator, () => list, () => ({ selected: '[aria-selected="true"]', at: at.value }))
|
|
69
|
+
|
|
70
|
+
const onKey = (e: KeyboardEvent) => {
|
|
71
|
+
const to = nextIndex(e.key, at.value, names.length)
|
|
72
|
+
if (to < 0) return
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
at.value = to
|
|
75
|
+
list?.querySelectorAll<HTMLElement>('[role="tab"]')[to]?.focus()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div data-scope="tabs" data-part="root">
|
|
80
|
+
<div ref={(n: any) => (list = n)} data-part="list" role="tablist" aria-label="Settings" onKeyDown={onKey}>
|
|
81
|
+
{names.map((name, i) => (
|
|
82
|
+
<button
|
|
83
|
+
key={name}
|
|
84
|
+
data-part="trigger"
|
|
85
|
+
role="tab"
|
|
86
|
+
type="button"
|
|
87
|
+
aria-selected={at.value === i ? 'true' : 'false'}
|
|
88
|
+
tabindex={at.value === i ? undefined : '-1'}
|
|
89
|
+
onClick={() => (at.value = i)}
|
|
90
|
+
>
|
|
91
|
+
{name}
|
|
92
|
+
</button>
|
|
93
|
+
))}
|
|
94
|
+
<span data-part="indicator" aria-hidden="true"></span>
|
|
95
|
+
</div>
|
|
96
|
+
<div data-part="content" role="tabpanel" tabindex="0">{names[at.value]}</div>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## A menu
|
|
103
|
+
|
|
104
|
+
`menu` moves focus in when `open` turns true. It handles arrows, typeahead, Enter, Escape, Tab and an outside press, and
|
|
105
|
+
calls `onClose`. Showing and hiding the panel is yours. Here that's `hidden`. Items need `tabindex="-1"`.
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
import { menu } from '@weft-js/warp-core'
|
|
109
|
+
import { use } from '@weft-js/warp-core/weft'
|
|
110
|
+
import { state } from '@weft-js/core'
|
|
111
|
+
|
|
112
|
+
export function AccountMenu() {
|
|
113
|
+
const open = state(false)
|
|
114
|
+
let button: HTMLButtonElement | undefined
|
|
115
|
+
let panel: HTMLDivElement | undefined
|
|
116
|
+
use(menu, () => panel, () => ({ open: open.value, trigger: () => button, onClose: () => (open.value = false) }))
|
|
117
|
+
|
|
118
|
+
const pick = (run: () => void) => {
|
|
119
|
+
open.value = false
|
|
120
|
+
button?.focus()
|
|
121
|
+
run()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div>
|
|
126
|
+
<button ref={(n: any) => (button = n)} type="button" aria-haspopup="menu" aria-expanded={open.value ? 'true' : 'false'} onClick={() => (open.value = !open.value)}>
|
|
127
|
+
Account
|
|
128
|
+
</button>
|
|
129
|
+
<div ref={(n: any) => (panel = n)} role="menu" hidden={!open.value}>
|
|
130
|
+
<button role="menuitem" tabindex="-1" type="button" onClick={() => pick(() => (location.hash = 'profile'))}>Profile</button>
|
|
131
|
+
<button role="menuitem" tabindex="-1" type="button" onClick={() => pick(() => (location.hash = 'out'))}>Sign out</button>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
```
|
package/bindings/weft.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// The weft binding, and the one file in this package that imports a framework. `@weft-js/warp-core` itself never does
|
|
2
|
+
// (test/agnostic.test.mjs), so this stays behind its own subpath: `@weft-js/warp-core/weft`.
|
|
3
|
+
import { onCleanup, onMount, untrack, watch } from '@weft-js/core'
|
|
4
|
+
import type { Action } from '../src/store'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Attach a warp-core action to an element once it is in the document (never on the server), feed it `opts` whenever
|
|
8
|
+
* they change, and destroy it with the component. Call it in a component's setup. `el` is read after mount, so a `ref`
|
|
9
|
+
* variable works. The action is made a microtask after mount, so hydration itself writes nothing: what an action
|
|
10
|
+
* writes (a counter, a tooltip's aria-describedby) lands just after the server HTML is adopted.
|
|
11
|
+
*/
|
|
12
|
+
export function use<E extends Element, O>(make: (el: E, o: O) => Action<O>, el: () => E | undefined, opts: () => O = () => undefined as O) {
|
|
13
|
+
onMount(() => {
|
|
14
|
+
let action: Action<O> | undefined
|
|
15
|
+
let live = true
|
|
16
|
+
queueMicrotask(() => {
|
|
17
|
+
const node = live && el()
|
|
18
|
+
if (node) action = make(node, untrack(opts))
|
|
19
|
+
})
|
|
20
|
+
watch(opts, (o, prev) => { if (prev !== undefined) action?.update(o) })
|
|
21
|
+
onCleanup(() => { live = false; action?.destroy() })
|
|
22
|
+
})
|
|
23
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,46 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weft-js/warp-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "warp core: tokens, CSS and framework-agnostic behaviour",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"*.css"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"styles",
|
|
13
|
+
"bindings",
|
|
14
|
+
"scripts/contrast-check.mjs",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.ts",
|
|
20
|
+
"./weft": "./bindings/weft.ts",
|
|
21
|
+
"./motion": "./src/motion.ts",
|
|
22
|
+
"./charts": "./src/charts/index.ts",
|
|
23
|
+
"./canvas": "./src/canvas.ts",
|
|
24
|
+
"./stage": "./src/stage.ts",
|
|
25
|
+
"./dither": "./src/dither/index.ts",
|
|
26
|
+
"./search": "./src/search.ts",
|
|
27
|
+
"./styles/*": "./styles/*"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@weft-js/core": "^0.1.1"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"weft": {
|
|
34
|
+
"optional": true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=26"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"weft"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// warp contrast check: parses tokens/warp.css, resolves every sys colour for each
|
|
3
|
+
// theme × expression, and checks the text/fill, UI and focus-ring pairs against
|
|
4
|
+
// WCAG 2.x. Plain Node, no dependencies.
|
|
5
|
+
//
|
|
6
|
+
// node scripts/contrast-check.mjs summary + failures (exit 1 on any failure)
|
|
7
|
+
// node scripts/contrast-check.mjs --full every pair, every combination
|
|
8
|
+
// node scripts/contrast-check.mjs --md markdown summary (pasted into 01-foundations.md)
|
|
9
|
+
// node scripts/contrast-check.mjs --json resolved colours + results
|
|
10
|
+
// node scripts/contrast-check.mjs path/to/other.css
|
|
11
|
+
//
|
|
12
|
+
// It also reads the sibling component files (tokens/warp-*.css) for every rule that re-points a
|
|
13
|
+
// control's resting border (--_base / --warp-color-sweep-base) and checks the full-accent sweep
|
|
14
|
+
// against that base (review 03 M9): fields and controls need sweep-vs-base >= 3:1. Surfaces
|
|
15
|
+
// (cards, panels, stat tiles) may rest on the hairline (SIGNATURE, surfaces exception).
|
|
16
|
+
|
|
17
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
import { dirname, resolve } from "node:path";
|
|
20
|
+
|
|
21
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const args = process.argv.slice(2);
|
|
23
|
+
const flag = (f) => args.includes(f);
|
|
24
|
+
const cssPath = args.find((a) => !a.startsWith("--")) ?? resolve(here, "../tokens/warp.css");
|
|
25
|
+
const css = readFileSync(cssPath, "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
26
|
+
|
|
27
|
+
const THEMES = ["light", "dark"];
|
|
28
|
+
const EXPRESSIONS = ["quiet", "balanced", "vivid"];
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------- CSS parsing
|
|
31
|
+
// Collect custom-property declarations with their selector and enclosing @media.
|
|
32
|
+
function parseBlocks(src, media = [], out = []) {
|
|
33
|
+
let i = 0;
|
|
34
|
+
while (i < src.length) {
|
|
35
|
+
const open = src.indexOf("{", i);
|
|
36
|
+
const semi = src.indexOf(";", i);
|
|
37
|
+
if (open === -1) break;
|
|
38
|
+
if (semi !== -1 && semi < open && src.slice(i, semi).trim().startsWith("@")) { i = semi + 1; continue; }
|
|
39
|
+
const prelude = src.slice(i, open).trim();
|
|
40
|
+
let depth = 1, j = open + 1;
|
|
41
|
+
while (j < src.length && depth) { if (src[j] === "{") depth++; else if (src[j] === "}") depth--; j++; }
|
|
42
|
+
const body = src.slice(open + 1, j - 1);
|
|
43
|
+
if (prelude.startsWith("@media")) parseBlocks(body, [...media, prelude.slice(6).trim()], out);
|
|
44
|
+
else if (/^@(container|supports)/.test(prelude)) parseBlocks(body, [...media, prelude], out);
|
|
45
|
+
else if (prelude.startsWith("@layer")) parseBlocks(body, media, out);
|
|
46
|
+
else if (prelude.startsWith("@")) { /* keyframes etc. */ }
|
|
47
|
+
else {
|
|
48
|
+
for (const d of splitTop(body, ";")) {
|
|
49
|
+
const m = d.match(/^\s*(--[\w-]+)\s*:\s*([\s\S]*)$/);
|
|
50
|
+
if (m) out.push({ selector: prelude, media, prop: m[1], value: m[2].trim(), order: out.length });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
i = j;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function splitTop(s, sep) {
|
|
59
|
+
const parts = []; let depth = 0, cur = "";
|
|
60
|
+
for (const ch of s) {
|
|
61
|
+
if (ch === "(" || ch === "[") depth++;
|
|
62
|
+
else if (ch === ")" || ch === "]") depth--;
|
|
63
|
+
if (ch === sep && depth === 0) { parts.push(cur); cur = ""; } else cur += ch;
|
|
64
|
+
}
|
|
65
|
+
if (cur.trim()) parts.push(cur);
|
|
66
|
+
return parts;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Match a selector against the root element carrying env attributes.
|
|
70
|
+
// Returns specificity (number) or -1. Only compound selectors on the root count.
|
|
71
|
+
function matchSelector(sel, attrs) {
|
|
72
|
+
let best = -1;
|
|
73
|
+
for (const raw of splitTop(sel, ",")) {
|
|
74
|
+
const s = raw.trim();
|
|
75
|
+
if (/[\s>+~]/.test(s.replace(/\[[^\]]*\]/g, "").replace(/\([^)]*\)/g, ""))) continue;
|
|
76
|
+
const r = matchCompound(s, attrs);
|
|
77
|
+
if (r > best) best = r;
|
|
78
|
+
}
|
|
79
|
+
return best;
|
|
80
|
+
}
|
|
81
|
+
function matchCompound(s, attrs) {
|
|
82
|
+
let spec = 0, rest = s;
|
|
83
|
+
const take = (re) => { const m = rest.match(re); if (m) rest = rest.slice(m[0].length); return m; };
|
|
84
|
+
while (rest.length) {
|
|
85
|
+
let m;
|
|
86
|
+
if ((m = take(/^:root/))) spec += 1;
|
|
87
|
+
else if ((m = take(/^:where\(([^)]*)\)/))) { if (matchSelector(m[1], attrs) < 0) return -1; }
|
|
88
|
+
else if ((m = take(/^:not\(\[([\w-]+)(?:="([^"]*)")?\]\)/))) { if (attrTest(attrs, m[1], m[2])) return -1; spec += 1; }
|
|
89
|
+
else if ((m = take(/^\[([\w-]+)(?:="([^"]*)")?\]/))) { if (!attrTest(attrs, m[1], m[2])) return -1; spec += 1; }
|
|
90
|
+
else return -1;
|
|
91
|
+
}
|
|
92
|
+
return spec;
|
|
93
|
+
}
|
|
94
|
+
const attrTest = (a, k, v) => (v === undefined ? k in a : a[k] === v);
|
|
95
|
+
|
|
96
|
+
// The cascade for one environment: highest specificity, then latest, wins.
|
|
97
|
+
function resolveEnv(decls, attrs) {
|
|
98
|
+
const winners = new Map();
|
|
99
|
+
for (const d of decls) {
|
|
100
|
+
if (d.media.length) continue; // media inputs (viewport, pointer, reduced motion) don't touch colour
|
|
101
|
+
const spec = matchSelector(d.selector, attrs);
|
|
102
|
+
if (spec < 0) continue;
|
|
103
|
+
const prev = winners.get(d.prop);
|
|
104
|
+
if (!prev || spec > prev.spec || (spec === prev.spec && d.order > prev.order)) winners.set(d.prop, { ...d, spec });
|
|
105
|
+
}
|
|
106
|
+
return new Map([...winners].map(([k, v]) => [k, v.value]));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function substitute(value, vars, theme, depth = 0) {
|
|
110
|
+
if (depth > 40) throw new Error("var() cycle near " + value);
|
|
111
|
+
let out = value.replace(/var\((--[\w-]+)\s*(?:,\s*([^()]*))?\)/g, (_, name, fb) => {
|
|
112
|
+
const v = vars.get(name) ?? fb;
|
|
113
|
+
if (v === undefined) throw new Error("undefined " + name);
|
|
114
|
+
return substitute(v, vars, theme, depth + 1);
|
|
115
|
+
});
|
|
116
|
+
// light-dark(a, b) → the theme's branch (innermost first)
|
|
117
|
+
let guard = 0;
|
|
118
|
+
while (out.includes("light-dark(") && guard++ < 50) {
|
|
119
|
+
const start = out.lastIndexOf("light-dark(");
|
|
120
|
+
let depthP = 0, j = start + "light-dark".length;
|
|
121
|
+
for (; j < out.length; j++) { if (out[j] === "(") depthP++; else if (out[j] === ")" && --depthP === 0) break; }
|
|
122
|
+
const [a, b] = splitTop(out.slice(start + 11, j), ",");
|
|
123
|
+
out = out.slice(0, start) + (theme === "light" ? a : b).trim() + out.slice(j + 1);
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// --------------------------------------------------------------- colour maths
|
|
129
|
+
const num = (expr) => {
|
|
130
|
+
const e = expr.replace(/calc\(/g, "(").replace(/\b(min|max)\(/g, "Math.$1(").trim();
|
|
131
|
+
if (!/^(?:[\d.\s+\-*/(),e%]|Math\.min|Math\.max)+$/.test(e)) throw new Error("not numeric: " + expr);
|
|
132
|
+
if (e.endsWith("%")) return Number(e.slice(0, -1)) / 100;
|
|
133
|
+
return Function(`"use strict";return (${e});`)();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// color-mix(in oklab, A p%, B): mixed in OKLab, returned as OKLCH (what the browser does)
|
|
137
|
+
const toLab = ({ L, C, H }) => [L, C * Math.cos((H * Math.PI) / 180), C * Math.sin((H * Math.PI) / 180)];
|
|
138
|
+
const fromLab = ([L, a, b]) => ({ L, C: Math.hypot(a, b), H: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 });
|
|
139
|
+
function parseColor(str) {
|
|
140
|
+
const s = str.trim();
|
|
141
|
+
let m;
|
|
142
|
+
if ((m = s.match(/^color-mix\(\s*in\s+oklab\s*,([\s\S]*)\)$/))) {
|
|
143
|
+
const [a, b] = splitTop(m[1], ",").map((x) => x.trim());
|
|
144
|
+
const pct = (x) => { const k = x.match(/\s([\d.]+)%$/); return k ? [x.slice(0, k.index).trim(), Number(k[1]) / 100] : [x, null]; };
|
|
145
|
+
let [ca, pa] = pct(a), [cb, pb] = pct(b);
|
|
146
|
+
if (pa === null && pb === null) pa = pb = 0.5; else if (pa === null) pa = 1 - pb; else if (pb === null) pb = 1 - pa;
|
|
147
|
+
const A = parseColor(ca), B = parseColor(cb);
|
|
148
|
+
if (A.lin || B.lin) throw new Error("color-mix needs oklch operands: " + s);
|
|
149
|
+
const la = toLab(A), lb = toLab(B), t = pa + pb;
|
|
150
|
+
const mixed = fromLab(la.map((v, i) => (v * pa + lb[i] * pb) / t));
|
|
151
|
+
return { ...mixed, alpha: 1 };
|
|
152
|
+
}
|
|
153
|
+
if ((m = s.match(/^oklch\(([\s\S]*)\)$/))) {
|
|
154
|
+
const [main, alpha] = splitTop(m[1], "/");
|
|
155
|
+
const [L, C, H] = splitTop(main.trim().replace(/\s+/g, " "), " ").filter(Boolean).map(num);
|
|
156
|
+
return { L, C, H, alpha: alpha ? num(alpha) : 1 };
|
|
157
|
+
}
|
|
158
|
+
if ((m = s.match(/^#([0-9a-f]{6})$/i))) {
|
|
159
|
+
const n = parseInt(m[1], 16);
|
|
160
|
+
return fromSrgb([(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255]);
|
|
161
|
+
}
|
|
162
|
+
throw new Error("unsupported colour: " + s);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function oklchToLinear({ L, C, H }) {
|
|
166
|
+
const h = (H * Math.PI) / 180, a = C * Math.cos(h), b = C * Math.sin(h);
|
|
167
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
168
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
169
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
170
|
+
return [
|
|
171
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
172
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
173
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
174
|
+
];
|
|
175
|
+
}
|
|
176
|
+
const inGamut = (rgb) => rgb.every((c) => c >= -1e-4 && c <= 1 + 1e-4);
|
|
177
|
+
// CSS Color 4 style gamut mapping: reduce chroma until it fits.
|
|
178
|
+
function toLinearSrgb(c) {
|
|
179
|
+
let rgb = oklchToLinear(c);
|
|
180
|
+
if (inGamut(rgb)) return { rgb, clipped: false };
|
|
181
|
+
let lo = 0, hi = c.C;
|
|
182
|
+
for (let k = 0; k < 24; k++) { const mid = (lo + hi) / 2; inGamut(oklchToLinear({ ...c, C: mid })) ? (lo = mid) : (hi = mid); }
|
|
183
|
+
rgb = oklchToLinear({ ...c, C: lo }).map((v) => Math.min(1, Math.max(0, v)));
|
|
184
|
+
return { rgb, clipped: c.C - lo > 0.004 };
|
|
185
|
+
}
|
|
186
|
+
function fromSrgb(srgb) {
|
|
187
|
+
const lin = srgb.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
188
|
+
return { lin, alpha: 1 };
|
|
189
|
+
}
|
|
190
|
+
const luminance = ([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
191
|
+
const ratio = (y1, y2) => (Math.max(y1, y2) + 0.05) / (Math.min(y1, y2) + 0.05);
|
|
192
|
+
const toHex = (lin) => "#" + lin.map((v) => {
|
|
193
|
+
const s = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
194
|
+
return Math.round(Math.min(1, Math.max(0, s)) * 255).toString(16).padStart(2, "0");
|
|
195
|
+
}).join("");
|
|
196
|
+
|
|
197
|
+
function resolveColor(name, vars, theme, warnings, combo) {
|
|
198
|
+
const raw = vars.get(name);
|
|
199
|
+
if (raw === undefined) throw new Error(`${name} not defined`);
|
|
200
|
+
const c = parseColor(substitute(raw, vars, theme));
|
|
201
|
+
if (c.lin) return { ...c, hex: toHex(c.lin) };
|
|
202
|
+
const { rgb, clipped } = toLinearSrgb(c);
|
|
203
|
+
if (clipped) warnings.add(`${combo} ${name} oklch(${c.L} ${c.C.toFixed(3)} ${c.H}) is outside sRGB; chroma mapped`);
|
|
204
|
+
return { ...c, lin: rgb, hex: toHex(rgb) };
|
|
205
|
+
}
|
|
206
|
+
const over = (fg, bg) => fg.lin.map((v, i) => v * fg.alpha + bg.lin[i] * (1 - fg.alpha));
|
|
207
|
+
|
|
208
|
+
// ------------------------------------------------------------------ the pairs
|
|
209
|
+
const T = "--warp-color-";
|
|
210
|
+
const SURFACES = ["page", "surface", "surface-raised", "surface-overlay", "surface-sunken", "surface-hover"];
|
|
211
|
+
const TONES = ["neutral", "accent", "positive", "caution", "critical", "info", "highlight"];
|
|
212
|
+
const pairs = [];
|
|
213
|
+
for (const fg of ["text", "text-subtle"]) for (const bg of SURFACES) pairs.push({ group: "Text on surfaces", fg, bg, min: 4.5 });
|
|
214
|
+
// text-faint is the third ink (meta, timestamps). It may carry information, so it is held to text contrast
|
|
215
|
+
// on every surface text sits on (review 03 M8); disabled ink is opacity, not faint.
|
|
216
|
+
for (const bg of ["page", "surface", "surface-raised", "surface-overlay", "surface-sunken"]) pairs.push({ group: "Faint text (meta, AA)", fg: "text-faint", bg, min: 4.5 });
|
|
217
|
+
pairs.push({ group: "Text on surfaces", fg: "on-inverse", bg: "surface-inverse", min: 4.5 });
|
|
218
|
+
for (const bg of ["page", "surface", "surface-raised", "surface-sunken"]) pairs.push({ group: "Control boundary", fg: "border-control", bg, min: 3 });
|
|
219
|
+
for (const bg of ["page", "surface", "surface-raised", "surface-overlay", "surface-sunken"]) pairs.push({ group: "Focus ring", fg: "focus", bg, min: 3 });
|
|
220
|
+
for (const t of TONES) {
|
|
221
|
+
pairs.push({ group: "On-solid ink", fg: `${t}-on-solid`, bg: `${t}-solid`, min: 4.5 });
|
|
222
|
+
pairs.push({ group: "On-solid ink", fg: `${t}-on-solid`, bg: `${t}-solid-hover`, min: 4.5 });
|
|
223
|
+
for (const bg of ["page", "surface", "surface-raised", "surface-sunken", `${t}-soft`])
|
|
224
|
+
pairs.push({ group: "Tone text (on surfaces + own soft)", fg: `${t}-text`, bg, min: 4.5 });
|
|
225
|
+
}
|
|
226
|
+
// pressed state layer (--warp-state-press, ink at 11% over the fill): ink must still pass
|
|
227
|
+
for (const t of TONES) pairs.push({ group: "Pressed state layer", fg: `${t}-text`, bg: `${t}-soft+${t}-text@0.11`, min: 4.5 });
|
|
228
|
+
for (const bg of ["surface", "surface-raised"]) pairs.push({ group: "Pressed state layer", fg: "text", bg: `${bg}+text@0.11`, min: 4.5 });
|
|
229
|
+
for (const bg of ["page", "surface", "surface-raised"]) pairs.push({ group: "Accent fill as UI (checkbox, switch)", fg: "accent-solid", bg, min: 3 });
|
|
230
|
+
// signature: sweep stroke + resting border are the field's boundary (UI ≥ 3:1);
|
|
231
|
+
// the floated label sits in the notch on the field's background (text ≥ 4.5:1)
|
|
232
|
+
for (const bg of ["page", "surface", "surface-raised", "field", "field-active"]) {
|
|
233
|
+
pairs.push({ group: "Sweep stroke + resting border (UI)", fg: "sweep", bg, min: 3 });
|
|
234
|
+
pairs.push({ group: "Sweep stroke + resting border (UI)", fg: "sweep-base", bg, min: 3 });
|
|
235
|
+
}
|
|
236
|
+
for (const bg of ["page", "surface", "surface-raised", "field-invalid"]) pairs.push({ group: "Sweep stroke + resting border (UI)", fg: "sweep-critical", bg, min: 3 });
|
|
237
|
+
// the sweep must read against what it replaces: accent stroke vs ink base, ≥ 3:1 at every stop
|
|
238
|
+
// (a 1px dark-on-dark swap reads as "no change"); and vs every surface a field sits on
|
|
239
|
+
pairs.push({ group: "Sweep vs resting border (visible swap)", fg: "sweep", bg: "sweep-base", min: 3 });
|
|
240
|
+
pairs.push({ group: "Sweep vs resting border (visible swap)", fg: "sweep", bg: "surface-sunken", min: 3 });
|
|
241
|
+
for (const fg of ["label-rest", "label-active", "critical-text"]) for (const bg of ["page", "surface", "surface-raised", "surface-sunken"])
|
|
242
|
+
pairs.push({ group: "Floating label (notch + field)", fg, bg, min: 4.5 });
|
|
243
|
+
for (const fg of ["text", "label", "label-active"]) for (const bg of ["field", "field-active", "field-invalid"]) pairs.push({ group: "Floating label (notch + field)", fg, bg, min: 4.5 });
|
|
244
|
+
for (const fg of ["label-rest", "critical-text"]) pairs.push({ group: "Floating label (notch + field)", fg, bg: "field", min: 4.5 });
|
|
245
|
+
for (let i = 1; i <= 8; i++) for (const bg of ["surface", "page"]) pairs.push({ group: "Data-viz marks", fg: `data-${i}`, bg, min: 3 });
|
|
246
|
+
|
|
247
|
+
// control hosts in the component files: sweep vs the base they actually rest on
|
|
248
|
+
const CONTROLS = ["field", "checkbox", "radio", "switch", "segmented", "chip", "choice-card", "otp", "dropzone", "quantity",
|
|
249
|
+
"editable", "knob", "xy-pad", "icon-tile", "button", "select", "combobox", "command", "toggle", "toggle-group", "slider",
|
|
250
|
+
"date-field", "stepper", "tag-input", "search"];
|
|
251
|
+
const BASE_PROPS = new Set(["--_base", "--warp-color-sweep-base"]);
|
|
252
|
+
const controlHosts = [], advisories = [];
|
|
253
|
+
if (!args.find((a) => !a.startsWith("--"))) {
|
|
254
|
+
const dir = resolve(here, "../tokens");
|
|
255
|
+
for (const f of readdirSync(dir).filter((n) => /^warp-.+\.css$/.test(n)).sort()) {
|
|
256
|
+
const src = readFileSync(resolve(dir, f), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
|
257
|
+
for (const d of parseBlocks(src)) {
|
|
258
|
+
if (!BASE_PROPS.has(d.prop)) continue;
|
|
259
|
+
const tok = d.value.match(/^var\(--warp-color-([\w-]+)\)$/);
|
|
260
|
+
if (!tok || !/^(border|border-strong|border-control|text-subtle|text-faint)$/.test(tok[1])) continue; // ink, accent or transparent: fine
|
|
261
|
+
const scopes = [...d.selector.matchAll(/data-scope="([\w-]+)"/g)].map((m) => m[1]);
|
|
262
|
+
const part = d.selector.match(/data-part="(switcher|command|trigger)"/)?.[1];
|
|
263
|
+
const scope = scopes.find((x) => CONTROLS.includes(x)) || (part && `${scopes.at(-1) ?? ""} ${part}`.trim());
|
|
264
|
+
if (!scope || (!part && scopes.some((x) => /surface|card|panel|stat|tile|kanban|plan/.test(x) && !CONTROLS.includes(x)))) continue;
|
|
265
|
+
// a control *part* (shell switcher, command trigger) is advisory: it has a visible label (review 03 m13)
|
|
266
|
+
if (part) { advisories.push(`${f} ${scope} rests on ${tok[1]}; sweep-vs-base below 3:1 (advisory, labelled control)`); continue; }
|
|
267
|
+
const key = `${f} ${scope} → ${tok[1]}`;
|
|
268
|
+
if (controlHosts.some((h) => h.key === key)) continue;
|
|
269
|
+
controlHosts.push({ key, file: f, scope, base: tok[1] });
|
|
270
|
+
pairs.push({ group: "Control sweep vs its base (component files)", fg: "sweep", bg: tok[1], min: 3, where: key });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// --------------------------------------------------------------------- run it
|
|
276
|
+
const decls = parseBlocks(css);
|
|
277
|
+
const warnings = new Set();
|
|
278
|
+
const results = [];
|
|
279
|
+
const resolved = {};
|
|
280
|
+
for (const theme of THEMES) for (const expression of EXPRESSIONS) {
|
|
281
|
+
const combo = `${theme}/${expression}`;
|
|
282
|
+
const vars = resolveEnv(decls, { "data-theme": theme, "data-expression": expression });
|
|
283
|
+
const cache = new Map();
|
|
284
|
+
const one = (n) => cache.get(n) ?? cache.set(n, resolveColor(T + n, vars, theme, warnings, combo)).get(n);
|
|
285
|
+
// "base+layer@alpha" = layer composited over base (state layers)
|
|
286
|
+
const get = (n) => {
|
|
287
|
+
const m = n.match(/^(.+)\+(.+)@([\d.]+)$/);
|
|
288
|
+
if (!m) return one(n);
|
|
289
|
+
const base = one(m[1]), layer = { ...one(m[2]), alpha: Number(m[3]) };
|
|
290
|
+
const lin = over(layer, base);
|
|
291
|
+
return { lin, hex: toHex(lin) };
|
|
292
|
+
};
|
|
293
|
+
resolved[combo] = {};
|
|
294
|
+
for (const p of pairs) {
|
|
295
|
+
const fg = get(p.fg), bg = get(p.bg);
|
|
296
|
+
const r = ratio(luminance(over(fg, bg)), luminance(bg.lin));
|
|
297
|
+
resolved[combo][p.fg] = fg.hex; resolved[combo][p.bg] = bg.hex;
|
|
298
|
+
const res = { ...p, combo, theme, expression, ratio: r, pass: r >= p.min, fgHex: fg.hex, bgHex: bg.hex };
|
|
299
|
+
if (!res.pass) res.suggest = suggest(fg, bg, p.min);
|
|
300
|
+
results.push(res);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// On failure: the same search the token build runs to *compute* ink —
|
|
305
|
+
// walk OKLCH lightness away from the background until the ratio passes.
|
|
306
|
+
function suggest(fg, bg, min) {
|
|
307
|
+
if (fg.L === undefined) return null;
|
|
308
|
+
const yb = luminance(bg.lin), dir = luminance(fg.lin) > yb ? 1 : -1;
|
|
309
|
+
for (let L = fg.L; L >= 0 && L <= 1; L += dir * 0.005) {
|
|
310
|
+
const { rgb } = toLinearSrgb({ ...fg, L });
|
|
311
|
+
if (ratio(luminance(rgb), yb) >= min) return `L ${L.toFixed(3)}`;
|
|
312
|
+
}
|
|
313
|
+
return "flip ink direction";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const fails = results.filter((r) => !r.pass);
|
|
317
|
+
const fmt = (n) => n.toFixed(2);
|
|
318
|
+
|
|
319
|
+
if (flag("--json")) {
|
|
320
|
+
console.log(JSON.stringify({ file: cssPath, resolved, results }, null, 2));
|
|
321
|
+
} else if (flag("--md")) {
|
|
322
|
+
const groups = [...new Set(results.map((r) => r.group))];
|
|
323
|
+
console.log(`Checked ${results.length} pairs (${pairs.length} × ${THEMES.length * EXPRESSIONS.length} combinations): ` +
|
|
324
|
+
`**${results.length - fails.length} pass, ${fails.length} fail.**\n`);
|
|
325
|
+
console.log("| Group | Need | Pairs | Lowest | Where | light q/b/v (min) | dark q/b/v (min) |");
|
|
326
|
+
console.log("|---|---|---|---|---|---|---|");
|
|
327
|
+
for (const g of groups) {
|
|
328
|
+
const rs = results.filter((r) => r.group === g);
|
|
329
|
+
const low = rs.reduce((a, b) => (b.ratio < a.ratio ? b : a));
|
|
330
|
+
const col = (th) => EXPRESSIONS.map((e) => fmt(Math.min(...rs.filter((r) => r.theme === th && r.expression === e).map((r) => r.ratio)))).join(" / ");
|
|
331
|
+
console.log(`| ${g} | ${low.min}:1 | ${rs.length} | **${fmt(low.ratio)}** | ${low.combo} \`${low.fg}\` on \`${low.bg}\` | ${col("light")} | ${col("dark")} |`);
|
|
332
|
+
}
|
|
333
|
+
if (warnings.size) console.log(`\nGamut notes: ${warnings.size} colour(s) chroma-mapped into sRGB.`);
|
|
334
|
+
} else {
|
|
335
|
+
for (const combo of Object.keys(resolved)) {
|
|
336
|
+
const rs = results.filter((r) => r.combo === combo);
|
|
337
|
+
const f = rs.filter((r) => !r.pass);
|
|
338
|
+
const low = rs.reduce((a, b) => (b.ratio / b.min < a.ratio / a.min ? b : a));
|
|
339
|
+
console.log(`${combo.padEnd(15)} ${String(rs.length - f.length).padStart(3)}/${rs.length} pass tightest: ${low.fg} on ${low.bg} ${fmt(low.ratio)} (need ${low.min})`);
|
|
340
|
+
if (flag("--full")) for (const r of rs) console.log(` ${r.pass ? "ok " : "FAIL"} ${fmt(r.ratio).padStart(6)} ${r.fg} ${r.fgHex} on ${r.bg} ${r.bgHex}`);
|
|
341
|
+
}
|
|
342
|
+
for (const r of fails) console.log(`FAIL ${r.combo} ${r.fg} on ${r.bg}: ${fmt(r.ratio)} < ${r.min} (try ${r.suggest})${r.where ? " [" + r.where + "]" : ""}`);
|
|
343
|
+
for (const a of new Set(advisories)) console.log("advisory " + a);
|
|
344
|
+
console.log(`control hosts re-pointing their base in component files: ${controlHosts.length ? controlHosts.map((h) => h.key).join("; ") : "none below ink"}`);
|
|
345
|
+
for (const w of warnings) console.log("note " + w);
|
|
346
|
+
console.log(`\n${results.length} checks, ${fails.length} failures.`);
|
|
347
|
+
}
|
|
348
|
+
process.exitCode = fails.length ? 1 : 0; // not process.exit(): lets piped stdout flush
|