zabi-components 7.0.2 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1924 -0
- package/README.md +287 -11
- package/THEME.md +149 -332
- package/THEMING.md +1329 -163
- package/dist/atoms/ActionPanel.svelte +24 -9
- package/dist/atoms/ActionPanel.svelte.d.ts +2 -0
- package/dist/atoms/Avatar.svelte +104 -0
- package/dist/atoms/Avatar.svelte.d.ts +34 -0
- package/dist/atoms/Badge.svelte +124 -58
- package/dist/atoms/Badge.svelte.d.ts +17 -5
- package/dist/atoms/Button.svelte +212 -62
- package/dist/atoms/Button.svelte.d.ts +33 -2
- package/dist/atoms/Card.svelte +31 -28
- package/dist/atoms/Card.svelte.d.ts +5 -2
- package/dist/atoms/CardContent.svelte +10 -2
- package/dist/atoms/CardContent.svelte.d.ts +2 -0
- package/dist/atoms/CardFooter.svelte +10 -2
- package/dist/atoms/CardFooter.svelte.d.ts +2 -0
- package/dist/atoms/CardHeader.svelte +26 -6
- package/dist/atoms/CardHeader.svelte.d.ts +2 -0
- package/dist/atoms/Checkbox.svelte +52 -21
- package/dist/atoms/Checkbox.svelte.d.ts +6 -2
- package/dist/atoms/CodeBlock.svelte +35 -9
- package/dist/atoms/CodeBlock.svelte.d.ts +13 -3
- package/dist/atoms/ColorPicker.svelte +272 -52
- package/dist/atoms/ColorPicker.svelte.d.ts +6 -1
- package/dist/atoms/Container.svelte +17 -10
- package/dist/atoms/DateField.svelte +107 -0
- package/dist/atoms/DateField.svelte.d.ts +53 -0
- package/dist/atoms/Divider.svelte +4 -4
- package/dist/atoms/FeatureCard.svelte +4 -3
- package/dist/atoms/FieldMessages.svelte +69 -0
- package/dist/atoms/FieldMessages.svelte.d.ts +23 -0
- package/dist/atoms/FloatingActionButton.svelte +174 -0
- package/dist/atoms/FloatingActionButton.svelte.d.ts +51 -0
- package/dist/atoms/Heading.svelte +93 -32
- package/dist/atoms/Heading.svelte.d.ts +24 -4
- package/dist/atoms/IconButton.svelte +242 -53
- package/dist/atoms/IconButton.svelte.d.ts +49 -4
- package/dist/atoms/Input.svelte +267 -74
- package/dist/atoms/Input.svelte.d.ts +32 -2
- package/dist/atoms/List.svelte +13 -2
- package/dist/atoms/List.svelte.d.ts +2 -0
- package/dist/atoms/ListItem.svelte +44 -11
- package/dist/atoms/ListItem.svelte.d.ts +5 -0
- package/dist/atoms/ListItemLeading.svelte +4 -2
- package/dist/atoms/ListItemLeading.svelte.d.ts +1 -1
- package/dist/atoms/OptimizedImage.svelte +11 -2
- package/dist/atoms/OptimizedImage.svelte.d.ts +2 -0
- package/dist/atoms/Progress.svelte +31 -9
- package/dist/atoms/Progress.svelte.d.ts +6 -0
- package/dist/atoms/Radio.svelte +23 -3
- package/dist/atoms/Radio.svelte.d.ts +6 -2
- package/dist/atoms/Rating.svelte +494 -0
- package/dist/atoms/Rating.svelte.d.ts +56 -0
- package/dist/atoms/Select.svelte +475 -135
- package/dist/atoms/Select.svelte.d.ts +52 -2
- package/dist/atoms/SelectionControl.svelte +99 -18
- package/dist/atoms/SelectionControl.svelte.d.ts +6 -3
- package/dist/atoms/Skeleton.svelte +5 -3
- package/dist/atoms/Slider.svelte +309 -0
- package/dist/atoms/Slider.svelte.d.ts +40 -0
- package/dist/atoms/Spinner.svelte +57 -0
- package/dist/atoms/Spinner.svelte.d.ts +19 -0
- package/dist/atoms/Table.svelte +152 -6
- package/dist/atoms/Table.svelte.d.ts +15 -3
- package/dist/atoms/TemporalField.svelte +140 -0
- package/dist/atoms/TemporalField.svelte.d.ts +32 -0
- package/dist/atoms/Text.svelte +56 -15
- package/dist/atoms/Text.svelte.d.ts +13 -4
- package/dist/atoms/Textarea.svelte +57 -45
- package/dist/atoms/Textarea.svelte.d.ts +14 -2
- package/dist/atoms/ThemeToggle.svelte +238 -56
- package/dist/atoms/ThemeToggle.svelte.d.ts +28 -3
- package/dist/atoms/TimeField.svelte +108 -0
- package/dist/atoms/TimeField.svelte.d.ts +54 -0
- package/dist/atoms/Toast.svelte +25 -10
- package/dist/atoms/Toast.svelte.d.ts +2 -0
- package/dist/atoms/Toggle.svelte +60 -19
- package/dist/atoms/Toggle.svelte.d.ts +18 -2
- package/dist/atoms/Tooltip.svelte +583 -40
- package/dist/atoms/Tooltip.svelte.d.ts +26 -0
- package/dist/atoms/ZabiStringsProvider.svelte +45 -0
- package/dist/atoms/ZabiStringsProvider.svelte.d.ts +27 -0
- package/dist/atoms/index.d.ts +17 -0
- package/dist/atoms/index.js +12 -0
- package/dist/atoms/selection-control.styles.d.ts +36 -5
- package/dist/atoms/selection-control.styles.js +40 -9
- package/dist/components/atoms/index.d.ts +17 -0
- package/dist/components/atoms/index.d.ts.map +1 -1
- package/dist/components/atoms/selection-control.styles.d.ts +36 -5
- package/dist/components/atoms/selection-control.styles.d.ts.map +1 -1
- package/dist/components/index.d.ts +83 -3
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/molecules/index.d.ts +46 -0
- package/dist/components/molecules/index.d.ts.map +1 -1
- package/dist/components/molecules/toast-store.d.ts +53 -7
- package/dist/components/molecules/toast-store.d.ts.map +1 -1
- package/dist/components/organisms/index.d.ts +6 -0
- package/dist/components/organisms/index.d.ts.map +1 -1
- package/dist/components/types/variants.d.ts +15 -1
- package/dist/components/types/variants.d.ts.map +1 -1
- package/dist/components/util/app-shell.d.ts +28 -0
- package/dist/components/util/app-shell.d.ts.map +1 -0
- package/dist/components/util/avatar.d.ts +36 -0
- package/dist/components/util/avatar.d.ts.map +1 -0
- package/dist/components/util/bottom-sheet.d.ts +30 -0
- package/dist/components/util/bottom-sheet.d.ts.map +1 -0
- package/dist/components/util/bottom-tab-bar.d.ts +35 -0
- package/dist/components/util/bottom-tab-bar.d.ts.map +1 -0
- package/dist/components/util/calendar.d.ts +56 -0
- package/dist/components/util/calendar.d.ts.map +1 -0
- package/dist/components/util/cn.d.ts +8 -0
- package/dist/components/util/cn.d.ts.map +1 -0
- package/dist/components/util/collapsible.d.ts +58 -0
- package/dist/components/util/collapsible.d.ts.map +1 -0
- package/dist/components/util/confirm-dialog.d.ts +12 -0
- package/dist/components/util/confirm-dialog.d.ts.map +1 -0
- package/dist/components/util/date.d.ts +96 -0
- package/dist/components/util/date.d.ts.map +1 -0
- package/dist/components/util/drawer.d.ts +13 -0
- package/dist/components/util/drawer.d.ts.map +1 -0
- package/dist/components/util/dropdown.d.ts +47 -0
- package/dist/components/util/dropdown.d.ts.map +1 -0
- package/dist/components/util/field.d.ts +39 -0
- package/dist/components/util/field.d.ts.map +1 -0
- package/dist/components/util/fit-in-viewport.d.ts +201 -0
- package/dist/components/util/fit-in-viewport.d.ts.map +1 -0
- package/dist/components/util/focus-utils.d.ts +75 -2
- package/dist/components/util/focus-utils.d.ts.map +1 -1
- package/dist/components/util/hydration.d.ts +45 -0
- package/dist/components/util/hydration.d.ts.map +1 -0
- package/dist/components/util/keyboard-inset.d.ts +18 -0
- package/dist/components/util/keyboard-inset.d.ts.map +1 -0
- package/dist/components/util/media-grid.d.ts +51 -0
- package/dist/components/util/media-grid.d.ts.map +1 -0
- package/dist/components/util/media.d.ts +6 -0
- package/dist/components/util/media.d.ts.map +1 -0
- package/dist/components/util/overlay-chrome.d.ts +53 -0
- package/dist/components/util/overlay-chrome.d.ts.map +1 -0
- package/dist/components/util/overlay.d.ts +72 -0
- package/dist/components/util/overlay.d.ts.map +1 -0
- package/dist/components/util/photo.d.ts +215 -0
- package/dist/components/util/photo.d.ts.map +1 -0
- package/dist/components/util/portal.d.ts +23 -0
- package/dist/components/util/portal.d.ts.map +1 -0
- package/dist/components/util/pull-to-refresh.d.ts +27 -0
- package/dist/components/util/pull-to-refresh.d.ts.map +1 -0
- package/dist/components/util/radio-keys.d.ts +25 -0
- package/dist/components/util/radio-keys.d.ts.map +1 -0
- package/dist/components/util/rating.d.ts +23 -0
- package/dist/components/util/rating.d.ts.map +1 -0
- package/dist/components/util/ready-made-strings.d.ts +77 -0
- package/dist/components/util/ready-made-strings.d.ts.map +1 -0
- package/dist/components/util/scroll-padding.d.ts +20 -0
- package/dist/components/util/scroll-padding.d.ts.map +1 -0
- package/dist/components/util/segmented-control.d.ts +16 -0
- package/dist/components/util/segmented-control.d.ts.map +1 -0
- package/dist/components/util/select.d.ts +41 -0
- package/dist/components/util/select.d.ts.map +1 -0
- package/dist/components/util/sheet-drag.d.ts +60 -0
- package/dist/components/util/sheet-drag.d.ts.map +1 -0
- package/dist/components/util/sidebar.d.ts +63 -0
- package/dist/components/util/sidebar.d.ts.map +1 -0
- package/dist/components/util/sortable-list.d.ts +65 -0
- package/dist/components/util/sortable-list.d.ts.map +1 -0
- package/dist/components/util/ssr-safe.d.ts +4 -4
- package/dist/components/util/ssr-safe.d.ts.map +1 -1
- package/dist/components/util/stepper.d.ts +40 -0
- package/dist/components/util/stepper.d.ts.map +1 -0
- package/dist/components/util/swipeable-list-item.d.ts +36 -0
- package/dist/components/util/swipeable-list-item.d.ts.map +1 -0
- package/dist/components/util/theme-mode.d.ts +80 -0
- package/dist/components/util/theme-mode.d.ts.map +1 -0
- package/dist/components/util/toaster.d.ts +93 -0
- package/dist/components/util/toaster.d.ts.map +1 -0
- package/dist/components/util/tooltip.d.ts +10 -0
- package/dist/components/util/tooltip.d.ts.map +1 -0
- package/dist/components/util/top-navbar.d.ts +11 -0
- package/dist/components/util/top-navbar.d.ts.map +1 -0
- package/dist/components/util/touch-target.d.ts +31 -0
- package/dist/components/util/touch-target.d.ts.map +1 -0
- package/dist/components/util/zabi-strings.d.ts +133 -0
- package/dist/components/util/zabi-strings.d.ts.map +1 -0
- package/dist/create-theme/cli.js +169 -0
- package/dist/create-theme/index.d.ts +176 -0
- package/dist/create-theme/index.js +817 -0
- package/dist/create-theme/lib/ramp-math.js +128 -0
- package/dist/create-theme/lib/resolve.js +141 -0
- package/dist/create-theme/theme-data.js +1116 -0
- package/dist/index.d.ts +83 -3
- package/dist/index.js +60 -8
- package/dist/molecules/Alert.svelte +73 -66
- package/dist/molecules/Alert.svelte.d.ts +9 -1
- package/dist/molecules/AppBar.svelte +415 -0
- package/dist/molecules/AppBar.svelte.d.ts +69 -0
- package/dist/molecules/AvatarGroup.svelte +116 -0
- package/dist/molecules/AvatarGroup.svelte.d.ts +40 -0
- package/dist/molecules/BottomSheet.svelte +540 -0
- package/dist/molecules/BottomSheet.svelte.d.ts +93 -0
- package/dist/molecules/BottomTabBar.svelte +297 -0
- package/dist/molecules/BottomTabBar.svelte.d.ts +64 -0
- package/dist/molecules/Calendar.svelte +597 -0
- package/dist/molecules/Calendar.svelte.d.ts +72 -0
- package/dist/molecules/Collapsible.svelte +243 -0
- package/dist/molecules/Collapsible.svelte.d.ts +44 -0
- package/dist/molecules/CollapsibleGroup.svelte +126 -0
- package/dist/molecules/CollapsibleGroup.svelte.d.ts +14 -0
- package/dist/molecules/ComponentDemo.svelte +25 -5
- package/dist/molecules/ComponentDemo.svelte.d.ts +5 -0
- package/dist/molecules/ConfirmDialog.svelte +253 -0
- package/dist/molecules/ConfirmDialog.svelte.d.ts +55 -0
- package/dist/molecules/ContactForm.svelte +46 -19
- package/dist/molecules/ContactForm.svelte.d.ts +10 -0
- package/dist/molecules/Drawer.svelte +394 -0
- package/dist/molecules/Drawer.svelte.d.ts +55 -0
- package/dist/molecules/Dropdown.svelte +540 -50
- package/dist/molecules/Dropdown.svelte.d.ts +49 -6
- package/dist/molecules/DropdownItem.svelte +122 -0
- package/dist/molecules/DropdownItem.svelte.d.ts +28 -0
- package/dist/molecules/EmptyState.svelte +56 -10
- package/dist/molecules/EmptyState.svelte.d.ts +8 -0
- package/dist/molecules/Form.svelte +25 -14
- package/dist/molecules/Form.svelte.d.ts +13 -5
- package/dist/molecules/FormField.svelte +11 -2
- package/dist/molecules/FormField.svelte.d.ts +2 -0
- package/dist/molecules/Header.svelte +13 -3
- package/dist/molecules/Header.svelte.d.ts +2 -0
- package/dist/molecules/ImageUpload.svelte +422 -70
- package/dist/molecules/ImageUpload.svelte.d.ts +61 -5
- package/dist/molecules/MediaGrid.svelte +527 -0
- package/dist/molecules/MediaGrid.svelte.d.ts +77 -0
- package/dist/molecules/Modal.svelte +340 -45
- package/dist/molecules/Modal.svelte.d.ts +59 -7
- package/dist/molecules/NavigationMenu.svelte +15 -3
- package/dist/molecules/NavigationMenu.svelte.d.ts +4 -0
- package/dist/molecules/NavigationMenuContent.svelte +91 -36
- package/dist/molecules/NavigationMenuContent.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuItem.svelte +10 -3
- package/dist/molecules/NavigationMenuItem.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuLink.svelte +10 -2
- package/dist/molecules/NavigationMenuLink.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuList.svelte +19 -4
- package/dist/molecules/NavigationMenuList.svelte.d.ts +2 -0
- package/dist/molecules/NavigationMenuTrigger.svelte +16 -3
- package/dist/molecules/NavigationMenuTrigger.svelte.d.ts +2 -0
- package/dist/molecules/Page.svelte +38 -2
- package/dist/molecules/Page.svelte.d.ts +13 -0
- package/dist/molecules/PhotoGrid.svelte +433 -0
- package/dist/molecules/PhotoGrid.svelte.d.ts +62 -0
- package/dist/molecules/PhotoViewer.svelte +1116 -0
- package/dist/molecules/PhotoViewer.svelte.d.ts +56 -0
- package/dist/molecules/PropsTable.svelte +26 -11
- package/dist/molecules/PropsTable.svelte.d.ts +5 -0
- package/dist/molecules/PullToRefresh.svelte +263 -0
- package/dist/molecules/PullToRefresh.svelte.d.ts +52 -0
- package/dist/molecules/RadioGroup.svelte +57 -15
- package/dist/molecules/RadioGroup.svelte.d.ts +16 -3
- package/dist/molecules/Section.svelte +13 -5
- package/dist/molecules/Section.svelte.d.ts +3 -1
- package/dist/molecules/SegmentedControl.svelte +323 -0
- package/dist/molecules/SegmentedControl.svelte.d.ts +34 -0
- package/dist/molecules/SidebarBrandHeader.svelte +24 -5
- package/dist/molecules/SidebarBrandHeader.svelte.d.ts +5 -0
- package/dist/molecules/SidebarFooter.svelte +39 -8
- package/dist/molecules/SidebarFooter.svelte.d.ts +5 -0
- package/dist/molecules/SidebarNavSection.svelte +12 -4
- package/dist/molecules/SidebarNavSection.svelte.d.ts +2 -0
- package/dist/molecules/SlideUp.svelte +253 -30
- package/dist/molecules/SlideUp.svelte.d.ts +31 -4
- package/dist/molecules/SortableList.svelte +632 -0
- package/dist/molecules/SortableList.svelte.d.ts +69 -0
- package/dist/molecules/Stepper.svelte +548 -0
- package/dist/molecules/Stepper.svelte.d.ts +39 -0
- package/dist/molecules/StickyActionBar.svelte +191 -0
- package/dist/molecules/StickyActionBar.svelte.d.ts +65 -0
- package/dist/molecules/SwipeableListItem.svelte +334 -0
- package/dist/molecules/SwipeableListItem.svelte.d.ts +54 -0
- package/dist/molecules/Tabs.svelte +254 -24
- package/dist/molecules/Tabs.svelte.d.ts +15 -4
- package/dist/molecules/Toaster.svelte +352 -6
- package/dist/molecules/Toaster.svelte.d.ts +95 -2
- package/dist/molecules/ToasterToast.svelte +279 -62
- package/dist/molecules/ToasterToast.svelte.d.ts +11 -0
- package/dist/molecules/UnsavedChangesBar.svelte +212 -0
- package/dist/molecules/UnsavedChangesBar.svelte.d.ts +43 -0
- package/dist/molecules/index.d.ts +46 -0
- package/dist/molecules/index.js +27 -0
- package/dist/molecules/toast-store.d.ts +53 -7
- package/dist/molecules/toast-store.js +21 -0
- package/dist/organisms/AppShell.svelte +196 -0
- package/dist/organisms/AppShell.svelte.d.ts +61 -0
- package/dist/organisms/SidebarAccountPanel.svelte +111 -12
- package/dist/organisms/SidebarAccountPanel.svelte.d.ts +21 -0
- package/dist/organisms/SidebarNavigation.svelte +193 -69
- package/dist/organisms/SidebarNavigation.svelte.d.ts +31 -1
- package/dist/organisms/SidebarPanel.svelte +45 -16
- package/dist/organisms/SidebarPanel.svelte.d.ts +2 -0
- package/dist/organisms/SidebarShell.svelte +263 -0
- package/dist/organisms/SidebarShell.svelte.d.ts +106 -0
- package/dist/organisms/TopNavbar.svelte +329 -33
- package/dist/organisms/TopNavbar.svelte.d.ts +28 -0
- package/dist/organisms/index.d.ts +6 -0
- package/dist/organisms/index.js +4 -0
- package/dist/routes/lib/variant-utils.d.ts +26 -0
- package/dist/routes/lib/{variant-utils.ts → variant-utils.js} +15 -46
- package/dist/types/components.d.ts +4 -0
- package/dist/types/components.d.ts.map +1 -1
- package/dist/types/events.js +1 -0
- package/dist/types/index.d.ts +605 -25
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +3 -0
- package/dist/types/variants.ts +17 -1
- package/dist/util/app-shell.d.ts +27 -0
- package/dist/util/app-shell.js +72 -0
- package/dist/util/avatar.d.ts +35 -0
- package/dist/util/avatar.js +52 -0
- package/dist/util/bottom-sheet.d.ts +29 -0
- package/dist/util/bottom-sheet.js +43 -0
- package/dist/util/bottom-tab-bar.d.ts +34 -0
- package/dist/util/bottom-tab-bar.js +54 -0
- package/dist/util/calendar.d.ts +55 -0
- package/dist/util/calendar.js +74 -0
- package/dist/util/cn.d.ts +7 -0
- package/dist/util/cn.js +48 -0
- package/dist/util/collapsible.d.ts +57 -0
- package/dist/util/collapsible.js +23 -0
- package/dist/util/confirm-dialog.d.ts +11 -0
- package/dist/util/confirm-dialog.js +2 -0
- package/dist/util/date.d.ts +95 -0
- package/dist/util/date.js +237 -0
- package/dist/util/drawer.d.ts +12 -0
- package/dist/util/drawer.js +7 -0
- package/dist/util/dropdown.d.ts +46 -0
- package/dist/util/dropdown.js +21 -0
- package/dist/util/field.d.ts +38 -0
- package/dist/util/field.js +38 -0
- package/dist/util/fit-in-viewport.d.ts +200 -0
- package/dist/util/fit-in-viewport.js +399 -0
- package/dist/util/focus-utils.d.ts +75 -2
- package/dist/util/focus-utils.js +182 -9
- package/dist/util/hydration.d.ts +44 -0
- package/dist/util/hydration.js +58 -0
- package/dist/util/keyboard-inset.d.ts +17 -0
- package/dist/util/keyboard-inset.js +53 -0
- package/dist/util/media-grid.d.ts +50 -0
- package/dist/util/media-grid.js +81 -0
- package/dist/util/media.d.ts +5 -0
- package/dist/util/media.js +10 -0
- package/dist/util/overlay-chrome.d.ts +52 -0
- package/dist/util/overlay-chrome.js +56 -0
- package/dist/util/overlay.d.ts +71 -0
- package/dist/util/overlay.js +240 -0
- package/dist/util/photo.d.ts +214 -0
- package/dist/util/photo.js +206 -0
- package/dist/util/portal.d.ts +22 -0
- package/dist/util/portal.js +50 -0
- package/dist/util/pull-to-refresh.d.ts +26 -0
- package/dist/util/pull-to-refresh.js +30 -0
- package/dist/util/radio-keys.d.ts +24 -0
- package/dist/util/radio-keys.js +49 -0
- package/dist/util/rating.d.ts +22 -0
- package/dist/util/rating.js +26 -0
- package/dist/util/ready-made-strings.d.ts +76 -0
- package/dist/util/ready-made-strings.js +67 -0
- package/dist/util/scroll-padding.d.ts +19 -0
- package/dist/util/scroll-padding.js +44 -0
- package/dist/util/segmented-control.d.ts +15 -0
- package/dist/util/segmented-control.js +1 -0
- package/dist/util/select.d.ts +40 -0
- package/dist/util/select.js +63 -0
- package/dist/util/sheet-drag.d.ts +59 -0
- package/dist/util/sheet-drag.js +241 -0
- package/dist/util/sidebar.d.ts +62 -0
- package/dist/util/sidebar.js +38 -0
- package/dist/util/sortable-list.d.ts +64 -0
- package/dist/util/sortable-list.js +53 -0
- package/dist/util/ssr-safe.d.ts +4 -4
- package/dist/util/stepper.d.ts +39 -0
- package/dist/util/stepper.js +38 -0
- package/dist/util/swipeable-list-item.d.ts +35 -0
- package/dist/util/swipeable-list-item.js +32 -0
- package/dist/util/theme-mode.d.ts +79 -0
- package/dist/util/theme-mode.js +121 -0
- package/dist/util/toaster.d.ts +92 -0
- package/dist/util/toaster.js +75 -0
- package/dist/util/tooltip.d.ts +9 -0
- package/dist/util/tooltip.js +18 -0
- package/dist/util/top-navbar.d.ts +10 -0
- package/dist/util/top-navbar.js +5 -0
- package/dist/util/touch-target.d.ts +30 -0
- package/dist/util/touch-target.js +30 -0
- package/dist/util/zabi-strings.d.ts +132 -0
- package/dist/util/zabi-strings.js +105 -0
- package/dist/zabi-components-colors.css +5 -2
- package/dist/zabi-components-theme-dark-only.css +574 -225
- package/dist/zabi-components-theme-dark.css +574 -225
- package/dist/zabi-components-theme-only.css +1680 -317
- package/dist/zabi-components-theme.css +1680 -317
- package/dist/zabi-components.css +4473 -1158
- package/docs/lucide-icons.md +41 -16
- package/docs/theme-imports.md +81 -11
- package/package.json +51 -29
- package/dist/lib/layout-width-tokens.d.ts +0 -17
- package/dist/lib/layout-width-tokens.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Button.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Button.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Input.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Input.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/List.d.ts +0 -3
- package/dist/lib/showcase/component-docs/List.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Modal.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Modal.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Radio.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Radio.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts +0 -3
- package/dist/lib/showcase/component-docs/SidebarNavigation.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/Skeleton.d.ts +0 -3
- package/dist/lib/showcase/component-docs/Skeleton.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/_shared.d.ts +0 -12
- package/dist/lib/showcase/component-docs/_shared.d.ts.map +0 -1
- package/dist/lib/showcase/component-docs/index.d.ts +0 -4
- package/dist/lib/showcase/component-docs/index.d.ts.map +0 -1
- package/dist/lib/showcase/components-catalog.d.ts +0 -3
- package/dist/lib/showcase/components-catalog.d.ts.map +0 -1
- package/dist/lib/showcase/components-showcase-constants.d.ts +0 -89
- package/dist/lib/showcase/components-showcase-constants.d.ts.map +0 -1
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts +0 -15
- package/dist/lib/showcase/docs-sidebar-helpers.d.ts.map +0 -1
- package/dist/routes/lib/focus-utils.d.ts +0 -12
- package/dist/routes/lib/focus-utils.ts +0 -110
- package/dist/routes/lib/ssr-safe.ts +0 -58
- package/scripts/fix-lucide-svelte-icon-dts.js +0 -35
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTheme — one brand colour in, a file of token overrides out.
|
|
3
|
+
*
|
|
4
|
+
* import { createTheme } from 'zabi-components/create-theme';
|
|
5
|
+
* const { css, tokens, warnings } = createTheme({ brand: '#0026EA' });
|
|
6
|
+
*
|
|
7
|
+
* The CSS holds `--zabi-brand-50 … 950` (and `--zabi-accent-*`, `--zabi-base-*`
|
|
8
|
+
* when asked for) on `:root`. Imported after `zabi-components/theme-only` and
|
|
9
|
+
* `zabi-components/theme-dark-only` it restyles light and dark; nothing is
|
|
10
|
+
* repeated for dark, because the dark theme only remaps roles onto the ramps.
|
|
11
|
+
*
|
|
12
|
+
* How the ramps are built:
|
|
13
|
+
* - On the library's shared lightness curve (./lib/ramp-math.js), so
|
|
14
|
+
* `<ramp>-600` means the same lightness as in every built-in ramp and every
|
|
15
|
+
* role keeps the contrast it was designed with. The colour you give
|
|
16
|
+
* supplies hue and chroma; it is NOT pinned to a step, so the exact hex may
|
|
17
|
+
* not appear in the ramp. `closest` says which step it landed nearest.
|
|
18
|
+
* - The neutral ramp keeps the base scale's own 21 lightness steps and is
|
|
19
|
+
* tinted toward the hue you give, at low chroma, so it stays a neutral.
|
|
20
|
+
*
|
|
21
|
+
* `pin` puts the exact colour on the primary action. The ramp stays on the
|
|
22
|
+
* curve, and the roles that ARE the brand colour (the primary fill, and where
|
|
23
|
+
* they still pass, the focus ring and links) are written as role overrides:
|
|
24
|
+
* light on `:root`, dark under the selectors the dark theme is published
|
|
25
|
+
* under. See "pinning" below.
|
|
26
|
+
*
|
|
27
|
+
* Then every role pair the library guards (the same list as
|
|
28
|
+
* scripts/check-contrast.js) is resolved in light and in dark with the new
|
|
29
|
+
* ramps in place, and anything under WCAG AA comes back in `warnings`.
|
|
30
|
+
*
|
|
31
|
+
* `./theme-data.js` is not in the source tree: scripts/build-create-theme.js
|
|
32
|
+
* writes it next to this file in dist/, from src/app.css and the pair list, on
|
|
33
|
+
* every build. It is the default theme's token map and cannot go stale.
|
|
34
|
+
*
|
|
35
|
+
* Plain ESM, no build step. Runtime dependency: culori.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import data from './theme-data.js';
|
|
39
|
+
import {
|
|
40
|
+
RAMP_STEPS,
|
|
41
|
+
TARGET_LIGHTNESS,
|
|
42
|
+
CHROMA_ENVELOPE,
|
|
43
|
+
generateRampFromSeed,
|
|
44
|
+
hexAt,
|
|
45
|
+
cieL,
|
|
46
|
+
toOklch,
|
|
47
|
+
toLab,
|
|
48
|
+
} from './lib/ramp-math.js';
|
|
49
|
+
import { resolveTokenColor } from './lib/resolve.js';
|
|
50
|
+
|
|
51
|
+
/** The neutral ramp's 21 steps. */
|
|
52
|
+
export const BASE_STEPS = [
|
|
53
|
+
50, 75, 100, 150, 200, 250, 300, 350, 400, 450, 500,
|
|
54
|
+
550, 600, 650, 700, 750, 800, 850, 900, 925, 950,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
export { RAMP_STEPS };
|
|
58
|
+
|
|
59
|
+
const TOOL = 'zabi-theme';
|
|
60
|
+
const WHITE = '#ffffff';
|
|
61
|
+
|
|
62
|
+
/** Below this OKLCH chroma a colour has no hue worth building a ramp from. */
|
|
63
|
+
const MIN_USEFUL_CHROMA = 0.02;
|
|
64
|
+
/** A ramp never asks for more chroma than this; sRGB cannot show it anyway. */
|
|
65
|
+
const MAX_PEAK_CHROMA = 0.4;
|
|
66
|
+
/** The envelope is not trusted below this when dividing a pale input by it. */
|
|
67
|
+
const MIN_ENVELOPE = 0.2;
|
|
68
|
+
/** A neutral stays a neutral: this is the most chroma any of its steps gets. */
|
|
69
|
+
const MAX_NEUTRAL_CHROMA = 0.03;
|
|
70
|
+
/** `neutralChroma` may lift the cap, but not past this: beyond it the steps stop being neutrals. */
|
|
71
|
+
const MAX_NEUTRAL_CHROMA_OPTION = 0.1;
|
|
72
|
+
/** The least any neutral step keeps of that, so the lightest steps still tint. */
|
|
73
|
+
const MIN_NEUTRAL_PROFILE = 0.25;
|
|
74
|
+
/** CIELAB ΔE past which the input is visibly not one of the ramp's steps. */
|
|
75
|
+
const FAR_FROM_RAMP = 10;
|
|
76
|
+
|
|
77
|
+
/* ---------- input ---------- */
|
|
78
|
+
|
|
79
|
+
function parseHex(value, option) {
|
|
80
|
+
const shown = typeof value === 'string' ? `"${value}"` : String(value);
|
|
81
|
+
const fail = () =>
|
|
82
|
+
new TypeError(`${TOOL}: ${option} must be a hex colour such as "#0026EA" or "#06e" (got ${shown})`);
|
|
83
|
+
if (typeof value !== 'string') throw fail();
|
|
84
|
+
const text = value.trim().replace(/^#/, '');
|
|
85
|
+
if (!/^(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text)) throw fail();
|
|
86
|
+
const full = text.length === 3 ? text.split('').map((c) => c + c).join('') : text;
|
|
87
|
+
return `#${full.toLowerCase()}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function describe(hex) {
|
|
91
|
+
const { c, h } = toOklch(hex);
|
|
92
|
+
return { hex, lightness: cieL(hex), chroma: c ?? 0, hue: Number.isFinite(h) ? h : 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Notes about an input that will not give the ramp its author expects. */
|
|
96
|
+
function inputWarnings(option, colour) {
|
|
97
|
+
const out = [];
|
|
98
|
+
const add = (message) => out.push({ type: 'input', option, message: `${option} ${colour.hex} ${message}` });
|
|
99
|
+
if (colour.chroma < MIN_USEFUL_CHROMA) {
|
|
100
|
+
add('has almost no colour in it, so the ramp is a grey scale. Give a more saturated colour for a visible hue.');
|
|
101
|
+
} else if (colour.lightness > 96) {
|
|
102
|
+
add('is nearly white: only its hue is used, and the ramp is built at the chroma that hue can carry.');
|
|
103
|
+
} else if (colour.lightness < 6) {
|
|
104
|
+
add('is nearly black: only its hue is used, and the ramp is built at the chroma that hue can carry.');
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* ---------- overrides ---------- */
|
|
110
|
+
|
|
111
|
+
const MODE_KEYS = ['light', 'dark', 'both'];
|
|
112
|
+
|
|
113
|
+
function checkDeclarations(map, where, flat) {
|
|
114
|
+
for (const [name, value] of Object.entries(map)) {
|
|
115
|
+
if (!name.startsWith('--') || typeof value !== 'string' || !value.trim()) {
|
|
116
|
+
throw new TypeError(
|
|
117
|
+
flat
|
|
118
|
+
? `${TOOL}: overrides must map custom property names ("--color-link") to values (got ${name}: ${value})`
|
|
119
|
+
: `${TOOL}: ${where}["${name}"] must map a custom property name ("--color-link") to a non-empty string value (got ${name}: ${typeof value === 'string' ? `"${value}"` : String(value)})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `overrides` is a flat map of `--token: value` (both modes), or an object with
|
|
127
|
+
* any of `light`, `dark` and `both`, each a flat map. Flat keys beside them
|
|
128
|
+
* count as `both`. Returns the maps by mode.
|
|
129
|
+
*/
|
|
130
|
+
function readOverrides(overrides) {
|
|
131
|
+
if (overrides === null || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
132
|
+
throw new TypeError(`${TOOL}: overrides must be an object of token names to values`);
|
|
133
|
+
}
|
|
134
|
+
const nested = MODE_KEYS.some((key) => key in overrides);
|
|
135
|
+
const out = { light: {}, dark: {}, both: {} };
|
|
136
|
+
if (!nested) {
|
|
137
|
+
checkDeclarations(overrides, 'overrides', true);
|
|
138
|
+
out.both = { ...overrides };
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
142
|
+
if (MODE_KEYS.includes(key)) {
|
|
143
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
144
|
+
throw new TypeError(`${TOOL}: overrides.${key} must be an object of token names to values (got ${JSON.stringify(value)})`);
|
|
145
|
+
}
|
|
146
|
+
checkDeclarations(value, `overrides.${key}`, false);
|
|
147
|
+
out[key] = { ...out[key], ...value };
|
|
148
|
+
} else {
|
|
149
|
+
if (!key.startsWith('--')) {
|
|
150
|
+
throw new TypeError(`${TOOL}: overrides has no "${key}"; it takes light, dark, both, or custom property names ("--color-link")`);
|
|
151
|
+
}
|
|
152
|
+
checkDeclarations({ [key]: value }, 'overrides', false);
|
|
153
|
+
out.both[key] = value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What the overrides write. `root` goes on `:root`: both, then light. `dark` is
|
|
161
|
+
* what the dark rules must say so that `:root` does not reach dark: the dark
|
|
162
|
+
* override; for a token set in `light` alone, dark's own value without it; for
|
|
163
|
+
* one also set in `both`, that value. `own` is that dark value, from what the
|
|
164
|
+
* file writes already, then the library's dark theme, then its light default.
|
|
165
|
+
*/
|
|
166
|
+
function overrideMaps({ light, dark, both }, tokens, darkTokens) {
|
|
167
|
+
const root = { ...both, ...light };
|
|
168
|
+
const out = { ...dark };
|
|
169
|
+
for (const name of Object.keys(light)) {
|
|
170
|
+
if (name in dark) continue;
|
|
171
|
+
out[name] = both[name] ?? darkTokens[name] ?? data.darkOnly[name] ?? tokens[name] ?? data.light[name];
|
|
172
|
+
}
|
|
173
|
+
for (const name of Object.keys(out)) if (out[name] === undefined) delete out[name];
|
|
174
|
+
const skip = new Set([...Object.keys(root), ...Object.keys(dark)]);
|
|
175
|
+
return { root, dark: out, skip };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---------- chromatic ramps ---------- */
|
|
179
|
+
|
|
180
|
+
/** The chroma envelope at any L*, by linear interpolation between the steps. */
|
|
181
|
+
function envelopeAt(lightness) {
|
|
182
|
+
const points = RAMP_STEPS.map((step) => [TARGET_LIGHTNESS[step], CHROMA_ENVELOPE[step]]).sort((a, b) => a[0] - b[0]);
|
|
183
|
+
if (lightness <= points[0][0]) return points[0][1];
|
|
184
|
+
if (lightness >= points[points.length - 1][0]) return points[points.length - 1][1];
|
|
185
|
+
for (let i = 0; i < points.length - 1; i += 1) {
|
|
186
|
+
const [l0, e0] = points[i];
|
|
187
|
+
const [l1, e1] = points[i + 1];
|
|
188
|
+
if (lightness >= l0 && lightness <= l1) return e0 + ((lightness - l0) / (l1 - l0)) * (e1 - e0);
|
|
189
|
+
}
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The input's own chroma, read back through the envelope: a colour at the
|
|
195
|
+
* lightness of step 500 is taken to be 92% of the ramp's peak, and so on. That
|
|
196
|
+
* is what lets the step nearest the input come out close to the input.
|
|
197
|
+
*/
|
|
198
|
+
function chromaticRamp(colour) {
|
|
199
|
+
const envelope = Math.max(envelopeAt(colour.lightness), MIN_ENVELOPE);
|
|
200
|
+
const peakChroma = Math.min(colour.chroma / envelope, MAX_PEAK_CHROMA);
|
|
201
|
+
return generateRampFromSeed({ hue: colour.hue, peakChroma, hueShift: 0 });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ---------- neutral ramp ---------- */
|
|
205
|
+
|
|
206
|
+
function neutralRamp(colour, chromaOption) {
|
|
207
|
+
const defaults = BASE_STEPS.map((step) => {
|
|
208
|
+
const hex = data.light[`--zabi-base-${step}`];
|
|
209
|
+
return { step, lightness: cieL(hex), chroma: toOklch(hex).c ?? 0 };
|
|
210
|
+
});
|
|
211
|
+
const peak = Math.max(...defaults.map((d) => d.chroma)) || 1;
|
|
212
|
+
// The default greys carry most chroma mid-ramp and almost none at the ends.
|
|
213
|
+
// The tint keeps that shape, with a floor so the page and card still tint.
|
|
214
|
+
const profile = (d) => Math.max(d.chroma / peak, MIN_NEUTRAL_PROFILE);
|
|
215
|
+
const nearest = defaults.reduce((best, d) =>
|
|
216
|
+
Math.abs(d.lightness - colour.lightness) < Math.abs(best.lightness - colour.lightness) ? d : best,
|
|
217
|
+
);
|
|
218
|
+
// `neutralChroma` replaces the capped value; the profile across the steps stays.
|
|
219
|
+
const peakChroma = chromaOption ?? Math.min(colour.chroma / profile(nearest), MAX_NEUTRAL_CHROMA);
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const d of defaults) out[d.step] = hexAt(d.lightness, peakChroma * profile(d), colour.hue);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* ---------- ink roles ---------- */
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The translucent roles that are fixed alpha tints of near-black (light) or
|
|
229
|
+
* near-white (dark) in the library, so grey over any ramp. With `neutralChroma`
|
|
230
|
+
* they are the neutral ramp's own ink at the same alpha instead. Each keeps the
|
|
231
|
+
* alpha the library gives it, read from the default theme's data. The dark
|
|
232
|
+
* `--color-border-overlay` is already a ramp step: it is restated as it is.
|
|
233
|
+
*/
|
|
234
|
+
const INK_ROLES = [
|
|
235
|
+
'--color-action-secondary',
|
|
236
|
+
'--color-action-secondary-hover',
|
|
237
|
+
'--color-action-secondary-active',
|
|
238
|
+
'--color-surface-hover',
|
|
239
|
+
'--color-surface-active',
|
|
240
|
+
'--color-border-overlay',
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
/** `rgba(9, 9, 11, 0.1)` -> 10. Null for anything that is not an rgba() tint. */
|
|
244
|
+
function alphaPercent(value) {
|
|
245
|
+
const match = /^rgba\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+\s*,\s*([\d.]+)\s*\)$/.exec(String(value ?? '').trim());
|
|
246
|
+
return match ? Math.round(Number(match[1]) * 1000) / 10 : null;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `#18181b` -> `24 24 27`, the form `--shadow-color` takes. */
|
|
250
|
+
function triplet(hex) {
|
|
251
|
+
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)).join(' ');
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Light and dark declarations for the ink roles over a neutral ramp. Roles the
|
|
256
|
+
* author overrides themselves are left out, so their override stays what both
|
|
257
|
+
* modes show.
|
|
258
|
+
*/
|
|
259
|
+
function inkTokens(ramp, overrides) {
|
|
260
|
+
const light = {};
|
|
261
|
+
const dark = {};
|
|
262
|
+
const mix = (step, percent) => `color-mix(in srgb, var(--zabi-base-${step}) ${percent}%, transparent)`;
|
|
263
|
+
for (const role of INK_ROLES) {
|
|
264
|
+
if (role in overrides) continue;
|
|
265
|
+
const lightPercent = alphaPercent(data.light[role]);
|
|
266
|
+
if (lightPercent !== null) light[role] = mix(900, lightPercent);
|
|
267
|
+
const darkPercent = alphaPercent(data.darkOnly[role]);
|
|
268
|
+
// A role the dark theme already points at a ramp step (the overlay edge)
|
|
269
|
+
// keeps that: the light value on `:root` would otherwise win there too.
|
|
270
|
+
if (darkPercent !== null) dark[role] = mix(50, darkPercent);
|
|
271
|
+
else if (data.darkOnly[role] !== undefined) dark[role] = data.darkOnly[role];
|
|
272
|
+
}
|
|
273
|
+
if (!('--shadow-color' in overrides)) {
|
|
274
|
+
light['--shadow-color'] = triplet(ramp[900]);
|
|
275
|
+
dark['--shadow-color'] = '0 0 0';
|
|
276
|
+
}
|
|
277
|
+
return { light, dark };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ---------- closest step ---------- */
|
|
281
|
+
|
|
282
|
+
function deltaE(a, b) {
|
|
283
|
+
const [x, y] = [toLab(a), toLab(b)];
|
|
284
|
+
return Math.hypot(x.l - y.l, x.a - y.a, x.b - y.b);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function closestStep(hex, ramp) {
|
|
288
|
+
let best = null;
|
|
289
|
+
for (const [step, value] of Object.entries(ramp)) {
|
|
290
|
+
const distance = deltaE(hex, value);
|
|
291
|
+
if (!best || distance < best.deltaE) best = { step: Number(step), hex: value, deltaE: distance };
|
|
292
|
+
}
|
|
293
|
+
return { ...best, deltaE: Math.round(best.deltaE * 10) / 10, exact: best.hex === hex };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* ---------- contrast ---------- */
|
|
297
|
+
|
|
298
|
+
function channel(c) {
|
|
299
|
+
const v = c / 255;
|
|
300
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function luminance(hex) {
|
|
304
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
305
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** WCAG contrast ratio, rounded to two decimals as the library's guard does. */
|
|
309
|
+
function contrast(a, b) {
|
|
310
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
311
|
+
return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* What the page computes once the generated file is imported after the theme
|
|
316
|
+
* files. The file is last in the cascade and `:root` has the specificity of
|
|
317
|
+
* `.dark`, so its declarations win in both modes.
|
|
318
|
+
*/
|
|
319
|
+
function themeMaps(tokens, darkTokens = {}) {
|
|
320
|
+
return {
|
|
321
|
+
light: { ...data.light, ...tokens },
|
|
322
|
+
// The generated dark rule comes after the generated `:root` rule.
|
|
323
|
+
dark: { ...data.light, ...data.darkOnly, ...tokens, ...darkTokens },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function checkPairs(tokens, darkTokens = {}, extraPairs = []) {
|
|
328
|
+
const maps = themeMaps(tokens, darkTokens);
|
|
329
|
+
const warnings = [];
|
|
330
|
+
let checked = 0;
|
|
331
|
+
for (const mode of ['light', 'dark']) {
|
|
332
|
+
for (const pair of [...data.pairs, ...extraPairs]) {
|
|
333
|
+
const bg = resolveTokenColor(maps[mode], pair.bg);
|
|
334
|
+
const fg = resolveTokenColor(maps[mode], pair.fg);
|
|
335
|
+
if (!bg || !fg) continue; // an alpha tint: not one flat colour
|
|
336
|
+
checked += 1;
|
|
337
|
+
const ratio = contrast(bg, fg);
|
|
338
|
+
if (ratio >= pair.min) continue;
|
|
339
|
+
// A pair with a second foreground passes when either one does.
|
|
340
|
+
const other = pair.orFg ? resolveTokenColor(maps[mode], pair.orFg) : null;
|
|
341
|
+
if (other && contrast(bg, other) >= pair.min) continue;
|
|
342
|
+
warnings.push({
|
|
343
|
+
type: 'contrast',
|
|
344
|
+
pair: pair.name,
|
|
345
|
+
mode,
|
|
346
|
+
ratio,
|
|
347
|
+
required: pair.min,
|
|
348
|
+
foreground: { token: pair.fg, value: fg },
|
|
349
|
+
background: { token: pair.bg, value: bg },
|
|
350
|
+
message: `${mode} · ${pair.name}: ${fg} on ${bg} is ${ratio}:1, needs ${pair.min}:1 (${pair.fg} on ${pair.bg})`,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { warnings, checked };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/* ---------- "on" colours ---------- */
|
|
358
|
+
|
|
359
|
+
const ON_ROLES = {
|
|
360
|
+
brand: {
|
|
361
|
+
role: '--color-on-brand',
|
|
362
|
+
fills: ['--color-action-primary', '--color-action-primary-hover', '--color-action-primary-active'],
|
|
363
|
+
},
|
|
364
|
+
accent: {
|
|
365
|
+
role: '--color-on-accent',
|
|
366
|
+
fills: ['--color-accent', '--color-accent-hover', '--color-accent-active'],
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The label for a solid fill: white or the ramp's dark end, whichever reaches
|
|
372
|
+
* 4.5:1 on the fill, its hover and its active step. The library's own choice
|
|
373
|
+
* for the mode is tried first; if neither passes, the better one is kept and
|
|
374
|
+
* the pair check reports it.
|
|
375
|
+
*/
|
|
376
|
+
function chooseOn(name, mode, tokens, darkTokens = {}) {
|
|
377
|
+
const { role, fills } = ON_ROLES[name];
|
|
378
|
+
const knob = mode === 'dark' ? `--zabi-on-${name}-dark` : `--zabi-on-${name}`;
|
|
379
|
+
const darkEnd = `var(--zabi-${name}-950)`;
|
|
380
|
+
const candidates = mode === 'dark' ? [darkEnd, WHITE] : [WHITE, darkEnd];
|
|
381
|
+
let best = null;
|
|
382
|
+
for (const value of candidates) {
|
|
383
|
+
const map = themeMaps({ ...tokens, [knob]: value }, darkTokens)[mode];
|
|
384
|
+
const label = resolveTokenColor(map, role);
|
|
385
|
+
const ratios = fills.map((fill) => {
|
|
386
|
+
const colour = resolveTokenColor(map, fill);
|
|
387
|
+
return label && colour ? contrast(label, colour) : 0;
|
|
388
|
+
});
|
|
389
|
+
const worst = Math.min(...ratios);
|
|
390
|
+
if (worst >= 4.5) return [knob, value];
|
|
391
|
+
if (!best || worst > best.worst) best = { value, worst };
|
|
392
|
+
}
|
|
393
|
+
return [knob, best.value];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* ---------- pinning ---------- */
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* A pinned colour is the fill; its hover and pressed states and one deeper
|
|
400
|
+
* step are the same hue and chroma moved along L* by the distances the curve
|
|
401
|
+
* puts between steps 600, 700, 800 and 900 in light (darker), and between
|
|
402
|
+
* 400, 300, 200 and 100 in dark (lighter).
|
|
403
|
+
*/
|
|
404
|
+
const SHADE_STEPS = {
|
|
405
|
+
light: [700, 800, 900].map((step) => TARGET_LIGHTNESS[step] - TARGET_LIGHTNESS[600]),
|
|
406
|
+
dark: [300, 200, 100].map((step) => TARGET_LIGHTNESS[step] - TARGET_LIGHTNESS[400]),
|
|
407
|
+
};
|
|
408
|
+
/** No shade is asked to go past these: there is nothing darker or lighter to show. */
|
|
409
|
+
const MIN_SHADE_L = 4;
|
|
410
|
+
const MAX_SHADE_L = 98;
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* [hover, pressed, deeper] for a pinned fill, going `darker` or `lighter`, or
|
|
414
|
+
* null when the pressed step would leave the range: a colour that near black
|
|
415
|
+
* has nothing darker to show.
|
|
416
|
+
*/
|
|
417
|
+
function shades(colour, mode, direction) {
|
|
418
|
+
const sign = direction === 'darker' ? -1 : 1;
|
|
419
|
+
const steps = SHADE_STEPS[mode].map((step) => sign * Math.abs(step));
|
|
420
|
+
const pressed = colour.lightness + steps[1];
|
|
421
|
+
if (pressed < MIN_SHADE_L || pressed > MAX_SHADE_L) return null;
|
|
422
|
+
return steps.map((step) =>
|
|
423
|
+
hexAt(Math.min(MAX_SHADE_L, Math.max(MIN_SHADE_L, colour.lightness + step)), colour.chroma, colour.hue),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The library's own direction first: darker in light, lighter in dark. */
|
|
428
|
+
const SHADE_DIRECTIONS = { light: ['darker', 'lighter'], dark: ['lighter', 'darker'] };
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The roles a pinned colour can take over. `fill` always follows, since that
|
|
432
|
+
* is what pinning means. Each of `follow` is the brand colour by design in
|
|
433
|
+
* the library (the focus ring is the primary fill; a link is the brand as
|
|
434
|
+
* text), so it follows too, but only if every guarded pair still passes with
|
|
435
|
+
* it; otherwise it stays on the ramp, which was built to pass.
|
|
436
|
+
*
|
|
437
|
+
* What stays on the ramp whatever happens: the tints (`-subtle`, `-border`),
|
|
438
|
+
* because a tint is a lightness, not the brand colour, and the ramp steps
|
|
439
|
+
* themselves (`--color-brand-*`).
|
|
440
|
+
*/
|
|
441
|
+
const PIN_ROLES = {
|
|
442
|
+
brand: {
|
|
443
|
+
fill: (c, [hover, pressed, deeper]) => ({
|
|
444
|
+
'--color-action-primary': c,
|
|
445
|
+
'--color-action-primary-hover': hover,
|
|
446
|
+
'--color-action-primary-active': pressed,
|
|
447
|
+
'--color-primary': c,
|
|
448
|
+
'--color-primary-weak': hover,
|
|
449
|
+
'--color-primary-medium': pressed,
|
|
450
|
+
'--color-primary-strong': deeper,
|
|
451
|
+
}),
|
|
452
|
+
follow: {
|
|
453
|
+
'focus ring': (c, [hover, pressed, deeper]) => ({
|
|
454
|
+
'--color-focus': c,
|
|
455
|
+
'--color-focus-weak': hover,
|
|
456
|
+
'--color-focus-medium': pressed,
|
|
457
|
+
'--color-focus-strong': deeper,
|
|
458
|
+
}),
|
|
459
|
+
link: (c, [hover]) => ({ '--color-link': c, '--color-link-hover': hover }),
|
|
460
|
+
},
|
|
461
|
+
// A fill has to be seen against what it sits on (WCAG 1.4.11). On the
|
|
462
|
+
// ramp, step 600 always is; an arbitrary colour may not be.
|
|
463
|
+
pairs: [
|
|
464
|
+
{ name: 'pinned primary fill against the page', bg: '--color-surface-base', fg: '--color-action-primary', min: 3 },
|
|
465
|
+
{ name: 'pinned primary fill against a card', bg: '--color-surface-raised', fg: '--color-action-primary', min: 3 },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
accent: {
|
|
469
|
+
fill: (c, [hover, pressed]) => ({
|
|
470
|
+
'--color-accent': c,
|
|
471
|
+
'--color-accent-hover': hover,
|
|
472
|
+
'--color-accent-active': pressed,
|
|
473
|
+
}),
|
|
474
|
+
follow: {
|
|
475
|
+
'accent text': (c) => ({ '--color-accent-text': c }),
|
|
476
|
+
},
|
|
477
|
+
pairs: [
|
|
478
|
+
{ name: 'pinned accent fill against the page', bg: '--color-surface-base', fg: '--color-accent', min: 3 },
|
|
479
|
+
{ name: 'pinned accent fill against a card', bg: '--color-surface-raised', fg: '--color-accent', min: 3 },
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
/** Which of the two colours `pin` asks for. */
|
|
485
|
+
function readPin(pin) {
|
|
486
|
+
if (pin === undefined || pin === null || pin === false) return { brand: false, accent: false };
|
|
487
|
+
if (pin === true) return { brand: true, accent: false };
|
|
488
|
+
if (typeof pin !== 'object') {
|
|
489
|
+
throw new TypeError(`${TOOL}: pin must be true, false or { brand, accent } (got ${JSON.stringify(pin)})`);
|
|
490
|
+
}
|
|
491
|
+
for (const key of Object.keys(pin)) {
|
|
492
|
+
if (key !== 'brand' && key !== 'accent') throw new TypeError(`${TOOL}: pin has no "${key}"; it takes brand and accent`);
|
|
493
|
+
}
|
|
494
|
+
return { brand: pin.brand === true, accent: pin.accent === true };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const failing = (warnings, mode) => new Set(warnings.filter((w) => w.mode === mode).map((w) => w.pair));
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Pins one colour. Returns the role overrides for `:root` and for the dark
|
|
501
|
+
* rule, the "on" knobs, and what was decided.
|
|
502
|
+
*
|
|
503
|
+
* Light: the fill is the colour, always. If that fails a pair, the pair is
|
|
504
|
+
* reported; the colour is not moved.
|
|
505
|
+
*
|
|
506
|
+
* Dark: the same hex is usually wrong there. A deep blue that carries white
|
|
507
|
+
* text on a white page is about 2:1 against a dark page, where a fill needs
|
|
508
|
+
* 3:1, and no label makes that a button. So dark takes the colour only when nothing guarded fails with it,
|
|
509
|
+
* and otherwise keeps what it has without `pin`: the mirrored ramp step.
|
|
510
|
+
*/
|
|
511
|
+
function pinColour(name, colour, tokens, earlierDark, userOverrides, extraPairs, skip) {
|
|
512
|
+
const roles = PIN_ROLES[name];
|
|
513
|
+
const keep = (group) => Object.fromEntries(Object.entries(group).filter(([role]) => !skip.has(role)));
|
|
514
|
+
const libraryDark = (role) => data.darkOnly[role] ?? data.light[role];
|
|
515
|
+
const light = {};
|
|
516
|
+
const dark = {};
|
|
517
|
+
const knobs = {};
|
|
518
|
+
const report = { hex: colour.hex, light: { followed: [], onRamp: [] }, dark: { pinned: false, followed: [], onRamp: [] } };
|
|
519
|
+
|
|
520
|
+
// What the page has when a set of roles is tried, with the label chosen for it.
|
|
521
|
+
const attempt = (mode, lightRoles, darkRoles) => {
|
|
522
|
+
const root = { ...tokens, ...knobs, ...lightRoles, ...userOverrides };
|
|
523
|
+
const darkRule = { ...earlierDark, ...darkRoles };
|
|
524
|
+
const [knob, value] = chooseOn(name, mode, root, darkRule);
|
|
525
|
+
const result = checkPairs({ ...root, [knob]: value }, darkRule, extraPairs);
|
|
526
|
+
return { knob, value, failures: failing(result.warnings, mode) };
|
|
527
|
+
};
|
|
528
|
+
const noWorse = (after, before) => [...after].every((pair) => before.has(pair));
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The fill with its states, in the direction that fails least. The states
|
|
532
|
+
* decide which label can be used: darker states under a dark label lose
|
|
533
|
+
* contrast with every step, so an amber that needs a dark label gets
|
|
534
|
+
* lighter states, as its mirrored ramp step has in dark.
|
|
535
|
+
*/
|
|
536
|
+
const bestFill = (mode, tryWith) => {
|
|
537
|
+
let best = null;
|
|
538
|
+
for (const direction of SHADE_DIRECTIONS[mode]) {
|
|
539
|
+
const states = shades(colour, mode, direction);
|
|
540
|
+
if (!states) continue;
|
|
541
|
+
const result = tryWith(keep(roles.fill(colour.hex, states)));
|
|
542
|
+
if (!best || result.failures.size < best.result.failures.size) best = { direction, states, result };
|
|
543
|
+
}
|
|
544
|
+
return best;
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// Every role written on `:root` reaches dark as well, so dark restates it;
|
|
548
|
+
// until dark is decided below, with the library's own dark value.
|
|
549
|
+
const restatedFor = (lightRoles) =>
|
|
550
|
+
Object.fromEntries(Object.keys(lightRoles).map((role) => [role, dark[role] ?? libraryDark(role)]));
|
|
551
|
+
const restated = () => restatedFor(light);
|
|
552
|
+
|
|
553
|
+
// Light.
|
|
554
|
+
const lightFill = bestFill('light', (group) => attempt('light', group, restatedFor(group)));
|
|
555
|
+
Object.assign(light, keep(roles.fill(colour.hex, lightFill.states)));
|
|
556
|
+
report.light.states = lightFill.direction;
|
|
557
|
+
let current = attempt('light', light, restated());
|
|
558
|
+
for (const [label, build] of Object.entries(roles.follow)) {
|
|
559
|
+
const group = keep(build(colour.hex, lightFill.states));
|
|
560
|
+
const next = attempt('light', { ...light, ...group }, restated());
|
|
561
|
+
if (noWorse(next.failures, current.failures)) {
|
|
562
|
+
Object.assign(light, group);
|
|
563
|
+
current = next;
|
|
564
|
+
report.light.followed.push(label);
|
|
565
|
+
} else {
|
|
566
|
+
report.light.onRamp.push(label);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
knobs[current.knob] = current.value;
|
|
570
|
+
|
|
571
|
+
// Dark.
|
|
572
|
+
const unpinned = attempt('dark', light, restated());
|
|
573
|
+
const darkBest = bestFill('dark', (group) => attempt('dark', light, { ...restated(), ...group }));
|
|
574
|
+
const darkFill = keep(roles.fill(colour.hex, darkBest.states));
|
|
575
|
+
let darkCurrent = darkBest.result;
|
|
576
|
+
if (noWorse(darkCurrent.failures, unpinned.failures)) {
|
|
577
|
+
Object.assign(dark, darkFill);
|
|
578
|
+
report.dark.pinned = true;
|
|
579
|
+
report.dark.states = darkBest.direction;
|
|
580
|
+
for (const [label, build] of Object.entries(roles.follow)) {
|
|
581
|
+
const group = keep(build(colour.hex, darkBest.states));
|
|
582
|
+
const next = attempt('dark', light, { ...restated(), ...dark, ...group });
|
|
583
|
+
// Only where light followed too: a role that is the brand colour in
|
|
584
|
+
// one mode and a ramp step in the other would be two brands.
|
|
585
|
+
if (report.light.followed.includes(label) && noWorse(next.failures, darkCurrent.failures)) {
|
|
586
|
+
Object.assign(dark, group);
|
|
587
|
+
darkCurrent = next;
|
|
588
|
+
report.dark.followed.push(label);
|
|
589
|
+
} else {
|
|
590
|
+
report.dark.onRamp.push(label);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
} else {
|
|
594
|
+
darkCurrent = unpinned;
|
|
595
|
+
report.dark.onRamp.push(...Object.keys(roles.follow));
|
|
596
|
+
report.dark.failed = [...darkBest.result.failures].filter((pair) => !unpinned.failures.has(pair));
|
|
597
|
+
}
|
|
598
|
+
knobs[darkCurrent.knob] = darkCurrent.value;
|
|
599
|
+
|
|
600
|
+
return { light, dark: { ...restated(), ...dark }, knobs, report };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/* ---------- output ---------- */
|
|
604
|
+
|
|
605
|
+
function rampTokens(name, ramp, steps) {
|
|
606
|
+
return Object.fromEntries(steps.map((step) => [`--zabi-${name}-${step}`, ramp[step]]));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function render({ inputs, closest, tokens, darkTokens, pinned, neutralChroma, contrastWarnings, checked }) {
|
|
610
|
+
const describeInput = (name) =>
|
|
611
|
+
inputs[name]
|
|
612
|
+
? `${inputs[name]} closest step ${closest[name].step} (${closest[name].hex}, ` +
|
|
613
|
+
`${closest[name].exact ? 'exact' : `ΔE ${closest[name].deltaE.toFixed(1)}`})`
|
|
614
|
+
: name === 'accent'
|
|
615
|
+
? 'not set (library default: citron)'
|
|
616
|
+
: 'not set (library default)';
|
|
617
|
+
const header = [
|
|
618
|
+
'/*',
|
|
619
|
+
` * Generated by ${TOOL} (zabi-components/create-theme). Do not edit; run it again.`,
|
|
620
|
+
' *',
|
|
621
|
+
` * brand ${describeInput('brand')}`,
|
|
622
|
+
` * accent ${describeInput('accent')}`,
|
|
623
|
+
` * neutral ${describeInput('neutral')}`,
|
|
624
|
+
...(neutralChroma === undefined
|
|
625
|
+
? []
|
|
626
|
+
: [
|
|
627
|
+
` * neutral chroma ${neutralChroma} (peak, OKLCH). The ink roles (secondary button,`,
|
|
628
|
+
' * hover and pressed tints, overlay edge, shadow colour) follow the neutral ramp.',
|
|
629
|
+
]),
|
|
630
|
+
' *',
|
|
631
|
+
...(pinned ? pinnedNotes(pinned) : [
|
|
632
|
+
' * The ramps sit on the library\'s lightness curve, so step 600 means the same',
|
|
633
|
+
' * lightness as in every built-in ramp. Your colour supplies hue and chroma and',
|
|
634
|
+
' * is not pinned to a step: the exact hex may not appear below.',
|
|
635
|
+
]),
|
|
636
|
+
' *',
|
|
637
|
+
' * Import after zabi-components/theme-only and zabi-components/theme-dark-only.',
|
|
638
|
+
' * One file covers light and dark.',
|
|
639
|
+
' *',
|
|
640
|
+
contrastWarnings.length === 0
|
|
641
|
+
? ` * Contrast: all ${checked} role pairs pass WCAG AA in light and dark.`
|
|
642
|
+
: ` * Contrast: ${contrastWarnings.length} of ${checked} role pairs are below WCAG AA:`,
|
|
643
|
+
...contrastWarnings.map((w) => ` * ${w.message}`),
|
|
644
|
+
' */',
|
|
645
|
+
];
|
|
646
|
+
const body = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
|
|
647
|
+
const root = `${header.join('\n')}\n:root {\n${body.join('\n')}\n}\n`;
|
|
648
|
+
if (!pinned && Object.keys(darkTokens).length === 0) return root;
|
|
649
|
+
// A role on `:root` is one value in light and in dark, so dark says its own,
|
|
650
|
+
// under each selector the dark theme is published under.
|
|
651
|
+
const dark = Object.entries(darkTokens).map(([name, value]) => `${name}: ${value};`);
|
|
652
|
+
const { always, auto, media } = data.darkSelectors;
|
|
653
|
+
return (
|
|
654
|
+
root +
|
|
655
|
+
`\n${always} {\n${dark.map((line) => ` ${line}`).join('\n')}\n}\n` +
|
|
656
|
+
`\n@media ${media} {\n ${auto} {\n${dark.map((line) => ` ${line}`).join('\n')}\n }\n}\n`
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function pinnedNotes(pinned) {
|
|
661
|
+
const lines = [
|
|
662
|
+
' * The ramps sit on the library\'s lightness curve, so step 600 means the same',
|
|
663
|
+
' * lightness as in every built-in ramp, and tints, borders and every role not',
|
|
664
|
+
' * named here keep their place on it.',
|
|
665
|
+
];
|
|
666
|
+
for (const [name, report] of Object.entries(pinned)) {
|
|
667
|
+
const what = name === 'brand' ? 'the primary action' : 'the solid accent fill';
|
|
668
|
+
const list = (items) => items.join(', ');
|
|
669
|
+
lines.push(' *');
|
|
670
|
+
lines.push(` * PINNED ${name} ${report.hex}: in light, ${what} is exactly this colour. Hover and`);
|
|
671
|
+
lines.push(` * pressed are the same hue and chroma, ${report.light.states} by the distance the curve puts`);
|
|
672
|
+
lines.push(' * between steps 600, 700 and 800.');
|
|
673
|
+
if (report.light.followed.length) lines.push(` * light, also this colour: ${list(report.light.followed)}`);
|
|
674
|
+
if (report.light.onRamp.length) {
|
|
675
|
+
lines.push(` * light, left on the ramp because the colour fails a guarded pair there: ${list(report.light.onRamp)}`);
|
|
676
|
+
}
|
|
677
|
+
if (report.dark.pinned) {
|
|
678
|
+
lines.push(` * dark: the same colour, since no guarded pair fails because of it; hover and pressed go ${report.dark.states}`);
|
|
679
|
+
if (report.dark.followed.length) lines.push(` * dark, also this colour: ${list(report.dark.followed)}`);
|
|
680
|
+
if (report.dark.onRamp.length) lines.push(` * dark, left on the ramp: ${list(report.dark.onRamp)}`);
|
|
681
|
+
} else {
|
|
682
|
+
lines.push(' * dark: NOT pinned. The colour fails there, so dark keeps the mirrored ramp step, as it');
|
|
683
|
+
lines.push(' * does without pin. With the colour, these would fail:');
|
|
684
|
+
for (const pair of report.dark.failed) lines.push(` * - ${pair}`);
|
|
685
|
+
lines.push(' * To force a dark value all the same, write the role in your own dark rule after this file.');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return lines;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* @param {{ brand: string, accent?: string, neutral?: string, neutralChroma?: number, overrides?: Record<string, string> | { light?: Record<string, string>, dark?: Record<string, string>, both?: Record<string, string> } & Record<string, unknown>, pin?: boolean | { brand?: boolean, accent?: boolean } }} options
|
|
693
|
+
*/
|
|
694
|
+
export function createTheme(options) {
|
|
695
|
+
if (options === null || typeof options !== 'object') {
|
|
696
|
+
throw new TypeError(`${TOOL}: createTheme needs an options object with at least { brand }`);
|
|
697
|
+
}
|
|
698
|
+
if (options.brand === undefined || options.brand === null) {
|
|
699
|
+
throw new TypeError(`${TOOL}: brand is required, for example { brand: "#0026EA" }`);
|
|
700
|
+
}
|
|
701
|
+
const modes = readOverrides(options.overrides ?? {});
|
|
702
|
+
// Roles the app sets in any mode are left to it: no ink role or pin replaces them.
|
|
703
|
+
const overridden = {};
|
|
704
|
+
for (const map of [modes.both, modes.light, modes.dark]) Object.assign(overridden, map);
|
|
705
|
+
|
|
706
|
+
const inputs = { brand: parseHex(options.brand, 'brand') };
|
|
707
|
+
if (options.accent !== undefined && options.accent !== null) inputs.accent = parseHex(options.accent, 'accent');
|
|
708
|
+
if (options.neutral !== undefined && options.neutral !== null) inputs.neutral = parseHex(options.neutral, 'neutral');
|
|
709
|
+
|
|
710
|
+
let neutralChroma;
|
|
711
|
+
if (options.neutralChroma !== undefined && options.neutralChroma !== null) {
|
|
712
|
+
const value = options.neutralChroma;
|
|
713
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > MAX_NEUTRAL_CHROMA_OPTION) {
|
|
714
|
+
throw new TypeError(
|
|
715
|
+
`${TOOL}: neutralChroma must be a number from 0 to ${MAX_NEUTRAL_CHROMA_OPTION}, the OKLCH chroma at the neutral ramp's peak (got ${
|
|
716
|
+
typeof value === 'string' ? `"${value}"` : String(value)
|
|
717
|
+
})`,
|
|
718
|
+
);
|
|
719
|
+
}
|
|
720
|
+
if (!inputs.neutral) throw new TypeError(`${TOOL}: neutralChroma needs a neutral colour, for example { neutral: "#607296", neutralChroma: 0.05 }`);
|
|
721
|
+
neutralChroma = value;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const pin = readPin(options.pin);
|
|
725
|
+
if (pin.accent && !inputs.accent) throw new TypeError(`${TOOL}: pin.accent needs an accent colour`);
|
|
726
|
+
const pinning = pin.brand || pin.accent;
|
|
727
|
+
|
|
728
|
+
const warnings = [];
|
|
729
|
+
const closest = {};
|
|
730
|
+
const described = {};
|
|
731
|
+
let tokens = {};
|
|
732
|
+
let darkTokens = {};
|
|
733
|
+
|
|
734
|
+
for (const name of ['brand', 'accent']) {
|
|
735
|
+
if (!inputs[name]) continue;
|
|
736
|
+
const colour = describe(inputs[name]);
|
|
737
|
+
described[name] = colour;
|
|
738
|
+
warnings.push(...inputWarnings(name, colour));
|
|
739
|
+
const ramp = chromaticRamp(colour);
|
|
740
|
+
closest[name] = closestStep(inputs[name], ramp);
|
|
741
|
+
if (colour.chroma >= MIN_USEFUL_CHROMA && closest[name].deltaE > FAR_FROM_RAMP) {
|
|
742
|
+
// Typically a very bright colour: the curve fixes each step's
|
|
743
|
+
// lightness, and sRGB has no such chroma at that lightness.
|
|
744
|
+
warnings.push({
|
|
745
|
+
type: 'input',
|
|
746
|
+
option: name,
|
|
747
|
+
message:
|
|
748
|
+
`${name} ${colour.hex} is not in the ramp built from it: the nearest step is ` +
|
|
749
|
+
`${closest[name].step} (${closest[name].hex}, ΔE ${closest[name].deltaE.toFixed(1)}). ` +
|
|
750
|
+
'The ramp keeps its hue on the library\'s lightness curve; fills use step 600 in light and 400 in dark.',
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
tokens = { ...tokens, ...rampTokens(name, ramp, RAMP_STEPS) };
|
|
754
|
+
}
|
|
755
|
+
if (inputs.neutral) {
|
|
756
|
+
const colour = describe(inputs.neutral);
|
|
757
|
+
if (neutralChroma === undefined && colour.chroma > MAX_NEUTRAL_CHROMA * 2) {
|
|
758
|
+
warnings.push({
|
|
759
|
+
type: 'input',
|
|
760
|
+
option: 'neutral',
|
|
761
|
+
message: `neutral ${colour.hex} is a saturated colour: only its hue is used, at low chroma, so the result is a tinted grey.`,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
const ramp = neutralRamp(colour, neutralChroma);
|
|
765
|
+
closest.neutral = closestStep(inputs.neutral, ramp);
|
|
766
|
+
tokens = { ...tokens, ...rampTokens('base', ramp, BASE_STEPS) };
|
|
767
|
+
if (neutralChroma !== undefined) {
|
|
768
|
+
// Opt-in: without it these roles are the library's grey tints, as before.
|
|
769
|
+
const ink = inkTokens(ramp, overridden);
|
|
770
|
+
tokens = { ...tokens, ...ink.light };
|
|
771
|
+
darkTokens = ink.dark;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Overrides take part in choosing the labels, and are written last. Each
|
|
776
|
+
// mode sees its own values.
|
|
777
|
+
for (const name of ['brand', 'accent']) {
|
|
778
|
+
if (!inputs[name] || pin[name]) continue;
|
|
779
|
+
const own = overrideMaps(modes, tokens, darkTokens);
|
|
780
|
+
for (const mode of ['light', 'dark']) {
|
|
781
|
+
const [knob, value] = chooseOn(name, mode, { ...tokens, ...own.root }, { ...darkTokens, ...own.dark });
|
|
782
|
+
tokens[knob] = value;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// Pinned colours: role overrides for light, their dark counterparts, and
|
|
787
|
+
// the labels chosen with those in place.
|
|
788
|
+
const pinned = {};
|
|
789
|
+
const extraPairs = ['brand', 'accent'].flatMap((name) => (pin[name] ? PIN_ROLES[name].pairs : []));
|
|
790
|
+
for (const name of ['brand', 'accent']) {
|
|
791
|
+
if (!pin[name]) continue;
|
|
792
|
+
const own = overrideMaps(modes, tokens, darkTokens);
|
|
793
|
+
const result = pinColour(name, described[name], tokens, { ...darkTokens, ...own.dark }, own.root, extraPairs, own.skip);
|
|
794
|
+
tokens = { ...tokens, ...result.knobs, ...result.light };
|
|
795
|
+
darkTokens = { ...darkTokens, ...result.dark };
|
|
796
|
+
pinned[name] = result.report;
|
|
797
|
+
}
|
|
798
|
+
const own = overrideMaps(modes, tokens, darkTokens);
|
|
799
|
+
tokens = { ...tokens, ...own.root };
|
|
800
|
+
darkTokens = { ...darkTokens, ...own.dark };
|
|
801
|
+
|
|
802
|
+
const { warnings: contrastWarnings, checked } = checkPairs(tokens, darkTokens, extraPairs);
|
|
803
|
+
warnings.push(...contrastWarnings);
|
|
804
|
+
|
|
805
|
+
const css = render({ inputs, closest, tokens, darkTokens, pinned: pinning ? pinned : null, neutralChroma, contrastWarnings, checked });
|
|
806
|
+
const hasDark = pinning || neutralChroma !== undefined || Object.keys(own.dark).length > 0;
|
|
807
|
+
return {
|
|
808
|
+
css,
|
|
809
|
+
tokens,
|
|
810
|
+
...(hasDark ? { darkTokens } : {}),
|
|
811
|
+
...(pinning ? { pinned } : {}),
|
|
812
|
+
warnings,
|
|
813
|
+
closest,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export default createTheme;
|