Compare commits

...

10 Commits

Author SHA1 Message Date
徐涛
612f1ba751 增加说明文件。 2025-01-10 15:16:00 +08:00
徐涛
c737712d3f 调整色卡中颜色名称输出内容,方便未来适配其他色卡系列。 2025-01-10 15:10:39 +08:00
徐涛
67164e35fa 调整基于Oklch的色相分界。 2025-01-10 14:50:14 +08:00
徐涛
9fec4a31e9 基本形成色卡页面功能。 2025-01-10 14:24:18 +08:00
徐涛
6708c40ffb 增加一个计划中的功能。 2025-01-10 09:16:06 +08:00
徐涛
12d6b04ddc 更新WASM中检索色卡的功能。 2025-01-10 09:05:11 +08:00
徐涛
f2031f3d8c 增加内置的颜色色卡支持。 2025-01-10 09:03:14 +08:00
徐涛
5e7b1e709d 增加serde对wasm的支持。 2025-01-10 08:50:47 +08:00
徐涛
f775c3b78f 完成WACG对比度检测功能。 2025-01-07 15:45:36 +08:00
徐涛
6bc0779f26 增加一个badge组件样式。 2025-01-07 15:17:16 +08:00
27 changed files with 879 additions and 73 deletions

View File

@ -1,50 +1,5 @@
# React + TypeScript + Vite
# Color Q
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
提供专业的多角度颜色选择、搭配、研究的网站。
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
基于React和WASM构建不使用任何服务端程序支持。内建常见颜色色卡。

View File

@ -10,7 +10,10 @@ crate-type = ["cdylib"]
color-name = "1.1.0"
palette = { version = "0.7.6", features = ["serde"] }
serde = { version = "1.0.216", features = ["derive"] }
serde-wasm-bindgen = "0.6.5"
serde_json = "1.0.134"
strum = { version = "0.26.3", features = ["derive", "strum_macros"] }
strum_macros = "0.26.4"
thiserror = "2.0.9"
wasm-bindgen = { version = "0.2.99", features = ["serde", "serde_json", "serde-serialize"] }

View File

