@@ -2,7 +2,7 @@
|
||||
|
||||
> Modern, cross-platform native desktop application for printer profiling, powered by ArgyllCMS.
|
||||
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://git.i3omb.com/gronod/ICCery)
|
||||
[](https://tauri.app)
|
||||
[](LICENCE.md)
|
||||
|
||||
+2
-1
@@ -79,7 +79,8 @@ ICCery is a native, cross-platform desktop application built with:
|
||||
- [x] **Stage 1 Layout Normalization & Stage 2 Deterministic Target Generation (#162, #163)** (`v0.5.5`): Reorganized Stage 1 Advanced Options into structured 2-column grids with normalized heights; enforced deterministic `-R 1` target generation with custom seed and raster order (`-r`) support in Stage 2.
|
||||
- [x] **macOS Universal Binary Target (#164)** (`v0.5.5`): Added macOS Universal Binary (`universal-apple-darwin`) build target combining Intel (`x86_64`) and Apple Silicon (`arm64`), ArgyllCMS universal sidecar packaging, runtime fallback resolution, and CI release asset automation.
|
||||
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.6.8`)
|
||||
### Milestone 11 — Enterprise Colour Workflow (`v0.6.0` – `v0.7.0`)
|
||||
- [x] **Workflow & Visualizer Enhancements (#176, #177, #178, #179)** (`v0.7.0`): Stage 4 OBA/FWA compensation and viewing conditions UI (`-c`, `-d`), global button standardization, Stage 3 swatch grid diagonal split rendering for CIEDE2000 visual comparison, and robust gamut `.gam` dual-table face parsing with regex fixes for profcheck.
|
||||
- [x] **CGATS Dataset Interoperability (#94)** (`v0.6.0`): Native Rust CGATS and Argyll `.ti3` dataset parser, canonical normalizer (0-255 scaling, field aliasing, metadata synthesis), and direct-jump workflow to Stage 4 (Profile Generation) and Stage 5 (Verification) using imported external datasets.
|
||||
- [x] **Stage 1 Layout Normalization (#162)** (`v0.6.1`): Standardized control heights and structural flexbox auto-margin layout for multi-column Stage 1 advanced settings.
|
||||
- [x] **Overlay Tooltip Rendering (#171)** (`v0.6.2`): Rendered tooltips as absolute overlay popups on hover/focus to prevent layout jitter while preserving in-flow hints in global tooltip toggle mode.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "iccery",
|
||||
"private": true,
|
||||
"version": "0.6.8",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"fetch-argyll": "node scripts/fetch-argyll.mjs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "iccery"
|
||||
version = "0.6.8"
|
||||
version = "0.7.0"
|
||||
description = "Modern Printer Profiling UI frontend for ArgyllCMS"
|
||||
authors = ["Gordon"]
|
||||
edition = "2021"
|
||||
|
||||
+184
-21
@@ -968,38 +968,81 @@ pub struct ColprofConfig {
|
||||
pub description: Option<String>,
|
||||
pub basename: String,
|
||||
pub cwd: String,
|
||||
#[serde(default)]
|
||||
pub fwa: Option<String>,
|
||||
#[serde(default)]
|
||||
pub illuminant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub observer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub input_viewing_cond: Option<String>,
|
||||
#[serde(default)]
|
||||
pub output_viewing_cond: Option<String>,
|
||||
}
|
||||
|
||||
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
|
||||
let mut args = vec![
|
||||
"-v".to_string(),
|
||||
"-a".to_string(),
|
||||
config.algorithm.clone(),
|
||||
"-q".to_string(),
|
||||
config.quality.clone(),
|
||||
];
|
||||
let mut args = vec!["-v".to_string(), "-a".to_string(), config.algorithm.clone(), "-q".to_string(), config.quality.clone()];
|
||||
|
||||
if let Some(ref intent) = config.intent {
|
||||
if !intent.trim().is_empty() {
|
||||
args.push("-p".to_string());
|
||||
if !intent.is_empty() {
|
||||
args.push("-t".to_string());
|
||||
args.push(intent.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref cr) = config.copyright {
|
||||
if !cr.trim().is_empty() {
|
||||
args.push("-C".to_string());
|
||||
args.push(cr.clone());
|
||||
if let Some(ref fwa) = config.fwa {
|
||||
if fwa.to_lowercase() != "none" {
|
||||
if fwa.is_empty() {
|
||||
args.push("-f".to_string());
|
||||
} else {
|
||||
args.push("-f".to_string());
|
||||
args.push(fwa.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref illuminant) = config.illuminant {
|
||||
if !illuminant.is_empty() {
|
||||
args.push("-i".to_string());
|
||||
args.push(illuminant.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref observer) = config.observer {
|
||||
if !observer.is_empty() {
|
||||
args.push("-o".to_string());
|
||||
args.push(observer.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref in_cond) = config.input_viewing_cond {
|
||||
if !in_cond.is_empty() && in_cond.to_lowercase() != "none" {
|
||||
args.push("-c".to_string());
|
||||
args.push(in_cond.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref out_cond) = config.output_viewing_cond {
|
||||
if !out_cond.is_empty() && out_cond.to_lowercase() != "none" {
|
||||
args.push("-d".to_string());
|
||||
args.push(out_cond.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref desc) = config.description {
|
||||
if !desc.trim().is_empty() {
|
||||
if !desc.is_empty() {
|
||||
args.push("-D".to_string());
|
||||
args.push(desc.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref copyright) = config.copyright {
|
||||
if !copyright.is_empty() {
|
||||
args.push("-C".to_string());
|
||||
args.push(copyright.clone());
|
||||
}
|
||||
}
|
||||
|
||||
args.push(config.basename.clone());
|
||||
args
|
||||
}
|
||||
@@ -1602,18 +1645,138 @@ mod tests {
|
||||
#[test]
|
||||
fn test_build_colprof_args() {
|
||||
let config = ColprofConfig {
|
||||
quality: "h".to_string(),
|
||||
algorithm: "l".to_string(),
|
||||
intent: None,
|
||||
description: Some("My Profile".to_string()),
|
||||
copyright: Some("2026 ACME".to_string()),
|
||||
basename: "my_profile".to_string(),
|
||||
cwd: "/home/user".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: Some("p".to_string()),
|
||||
copyright: Some("Test".to_string()),
|
||||
description: Some("Test Desc".to_string()),
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-C", "2026 ACME", "-D", "My Profile", "my_profile"]
|
||||
vec!["-v", "-a", "l", "-q", "h", "-t", "p", "-D", "Test Desc", "-C", "Test", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_fwa_d50() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("D50".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-f", "D50", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_fwa_none() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("none".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_custom_sp() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: Some("/path/to/viewing_booth.sp".to_string()),
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-f", "/path/to/viewing_booth.sp", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_illuminant_observer() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: Some("D65".to_string()),
|
||||
observer: Some("1964_10".to_string()),
|
||||
input_viewing_cond: None,
|
||||
output_viewing_cond: None,
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-i", "D65", "-o", "1964_10", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_colprof_args_with_viewing_conditions() {
|
||||
let config = ColprofConfig {
|
||||
algorithm: "l".to_string(),
|
||||
quality: "h".to_string(),
|
||||
intent: None,
|
||||
copyright: None,
|
||||
description: None,
|
||||
basename: "basename".to_string(),
|
||||
cwd: "".to_string(),
|
||||
fwa: None,
|
||||
illuminant: None,
|
||||
observer: None,
|
||||
input_viewing_cond: Some("pp".to_string()),
|
||||
output_viewing_cond: Some("mt".to_string()),
|
||||
};
|
||||
let args = build_colprof_args(&config);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-v", "-a", "l", "-q", "h", "-c", "pp", "-d", "mt", "basename"]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,16 @@ pub struct ProfilingPreset {
|
||||
pub colprof_algorithm: String,
|
||||
pub colprof_quality: String,
|
||||
pub colprof_intent: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_fwa: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_illuminant: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_observer: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_input_viewing_cond: Option<String>,
|
||||
#[serde(default)]
|
||||
pub colprof_output_viewing_cond: Option<String>,
|
||||
|
||||
// Advanced Stage 1 fields
|
||||
#[serde(default)]
|
||||
@@ -176,6 +186,11 @@ pub fn get_default_presets() -> Vec<ProfilingPreset> {
|
||||
device_power: None,
|
||||
random_seed: Some(1),
|
||||
no_randomize: Some(false),
|
||||
colprof_fwa: Some("D50".to_string()),
|
||||
colprof_illuminant: None,
|
||||
colprof_observer: None,
|
||||
colprof_input_viewing_cond: None,
|
||||
colprof_output_viewing_cond: None,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "ICCery",
|
||||
"version": "0.6.8",
|
||||
"version": "0.7.0",
|
||||
"identifier": "com.gronod.iccery",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
|
||||
+100
-18
@@ -16,13 +16,13 @@
|
||||
<div class="brand">
|
||||
<img src="./assets/ICCery-logo.svg" alt="ICCery" class="main-logo" />
|
||||
<div class="brand-actions">
|
||||
<button id="openSettingsBtn" class="icon-btn" title="Settings" style="display: flex; align-items: center; justify-content: center;">
|
||||
<button id="openSettingsBtn" class="icon-btn" title="Settings">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="openAboutBtn" class="icon-btn" title="About" style="display: flex; align-items: center; justify-content: center;">
|
||||
<button id="openAboutBtn" class="icon-btn" title="About">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
@@ -36,9 +36,13 @@
|
||||
<div class="preset-selector-container" style="padding: 0 16px 12px; border-bottom: 1px solid var(--border-color, #2a2a30);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label for="presetSelect" style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600;">Profiling Preset</label>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<button type="button" id="btnSavePresetModal" class="icon-btn" title="Save current settings as Preset" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">💾</button>
|
||||
<button type="button" id="btnOpenPresetsDialog" class="icon-btn" title="Manage Presets (Import/Export/Delete)" style="font-size: 0.85rem; padding: 2px 4px; border: 1px solid var(--border-color, #333); border-radius: 4px; background: transparent; cursor: pointer;">⚙️</button>
|
||||
<div style="display: flex; gap: 6px; align-items: center; justify-content: flex-end;">
|
||||
<button type="button" id="btnSavePresetModal" class="icon-btn btn-sm" title="Save current settings as Preset">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
|
||||
</button>
|
||||
<button type="button" id="btnOpenPresetsDialog" class="icon-btn btn-sm" title="Manage Presets (Import/Export/Delete)">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<select id="presetSelect" style="width: 100%; font-size: 0.85rem; padding: 6px 8px; border-radius: 6px; background: var(--bg-card, #1a1a22); color: var(--text-color, #eee); border: 1px solid var(--border-color, #333); cursor: pointer;">
|
||||
@@ -65,13 +69,10 @@
|
||||
|
||||
<!-- Stage 1: targen -->
|
||||
<section id="stage-1" class="stage active">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<h2>Generate Target</h2>
|
||||
<p>Configure the colour patches for your profiling target.</p>
|
||||
</div>
|
||||
<button type="button" id="btnToggleAllHelp" class="secondary" style="font-size: 0.75rem; padding: 4px 8px;" title="Toggle tooltip hints for all options">💡 Show Tooltip Hints</button>
|
||||
</div>
|
||||
<h2 style="display:flex; justify-content:space-between; align-items:center;">
|
||||
Define Target
|
||||
<button type="button" id="btnToggleAllHelp" class="secondary btn-sm">Toggle Help Mode</button>
|
||||
</h2>
|
||||
|
||||
<div class="form-container" id="stage1FormContainer">
|
||||
<!-- Basic Settings -->
|
||||
@@ -131,9 +132,9 @@
|
||||
<div class="form-group has-tooltip">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0;">Project Directory & Filename</label>
|
||||
<div>
|
||||
<button type="button" id="btn-import-dataset" class="secondary" style="font-size: 0.75rem; padding: 2px 8px; margin-right: 4px;" title="Import external dataset (.ti3, .csv) and jump to Profiling">📥 Import Dataset...</button>
|
||||
<button type="button" id="btnOpenExisting" class="secondary" style="font-size: 0.75rem; padding: 2px 8px;" title="Open existing .ti1 or .ti2 target file">📂 Open Target...</button>
|
||||
<div style="display:flex; justify-content:flex-end;">
|
||||
<button type="button" id="btn-import-dataset" class="secondary btn-sm" style="margin-right: 4px;">Import Dataset...</button>
|
||||
<button type="button" id="btnOpenExisting" class="secondary btn-sm">Open Target (.ti2/.ti3)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-row has-btn">
|
||||
@@ -304,7 +305,9 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<button class="primary" id="btnGenerate" disabled style="margin-top: 14px;">Generate (.ti1)</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnGenerate" disabled>Generate (.ti1)</button>
|
||||
</div>
|
||||
|
||||
<details class="log-container hidden" id="targenLogContainer">
|
||||
<summary>Process Output</summary>
|
||||
@@ -405,7 +408,7 @@
|
||||
<div class="form-group" style="margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<label style="margin: 0; font-weight: 600;">Target Label & Metadata (Optional)</label>
|
||||
<button type="button" class="secondary" id="btnToggleLabelEdit" style="font-size: 0.75rem; padding: 2px 8px;">✏️ Edit Label</button>
|
||||
<button type="button" class="secondary btn-sm" id="btnToggleLabelEdit">Edit Custom Field</button>
|
||||
</div>
|
||||
<p class="sub-label" style="margin-bottom: 10px;">Printed below test patches to identify hardware, ink, and media used for this profiling run.</p>
|
||||
|
||||
@@ -439,7 +442,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnCreateLayout">Create Layout (.ti2 + .tif)</button>
|
||||
</div>
|
||||
|
||||
<details class="log-container hidden" id="printtargLogContainer">
|
||||
<summary>Process Output</summary>
|
||||
@@ -634,9 +639,84 @@
|
||||
<option value="m">Matrix Only (Simple)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>OBA / FWA Compensation</label>
|
||||
<select id="colprofFwa">
|
||||
<option value="D50" selected>Calculated M1 — D50 FWA Compensation (Recommended)</option>
|
||||
<option value="none">None — Native M0 (No Compensation)</option>
|
||||
<option value="D65">D65 FWA Compensation</option>
|
||||
<option value="custom">Custom Ambient Spectrum (.sp)...</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group hidden" id="colprofCustomSpRow">
|
||||
<label>Custom Spectrum File (.sp)</label>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<input type="text" id="colprofCustomSpPath" placeholder="Select .sp file..." readonly style="flex:1;">
|
||||
<button class="secondary btn-sm" id="btnBrowseCustomSp">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="advanced-section-details">
|
||||
<summary>
|
||||
<span>⚙️ Advanced Spectral & Viewing Options</span>
|
||||
<span style="opacity:0.5; font-size:0.8rem;">▼</span>
|
||||
</summary>
|
||||
<div class="advanced-content">
|
||||
<div class="advanced-grid-2col">
|
||||
<div class="form-group">
|
||||
<label>Standard Illuminant</label>
|
||||
<select id="colprofIlluminant">
|
||||
<option value="" selected>Default (D50)</option>
|
||||
<option value="A">A</option>
|
||||
<option value="C">C</option>
|
||||
<option value="D50M2">D50M2</option>
|
||||
<option value="D65">D65</option>
|
||||
<option value="F5">F5</option>
|
||||
<option value="F8">F8</option>
|
||||
<option value="F10">F10</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Standard Observer</label>
|
||||
<select id="colprofObserver">
|
||||
<option value="" selected>Default (1931 2°)</option>
|
||||
<option value="1964_10">1964 10°</option>
|
||||
<option value="2015_2">2015 2°</option>
|
||||
<option value="2015_10">2015 10°</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Input Viewing Condition</label>
|
||||
<select id="colprofInputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="pc">Critical print evaluation (ISO-3664 P1)</option>
|
||||
<option value="pp">Practical Reflection Print (ISO-3664 P2)</option>
|
||||
<option value="pe">Print evaluation (CIE 116-1995)</option>
|
||||
<option value="pm">Print evaluation (partial mid-tone adaptation)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Output Viewing Condition</label>
|
||||
<select id="colprofOutputViewCond">
|
||||
<option value="none" selected>None</option>
|
||||
<option value="mt">Monitor in typical work environment</option>
|
||||
<option value="mb">Bright monitor in bright environment</option>
|
||||
<option value="md">Monitor in darkened environment</option>
|
||||
<option value="jm">Projector in dim environment</option>
|
||||
<option value="jd">Projector in dark environment</option>
|
||||
<option value="tv">Television/Film Studio</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
|
||||
</div>
|
||||
|
||||
<!-- Spinner and Progress Stage -->
|
||||
<div class="spinner-container hidden" id="colprofSpinnerContainer">
|
||||
@@ -663,7 +743,9 @@
|
||||
<h2>Verify Profile</h2>
|
||||
<p>Check the numerical accuracy of your profile against the original measurement data.</p>
|
||||
|
||||
<button class="primary" id="btnVerify">Run Profile Verification</button>
|
||||
<div class="stage-actions">
|
||||
<button class="primary" id="btnVerify">Verify Profile Accuracy</button>
|
||||
</div>
|
||||
|
||||
<!-- Report Card -->
|
||||
<div class="report-card hidden" id="profcheckReportCard">
|
||||
|
||||
@@ -65,3 +65,11 @@ export function labToCss(lab) {
|
||||
const [r, g, b] = labToSrgb(lab[0], lab[1], lab[2]);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
export function deviceCmykToCss(device) {
|
||||
const c = device[0] / 100, m = device[1] / 100, y = device[2] / 100, k = device[3] / 100;
|
||||
const r = Math.round(255 * (1 - c) * (1 - k));
|
||||
const g = Math.round(255 * (1 - m) * (1 - k));
|
||||
const b = Math.round(255 * (1 - y) * (1 - k));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
@@ -30,9 +30,42 @@ export function initColprof() {
|
||||
const successCard = document.getElementById("colprofSuccessCard");
|
||||
const successInfo = document.getElementById("colprofSuccessInfo");
|
||||
const btnGoToVerify = document.getElementById("btnGoToVerify");
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
const btnBrowseCustomSp = document.getElementById("btnBrowseCustomSp");
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
|
||||
if (!btnCreateProfile) return;
|
||||
|
||||
if (colprofFwa && colprofCustomSpRow) {
|
||||
colprofFwa.addEventListener("change", () => {
|
||||
if (colprofFwa.value === "custom") {
|
||||
colprofCustomSpRow.classList.remove("hidden");
|
||||
} else {
|
||||
colprofCustomSpRow.classList.add("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (btnBrowseCustomSp && colprofCustomSpPath) {
|
||||
btnBrowseCustomSp.addEventListener("click", async () => {
|
||||
try {
|
||||
const selected = await window.__TAURI__.dialog.open({
|
||||
filters: [{ name: 'Spectrum', extensions: ['sp'] }]
|
||||
});
|
||||
if (selected) {
|
||||
colprofCustomSpPath.value = selected;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to open dialog:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
btnCreateProfile.addEventListener("click", async () => {
|
||||
const basename = chartreadBasename || wizardState.basename;
|
||||
const cwd = chartreadCwd || wizardState.cwd;
|
||||
@@ -65,6 +98,11 @@ export function initColprof() {
|
||||
copyright: copyrightInput.value.trim() || null,
|
||||
basename: basename,
|
||||
cwd: cwd,
|
||||
fwa: colprofFwa.value === "custom" ? (colprofCustomSpPath.value || "none") : colprofFwa.value,
|
||||
illuminant: colprofIlluminant.value || null,
|
||||
observer: colprofObserver.value || null,
|
||||
input_viewing_cond: colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null,
|
||||
output_viewing_cond: colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null,
|
||||
};
|
||||
|
||||
const processId = `colprof_${basename}`;
|
||||
|
||||
+71
-49
@@ -87,61 +87,43 @@ function animate() {
|
||||
if (renderer && scene && camera) renderer.render(scene, camera);
|
||||
}
|
||||
|
||||
export function parseCGATS(text) {
|
||||
export function parseGamutFile(text) {
|
||||
const lines = text.split('\n');
|
||||
const vertices = []; // [[L, a, b], ...]
|
||||
const faces = []; // [[v0, v1, v2], ...]
|
||||
let dataStarted = false;
|
||||
const points = [];
|
||||
let dataBlock = 0; // 0 = not in data, 1 = vertices, 2 = faces
|
||||
let fieldCount = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('BEGIN_DATA')) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('NUMBER_OF_FIELDS')) {
|
||||
fieldCount = parseInt(trimmed.split(/\s+/)[1], 10);
|
||||
}
|
||||
if (trimmed === 'BEGIN_DATA') {
|
||||
dataBlock++;
|
||||
dataStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('END_DATA')) break;
|
||||
if (trimmed === 'END_DATA') {
|
||||
dataStarted = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dataStarted) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 4) {
|
||||
const L = parseFloat(parts[1]);
|
||||
const a = parseFloat(parts[2]);
|
||||
const b = parseFloat(parts[3]);
|
||||
if (!isNaN(L) && !isNaN(a) && !isNaN(b)) {
|
||||
points.push({ L, a, b });
|
||||
const parts = trimmed.split(/\s+/).map(Number);
|
||||
if (dataBlock === 1 && parts.length >= 4) {
|
||||
// Vertex: VERTEX_NO LAB_L LAB_A LAB_B
|
||||
vertices.push([parts[1], parts[2], parts[3]]); // [L, a, b]
|
||||
} else if (dataBlock === 2 && parts.length >= 3) {
|
||||
// Face: VERTEX_0 VERTEX_1 VERTEX_2
|
||||
faces.push([parts[0], parts[1], parts[2]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* 3D Convex Hull (QuickHull in 3D) for CIELAB point clouds.
|
||||
* Input: points array of { L, a, b }
|
||||
* Output: { vertices: Float32Array, indices: Uint32Array } or null
|
||||
*/
|
||||
export function compute3DConvexHull(pts) {
|
||||
const faces = computeQuickHull(pts);
|
||||
if (!faces || faces.length === 0) return null;
|
||||
|
||||
const verticesList = [];
|
||||
const indices = [];
|
||||
const ptMap = new Map();
|
||||
|
||||
for (const f of faces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) {
|
||||
ptMap.set(key, verticesList.length / 3);
|
||||
verticesList.push(p.x, p.y, p.z);
|
||||
}
|
||||
indices.push(ptMap.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
vertices: new Float32Array(verticesList),
|
||||
indices: new Uint32Array(indices)
|
||||
};
|
||||
return { vertices, faces };
|
||||
}
|
||||
|
||||
export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
@@ -153,16 +135,56 @@ export function renderGamutFromText(text, color, isWireframe, previousMesh) {
|
||||
if (previousMesh.material) previousMesh.material.dispose();
|
||||
}
|
||||
|
||||
const points = parseCGATS(text);
|
||||
if (points.length < 4) return null;
|
||||
const { vertices, faces } = parseGamutFile(text);
|
||||
if (vertices.length < 4) return null;
|
||||
|
||||
const hull = compute3DConvexHull(points);
|
||||
if (!hull) return null;
|
||||
let geometry = new THREE.BufferGeometry();
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(hull.vertices, 3));
|
||||
geometry.setIndex(Array.from(hull.indices));
|
||||
if (faces.length > 0) {
|
||||
// We have a pre-triangulated mesh
|
||||
const positionArray = new Float32Array(vertices.length * 3);
|
||||
for (let i = 0; i < vertices.length; i++) {
|
||||
const [L, a, b] = vertices[i];
|
||||
positionArray[i * 3] = a; // x = a*
|
||||
positionArray[i * 3 + 1] = L; // y = L*
|
||||
positionArray[i * 3 + 2] = b; // z = b*
|
||||
}
|
||||
|
||||
const indexArray = new Uint32Array(faces.length * 3);
|
||||
for (let i = 0; i < faces.length; i++) {
|
||||
indexArray[i * 3] = faces[i][0];
|
||||
indexArray[i * 3 + 1] = faces[i][1];
|
||||
indexArray[i * 3 + 2] = faces[i][2];
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positionArray, 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(indexArray, 1));
|
||||
geometry.computeVertexNormals();
|
||||
} else {
|
||||
// Fallback to point cloud hull if no faces are present
|
||||
const pts = vertices.map(v => ({ x: v[1], y: v[0], z: v[2] })); // { x: a, y: L, z: b }
|
||||
const hullFaces = computeQuickHull(pts);
|
||||
if (!hullFaces || hullFaces.length === 0) return null;
|
||||
|
||||
const verticesList = [];
|
||||
const indices = [];
|
||||
const ptMap = new Map();
|
||||
|
||||
for (const f of hullFaces) {
|
||||
for (const p of [f.a, f.b, f.c]) {
|
||||
const key = `${p.x}_${p.y}_${p.z}`;
|
||||
if (!ptMap.has(key)) {
|
||||
ptMap.set(key, verticesList.length / 3);
|
||||
verticesList.push(p.x, p.y, p.z);
|
||||
}
|
||||
indices.push(ptMap.get(key));
|
||||
}
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(verticesList), 3));
|
||||
geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1));
|
||||
geometry.computeVertexNormals();
|
||||
}
|
||||
|
||||
const material = new THREE.MeshLambertMaterial({
|
||||
color: color,
|
||||
|
||||
@@ -244,6 +244,32 @@ export async function initPresets() {
|
||||
|
||||
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
|
||||
if (colprofAlgorithm && preset.colprof_algorithm) colprofAlgorithm.value = preset.colprof_algorithm;
|
||||
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpRow = document.getElementById("colprofCustomSpRow");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
if (colprofFwa) {
|
||||
if (preset.colprof_fwa && preset.colprof_fwa.endsWith(".sp")) {
|
||||
colprofFwa.value = "custom";
|
||||
if (colprofCustomSpPath) colprofCustomSpPath.value = preset.colprof_fwa;
|
||||
if (colprofCustomSpRow) colprofCustomSpRow.classList.remove("hidden");
|
||||
} else {
|
||||
colprofFwa.value = preset.colprof_fwa || "D50";
|
||||
if (colprofCustomSpRow) colprofCustomSpRow.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
if (colprofIlluminant) colprofIlluminant.value = preset.colprof_illuminant || "";
|
||||
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
if (colprofObserver) colprofObserver.value = preset.colprof_observer || "";
|
||||
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
if (colprofInputViewCond) colprofInputViewCond.value = preset.colprof_input_viewing_cond || "none";
|
||||
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
if (colprofOutputViewCond) colprofOutputViewCond.value = preset.colprof_output_viewing_cond || "none";
|
||||
}
|
||||
|
||||
function collectCurrentSettingsAsPreset(name, description) {
|
||||
@@ -335,6 +361,29 @@ export async function initPresets() {
|
||||
const colprofAlgorithm = document.getElementById("colprofAlgorithm");
|
||||
const colprof_algorithm = colprofAlgorithm ? colprofAlgorithm.value : "l";
|
||||
|
||||
const colprofFwa = document.getElementById("colprofFwa");
|
||||
const colprofCustomSpPath = document.getElementById("colprofCustomSpPath");
|
||||
let colprof_fwa = "D50";
|
||||
if (colprofFwa) {
|
||||
if (colprofFwa.value === "custom") {
|
||||
colprof_fwa = colprofCustomSpPath ? colprofCustomSpPath.value : "none";
|
||||
} else {
|
||||
colprof_fwa = colprofFwa.value;
|
||||
}
|
||||
}
|
||||
|
||||
const colprofIlluminant = document.getElementById("colprofIlluminant");
|
||||
const colprof_illuminant = colprofIlluminant && colprofIlluminant.value ? colprofIlluminant.value : null;
|
||||
|
||||
const colprofObserver = document.getElementById("colprofObserver");
|
||||
const colprof_observer = colprofObserver && colprofObserver.value ? colprofObserver.value : null;
|
||||
|
||||
const colprofInputViewCond = document.getElementById("colprofInputViewCond");
|
||||
const colprof_input_viewing_cond = colprofInputViewCond && colprofInputViewCond.value !== "none" ? colprofInputViewCond.value : null;
|
||||
|
||||
const colprofOutputViewCond = document.getElementById("colprofOutputViewCond");
|
||||
const colprof_output_viewing_cond = colprofOutputViewCond && colprofOutputViewCond.value !== "none" ? colprofOutputViewCond.value : null;
|
||||
|
||||
return {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: name || "Custom Preset",
|
||||
@@ -363,6 +412,11 @@ export async function initPresets() {
|
||||
colprof_algorithm,
|
||||
colprof_quality,
|
||||
colprof_intent: null,
|
||||
colprof_fwa,
|
||||
colprof_illuminant,
|
||||
colprof_observer,
|
||||
colprof_input_viewing_cond,
|
||||
colprof_output_viewing_cond,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -133,9 +133,9 @@ export function initProfcheck() {
|
||||
}
|
||||
} else {
|
||||
// Regex fallbacks for standard profcheck output
|
||||
const avgMatch = stdout.match(/avg\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/average\s*dE\s*:\s*([\d\.]+)/i);
|
||||
const maxMatch = stdout.match(/max\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/peak\s*dE\s*:\s*([\d\.]+)/i);
|
||||
const rmsMatch = stdout.match(/rms\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/rms\s*dE\s*:\s*([\d\.]+)/i);
|
||||
const avgMatch = stdout.match(/avg\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/average\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
||||
const maxMatch = stdout.match(/max\.\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/peak\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
||||
const rmsMatch = stdout.match(/RMS\s*(?:dE\s*)?=\s*([\d\.]+)/i) || stdout.match(/rms\s*(?:dE\s*)?[:=]\s*([\d\.]+)/i);
|
||||
|
||||
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
|
||||
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
|
||||
|
||||
+34
-11
@@ -1,5 +1,5 @@
|
||||
import { computeDeltaE00 } from './delta_e.js';
|
||||
import { labToCss, deviceRgbToCss } from './color_convert.js';
|
||||
import { labToCss, deviceRgbToCss, deviceCmykToCss } from './color_convert.js';
|
||||
|
||||
const { listen } = window.__TAURI__.event;
|
||||
|
||||
@@ -63,26 +63,47 @@ export async function startSwatchListener(processId, onRowComplete) {
|
||||
rowPatches.className = "swatch-row-patches";
|
||||
|
||||
for (const patch of data.patches) {
|
||||
if (patch.is_pad) continue; // Skip spacer patches
|
||||
if (patch.is_pad && !patch.measured && (!patch.device || patch.device.every(v => v === 0))) continue;
|
||||
|
||||
const patchEl = document.createElement("div");
|
||||
patchEl.className = "swatch-patch";
|
||||
|
||||
// Determine the display colour
|
||||
let bgColor;
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
bgColor = labToCss(patch.measured.Lab);
|
||||
let intendedCss = "#888";
|
||||
if (patch.expected && patch.expected.Lab) {
|
||||
intendedCss = labToCss(patch.expected.Lab);
|
||||
} else if (patch.device && patch.device.length === 3) {
|
||||
bgColor = deviceRgbToCss(patch.device);
|
||||
} else {
|
||||
bgColor = "#888";
|
||||
intendedCss = deviceRgbToCss(patch.device);
|
||||
} else if (patch.device && patch.device.length === 4) {
|
||||
intendedCss = deviceCmykToCss(patch.device);
|
||||
}
|
||||
|
||||
let measuredCss = null;
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
measuredCss = labToCss(patch.measured.Lab);
|
||||
}
|
||||
|
||||
const swatch = document.createElement("div");
|
||||
swatch.className = "swatch-color";
|
||||
swatch.style.backgroundColor = bgColor;
|
||||
|
||||
if (measuredCss) {
|
||||
swatch.style.background = `linear-gradient(135deg, ${intendedCss} 50%, ${measuredCss} 50%)`;
|
||||
} else {
|
||||
swatch.style.backgroundColor = intendedCss;
|
||||
}
|
||||
|
||||
patchEl.appendChild(swatch);
|
||||
|
||||
let titleStr = `${patch.loc} (ID: ${patch.id})`;
|
||||
if (patch.expected && patch.expected.Lab) {
|
||||
titleStr += `\nIntended Lab: ${patch.expected.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
||||
} else if (patch.device) {
|
||||
titleStr += `\nDevice: ${patch.device.map(v => v.toFixed(1)).join(', ')}`;
|
||||
}
|
||||
|
||||
if (patch.measured && patch.measured.Lab) {
|
||||
titleStr += `\nMeasured Lab: ${patch.measured.Lab.map(v => v.toFixed(1)).join(', ')}`;
|
||||
}
|
||||
|
||||
// Compute and display ΔE₀₀ if both expected and measured Lab are present
|
||||
if (patch.expected && patch.expected.Lab && patch.measured && patch.measured.Lab) {
|
||||
const deltaE = computeDeltaE00(patch.expected.Lab, patch.measured.Lab);
|
||||
@@ -91,6 +112,8 @@ export async function startSwatchListener(processId, onRowComplete) {
|
||||
deLabel.className = "swatch-de";
|
||||
deLabel.textContent = deltaE.toFixed(1);
|
||||
|
||||
titleStr += `\nΔE₀₀: ${deltaE.toFixed(2)}`;
|
||||
|
||||
// Traffic light classification
|
||||
if (deltaE < 2) {
|
||||
patchEl.classList.add("de-good"); // Green
|
||||
@@ -108,7 +131,7 @@ export async function startSwatchListener(processId, onRowComplete) {
|
||||
if (deltaE > maxDeltaE) maxDeltaE = deltaE;
|
||||
}
|
||||
|
||||
patchEl.title = `${patch.loc} (ID: ${patch.id})`;
|
||||
patchEl.title = titleStr;
|
||||
rowPatches.appendChild(patchEl);
|
||||
}
|
||||
|
||||
|
||||
+38
-6
@@ -124,19 +124,43 @@ h2 {
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--accent-color), #2d73a8);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 0 18px;
|
||||
height: 36px;
|
||||
font-size: 0.95rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 14px;
|
||||
transition: opacity 0.2s, transform 0.1s;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
background-color: #005f9e;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Button sizing system */
|
||||
.btn-sm { height: 28px; padding: 0 10px; font-size: 0.78rem; border-radius: 4px; }
|
||||
.btn-md { height: 36px; padding: 0 16px; font-size: 0.875rem; border-radius: 6px; }
|
||||
.btn-lg { height: 40px; padding: 0 20px; font-size: 0.95rem; font-weight: 600; border-radius: 6px; }
|
||||
.btn-icon-sq { width: 36px; height: 36px; min-width: 36px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
|
||||
/* Action containers */
|
||||
.stage-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -558,6 +582,8 @@ button.danger:hover {
|
||||
.swatch-color {
|
||||
width: 100%;
|
||||
height: 28px;
|
||||
background-size: 100% 100%;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.swatch-de {
|
||||
@@ -752,10 +778,16 @@ button.danger:hover {
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
color: #d1d5db;
|
||||
transition: color 0.15s ease, background-color 0.15s ease;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
|
||||
Reference in New Issue
Block a user