Compare commits

..

147 Commits

Author SHA1 Message Date
Lea Verou
5a0b2da5a1 Merge branch 'next' into custom-palettes 2025-03-21 19:02:11 -04:00
Lea Verou
d64d75b3f4 Update generate-palette.js 2025-03-20 10:59:01 -04:00
Lea Verou
fe2829698a Update data.js 2025-03-20 10:58:52 -04:00
Lea Verou
fe2be5cbdb Patch gray bugs 2025-03-18 13:38:02 -04:00
Lea Verou
f9b932042e Swatch picker component 2025-03-18 13:32:54 -04:00
Lea Verou
8dee82a44a Fix gray chroma bugs 2025-03-18 13:18:46 -04:00
Lea Verou
0b883866d1 Spacing 2025-03-18 12:53:59 -04:00
Lea Verou
d0a60d2c30 Fix gray tweaks 2025-03-18 10:31:44 -04:00
Lea Verou
398ae15979 Present default roles differently 2025-03-17 17:38:12 -04:00
Lea Verou
0780c12adb Desaturate pro badge once I've started editing to reduce distraction 2025-03-17 12:03:15 -04:00
Lea Verou
bfafc08761 Fix generated palette code
- Only include my scales for custom palettes
- Do not include bogus role declarations
2025-03-17 11:15:03 -04:00
Lea Verou
2ac15dcda1 Less jargony sliders 2025-03-17 11:10:28 -04:00
Lea Verou
67437b719d Bugfix 2025-03-17 10:53:44 -04:00
Lea Verou
f05c8f7b84 Hide experimental badge once you start editing 2025-03-17 10:47:47 -04:00
Lea Verou
2c0ff72f0d wa-details for suggested colors 2025-03-17 10:44:49 -04:00
lindsaym-fa
672fc3a5ad Make suggested swatches smaller 2025-03-17 10:34:13 -04:00
Lea Verou
ff45ca2232 Add text for My Colors 2025-03-17 10:25:38 -04:00
Lea Verou
7dcbd7407f Suggested -> Common 2025-03-17 10:10:39 -04:00
Lea Verou
7c04550753 Focus input when added via add button 2025-03-17 10:09:21 -04:00
Lea Verou
08bf971f91 Fix bug where editing color did not update it in scales 2025-03-17 10:04:55 -04:00
Lea Verou
8245d8a40a Remove unused method 2025-03-17 10:04:43 -04:00
Lea Verou
e342f513b7 Better defaults when adding colors 2025-03-17 09:41:05 -04:00
Lea Verou
cdaa34e1bc Remove dead code 2025-03-17 09:40:54 -04:00
Lea Verou
0cca25a118 Move log to utils 2025-03-17 09:32:07 -04:00
Lea Verou
e9edc572b5 UI to override detected hue (and to communicate that a hue has been pinned) 2025-03-17 09:28:56 -04:00
Lea Verou
77da38fda3 Rotate pin icons 45deg 2025-03-17 09:28:23 -04:00
Lea Verou
cd4486cc86 Fade out tweak icon when not interacted with 2025-03-17 09:28:09 -04:00
Lea Verou
badc6c9dc2 Refactor: allHues = hues + gray 2025-03-17 04:52:10 -04:00
Lea Verou
33f3f8d4c0 Colorfulness sliders 2025-03-17 04:30:16 -04:00
Lea Verou
2bdfcae9ba Fix 2025-03-17 04:15:26 -04:00
Lea Verou
f369916f01 Ability to pin hue so that colors don't jump to another scale when pinned 2025-03-16 23:06:47 -04:00
Lea Verou
c9a1e21cdb Refactor: Move exposed properties to array 2025-03-16 23:06:23 -04:00
Lea Verou
d649d2ee3b More slider bugfixes 2025-03-16 22:31:54 -04:00
Lea Verou
44469183cb Oopsie 2025-03-16 21:12:20 -04:00
Lea Verou
d30149e718 Fix normalizeAngle(), which fixes generated pinks 2025-03-16 21:11:32 -04:00
Lea Verou
416aaee672 Maximize distance between generated hue and both hues before and after it 2025-03-16 21:11:14 -04:00
Lea Verou
e9ea0b7f1c Simplify color-slider and fix a bunch of bugs around it 2025-03-16 20:20:01 -04:00
Lea Verou
5d97db178a Remove more tweaking stuff 2025-03-16 18:35:11 -04:00
Lea Verou
45b3a8e76e More hueBefore/hueAfter to data 2025-03-16 18:32:06 -04:00
Lea Verou
550df496e1 Fix tweaking sliders for predefined palettes 2025-03-16 18:13:35 -04:00
Lea Verou
cde67b7984 Reduce visual impact of Save button when saved 2025-03-16 18:09:31 -04:00
Lea Verou
fd6e7e19f0 Remove unused tweaking classes 2025-03-16 18:06:23 -04:00
Lea Verou
c442e52c63 Easier pinning of generated colors 2025-03-16 17:47:10 -04:00
Lea Verou
9c57646f48 Fix bug where edges where unintentionally added to my colors 2025-03-16 17:37:24 -04:00
Lea Verou
344e693c8b Fix bug where saving colors changed their order 2025-03-16 17:36:43 -04:00
Lea Verou
12b2ab133a Prevent bug where edges where auto-added 2025-03-16 17:25:22 -04:00
Lea Verou
1b26bee1af Tweak edges 2025-03-16 17:12:48 -04:00
Lea Verou
27c7e56a7e Hide general colorfulness slider from custom palettes (for now) 2025-03-16 17:12:32 -04:00
Lea Verou
22e5850a3f Move delete button inside popup 2025-03-16 17:12:20 -04:00
Lea Verou
f4897dcabe Show default values, make color tweak sliders work properly 2025-03-16 16:51:38 -04:00
Lea Verou
3dd5e0e8aa Refactor 2025-03-16 15:32:48 -04:00
Lea Verou
515b48f8a5 Make seed tweak popup wider 2025-03-16 15:27:59 -04:00
Lea Verou
9f141dbc4a Fix clear button 2025-03-16 02:14:03 -04:00
Lea Verou
ca60751cb8 decorated-slider -> color-slider, move template to top 2025-03-16 01:14:16 -04:00
Lea Verou
7dfa2f6a93 Sliders to tweak key colors 2025-03-16 00:22:49 -04:00
Lea Verou
31c4dc658f Merge branch 'next' into custom-palettes 2025-03-15 15:37:28 -04:00
Lea Verou
82c34a8fe6 Merge branch 'next' into custom-palettes 2025-03-15 15:35:11 -04:00
Lea Verou
15ac2d169d Pin any color 2025-03-15 01:13:55 -04:00
Lea Verou
412670a21d Show experimental and pro badges on palette index 2025-03-15 01:13:27 -04:00
Lea Verou
c70ea3627c Prevent scales not in palette from showing up in contrast tables 2025-03-14 17:39:24 -04:00
Lea Verou
0a938d5cf3 Drop functionality where we show the old color in the swatch
Too disorienting and adds complexity
2025-03-14 17:18:57 -04:00
Lea Verou
1a9372839c Color popup 2025-03-14 17:18:57 -04:00
Lea Verou
12c5747cd2 Various changes around tweaks 2025-03-14 16:33:14 -04:00
Lea Verou
bb24db30b5 Update slider.ts 2025-03-14 16:32:53 -04:00
Lea Verou
48d7e45d30 Update custom.njk 2025-03-14 15:23:40 -04:00
Lea Verou
6dd2fbec74 Presentational 2025-03-14 15:23:26 -04:00
Lea Verou
d7dbf0f3f9 Nicer loading 2025-03-14 14:09:30 -04:00
Lea Verou
ba9d4c1f21 Button 2025-03-13 18:16:36 -04:00
Lea Verou
d4131095a8 Rework seed colors to support undoable tweaks 2025-03-13 16:44:40 -04:00
Lea Verou
1dd47557c0 Persist roles in permalink 2025-03-12 17:22:43 -04:00
Lea Verou
054058a52c Remove color from roles if its scale is deleted 2025-03-12 17:22:35 -04:00
Lea Verou
9f0d1df974 Do not wrap when a color has multiple roles 2025-03-12 17:00:54 -04:00
Lea Verou
a918c2297d Mark as experimental 2025-03-12 16:58:23 -04:00
Lea Verou
96704a2d7e Merge branch 'next' into custom-palettes 2025-03-12 16:53:10 -04:00
Lea Verou
3ae89b827f Role multiselect in seed colors 2025-03-12 16:20:43 -04:00
Lea Verou
6523925eaf Dynamic default roles 2025-03-11 22:10:12 -04:00
Lea Verou
9d6cf9efb8 Formatting 2025-03-11 19:12:39 -04:00
Lea Verou
73892da3a7 Prevent gray inadvertently showing up as tweaked 2025-03-11 18:48:57 -04:00
Lea Verou
b1a29ecf69 Take pinned colors beyond core color more into account 2025-03-11 17:29:43 -04:00
Lea Verou
089450c25e Edit seed color when input is edited
Does not yet remove tweaks though
2025-03-11 16:30:22 -04:00
Lea Verou
ed9a1280c1 Update custom.css 2025-03-11 16:28:55 -04:00
Lea Verou
b50b5983d3 Show tweaked and original color in My Colors 2025-03-11 15:20:30 -04:00
Lea Verou
748fd42d40 Refactor 2025-03-11 15:19:39 -04:00
Lea Verou
efe570f7b3 If gray is provided, use it 2025-03-11 15:08:06 -04:00
Lea Verou
110dc7da60 Formatting 2025-03-11 14:20:29 -04:00
Lea Verou
d778013667 Identify gray 2025-03-11 14:17:42 -04:00
Lea Verou
e898179802 Fix bug 2025-03-11 14:16:54 -04:00
Lea Verou
5c78e3226f Min height for add button 2025-03-11 11:22:22 -04:00
Lea Verou
daa0ccee26 Use thumbnail placeholder until I figure out palette icons 2025-03-11 11:20:46 -04:00
Lea Verou
890791f94e Define and use <color-slider> Vue component rather than ad hoc markup 2025-03-11 11:14:33 -04:00
Lea Verou
9a03cea920 Reduce duplicate calculations, pave the way for passing in custom identified hue 2025-03-10 20:53:30 -04:00
Lea Verou
fd9235fe29 Make color identification more clearly output 2025-03-10 20:01:06 -04:00
Lea Verou
df108ba346 Hide colors not in my scale from hue wheel 2025-03-10 19:45:01 -04:00
Lea Verou
510a6c4eac Include role assignments to generated CSS 2025-03-10 18:00:49 -04:00
Lea Verou
b627c9b7d5 Add core tint to generated CSS 2025-03-10 18:00:34 -04:00
Lea Verou
29aeb078b7 Fix bug 2025-03-10 17:22:44 -04:00
Lea Verou
b966f57a83 Custom class name, align new palette UX closer to sketches 2025-03-10 16:28:46 -04:00
Lea Verou
9928f77091 Hide "Used By" section for custom palettes 2025-03-10 15:35:21 -04:00
Lea Verou
baae409bfc Less cluttered default indication 2025-03-10 15:33:39 -04:00
Lea Verou
15abc6d21c Merge branch 'next' into custom-palettes 2025-03-10 15:28:08 -04:00
Lea Verou
353c053153 MVP for assigning roles to palettes, rel ##782 2025-03-07 19:43:52 -05:00
Lea Verou
ab01fbb5af Refactor: core-color-input -> color-input 2025-03-07 18:03:16 -05:00
Lea Verou
a73b3d5697 Move vue components to separate directory 2025-03-07 17:46:12 -05:00
Lea Verou
7b6b570ac9 Fix theme remixing regressions 2025-03-07 17:43:29 -05:00
Lea Verou
438ddf5ba2 Suggested colors 2025-03-07 15:27:07 -05:00
Lea Verou
5216061c39 Remove commented out Safari workaround 2025-03-07 15:26:48 -05:00
Lea Verou
e466a0aa8d Pin instead of star 2025-03-07 15:25:39 -05:00
Lea Verou
08876bbda9 Fix bug with seed color order 2025-03-07 15:25:20 -05:00
Lea Verou
a73daf9426 Orange 2025-03-07 15:25:12 -05:00
Lea Verou
af832017d3 stringifyColor() 2025-03-07 14:28:13 -05:00
Lea Verou
9244bfbe15 Orange 2025-03-07 14:27:59 -05:00
Lea Verou
1f89043040 Refactor: move color-related code to separate modules 2025-03-07 11:22:52 -05:00
Lea Verou
9865a71499 Rename palettes/edit/palettes/app/ 2025-03-07 10:26:56 -05:00
Lea Verou
f2e8a71567 Uncomment orange 2025-03-07 10:23:55 -05:00
Lea Verou
72d8058259 Merge branch 'next' into custom-palettes 2025-03-05 23:19:39 -05:00
Lea Verou
08f652f0dc Start show saved variations, rework renaming/saving UI 2025-03-05 13:18:05 -05:00
Lea Verou
48b37b05bb Ensure generated tint lightness is still within range 2025-03-04 14:08:07 -05:00
Lea Verou
a3e1cebf18 My scales filter 2025-03-04 13:26:35 -05:00
Lea Verou
9632e57fd0 Thumbtack icon to star 2025-03-04 13:22:52 -05:00
Lea Verou
5bfac00428 Fix hue shift for darker colors 2025-03-04 12:55:18 -05:00
Lea Verou
a0069c9783 Improve chroma curves 2025-03-04 12:55:04 -05:00
Lea Verou
b43a3f736a Generate palette code 2025-03-04 12:07:47 -05:00
Lea Verou
7ed3e5e92b Another attempt to improve yellows + cleanup
- Generate yellow based on most vibrant scale, even if not neighboring
- Only take seed hues into account, not generated hues (which would compound any error)
- General cleanup
2025-03-04 11:58:14 -05:00
Lea Verou
01b697d9e6 Avoid premature optimization 2025-03-04 10:25:26 -05:00
Lea Verou
fa2e35a299 Fix bug 2025-02-28 17:52:22 -05:00
Lea Verou
cb5f8433d5 Interpolate % of chroma from gamut boundary rather than absolute chroma
Produces brighter, more balanced colors overall
2025-02-28 16:22:55 -05:00
Lea Verou
1fa95f66e8 Evaluate palette lightness relative to hue, better capping of consecutive hue shifts 2025-02-28 14:34:20 -05:00
Lea Verou
f682293c38 Cap hue shift for consecutive tints 2025-02-28 12:26:43 -05:00
Lea Verou
1993182f43 Reorder 2025-02-28 09:18:55 -05:00
Lea Verou
6f39781f1f Reorder 2025-02-28 09:17:57 -05:00
Lea Verou
bc170cce15 Permalinks for seed colors 2025-02-27 19:31:17 -05:00
Lea Verou
27af62591f Simplify permalinks 2025-02-27 19:25:14 -05:00
Lea Verou
e07aecb0a7 Fix 2025-02-27 11:00:45 -05:00
Lea Verou
8caeb26957 Fix 2025-02-27 10:27:26 -05:00
Lea Verou
ae6b66a3a4 Better hue spacing 2025-02-26 22:22:49 -05:00
Lea Verou
e9389b8bd5 Hue wheel visualization for every palette 2025-02-26 21:43:37 -05:00
Lea Verou
668666e1c9 Fixes 2025-02-26 21:43:29 -05:00
Lea Verou
a679693128 First stab at generating other hues based on seed colors 2025-02-26 19:02:30 -05:00
Lea Verou
3c02ce245e Refactor 2025-02-26 13:22:45 -05:00
Lea Verou
4a5b99c60d Refactor 2025-02-26 13:08:55 -05:00
Lea Verou
1a2d9ea4f1 Fix 2025-02-26 11:12:07 -05:00
Lea Verou
91d93d83f2 Emulate other palette 2025-02-26 11:05:51 -05:00
Lea Verou
6693cafe8e Interpolate subsequent hues 2025-02-26 11:01:24 -05:00
Lea Verou
d04e3d860e Moar Vue 2025-02-26 11:01:24 -05:00
Lea Verou
65f89cff84 Fix radius 2025-02-26 11:01:24 -05:00
Lea Verou
e26af1c293 Iterate 2025-02-26 11:01:24 -05:00
Lea Verou
538e132a27 Move tweak.js and tweak.css 2025-02-24 16:25:23 -05:00
13 changed files with 1337 additions and 114 deletions