@ -0,0 +1,89 @@
use std::sync::LazyLock;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumIter, EnumString};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorDescription {
pub name: String,
pub pinyin: Vec<String>,
pub hue: f32,
pub lightness: f32,
pub category: String,
pub tags: Vec<String>,
pub rgb: [u8; 3],
pub hsl: [f32; 3],
pub lab: [f32; 3],
pub oklch: [f32; 3],
}
const COLOR_CARDS_JSON: &str = include_str!("colorcards.json");
pub const COLOR_CARDS: LazyLock<Vec<ColorDescription>> =
LazyLock::new(|| serde_json::from_str(COLOR_CARDS_JSON).expect("Failed to parse color cards"));
const CHROMA_EPSILON: f32 = 0.02;
#[derive(Debug, Clone, PartialEq, Display, EnumString, EnumIter)]
#[strum(serialize_all = "lowercase")]
pub enum Category {
Red,
Orange,
Yellow,
Green,
Cyan,
Blue,
Purple,
Magenta,
White,
Black,
Gray,
Unknown,
}
impl Category {
pub fn from_oklch_components(lightness: f32, chroma: f32, hue: f32) -> Self {
if chroma < CHROMA_EPSILON {
if lightness < 0.15 {
Category::Black
} else if lightness > 0.9 {
Category::White
} else {
Category::Gray
}
} else {
let processed_hue = hue % 360.0;
match processed_hue {
0.0..=15.0 => Category::Magenta,
15.0..=45.0 => Category::Red,
45.0..=75.0 => Category::Orange,
75.0..=120.0 => Category::Yellow,
120.0..=180.0 => Category::Green,
180.0..=210.0 => Category::Cyan,
210.0..=270.0 => Category::Blue,
270.0..=345.0 => Category::Purple,
345.0..=360.0 => Category::Magenta,
_ => Category::Unknown,
}
}
}
pub fn from_oklch(oklch: &[f32; 3]) -> Self {
Category::from_oklch_components(oklch[0], oklch[1], oklch[2])
}
pub fn label(&self) -> String {
match self {
Category::Red => "Red".to_string(),
Category::Orange => "Orange".to_string(),
Category::Yellow => "Yellow".to_string(),
Category::Green => "Green".to_string(),
Category::Cyan => "Cyan".to_string(),
Category::Blue => "Blue".to_string(),
Category::Purple => "Purple".to_string(),
Category::Magenta => "Magenta".to_string(),
Category::White => "White".to_string(),
Category::Black => "Black".to_string(),
Category::Gray => "Gray".to_string(),
Category::Unknown => "Unknown".to_string(),
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,6 @@
use std::{str::FromStr, sync::Arc};
use color_card::Category;
use palette::{
cam16::{Cam16Jch, Parameters},
color_difference::Wcag21RelativeContrast,
@ -7,8 +8,10 @@ use palette::{
convert::FromColorUnclamped,
Darken, FromColor, Hsl, IntoColor, IsWithinBounds, Lab, Lighten, Mix, Oklch, ShiftHue, Srgb,
};
use strum::IntoEnumIterator;
use wasm_bindgen::prelude::*;
mod color_card;
mod errors;
#[wasm_bindgen]
@ -377,3 +380,45 @@ pub fn tonal_darken_series(
Ok(color_series.into_iter().rev().collect())
}
#[wasm_bindgen]
pub fn color_categories() -> Result<JsValue, String> {
let categories = Category::iter()
.map(|variant| {
serde_json::json!({
"label": variant.label(),
"value": variant.to_string(),
})
})
.collect::<Vec<_>>();
serde_wasm_bindgen::to_value(&categories).map_err(|e| e.to_string())
}
#[wasm_bindgen]
pub fn search_color_cards(tag: String, category: Option<String>) -> Result<JsValue, String> {
let selected_category = category.and_then(|c| Category::from_str(&c).ok());
let all_cards = &*color_card::COLOR_CARDS;
let mut cards = all_cards
.iter()
.filter(|card| card.tags.contains(&tag))
.filter(|card| {
if let Some(category) = &selected_category {
let card_category = Category::from_oklch(&card.oklch);
card_category == *category
} else {
true
}
})
.collect::<Vec<_>>();
cards.sort_by(|a, b| {
a.oklch[2]
.partial_cmp(&b.oklch[2])
.or_else(|| a.oklch[1].partial_cmp(&b.oklch[1]))
.or_else(|| a.oklch[0].partial_cmp(&b.oklch[0]))
.unwrap_or(std::cmp::Ordering::Equal)
});
serde_wasm_bindgen::to_value(&cards).map_err(|e| e.to_string())
}

View File

@ -1,6 +1,8 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { ColorFunctionProvider } from './ColorFunctionContext';
import { Notifications } from './components/Notifications';
import { ColorCards } from './pages/Cards';
import { CardsDetail } from './pages/CardsDetail';
import { Harmonies } from './pages/Harmonies';
import { Home } from './pages/Home';
import { LightenDarken } from './pages/LightenDarken';
@ -12,6 +14,7 @@ import { SchemeNotFound } from './pages/SchemeNotFound';
import { Schemes } from './pages/Schemes';
import { TintsShades } from './pages/TintsShades';
import { Tones } from './pages/Tones';
import { WACGCheck } from './pages/WACG';
import { Wheels } from './pages/Wheels';
const routes = createBrowserRouter([
@ -35,6 +38,15 @@ const routes = createBrowserRouter([
{ path: 'tints-shades', element: <TintsShades /> },
{ path: 'lighten-darken', element: <LightenDarken /> },
{ path: 'mixer', element: <Mixer /> },
{ path: 'wacg', element: <WACGCheck /> },
{
path: 'cards',
element: <ColorCards />,
children: [
{ path: 'chinese', element: <CardsDetail mainTag="chinese" /> },
{ path: 'japanese', element: <CardsDetail mainTag="japanese" /> },
],
},
],
},
]);

View File

@ -28,6 +28,8 @@ export function triadic(color: string): (string)[];
export function series(color: string, expand_amount: number, step: number): (string)[];
export function tonal_lighten_series(color: string, expand_amount: number, step: number): (string)[];
export function tonal_darken_series(color: string, expand_amount: number, step: number): (string)[];
export function color_categories(): any;
export function search_color_cards(tag: string, category?: string): any;
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
@ -61,9 +63,11 @@ export interface InitOutput {
readonly series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly tonal_lighten_series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly tonal_darken_series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
readonly __wbindgen_export_0: WebAssembly.Table;
readonly color_categories: () => [number, number, number];
readonly search_color_cards: (a: number, b: number, c: number, d: number) => [number, number, number];
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __wbindgen_export_2: WebAssembly.Table;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __externref_drop_slice: (a: number, b: number) => void;

View File

@ -1,8 +1,6 @@
let wasm;
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
let WASM_VECTOR_LEN = 0;
let cachedUint8ArrayMemory0 = null;
@ -13,13 +11,6 @@ function getUint8ArrayMemory0() {
return cachedUint8ArrayMemory0;
}
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
let WASM_VECTOR_LEN = 0;
const cachedTextEncoder = (typeof TextEncoder !== 'undefined' ? new TextEncoder('utf-8') : { encode: () => { throw Error('TextEncoder not available') } } );
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
@ -74,8 +65,26 @@ function passStringToWasm0(arg, malloc, realloc) {
return ptr;
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );
if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); };
function getStringFromWasm0(ptr, len) {
ptr = ptr >>> 0;
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
}
function takeFromExternrefTable0(idx) {
const value = wasm.__wbindgen_export_0.get(idx);
const value = wasm.__wbindgen_export_2.get(idx);
wasm.__externref_table_dealloc(idx);
return value;
}
@ -530,21 +539,12 @@ export function wacg_relative_contrast(fg_color, bg_color) {
return ret[0];
}
let cachedDataViewMemory0 = null;
function getDataViewMemory0() {
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
}
return cachedDataViewMemory0;
}
function getArrayJsValueFromWasm0(ptr, len) {
ptr = ptr >>> 0;
const mem = getDataViewMemory0();
const result = [];
for (let i = ptr; i < ptr + 4 * len; i += 4) {
result.push(wasm.__wbindgen_export_0.get(mem.getUint32(i, true)));
result.push(wasm.__wbindgen_export_2.get(mem.getUint32(i, true)));
}
wasm.__externref_drop_slice(ptr, len);
return result;
@ -708,6 +708,37 @@ export function tonal_darken_series(color, expand_amount, step) {
return v2;
}
/**
* @returns {any}
*/
export function color_categories() {
const ret = wasm.color_categories();
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
function isLikeNone(x) {
return x === undefined || x === null;
}
/**
* @param {string} tag
* @param {string | undefined} [category]
* @returns {any}
*/
export function search_color_cards(tag, category) {
const ptr0 = passStringToWasm0(tag, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
var ptr1 = isLikeNone(category) ? 0 : passStringToWasm0(category, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
const ret = wasm.search_color_cards(ptr0, len0, ptr1, len1);
if (ret[2]) {
throw takeFromExternrefTable0(ret[1]);
}
return takeFromExternrefTable0(ret[0]);
}
async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') {
@ -742,8 +773,49 @@ async function __wbg_load(module, imports) {
function __wbg_get_imports() {
const imports = {};
imports.wbg = {};
imports.wbg.__wbg_String_8f0eb39a4a4c2f66 = function(arg0, arg1) {
const ret = String(arg1);
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbg_new_254fa9eac11932ae = function() {
const ret = new Array();
return ret;
};
imports.wbg.__wbg_new_688846f374351c92 = function() {
const ret = new Object();
return ret;
};
imports.wbg.__wbg_new_bc96c6a1c0786643 = function() {
const ret = new Map();
return ret;
};
imports.wbg.__wbg_set_1d80752d0d5f0b21 = function(arg0, arg1, arg2) {
arg0[arg1 >>> 0] = arg2;
};
imports.wbg.__wbg_set_3f1d0b984ed272ed = function(arg0, arg1, arg2) {
arg0[arg1] = arg2;
};
imports.wbg.__wbg_set_76818dc3c59a63d5 = function(arg0, arg1, arg2) {
const ret = arg0.set(arg1, arg2);
return ret;
};
imports.wbg.__wbindgen_bigint_from_i64 = function(arg0) {
const ret = arg0;
return ret;
};
imports.wbg.__wbindgen_bigint_from_u64 = function(arg0) {
const ret = BigInt.asUintN(64, arg0);
return ret;
};
imports.wbg.__wbindgen_error_new = function(arg0, arg1) {
const ret = new Error(getStringFromWasm0(arg0, arg1));
return ret;
};
imports.wbg.__wbindgen_init_externref_table = function() {
const table = wasm.__wbindgen_export_0;
const table = wasm.__wbindgen_export_2;
const offset = table.grow(4);
table.set(0, undefined);
table.set(offset + 0, undefined);
@ -752,10 +824,21 @@ function __wbg_get_imports() {
table.set(offset + 3, false);
;
};
imports.wbg.__wbindgen_is_string = function(arg0) {
const ret = typeof(arg0) === 'string';
return ret;
};
imports.wbg.__wbindgen_number_new = function(arg0) {
const ret = arg0;
return ret;
};
imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1);
return ret;
};
imports.wbg.__wbindgen_throw = function(arg0, arg1) {
throw new Error(getStringFromWasm0(arg0, arg1));
};
return imports;
}

View File

@ -29,9 +29,11 @@ export const triadic: (a: number, b: number) => [number, number, number, number]
export const series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
export const tonal_lighten_series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
export const tonal_darken_series: (a: number, b: number, c: number, d: number) => [number, number, number, number];
export const __wbindgen_export_0: WebAssembly.Table;
export const color_categories: () => [number, number, number];
export const search_color_cards: (a: number, b: number, c: number, d: number) => [number, number, number];
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_export_2: WebAssembly.Table;
export const __externref_table_dealloc: (a: number) => void;
export const __wbindgen_free: (a: number, b: number, c: number) => void;
export const __externref_drop_slice: (a: number, b: number) => void;

View File

@ -170,7 +170,7 @@
}
/* 输入框以及输入框组合体默认样式 */
:where(input, textarea) {
:where(input, textarea, select) {
border: 1px solid oklch(from var(--color-bg) calc(l + (1 - l) * 0.1) c h);
border-radius: var(--border-radius-xxs);
padding: calc(var(--spacing) * 2) calc(var(--spacing) * 4);
@ -385,4 +385,42 @@
border-radius: var(--border-radius-xxs);
}
}
/* Badge */
.badge {
display: inline-flex;
justify-content: center;
align-items: center;
padding: var(--spacing-xs) var(--spacing-m);
border-radius: var(--border-radius-xxs);
font-size: var(--font-size-m);
font-weight: bold;
line-height: 1.2em;
color: var(--color-fg);
background-color: var(--color-neutral);
&.uppercase {
text-transform: uppercase;
}
&.primary {
background-color: var(--color-primary);
}
&.secondary {
background-color: var(--color-secondary);
}
&.accent {
background-color: var(--color-accent);
}
&.danger {
background-color: var(--color-danger);
}
&.warn {
background-color: var(--color-warn);
}
&.success {
background-color: var(--color-success);
}
&.info {
background-color: var(--color-info);
}
}
}

View File

@ -7,3 +7,16 @@ export type HarmonyColor = {
color: string;
ratio: number;
};
export type ColorDescription = {
name: string;
pinyin: string[];
hue: number;
lightness: number;
category: string;
tags: string[];
rgb: [number, number, number];
hsl: [number, number, number];
lab: [number, number, number];
oklch: [number, number, number];
};

View File

@ -0,0 +1,42 @@
@layer pages {
.card {
width: 100%;
display: flex;
flex-direction: column;
gap: var(--spacing-n);
font-size: var(--font-size-xxs);
line-height: var(--font-size-xxs);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-xs);
cursor: pointer;
}
.color_block {
width: 100%;
height: 5em;
}
.description_line {
display: flex;
flex-direction: row;
align-items: center;
padding: var(--spacing-xs) var(--spacing-s);
}
.title {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-xxs);
flex: 1;
.name {
font-size: var(--font-size-xs);
line-height: var(--font-size-xs);
font-weight: bold;
}
.en_name {
font-style: italic;
color: var(--color-neutral-focus);
}
}
.color_value {
text-transform: uppercase;
}
}

View File

@ -0,0 +1,76 @@
import { capitalize, isEmpty } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import { useColorFunction } from '../../ColorFunctionContext';
import { useCopyColor } from '../../hooks/useCopyColor';
import { ColorDescription } from '../../models';
import styles from './ColorCard.module.css';
type ColorCardProps = {
color: ColorDescription;
copyMode?: 'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch';
};
export function ColorCard({ color, copyMode }: ColorCardProps) {
const { colorFn } = useColorFunction();
const copytToClipboard = useCopyColor();
const colorHex = useMemo(() => {
const [r, g, b] = color.rgb;
if (colorFn) {
try {
const hex = colorFn.rgb_to_hex(r, g, b);
return hex;
} catch (e) {
console.error('[Convert RGB]', e);
}
}
return `${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}, [colorFn, color]);
const handleCopy = useCallback(() => {
switch (copyMode) {
case 'rgb':
copytToClipboard(`rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})`);
break;
case 'hsl':
copytToClipboard(
`hsl(${color.hsl[0].toFixed(1)}, ${(color.hsl[1] * 100).toFixed(2)}%, ${(
color.hsl[2] * 100
).toFixed(2)}%)`,
);
break;
case 'lab':
copytToClipboard(
`lab(${color.lab[0].toFixed(1)}, ${color.lab[1].toFixed(2)}, ${color.lab[2].toFixed(2)})`,
);
break;
case 'oklch':
copytToClipboard(
`oklch(${(color.oklch[0] * 100).toFixed(2)}%, ${color.oklch[1].toFixed(
4,
)}, ${color.oklch[2].toFixed(1)})`,
);
break;
case 'hex':
default:
copytToClipboard(`#${colorHex}`);
break;
}
}, [copytToClipboard, color, copyMode, colorHex]);
return (
<div className={styles.card} onClick={handleCopy}>
<div
className={styles.color_block}
style={{ backgroundColor: `rgb(${color.rgb[0]}, ${color.rgb[1]}, ${color.rgb[2]})` }}
/>
<div className={styles.description_line}>
<div className={styles.title}>
<span className={styles.name}>{color.name}</span>
{!isEmpty(color.pinyin) && (
<span className={styles.en_name}>{color.pinyin.map(capitalize).join(' ')}</span>
)}
</div>
<div className={styles.color_value}>#{colorHex}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,26 @@
import cx from 'clsx';
import { NavLink } from 'react-router-dom';
import styles from './CardsNavigation.module.css';
export function CardsNavigation() {
return (
<div className={styles.cards_list}>
<menu className={styles.nav_menu}>
<li>
<NavLink
to="chinese"
className={({ isActive }) => cx(styles.nav_link, isActive && styles.active)}>
Chinese Traditional
</NavLink>
</li>
<li>
<NavLink
to="japanese"
className={({ isActive }) => cx(styles.nav_link, isActive && styles.active)}>
Japanese Traditional
</NavLink>
</li>
</menu>
</div>
);
}

View File

@ -0,0 +1,33 @@
@layer pages {
.cards_list {
max-width: calc(var(--spacing) * 125);
flex: 1 1 calc(var(--spacing) * 125);
padding: calc(var(--spacing) * 4) 0;
box-shadow: 2px 0 8px oklch(from var(--color-black) l c h / 65%);
z-index: 40;
}
.nav_menu {
flex: 1 0;
padding: var(--spacing-n);
padding-block-start: var(--spacing-s);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-xs);
li {
list-style: none;
a.nav_link {
display: inline-block;
width: 100%;
padding-inline: var(--spacing-l);
padding-block: var(--spacing-s);
&.active {
background-color: var(--color-primary-active);
}
&:hover {
background-color: var(--color-primary-hover);
}
}
}
}
}

View File

@ -70,6 +70,13 @@ export function NavigationMenu() {
WACG Check
</NavLink>
</li>
<li>
<NavLink
to="/compare"
className={({ isActive }) => cx(styles.nav_link, isActive && styles.active)}>
Compare
</NavLink>
</li>
<li>
<NavLink
to="/cards"

View File

@ -0,0 +1,13 @@
@layer pages {
.ratio_layout {
width: 100%;
display: flex;
flex-direction: row;
justify-content: center;
padding: var(--spacing-s) var(--spacing-m);
.ratio {
font-size: calc(var(--font-size) * 5);
font-weight: bold;
}
}
}

View File

@ -0,0 +1,13 @@
import styles from './Ratio.module.css';
type ContrastRatioProps = {
ratio: number;
};
export function ContrastRatio({ ratio }: ContrastRatioProps) {
return (
<div className={styles.ratio_layout}>
<div className={styles.ratio}>{ratio.toFixed(2)} : 1</div>
</div>
);
}

View File

@ -0,0 +1,46 @@
@layer pages {
.demo_block {
border: 1px solid var(--color-border);
border-radius: var(--border-radius-xxs);
padding: var(--spacing-m) var(--spacing-s);
display: flex;
flex-direction: column;
align-items: center;
white-space: nowrap;
gap: var(--spacing-s);
.normal_text {
font-size: 14pt;
}
.large_text {
font-size: 18pt;
}
.bold_text {
font-size: 14pt;
font-weight: bold;
}
}
.wacg_rating {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-s);
.rating_unit {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-m);
}
}
.sub_header {
display: flex;
flex-direction: row;
align-items: baseline;
gap: var(--spacing-m);
}
.description {
font-size: var(--font-size-xs);
color: var(--color-neutral-focus);
}
}