View File

@@ -12,7 +12,7 @@
</tr>
</thead>
{% for hue in hues -%}
<tr data-hue="{{ hue }}">
<tr data-hue="{{ hue }}" v-if="'{{hue}}' in paletteScales">
<th>{{ hue | capitalize }}</th>
{% for tint_bg in tints -%}
{% set color_bg = palettes[paletteId][hue][tint_bg] %}

View File

@@ -1,6 +1,7 @@
{% set hasSidebar = true %}
{% set hasOutline = true %}
{% set paletteId = page.fileSlug %}
{% set paletteId = "default" if page.fileSlug == 'custom' else page.fileSlug %}
{% set isCustom = page.fileSlug == 'custom' %}
{% set tints = ["95", "90", "80", "70", "60", "50", "40", "30", "20", "10", "05"] %}
{% extends '../_includes/base.njk' %}
@@ -15,6 +16,7 @@
<div id="palette-app" data-slug="{{ page.fileSlug }}" data-palette-id="{{ page.fileSlug }}">
<div
:class="{
seeded: isSeeded,
'tweaked-chroma': tweaked?.chroma,
'tweaked-hue': tweaked?.hue,
'tweaked-any': Object.keys(tweaksHumanReadable).length,
@@ -46,10 +48,10 @@
</h1>
<div class="block-info" v-cloak>
<code class="class">.wa-palette-<span v-content="slug">{{ page.fileSlug }}</span></code>
<code class="class" v-if="saved || !isCustom || step > 0">.wa-palette-<span v-content="slug">{{ page.fileSlug }}</span></code>
{% include '../_includes/status.njk' %}
{% if not isPro %}
<wa-badge class="pro" v-if="tweaked">PRO</wa-badge>
<wa-badge class="pro" v-if="tweaked || isCustom">PRO</wa-badge>
{% endif %}
</div>
{% if description %}
@@ -57,28 +59,32 @@
{{ description | inlineMarkdown | safe }}
</p>
{% endif %}
<div class="hue-wheel">
{% raw %}
<div class="hue-wheel" v-if="!isCustom || step > 1">
<template v-for="color, hue in coreColors">
<div :id="`hue-wheel-${hue}`" class="color"
:style="{
'--color': color,
'--h': color.get('oklch.h'),
'--c': color.get('oklch.c'),
'--l': color.get('oklch.l'),
}"></div>
<wa-tooltip :for="`hue-wheel-${ hue }`" hoist v-content="capitalize(hue) + ' ' + coreLevels[hue]"></wa-tooltip>
<template v-if="!isCustom || seedHues[hue]">
<div :id="`hue-wheel-${hue}`" class="color"
:style="{
'--color': color,
'--h': color.get('oklch.h'),
'--c': color.get('oklch.c'),
'--l': color.get('oklch.l'),
}"></div>
<wa-tooltip :for="`hue-wheel-${ hue }`" hoist>{{ capitalize(hue) }} {{ coreLevels[hue] }}</wa-tooltip>
</template>
</template>
</div>
</div>
{% endraw %}
</header>
{% endblock %}
{% block afterContent %}
<wa-callout size="small" class="tweaked-callout" variant="warning">
<wa-callout size="small" class="tweaked-callout" variant="warning" v-if="!isCustom">
<wa-icon name="sliders-simple" slot="icon" variant="regular"></wa-icon>
This palette has been tweaked.
<div class="wa-cluster wa-gap-xs">
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="reset(param)" v-content="tweakHumanReadable"></wa-tag>
<wa-tag v-for="tweakHumanReadable, param in tweaksHumanReadable" removable @wa-remove="reset(param)">{% raw %}{{ tweakHumanReadable }}{% endraw %}</wa-tag>
</div>
<wa-button @click="reset()" appearance="outlined" variant="danger">
@@ -103,8 +109,9 @@
{%- endfor %}
</tr>
</thead>
{% raw %}
<tbody v-cloak>
<tr v-for="hue of allHues" :data-hue="hue" :key="hue"
<tr v-for="hue in paletteScalesList" :data-hue="hue" :key="hue"
class="color-scale" :class="{
tweaked: hue === 'gray' ? tweaked.grayChroma || tweaked.grayColor : hueShifts[hue],
}"
@@ -112,9 +119,18 @@
'--swatch-text-color': `light-dark(var(--wa-color-${ hue }-10), white)`,
'--hue-shift': hueShifts[hue] || ''
}">
<th v-content="capitalize(hue)"></th>
<th>
{{ capitalize(hue) }}
<info-tip v-if="isCustom && !seedHues[hue]">
<wa-icon name="sparkles" style="color: var(--wa-color-gray-50)"></wa-icon>
<template #content>Generated scale</template>
</info-tip>
</th>
<td class="core-column" :style="{'--original-color': `var(--wa-color-${ hue })`, '--color': colors[hue][coreLevels[hue]]}">
<color-popup :title="capitalize(hue) + ' (core)'" :token="`--wa-color-${ hue }`" :color="coreColors[hue]">
<color-popup :title="capitalize(hue) + ' (core)'" :token="`--wa-color-${ hue }`" :color="coreColors[hue]"
:pinned="!!seedColors[colorToIndex[hue].core]"
:deletable="isCustom" @delete="deleteColor(colorToIndex[hue].core)"
:pinnable="isCustom" @pin="addColor(coreColors[hue] + '')">
<div slot="trigger" :id="`core-${ hue }-swatch`" class="color swatch" :style="{colorScheme: coreLevels[hue] > 60 ? 'light' : 'dark'}">
<span v-content="coreLevels[hue]"></span>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
@@ -124,7 +140,15 @@
<color-swatch-picker :model-value="computedGrayColor" @update:model-value="grayColor = $event" label="Gray undertone" :colors="coreColors"></color-swatch-picker>
</template>
<template v-else>
<color-slider v-if="baseCoreColors[hue]"
<color-slider v-if="isCustom && seedColors[colorToIndex[hue].core]"
coord="h" type="shift"
v-model:color="seedColors[colorToIndex[hue].core].color"
:default-value="seedColors[colorToIndex[hue].core].inputColor.oklch.h"
:min="HUE_RANGES[hue].min + 1" :max="HUE_RANGES[hue].max"
label="Adjust hue" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
></color-slider>
<color-slider v-if="!isCustom && baseCoreColors[hue]"
coord="h" type="shift"
v-model="hueShifts[hue]"
:default-color="baseCoreColors[hue]"
@@ -142,30 +166,69 @@
:min="0" :max-relative="maxGrayChroma" :step="0.00001"
label="Gray colorfulness" label-min="Neutral" :label-max="moreHue[computedGrayColor]"
></color-slider>
<color-slider v-else-if="isCustom" v-model:color="seedColors[colorToIndex[hue].core].color"
:default-value="seedColors[colorToIndex[hue].core].inputColor?.oklch.c"
coord="c"
:min="Math.max(coreColors.gray.oklch.c, ...Object.keys(seedHues[hue]).filter(t => t !== coreLevels[hue]).map(t => seedHues[hue][t].oklch.c))"
:max="getMaxChroma(colors[hue].core?.oklch.l, colors[hue].core?.oklch.h) - 0.001" :step="0.00001"
label="Adjust colorfulness" label-min="More muted" label-max="More vibrant"
label-default="Entered color"
format-type="scale"
></color-slider>
</template>
</color-popup>
</td>
<td v-for="tint in tints.toReversed()" :data-tint="tint" :style="{'--original-color': `var(--wa-color-${ hue }-${tint})`, '--color': colors[hue][tint] }">
<color-popup :title="capitalize(hue) + ' ' + tint" :token="`--wa-color-${ hue }-${ tint }`" :color="colors[hue][tint]">
<color-popup :title="capitalize(hue) + ' ' + tint" :token="`--wa-color-${ hue }-${ tint }`" :color="colors[hue][tint]"
:pinned="!!seedColors[colorToIndex[hue][tint]]"
:deletable="isCustom" @delete="deleteColor(colorToIndex[hue][tint])"
:pinnable="isCustom" @pin="addColor({hue, pinnedHue: hue, level: tint})">
<div slot="trigger" class="color swatch" :style="{ colorScheme: tint > 60 ? 'light' : 'dark' }">
<wa-icon name="copy" variant="regular" class="copy-icon"></wa-icon>
<wa-icon class="pinned-icon" name="thumbtack" variant="regular" v-if="seedColors[colorToIndex[hue][tint]]"></wa-icon>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<template #content v-if="isCustom && seedHues[hue] && (tint == '95' || tint == '05' || seedColors[colorToIndex[hue][tint]]) && tweakBase[hue][tint]">
<color-slider v-if="HUE_RANGES[hue]" v-model:color="colors[hue][tint]"
:default-value="colors[hue][tweakBase[hue][tint]].oklch.h"
@input="!seedColors[colorToIndex[hue][tint]] ? addColor({hue, pinnedHue: hue, level: tint}) : null"
@update:color="seedColors[colorToIndex[hue][tint]] ? seedColors[colorToIndex[hue][tint]].color = $event : null"
coord="h"
:min="HUE_RANGES[hue].mid - 70" :max="HUE_RANGES[hue].mid + 70" :step="1"
label="Hue shift" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
:label-default="`${capitalize(hue)} ${tweakBase[hue][tint]}`"
format-type="shift"
></color-slider>
<color-slider v-if="hue != 'gray'" v-model:color="colors[hue][tint]"
:default-value="colors[hue][tweakBase[hue][tint]].oklch.c"
@input="!seedColors[colorToIndex[hue][tint]] ? addColor({hue, pinnedHue: hue, level: tint}) : null"
@update:color="seedColors[colorToIndex[hue][tint]] ? seedColors[colorToIndex[hue][tint]].color = $event : null"
coord="c"
:min="coreColors.gray.oklch.c + 0.001"
:max="tint == coreLevels[hue] ? maxChroma(colors[hue][tweakBase[hue][tint]].oklch.l, colors[hue][tweakBase[hue][tint]].oklch.h) : coreColors[hue].oklch.c - 0.001" :step="0.001"
label="Colorfulness" label-min="More muted" label-max="More vibrant"
format-type="scale"
:label-default="`${capitalize(hue)} ${tweakBase[hue][tint]}`"
></color-slider>
</template>
</color-popup>
</td>
</tr>
</tbody>
{% endraw %}
</tbody>
</table>
<color-slider :class="{ tweaked: chromaScale !== 1 }"
<color-slider v-if="!isCustom" :class="{ tweaked: chromaScale !== 1 }"
type="scale"
v-model="chromaScale"
coord="c"
:default-color="baseMaxChromaColor"
:default-value="baseMaxChroma"
:min="MAX_CHROMA_BOUNDS.min" :max="MAX_CHROMA_BOUNDS.max" :step="0.001"
:min="MAX_CHROMA_BOUNDS.min" :max="MAX_CHROMA_BOUNDS.max" :step="0.01"
label="Overall colorfulness" label-min="More muted" label-max="More vibrant"
></color-slider>
{% if page.fileSlug != 'custom' %}
<h2>Used By</h2>
<section class="index-grid">
@@ -175,6 +238,7 @@
{%- endif -%}
{% endfor %}
</section>
{% endif %}
{% markdown %}
## Color Contrast
@@ -270,7 +334,7 @@ Add the following code at the top of your CSS file:
{% endmarkdown %}
<section id="saved" class="index-grid" v-if="savedVariations?.length">
<h2 class="index-category">Saved variations</h2>
<h2 class="index-category">Saved {{ 'custom palettes' if page.fileSlug == 'custom' else title + ' variations' }}</h2>
<a v-for="palette of savedVariations" :href="'/docs/palettes/' + palette.id">
<wa-card with-header>
<div slot="header">

View File

@@ -80,7 +80,7 @@ sidebar.palette = {
return;
}
for (let index; (index = savedPalettes.findIndex(p => p.uid === palette.uid)) > -1; ) {
for (let index; index > -1; index = savedPalettes.findIndex(p => p.uid === palette.uid)) {
savedPalettes.splice(index, 1);
}

View File

@@ -1,11 +1,11 @@
import { tints } from '/assets/scripts/tweak/data.js';
export function generateGrays(colors, { grayColor, grayChroma, grayLevel }) {
export function generateGrays(colors, { grayColor, grayChroma }) {
let ret = {};
let undertoneScale = colors[grayColor];
// These will be the same, since scaling them won't change the relationship
ret.maxChromaTint = grayLevel ?? undertoneScale.maxChromaTint;
ret.maxChromaTint = undertoneScale.maxChromaTint;
Object.defineProperty(ret, 'core', {
enumerable: false,
get() {

View File

@@ -2,10 +2,10 @@ import { stringifyColor } from './util.js';
import { cssImport, cssLiteral, cssRule } from '/assets/scripts/tweak/code.js';
import { selectors, tints, urls } from '/assets/scripts/tweak/data.js';
export function getPaletteCode({ base, colors, tweaked, ...options }) {
export function getPaletteCode({ base, slug = base, colors, tweaked, roles, ...options }) {
let imports = [];
if (base && options.imports !== false) {
if (base && options.imports !== false && !tweaked.seedColors) {
imports.push(urls.palette(base));
}
@@ -18,12 +18,14 @@ export function getPaletteCode({ base, colors, tweaked, ...options }) {
if (tweaked) {
for (let hue in colors) {
if (hue === 'gray') {
if (!tweaked.grayChroma && !tweaked.grayColor) {
if (!tweaked.seedColors) {
if (hue === 'gray') {
if (!tweaked.grayChroma && !tweaked.grayColor) {
continue;
}
} else if (!tweaked.chromaScale && !tweaked.hue?.[hue]) {
continue;
}
} else if (!tweaked.chromaScale && !tweaked.hue?.[hue]) {
continue;
}
let scale = colors[hue];
@@ -46,8 +48,24 @@ export function getPaletteCode({ base, colors, tweaked, ...options }) {
}
}
if (roles) {
for (let role in roles) {
let hue = roles[role];
if (!hue) {
continue;
}
for (let suffix of [...tints.map(t => '-' + t), '', '-key']) {
declarations.push(`--${prefix}-${role}${suffix}: var(--${prefix}-${hue}${suffix});`);
}
declarations.push('');
}
}
if (declarations.length > 0) {
let selector = options.selector ?? selectors.palette(base);
let selector = options.selector ?? selectors.palette(slug);
css += cssRule(selector, declarations);
}

View File

@@ -28,8 +28,7 @@ export function tweakPalette(baseColors, tweaks, tweaked) {
if (tweaked.grayChroma || tweaked.grayColor) {
let grayColor = tweaks.grayColor ?? this.originalGrayColor;
let grayChroma = this.computedGrayChroma;
let grayLevel = baseColors.gray?.maxChromaTint;
ret.gray = generateGrays(baseColors, { grayColor, grayChroma, grayLevel });
ret.gray = generateGrays(baseColors, { grayColor, grayChroma });
} else {
ret.gray = originalScale;
}

View File

@@ -0,0 +1,187 @@
/* CSS for custom palettes only */
#seed-colors {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(22ch, 1fr));
gap: var(--wa-space-m);
> .add-button {
flex-flow: column wrap;
height: auto;
min-height: 15ch;
border: var(--wa-panel-border-width) var(--wa-panel-border-style) var(--wa-color-surface-border);
--border-color: var(--wa-color-surface-border);
border-radius: var(--wa-panel-border-radius);
background-color: var(--wa-color-surface-default);
box-shadow: var(--wa-shadow-s);
wa-icon {
font-size: 200%;
margin: 0;
margin-top: 0.35em;
}
}
> wa-card {
--spacing: var(--wa-space-s);
[slot='image'] {
position: relative;
height: 5.5rem;
width: 100%;
border-start-start-radius: var(--inner-border-radius);
border-start-end-radius: var(--inner-border-radius);
background-color: var(--color);
color: canvastext;
.tweak-icon {
position: absolute;
top: var(--wa-space-s);
right: var(--wa-space-s);
--background-color-hover: oklab(from currentColor l a b / 15%);
--text-color-hover: currentColor;
&:not(:hover, :focus, :has(+ :focus-within)) {
opacity: 50%;
}
&:is(.tweaked *) {
&::part(base) {
transition: var(--wa-transition-normal);
transition-property: padding, border, opacity;
background-color: var(--color-original);
padding: var(--wa-space-s);
border: 1px solid hsl(0 0 100 / 60%);
}
}
}
.name {
display: flex;
gap: var(--wa-space-xs);
position: absolute;
bottom: var(--wa-space-xs);
left: var(--wa-space-s);
font-weight: var(--wa-font-weight-semibold);
wa-dropdown.pin-hue {
wa-button {
--outlined-border-color: oklab(from currentColor l a b / 10%);
--outlined-background-color-hover: transparent;
--border-width: 1.5px;
--text-color: currentColor;
--wa-space: var(--wa-space-xs);
--wa-space-smaller: var(--wa-space-2xs);
}
&.pin-hue.pinned {
wa-button {
--outlined-border-color: oklab(from currentColor l a b / 40%);
font-weight: var(--wa-font-weight-bold);
}
}
wa-icon[name='thumbtack'] {
opacity: 60%;
}
}
.level {
font-weight: var(--wa-font-weight-bold);
}
}
}
wa-input {
margin-top: var(--wa-space-xs);
}
wa-icon-button {
color: light-dark(black, white);
transition: opacity var(--wa-transition-slow);
--background-color-hover: oklab(from currentColor l a b / 15%);
--text-color-hover: currentColor;
}
}
.color-to-role {
--border-width: 0;
margin-inline-start: calc(-1 * var(--wa-space-3xs));
&::part(tags) {
margin-inline-start: 0;
}
&::part(combobox) {
padding: var(--wa-space-3xs);
min-height: auto;
}
}
}
wa-icon-button.delete-button {
position: absolute;
top: var(--wa-space-s);
right: var(--wa-space-s);
--text-color-hover: var(--wa-color-danger-on-normal);
}
.pinned-icon {
opacity: 70%;
}
#suggested-colors {
margin-top: var(--wa-space-2xl);
h3 {
margin-bottom: 0;
}
&::part(content) {
padding-block-start: 0;
}
p.wa-caption-m {
margin-block: var(--wa-space-xs) var(--wa-space-m);
text-wrap: pretty;
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: var(--wa-space-s);
wa-button {
/* --background-color-hover: var(--background-color); */
height: var(--wa-form-control-height);
aspect-ratio: 1.2;
wa-icon {
transition: var(--wa-transition-normal);
}
&:not(:focus, :hover) wa-icon {
opacity: 0;
}
}
}
}
#roles {
margin-block: var(--wa-space-2xl);
> div {
display: flex;
flex-wrap: wrap;
gap: var(--wa-space-m);
> wa-select {
flex: 1;
max-width: 20ch;
}
}
}
.seed-color-tweak .popup {
min-width: clamp(0ch, 50ch, 90vw);
}

View File

@@ -150,21 +150,8 @@ wa-dropdown > .color.swatch {
opacity: var(--tweak-icon-opacity, 0%);
}
.copy-icon {
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
opacity: var(--copy-icon-opacity, 0%);
}
.color.swatch:hover {
--tweak-icon-opacity: 40%;
--copy-icon-opacity: 40%;
}
wa-dropdown[open] .copy-icon {
--copy-icon-opacity: 60%;
}
&.tweaked .core-column {
@@ -225,6 +212,11 @@ wa-dropdown > .color.swatch {
}
}
[data-slug='custom'] > :not(.seeded) #seed-colors ~ :not(#saved),
#outline:has(+ main > [data-slug='custom'] > :not(.seeded)) li:nth-child(n + 2) {
display: none;
}
[id='palette-info'] {
display: grid;
grid-template-columns: 1fr auto;
@@ -242,33 +234,20 @@ wa-dropdown > .color.swatch {
position: relative;
width: calc(var(--r) * 2);
aspect-ratio: 1;
border: 2px solid transparent;
border-radius: 50%;
--lc: var(--avg-l) var(--max-c);
--lc2: var(--avg-l) calc(var(--max-c) / 2);
margin-top: calc(var(--r) * -0.05);
--cover-size: calc(100% - var(--visible-size, 0%));
background:
radial-gradient(closest-side, var(--wa-color-surface-default) 100%, transparent 0) center / var(--cover-size)
var(--cover-size),
conic-gradient(
in oklch,
oklch(var(--lc) 0),
oklch(var(--lc) 60),
oklch(var(--lc) 120),
oklch(var(--lc) 180),
oklch(var(--lc) 240),
oklch(var(--lc) 300),
oklch(var(--lc) 360)
);
background-origin: border-box;
background-clip: padding-box, border-box;
background-repeat: no-repeat;
transition: var(--wa-transition-slow) ease-in background-size;
&:is(:hover, :focus-within) {
--visible-size: 100%;
}
background: conic-gradient(
in oklch,
oklch(var(--lc) 0),
oklch(var(--lc) 60),
oklch(var(--lc) 120),
oklch(var(--lc) 180),
oklch(var(--lc) 240),
oklch(var(--lc) 300),
oklch(var(--lc) 360)
);
&,
&::before {
@@ -281,11 +260,7 @@ wa-dropdown > .color.swatch {
display: block;
height: 100%;
border-radius: 50%;
mask: radial-gradient(white, transparent);
mask-size: var(--visible-size, 0%) var(--visible-size, 0%);
mask-repeat: no-repeat;
transition: inherit;
transition-property: mask-size;
-webkit-mask: radial-gradient(white, transparent);
background: radial-gradient(oklch(var(--avg-l) calc(var(--gray-chroma) * var(--max-c)) 0) 5%, transparent 30%),
conic-gradient(
in oklch,
@@ -315,7 +290,6 @@ wa-dropdown > .color.swatch {
--scale: 1.2;
--line-color: white;
--line-style: solid;
z-index: 2;
}
&::before {
@@ -361,6 +335,16 @@ wa-dropdown > .color.swatch {
margin-inline-start: var(--wa-space-xs);
}
.seeded {
wa-badge.status {
display: none;
}
wa-badge.pro {
filter: grayscale(0.95);
}
}
.selected-swatch,
.color-select wa-option::before {
content: '';

View File

@@ -8,7 +8,9 @@ import getPaletteCode from './color/get-palette-code.js';
import allPalettes from './color/palettes.js';
import { tweakColor, tweakPalette } from './color/tweak.js';
import { getContrasts, identifyColor } from './color/util.js';
import ColorInput from './vue-components/color-input.js';
import ColorPopup from './vue-components/color-popup.js';
import ColorSelect from './vue-components/color-select.js';
import ColorSlider from './vue-components/color-slider.js';
import ColorSwatchPicker from './vue-components/color-swatch-picker.js';
import InfoTip from './vue-components/info-tip.js';
@@ -30,6 +32,12 @@ import {
} from '/assets/scripts/tweak/data.js';
import { camelCase, capitalize, log, slugify, subtractAngles } from '/assets/scripts/tweak/util.js';
const firstSeedColor = '#0071ec';
const defaults = {
grayChroma: 0.15,
grayColor: 'indigo',
};
let paletteAppSpec = {
data() {
let appRoot = document.querySelector('#palette-app');
@@ -38,10 +46,20 @@ let paletteAppSpec = {
return {
uid: undefined,
maxSeedUid: 0,
seedColors: [],
seedColorSamples: [
'#0071ec',
'oklch(77% 0.19 70)',
'rgb(95, 59, 255)',
'#f06',
'yellowgreen',
'oklch(82% 0.185 195)',
'oklch(30% 0.18 150)',
],
paletteId,
originalPaletteTitle: palette.title,
originalColors: palette.colors,
baseColors: { ...palette.colors },
originalColors: paletteId === 'custom' ? allPalettes.default.colors : palette.colors,
permalink: new Permalink(),
hueShifts: Object.fromEntries(hues.map(hue => [hue, 0])),
chromaScale: 1,
@@ -50,22 +68,13 @@ let paletteAppSpec = {
saved: null,
unsavedChanges: false,
savedPalettes: sidebar.palettes.saved,
roles: Object.fromEntries(ROLES.map(role => [role, undefined])),
};
},
created() {
// Non-reactive variables to expose
Object.assign(this, {
moreHue,
hueBefore,
hueAfter,
HUE_RANGES,
L_RANGES,
hues,
allHues,
tints,
MAX_CHROMA_BOUNDS,
});
Object.assign(this, { moreHue, hueBefore, hueAfter, HUE_RANGES, L_RANGES, hues, tints, MAX_CHROMA_BOUNDS });
if (location.search) {
// Read URL params and apply them. This facilitates permalinks.
@@ -89,18 +98,37 @@ let paletteAppSpec = {
}
}
if (this.permalink.has('color')) {
this.seedColors = this.permalink.getAll('color').map(value => {
if (value.startsWith('{')) {
try {
return JSON.parse(value);
} catch (e) {
return { value };
}
} else {
return { value };
}
});
}
if (this.permalink.has('uid')) {
this.uid = Number(this.permalink.get('uid'));
this.saved = sidebar.palettes.saved.find(p => p.uid === this.uid);
}
for (let role in this.roles) {
let value = this.permalink.get(`role-${role}`);
if (value) {
this.roles[role] = value;
}
}
}
},
mounted() {
nextTick().then(() => {
if (!this.tweaked || this.saved) {
this.unsavedChanges = false;
}
this.unsavedChanges = false;
});
},
@@ -113,16 +141,110 @@ let paletteAppSpec = {
* @returns
*/
step() {
return this.tweaked ? 1 : 0;
if (this.isCustom) {
if (this.isSeeded) {
return 2;
} else if (this.seedColors.length > 0) {
return 1;
} else {
return 0;
}
} else {
return this.tweaked ? 1 : 0;
}
},
suggestedForRole() {
let ret = {};
if (!this.seedHues.green) {
ret.success = ['green'];
}
ret.warning = [];
if (!this.seedHues.yellow) {
ret.warning.push('yellow');
}
if (!this.seedHues.orange) {
ret.warning.push('orange');
}
if (!this.seedHues.red) {
ret.danger = ['red'];
}
return ret;
},
defaultRoles() {
let seedHues = new Set(this.seedHueList);
// Arrays define candidates in preference order
let ret = {
brand: ['blue', 'indigo', 'purple', 'cyan', 'pink', 'green', 'orange', 'yellow', 'red', 'gray'],
neutral: 'gray',
success: ['green'],
warning: ['yellow', 'orange'],
danger: ['red'],
};
// Reduce to first candidate in seed hues
for (let role in ret) {
if (Array.isArray(ret[role])) {
ret[role] = ret[role].find(hue => seedHues.has(hue));
}
}
if (this.seedColors.length === 0) {
return ret;
}
// Now apply brand color to anything empty
for (let role in ret) {
if (!ret[role]) {
ret[role] = 'brand';
}
}
return ret;
},
computedRoles() {
return Object.fromEntries(ROLES.map(role => [role, this.roles[role] ?? this.defaultRoles[role]]));
},
suggestedColors() {
let ret = {};
for (let hue in this.coreColors) {
if (!this.seedHues[hue] && hue !== 'gray') {
ret[hue] = this.coreColors[hue];
}
}
return ret;
},
isCustom() {
return this.paletteId === 'custom';
},
slug() {
return this.paletteId;
if (this.isCustom) {
return slugify(this.paletteTitle);
} else {
// The slug does not change for tweaked palettes
return this.paletteId;
}
},
/** Default palette title for saving */
defaultPaletteTitle() {
return this.originalPaletteTitle + ' (tweaked)';
if (this.isCustom) {
return 'My Palette';
} else {
return this.originalPaletteTitle + ' (tweaked)';
}
},
paletteTitle() {
@@ -135,6 +257,148 @@ let paletteAppSpec = {
}
},
seedColorValues() {
return this.seedColors.map(c => {
if (c.pinnedHue) {
let { value, pinnedHue } = c;
return { value, pinnedHue };
} else {
return c.value;
}
});
},
seedColorObjectsRaw() {
return this.seedColors.map(c => c.colorRaw);
},
seedColorObjects() {
return this.seedColors.map(c => c.color);
},
isSeeded() {
return this.seedColorObjectsRaw.filter(Boolean).length > 0;
},
seedColorInfo() {
return this.seedColors.map(({ hue, level }) => ({ hue, level }));
},
/**
* Map hue + level to index in seedColors
*/
colorToIndex() {
let ret = {};
for (let hue of allHues) {
ret[hue] = {};
}
if (!this.isSeeded) {
return ret;
}
for (let i = 0; i < this.seedColors.length; i++) {
let { hue, level } = this.seedColors[i];
if (!hue || !level) {
continue;
}
ret[hue][level] = i;
}
for (let hue in this.coreLevels) {
if (ret[hue]) {
ret[hue].core = ret[hue][this.coreLevels[hue]];
}
}
return ret;
},
hueRoles() {
let ret = {};
for (let role in this.computedRoles) {
let value = this.computedRoles[role];
ret[value] ??= {};
ret[value][role] = this.roles[role];
}
return ret;
},
seedColorRoles() {
return this.seedColorInfo.map(info => {
if (!info) {
return [];
}
let { hue } = info;
return this.hueRoles[hue];
});
},
seedHueList() {
return Object.keys(this.seedHues);
},
seedHues() {
// Make sure hues are in the right order
let ret = {};
for (let hue of hues) {
Object.defineProperty(ret, hue, { value: undefined, enumerable: false, writable: true, configurable: true });
}
for (let i = 0; i < this.seedColors.length; i++) {
let seed = this.seedColors[i];
let { hue, level, color } = seed;
if (!hue) {
continue;
}
if (!ret[hue]) {
// First color of this hue
delete ret[hue]; // remove non-enumerable descriptor
ret[hue] = {};
}
ret[hue][level] = color;
}
return ret;
},
paletteScales() {
if (!this.isCustom) {
return this.colors;
}
let ret = Object.fromEntries(
Object.keys(this.colors)
.filter(hue => this.seedHues[hue] || hue === 'gray')
.map(hue => [hue, this.colors[hue]]),
);
// Ensure gray is last
if (ret.gray) {
let grayScale = ret.gray;
delete ret.gray;
ret.gray = grayScale;
}
return ret;
},
paletteScalesList() {
return Object.keys(this.paletteScales);
},
paletteScalesSet() {
return new Set(this.paletteScalesList);
},
tweaks() {
return {
hueShifts: this.hueShifts,
@@ -149,8 +413,10 @@ let paletteAppSpec = {
for (let language of ['html', 'css']) {
let code = getPaletteCode({
base: this.paletteId,
colors: this.colors,
slug: this.isCustom ? this.slug : undefined,
colors: this.paletteScales,
tweaked: this.tweaked,
roles: this.isCustom ? this.computedRoles : this.roles,
language,
cdnUrl,
});
@@ -163,6 +429,18 @@ let paletteAppSpec = {
return ret;
},
baseColors() {
if (!this.isSeeded) {
return this.originalColors;
}
let { huesAfter } = this;
return (
generatePalette(this.seedHues, { huesAfter, grayChroma: defaults.grayChroma, grayColor: defaults.grayColor }) ??
this.originalColors
);
},
colors() {
return tweakPalette.call(this, this.baseColors, this.tweaks, this.tweaked);
},
@@ -174,6 +452,7 @@ let paletteAppSpec = {
: false;
let ret = {
seedColors: this.seedColors.length > 0,
chromaScale: this.chromaScale !== 1,
hue,
grayChroma: this.grayChroma !== undefined && this.grayChroma !== this.originalGrayChroma,
@@ -227,7 +506,7 @@ let paletteAppSpec = {
},
contrasts() {
return getContrasts(this.colors, this.originalContrasts);
return getContrasts(this.paletteScales, this.originalContrasts);
},
baseCoreColors() {
@@ -357,7 +636,7 @@ let paletteAppSpec = {
},
maxGrayChroma() {
return MAX_GRAY_CHROMA_SCALE[this.grayColor] ?? 0.35;
return MAX_GRAY_CHROMA_SCALE[this.grayColor] ?? 0.3;
},
huesAfter() {
@@ -375,6 +654,41 @@ let paletteAppSpec = {
savedVariations() {
return this.savedPalettes.filter(palette => palette.id === this.paletteId && palette.uid !== this.uid);
},
/** When tweaking a non-core tint, which tint are we tweaking relative to? */
tweakBase() {
let ret = {};
for (let hue in this.paletteScales) {
let pinned = Object.keys(this.seedHues[hue] ?? {}).sort((a, b) => a - b);
let core = this.coreLevels[hue];
ret[hue] ??= {};
for (let tint in this.paletteScales[hue]) {
if (tint === core) {
continue;
}
let delta = tint - core;
if (pinned.length <= 1) {
// If nothing is pinned or just the core level is pinned, all other tints are edited relative to that
ret[hue][tint] = core;
} else {
// Find closest pinned tint in the direction of the core color
if (delta < 0) {
// We want the first pinned tint that is larger than tint
ret[hue][tint] = pinned.find(pinnedTint => pinnedTint > tint);
} else {
// We want the last pinned tint that is smaller than tint
ret[hue][tint] = pinned.findLast(pinnedTint => pinnedTint < tint);
}
}
}
}
return ret;
},
}, // end computed
watch: {
@@ -399,6 +713,19 @@ let paletteAppSpec = {
this.permalink.set('gray-chroma', this.grayChroma, this.originalGrayChroma);
},
seedColorValues: {
deep: true,
handler() {
this.permalink.set('color', this.seedColorValues);
this.permalink.updateLocation();
if (this.saved || this.isCustom) {
this.unsavedChanges = true;
}
},
},
tweaks: {
deep: true,
async handler(value, oldValue) {
@@ -407,14 +734,37 @@ let paletteAppSpec = {
// Update page URL
this.permalink.updateLocation();
this.unsavedChanges = true;
if (this.saved || this.isCustom) {
this.unsavedChanges = true;
}
},
},
saved: {
roles: {
deep: true,
handler() {
this.unsavedChanges = !this.saved;
for (let role in this.roles) {
this.permalink.set(`role-${role}`, this.roles[role]);
}
// Update page URL
this.permalink.updateLocation();
if (this.saved || this.isCustom) {
this.unsavedChanges = true;
}
},
},
paletteScalesSet: {
deep: true,
handler() {
for (let role in this.roles) {
if (this.roles[role] && !this.paletteScalesSet.has(this.roles[role])) {
// Role color is no longer in the palette
this.roles[role] = undefined;
}
}
},
},
}, // end watch
@@ -425,10 +775,26 @@ let paletteAppSpec = {
getMaxChroma,
log,
async save({ title } = {}) {
/**
* Testing method. Import all core colors from a given palette.
* @param {string} paletteId
*/
emulate(paletteId) {
this.seedColors = [];
for (let hue in allPalettes[paletteId].colors) {
if (hue !== 'gray') {
let coreTint = allPalettes[paletteId].colors[hue].maxChromaTint;
let coreColor = allPalettes[paletteId].colors[hue][coreTint];
this.addColor(coreColor);
}
}
},
save({ title } = {}) {
let uid = this.uid;
this.saved ??= { id: this.paletteId, uid: this.uid };
this.saved ??= { id: this.paletteId, uid: this.uid, search: location.search };
if (title) {
// Renaming
@@ -437,17 +803,13 @@ let paletteAppSpec = {
this.saved.title ??= this.defaultPaletteTitle;
}
this.saved.search = location.search;
this.saved = sidebar.palette.save(this.saved);
sidebar.palette.save(this.saved);
if (uid !== this.saved.uid) {
// UID changed (most likely from saving a new palette)
this.uid = this.saved.uid;
this.permalink.set('uid', this.uid);
this.permalink.set('uid', uid);
this.permalink.updateLocation();
await this.$nextTick();
this.save(); // Save again to update the search param to include the UID
}
this.unsavedChanges = false;
@@ -500,6 +862,100 @@ let paletteAppSpec = {
return context;
},
/**
* Assign a hue to a role
* @param {string} role - Role we are setting
* @param {string} hue - Hue (literal or semantic)
*/
setRoleColor(role, hue) {
if (!this.seedHues[hue] && hue !== 'gray' && !ROLES.includes(hue)) {
// We're also adding it
this.addColor(this.coreColors[hue]);
}
this.roles[role] = hue;
},
/**
* Set a color's role(s)
* @param {string | number} hueOrIndex
* @param {string | string[]} roles
*/
setColorRole(hueOrIndex, roles) {
let hue = hueOrIndex >= 0 ? this.seedColorInfo[hueOrIndex]?.hue : hueOrIndex;
roles = new Set(Array.isArray(roles) ? roles : [roles]);
for (let role in this.roles) {
if (roles.has(role)) {
this.roles[role] = hue;
} else if (this.roles[role] === hue) {
this.roles[role] = undefined;
}
}
},
addColor(value, options) {
if (!value) {
if (this.seedColors.length === 0) {
value = firstSeedColor;
} else {
// Add suggestions
for (let hue of ['red', 'green', 'yellow', 'blue', 'orange', 'cyan', 'purple', 'pink', 'indigo']) {
if (hue in this.suggestedColors) {
value = { hue };
break;
}
}
}
}
if (value?.hue) {
// Pinning a generated color
let { hue, level, pinnedHue } = value;
level ??= this.coreLevels[hue];
let color = this.colors[hue][level];
value = { value: color + '', color, pinnedHue };
}
if (typeof value === 'string') {
value = { value };
} else if (value instanceof Color || value?.constructor.name === 'Color') {
value = { value: value + '', color: value };
}
if (options) {
Object.assign(value, options);
}
value.uid ??= this.maxSeedUid++;
this.seedColors.push(value);
},
deleteColor(index) {
this.seedColors.splice(index, 1);
},
getColor(ref) {
let color, index;
if (this.isCustom) {
if (ref?.hue) {
let { hue, level } = ref;
color = this.colors[hue][level];
index = this.colorToIndex[hue][level];
} else if (ref > 0) {
index = ref;
color = this.seedColors[index]?.color;
}
} else {
let { hue, level } = ref;
color = this.baseColors[hue][level];
}
return { color, index };
},
}, // end methods
directives: {
@@ -529,6 +985,8 @@ let paletteAppSpec = {
components: {
ColorPopup,
ColorInput,
ColorSelect,
ColorSlider,
ColorSwatchPicker,
InfoTip,

View File

@@ -0,0 +1,357 @@
const template = `
<wa-card size="small" class="color" :class="{tweaked}"
:style="{'--color': value, '--color-original': inputValue}">
<div slot="image" :style="{ colorScheme: level <= 60 ? 'dark' : 'light'}">
<color-popup placement="top-start" class="seed-color-tweak" :pinned=true deletable @delete="$emit('delete')" title="Edit color">
<wa-icon-button name="sliders-simple" class="tweak-icon"></wa-icon-button>
<template #content>
<color-slider label="Hue" label-default="Entered color"
coord="h" :min="0" :max="359" :step="1"
v-model:color="color" :default-value="inputLCH[2]" ></color-slider>
<color-slider label="Colorfulness" label-default="Entered color"
coord="c" :min="0" :max="maxChroma" :step="0.001"
v-model:color="color" :default-value="inputLCH[1]" format-type="scale" :format-base-value="maxChroma" ></color-slider>
<color-slider label="Lightness" label-default="Entered color"
coord="l" :min="0" :max="1" :step="0.01"
v-model:color="color" :default-value="inputLCH[0]" format-type="scale" :format-base-value="1" ></color-slider>
</template>
</color-popup>
<div class="name">
<wa-dropdown class="pin-hue" :class="{pinned: pinnedHue}">
<wa-button slot="trigger" appearance="outlined" caret>
<wa-icon name="thumbtack" v-if="pinnedHue" variant="solid" slot="prefix"></wa-icon>
{{ capitalize(hue) || 'New color' }}
</wa-button>
<wa-menu @wa-select="pinnedHue = $event.detail.item.value">
<wa-menu-item type="checkbox" :checked="pinnedHue ? null : ''">Automatic <em>({{ capitalize(detectedColorInfo.hue) }})</em></wa-menu-item>
<wa-divider></wa-divider>
<wa-menu-label>Pin to…</wa-menu-label>
<wa-menu-item v-for="hue in allHues" type="checkbox" :value="hue" :checked="pinnedHue === hue ? '' : null">{{ capitalize(hue) }}</wa-menu-item>
</wa-menu>
</wa-dropdown>
<span class="level">{{ level }}</span>
</div>
</div>
<wa-select class="color-to-role" multiple appearance="plain" placeholder="(No states)" max-options-visible="2"
ref="roles" :value.attr="Object.keys(roles).join(' ')" :value="Object.keys(roles)"
:getTag="getTag"
@input="$emit('update:roles', $event.target.value)">
<wa-option v-for="role in ROLES" :value="role" :class="{'default': !roles[role]}">{{ capitalize(role) }}</wa-option>
</wa-select>
<wa-input :value="valueRaw" @input="handleInput" @focus="inputFocused = true" @blur="inputFocused = false" ref="input"></wa-input>
</wa-card>
`;
import Color from 'https://colorjs.io/dist/color.js';
// import { nextTick } from 'https://unpkg.com/vue@3/dist/vue.esm-browser.js';
import { nextTick } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';
import getMaxChroma from '../color/get-max-chroma.js';
import { identifyColor } from '../color/util.js';
import ColorPopup from './color-popup.js';
import ColorSlider from './color-slider.js';
import InfoTip from './info-tip.js';
import { ROLES, allHues } from '/assets/scripts/tweak/data.js';
import { capitalize } from '/assets/scripts/tweak/util.js';
await customElements.whenDefined('wa-select');
let maxUid = 0;
const expose = [
'valueRaw',
'value',
'inputValueRaw',
'inputValue',
'colorRaw',
'color',
'inputColorRaw',
'inputColor',
'hue',
'pinnedHue',
'level',
'tweaked',
];
export default {
expose,
props: {
modelValue: {
type: Object,
default(rawProps) {
return { value: '' };
},
},
otherColors: {
type: Array,
},
roles: {
type: Object,
default: {},
},
},
emits: ['update:modelValue', 'update:roles', 'delete'],
data() {
let uid = this.modelValue.uid ?? maxUid++;
if (this.modelValue.uid) {
maxUid = Math.max(maxUid, uid);
}
this.modelValue.uid = uid;
let valueRaw = this.modelValue.value;
let inputValueRaw = this.modelValue.inputValue ?? valueRaw;
let color = tryColor(this.modelValue.value);
let inputColor = tryColor(inputValueRaw);
return {
uid,
initialProps: { ...this.modelValue },
valueRaw,
value: color ? valueRaw : undefined,
color,
inputValueRaw,
inputValue: inputColor ? inputValueRaw : undefined,
inputColor,
pinnedHue: this.modelValue.pinnedHue,
editing: 0,
inputFocused: false,
watching: {},
};
},
created() {
// Non-reactive variables to expose
Object.assign(this, { ROLES, allHues });
},
async mounted() {
if (this.modelValue.editImmediately) {
let input = this.$refs.input;
await input.updateComplete;
input.focus();
input.select();
}
},
computed: {
inputLCH() {
return this.inputColor?.oklch;
},
currentLCH() {
return this.color?.oklch;
},
tweaked() {
if (this.inputFocused || this.editing > 0 || !this.inputLCH || !this.currentLCH) {
return false;
}
return this.inputLCH.some((coord, i) => coord !== this.currentLCH[i]);
},
computedValue() {
let ret = {};
for (let property of expose) {
ret[property] = this[property];
}
return ret;
},
colorRaw() {
let ret = tryColor(this.modelValue.valueRaw);
if (ret) {
this.value = this.modelValue.valueRaw;
}
return ret;
},
colorInfo() {
let ret = { ...this.detectedColorInfo };
if (this.pinnedHue) {
ret.hue = this.pinnedHue;
}
return ret;
},
detectedColorInfo() {
if (!this.color) {
return { hue: undefined, level: undefined };
}
return identifyColor(this.color, this.otherColors);
},
hue() {
return this.colorInfo.hue;
},
level() {
return this.colorInfo.level;
},
stringifiedColor() {
// return stringifyColor(this.colorRaw);
return this.color + '';
},
inputColorRaw() {
let ret = tryColor(this.inputValueRaw);
if (ret) {
this.inputValue = this.inputValueRaw;
}
return ret;
},
maxChroma() {
if (!this.color) {
return 0.4;
}
return getMaxChroma(this.color.oklch.l, this.color.oklch.h);
},
},
methods: {
capitalize,
handleInput(event) {
this.editing++;
let value = event.target.value;
// Editing the input manually also incorporates any tweaks as part of the color itself
// I.e. input color and color are now the same
this.valueRaw = this.inputValueRaw = value;
nextTick().then(() => {
if (this.colorRaw) {
this.color = this.colorRaw;
this.$refs.input.setCustomValidity('');
} else {
this.$refs.input.setCustomValidity('Invalid color');
this.$refs.input.reportValidity();
}
this.editing--;
});
},
mutateModelValue(mutator) {
if (this.watching.modelValue === null) {
// If we're not watching modelValue, it means we're reacting to a change to it
// so no point in updating it again
return;
}
if (this.watching.modelValue) {
this.watching.modelValue();
this.watching.modelValue = null;
}
mutator();
this.watching.modelValue = this.$watch('modelValue', {
deep: true,
handler() {
let computedValue = this.computedValue;
// What changed?
if (this.modelValue.value !== computedValue.value) {
this.valueRaw = this.modelValue.value;
}
if (this.modelValue.color + '' !== computedValue.color + '') {
this.color = this.modelValue.color;
}
},
});
},
getTag(option) {
let isDefault = option.classList.contains('default');
let tag = Object.assign(document.createElement('wa-tag'), {
part: `tag${isDefault ? ' default' : ''}`,
exportparts: `
base:tag__base,
content:tag__content,
remove-button:tag__remove-button,
remove-button__base:tag__remove-button__base`,
size: 'small',
removable: !isDefault,
'data-value': option.value,
id: 'tag-' + option.value,
innerHTML: option.label + ` <wa-tooltip hoist for="tag-${option.value}">Default role</wa-tooltip>`,
});
return tag;
},
},
watch: {
/** colorRaw -> color */
colorRaw: {
deep: true,
handler() {
if (this.colorRaw) {
this.color = this.colorRaw;
}
},
},
/** inputColorRaw -> inputColor */
inputColorRaw: {
deep: true,
handler() {
if (this.inputColorRaw) {
this.inputColor = this.inputColorRaw;
}
},
},
/** color -> value, valueRaw, modelValue.value */
color: {
deep: true,
handler() {
if (this.tweaked && this.color) {
// If tweaked, color is the source of truth
this.value = this.valueRaw = this.color + '';
}
},
},
/** computedValue -> modelValue */
computedValue: {
deep: true,
immediate: true,
handler() {
this.mutateModelValue(() => {
Object.assign(this.modelValue, this.computedValue);
this.$emit('update:modelValue', this.modelValue);
});
},
},
},
template,
components: { InfoTip, ColorSlider, ColorPopup },
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};
function tryColor(value) {
if (!value) {
return null;
}
if (value instanceof Color) {
return value;
}
try {
return new Color(value);
} catch (e) {
return null;
}
}

View File

@@ -56,7 +56,7 @@ export default {
maxRelative: Number,
step: {
type: Number,
default: 0.001,
default: 1,
},
type: {

View File

@@ -0,0 +1,85 @@
const template = `
<color-popup :title :token="token" :color="modelValue"
:pinned :pinnable @pin="$emit('pin')" :deletable @delete="$emit('delete')">
<div slot="trigger" class="color swatch" :style="{ '--color': modelValue, colorScheme: level > 60 ? 'light' : 'dark' }">
<wa-icon class="pinned-icon" name="thumbtack" variant="regular" v-if="pinned"></wa-icon>
<wa-icon name="sliders-simple" class="tweak-icon"></wa-icon>
</div>
<template #content>
<color-slider v-if="(isEdge || pinned) && tweakBase && HUE_RANGES[hue]"
:color="modelValue" @update:model-value="$emit('update:modelValue', $event)" :default-value="colors[hue][tweakBase].oklch.h"
@input="!pinned ? $emit('pin') : null"
coord="h" :min="HUE_RANGES[hue].min + 1" :max="HUE_RANGES[hue].max" :step="1"
label="Hue shift" :label-min="moreHue[hueBefore[hue]]" :label-max="moreHue[hueAfter[hue]]"
:label-default="\`\${capitalize(hue)} \${tweakBase}\`"
></color-slider>
</template>
</color-popup>
`;
import Color from 'https://colorjs.io/dist/color.js';
import ColorPopup from './color-popup.js';
import ColorSlider from './color-slider.js';
import InfoTip from './info-tip.js';
import { HUE_RANGES, hueAfter, hueBefore, hues, moreHue } from '/assets/scripts/tweak/data.js';
import { capitalize, clamp, promise, roundTo } from '/assets/scripts/tweak/util.js';
export default {
props: {
modelValue: Color,
hue: {
type: String,
required: true,
},
level: {
type: [String, Number],
required: true,
},
coreLevel: {
type: [String, Number],
required: true,
},
pinned: Boolean,
pinnable: Boolean,
deletable: Boolean,
colors: {
type: Object,
required: true,
},
tweakBase: [String, Number],
},
emits: ['update:modelValue', 'pin', 'delete'],
data() {
return {};
},
created() {
// Attach non-reactive data
Object.assign(this, { moreHue, HUE_RANGES });
},
computed: {
title() {
return capitalize(this.hue) + ' ' + this.level;
},
hueBefore() {
return hueBefore[this.hue];
},
hueAfter() {
return hueAfter[this.hue];
},
token() {
return `--wa-color-${this.hue}-${this.level}`;
},
isEdge() {
return this.level == '95' || this.level == '05';
},
isCore() {
return this.level == this.coreLevel;
},
},
methods: { capitalize },
template,
components: { InfoTip, ColorSlider, ColorPopup },
compilerOptions: {
isCustomElement: tag => tag.startsWith('wa-'),
},
};

View File

@@ -0,0 +1,71 @@
---
title: Custom
isPro: true
override:tags: [palettes, pro]
order: 99
description: Create your own color palette from scratch, from one or more seed colors.
status: experimental
---
<link href="{{ page.url }}../app/custom.css" rel="stylesheet">
<h2 v-if="step > 0" v-cloak>My Colors</h2>
<p v-if="step > 0" v-cloak>
Just add your colors, in any order. Well sort them out for you, generate tints, and suggest additional colors.
</p>
<div id="seed-colors">
<template v-for="color, i in seedColors" :key="color.uid ?? maxSeedUid">
<color-input v-model="seedColors[i]"
:other-colors="seedColors.filter((_, j) => j !== i).map(c => c.color)"
:roles="seedColorRoles[i]"
@update:roles="roles => setColorRole(i, roles)"
@delete="deleteColor(i)"></color-input>
</template>
<wa-button class="add-button" appearance="outlined" @click="addColor(undefined, {editImmediately: true})">
<wa-icon slot="prefix" name="plus" variant="regular"></wa-icon>
<span v-content="step > 0 ? 'Add color' : 'New palette'">New palette</span>
</wa-button>
</div>
<wa-details id="suggested-colors" v-if="step > 0" v-cloak open>
<h3 class="wa-heading-m" slot="summary">Suggestions</h3>
<p class="wa-caption-m">
Generated by our fancy-schmancy algorithm to complement your colors.
See a color you like? Grab it before its gone!
</p>
<div class="suggestions wa-cluster wa-align-items-start wa-gap-s">
<template v-for="color, hue in suggestedColors">
<info-tip>
<wa-button :style="{'--background-color': color}" @click="addColor({hue})">
<wa-icon name="plus"></wa-icon>
</wa-button>
<template #content>{% raw %}{{ capitalize(hue) }}{% endraw %}</template>
</info-tip>
</template>
</div>
</wa-details>
<section id="roles" v-if="step > 0" v-cloak>
<h2>Roles</h2>
<div>
<color-select v-for="computedRole, role in computedRoles"
:model-value="computedRoles[role]"
@update:model-value="value => setRoleColor(role, value)"
:class="{'default': !roles[role]}"
:label="capitalize(role) + ':'"
:groups="{
Dynamic: !['brand', 'neutral'].includes(role) ? ['brand', 'neutral'] : undefined,
Colors: Object.keys(paletteScales),
Common: suggestedForRole[role]
}"
:get-label="capitalize"
:get-content="value => capitalize(value) + (seedHues[value] || computedRoles[value] || value === 'gray' ? '' : ' <wa-icon name=square-plus variant=regular></wa-icon>')"
:get-color="value => coreColors[computedRoles[value] ?? value]">
{# <wa-badge class="default-badge" v-if="!roles[role]" slot="suffix" variant="neutral" appearance="outlined">Default</wa-badge> #}
</color-select>
</div>
</section>