View File

@ -0,0 +1,61 @@
import cx from 'clsx';
import styles from './TextDemo.module.css';
type TextDemoProps = {
fg: string;
bg: string;
ratio: number;
};
export function TextDemo({ fg, bg, ratio }: TextDemoProps) {
return (
<>
<header className={styles.sub_header}>
<h5>Normal Text</h5>
<p className={styles.description}>14pt normal weight text.</p>
</header>
<div className={styles.demo_block} style={{ backgroundColor: `#${bg}`, color: `#${fg}` }}>
<div className={styles.normal_text}>The quick brown fox jumps over the lazy dog.</div>
<div className={styles.normal_text}></div>
</div>
<div className={styles.wacg_rating}>
<div className={styles.rating_unit}>
<span>WACG AA:</span>
<span className={cx('badge', 'uppercase', ratio > 4.5 ? 'success' : 'danger')}>
{ratio > 4.5 ? 'pass' : 'failed'}
</span>
</div>
<div className={styles.rating_unit}>
<span>WACG AAA:</span>
<span className={cx('badge', 'uppercase', ratio > 7 ? 'success' : 'danger')}>
{ratio > 7 ? 'pass' : 'failed'}
</span>
</div>
</div>
<header className={styles.sub_header}>
<h5>Large/Bold Text</h5>
<p className={styles.description}>18pt normal weight text and 14pt bold text.</p>
</header>
<div className={styles.demo_block} style={{ backgroundColor: `#${bg}`, color: `#${fg}` }}>
<div className={styles.large_text}>The quick brown fox jumps over the lazy dog.</div>
<div className={styles.large_text}></div>
<div className={styles.bold_text}>The quick brown fox jumps over the lazy dog.</div>
<div className={styles.bold_text}></div>
</div>
<div className={styles.wacg_rating}>
<div className={styles.rating_unit}>
<span>WACG AA:</span>
<span className={cx('badge', 'uppercase', ratio > 3 ? 'success' : 'danger')}>
{ratio > 3 ? 'pass' : 'failed'}
</span>
</div>
<div className={styles.rating_unit}>
<span>WACG AAA:</span>
<span className={cx('badge', 'uppercase', ratio > 4.5 ? 'success' : 'danger')}>
{ratio > 4.5 ? 'pass' : 'failed'}
</span>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,13 @@
@layer pages {
.cards_workspace {
height: 100%;
width: 100%;
overflow: hidden;
display: flex;
flex-direction: row;
align-items: stretch;
}
.cards_container {
flex: 1 0;
}
}

14
src/pages/Cards.tsx Normal file
View File

@ -0,0 +1,14 @@
import { Outlet } from 'react-router-dom';
import { CardsNavigation } from '../page-components/color-cards/CardNavigation';
import styles from './Cards.module.css';
export function ColorCards() {
return (
<div className={styles.cards_workspace}>
<CardsNavigation />
<div className={styles.cards_container}>
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,28 @@
@layer pages {
.cards_workspace {
padding: var(--spacing-l) var(--spacing-m);
flex-direction: column;
.filters {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-s);
.cate_select {
padding: var(--spacing-xxs) var(--spacing-s);
min-width: 7em;
font-size: var(--font-size-s);
}
}
.cards_container {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--spacing-s);
padding: 0 var(--spacing-s);
.card {
flex-basis: calc((100% - var(--spacing-s) * 4) / 5);
}
}
}
}

102
src/pages/CardsDetail.tsx Normal file
View File

@ -0,0 +1,102 @@
import cx from 'clsx';
import { isEqual } from 'lodash-es';
import { ChangeEvent, useMemo, useState } from 'react';
import { useColorFunction } from '../ColorFunctionContext';
import { HSegmentedControl } from '../components/HSegmentedControl';
import { ScrollArea } from '../components/ScrollArea';
import { ColorDescription } from '../models';
import { ColorCard } from '../page-components/cards-detail/ColorCard';
import styles from './CardsDetail.module.css';
type CardsDetailProps = {
mainTag: string;
};
export function CardsDetail({ mainTag }: CardsDetailProps) {
const { colorFn } = useColorFunction();
const categories = useMemo(() => {
if (!colorFn) {
return [];
}
try {
const embededCategories = colorFn.color_categories() as { label: string; value: string }[];
return embededCategories.filter((cate) => !isEqual(cate.get('value'), 'unknown'));
} catch (e) {
console.error('[Fetch color categories]', e);
}
return [];
}, [colorFn]);
const [colorCategory, setCategory] = useState<string | 'null'>('null');
const handleSelectCategory = (e: ChangeEvent<HTMLSelectElement>) => {
const selectedValue = e.target.value;
setCategory(selectedValue);
};
const [mode, setMode] = useState<'hex' | 'rgb' | 'hsl' | 'lab' | 'oklch'>('hex');
const colors = useMemo(() => {
if (!colorFn) {
return [];
}
try {
const colorCate = isEqual(colorCategory, 'null') ? undefined : colorCategory;
let tag = '';
switch (mainTag) {
case 'japanese':
tag = 'japanese_traditional';
break;
case 'chinese':
tag = 'chinese_traditional';
break;
default:
tag = '';
break;
}
const embedColors = colorFn.search_color_cards(tag, colorCate) as ColorDescription[];
console.debug('[Fetch cards]', embedColors);
return embedColors;
} catch (e) {
console.error('[Fetch colors]', e);
}
return [];
}, [colorFn, mainTag, colorCategory]);
return (
<div className={cx('workspace', styles.cards_workspace)}>
<div className={styles.filters}>
<span>Show</span>
<select
className={styles.cate_select}
value={colorCategory}
onChange={handleSelectCategory}>
<option value="null">All</option>
{categories.map((cate, index) => (
<option key={`${cate.get('value')}-${index}`} value={cate.get('value')}>
{cate.get('label')}
</option>
))}
</select>
<span>colors.</span>
<div>Copy color value in</div>
<HSegmentedControl
options={[
{ label: 'HEX', value: 'hex' },
{ label: 'RGB', value: 'rgb' },
{ label: 'HSL', value: 'hsl' },
{ label: 'LAB', value: 'lab' },
{ label: 'OKLCH', value: 'oklch' },
]}
value={mode}
onChange={setMode}
/>
</div>
<ScrollArea enableY>
<div className={styles.cards_container}>
{colors.map((c, index) => (
<div key={`${c.name}-${index}`} className={styles.card}>
<ColorCard color={c} copyMode={mode} />
</div>
))}
</div>
</ScrollArea>
</div>
);
}

40
src/pages/WACG.module.css Normal file
View File

@ -0,0 +1,40 @@
@layer pages {
.wacg_workspace {
flex-direction: column;
}
.explore_section {
width: 100%;
flex: 1;
display: flex;
flex-direction: row;
align-items: stretch;
gap: var(--spacing-m);
}
.function_side {
display: flex;
flex-direction: column;
gap: var(--spacing-m);
font-size: var(--font-size-s);
.mode_navigation {
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-s);
}
h5 {
padding-block: var(--spacing-m);
font-size: var(--font-size-m);
}
}
.wacg_content {
flex: 1;
padding: 0 var(--spacing-m);
display: flex;
flex-direction: column;
gap: var(--spacing-s);
h5 {
padding-block: var(--spacing-m);
font-size: var(--font-size-m);
}
}
}

47
src/pages/WACG.tsx Normal file
View File

@ -0,0 +1,47 @@
import cx from 'clsx';
import { useMemo, useState } from 'react';
import { useColorFunction } from '../ColorFunctionContext';
import { ColorPicker } from '../components/ColorPicker';
import { ScrollArea } from '../components/ScrollArea';
import { ContrastRatio } from '../page-components/wacg/Ratio';
import { TextDemo } from '../page-components/wacg/TextDemo';
import styles from './WACG.module.css';
export function WACGCheck() {
const { colorFn } = useColorFunction();
const [fgColor, setFgColor] = useState('ffffff');
const [bgColor, setBgColor] = useState('000000');
const contrastRatio = useMemo(() => {
try {
if (!colorFn) return 1;
const ratio = colorFn.wacg_relative_contrast(fgColor, bgColor);
return ratio;
} catch (e) {
console.error('[WACG Check]', e);
}
return 1;
}, [fgColor, bgColor]);
return (
<div className={cx('workspace', styles.wacg_workspace)}>
<header>
<h3>WACG Check</h3>
</header>
<ScrollArea enableY>
<section className={styles.explore_section}>
<aside className={styles.function_side}>
<h5>Foreground Color</h5>
<ColorPicker color={fgColor} onSelect={setFgColor} />
<h5>Background Color</h5>
<ColorPicker color={bgColor} onSelect={setBgColor} />
</aside>
<div className={styles.wacg_content}>
<h5>WACG Contrast Ratio</h5>
<ContrastRatio ratio={contrastRatio} />
<TextDemo fg={fgColor} bg={bgColor} ratio={contrastRatio} />
</div>
</section>
</ScrollArea>
</div>
);
}