feat(print): integrate native raw printing engine into Stage 2 UI (#28) #31

Merged
gronod merged 4 commits from feature/issue-28-raw-print-ui into development 2026-08-23 14:47:20 +01:00
6 changed files with 595 additions and 16 deletions
+43 -1
View File
@@ -346,7 +346,7 @@ pub async fn run_profcheck(
pub async fn get_windows_printers() -> Result<Vec<String>, String> { pub async fn get_windows_printers() -> Result<Vec<String>, String> {
#[cfg(windows)] #[cfg(windows)]
{ {
crate::print::windows::get_printers() crate::print::windows::get_printers().map(|list| list.into_iter().map(|p| p.name).collect())
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
@@ -400,6 +400,48 @@ pub async fn print_target_cups(
} }
} }
#[tauri::command]
pub async fn get_printers() -> Result<Vec<crate::print::Printer>, String> {
#[cfg(windows)]
{
crate::print::windows::get_printers()
}
#[cfg(unix)]
{
crate::print::unix::get_printers()
}
#[cfg(not(any(windows, unix)))]
{
Err("Native printer enumeration is not supported on this platform.".to_string())
}
}
#[tauri::command]
pub async fn print_target_native(
printer_name: String,
tiff_path: String,
ppd_uncorrected_passthrough: Option<bool>,
) -> Result<(), String> {
#[cfg(windows)]
{
let _ = ppd_uncorrected_passthrough;
crate::print::windows::print_target(&printer_name, &tiff_path)
}
#[cfg(unix)]
{
crate::print::unix::print_target(
&printer_name,
&tiff_path,
ppd_uncorrected_passthrough.unwrap_or(false),
)
}
#[cfg(not(any(windows, unix)))]
{
let _ = (printer_name, tiff_path, ppd_uncorrected_passthrough);
Err("Native raw printing is not supported on this platform.".to_string())
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+2
View File
@@ -28,6 +28,8 @@ pub fn run() {
commands::print_target_windows, commands::print_target_windows,
commands::get_cups_printers, commands::get_cups_printers,
commands::print_target_cups, commands::print_target_cups,
commands::get_printers,
commands::print_target_native,
settings::load_settings, settings::load_settings,
settings::save_settings, settings::save_settings,
]) ])
+13 -3
View File
@@ -15,12 +15,14 @@ use windows::Win32::Graphics::Printing::{
PRINTER_ENUM_LOCAL, PRINTER_INFO_2W, PRINTER_INFO_4W, PRINTER_ENUM_LOCAL, PRINTER_INFO_2W, PRINTER_INFO_4W,
}; };
use crate::print::Printer;
fn to_wide(s: &str) -> Vec<u16> { fn to_wide(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect() OsStr::new(s).encode_wide().chain(std::iter::once(0)).collect()
} }
/// Enumerate available printers on the system /// Enumerate available printers on the system
pub fn get_printers() -> Result<Vec<String>, String> { pub fn get_printers() -> Result<Vec<Printer>, String> {
unsafe { unsafe {
let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS; let flags = PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS;
let mut bytes_needed = 0u32; let mut bytes_needed = 0u32;
@@ -74,7 +76,11 @@ pub fn get_printers() -> Result<Vec<String>, String> {
let info = *p_info.add(i); let info = *p_info.add(i);
if !info.pPrinterName.is_null() { if !info.pPrinterName.is_null() {
let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?; let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?;
printers.push(name); printers.push(Printer {
name,
status: "Ready".to_string(),
is_default: false,
});
} }
} }
return Ok(printers); return Ok(printers);
@@ -101,7 +107,11 @@ pub fn get_printers() -> Result<Vec<String>, String> {
let info = *p_info.add(i); let info = *p_info.add(i);
if !info.pPrinterName.is_null() { if !info.pPrinterName.is_null() {
let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?; let name = info.pPrinterName.to_string().map_err(|e| e.to_string())?;
printers.push(name); printers.push(Printer {
name,
status: "Ready".to_string(),
is_default: false,
});
} }
} }
Ok(printers) Ok(printers)
+50
View File
@@ -190,6 +190,56 @@
<div class="gallery-info" id="galleryInfo"></div> <div class="gallery-info" id="galleryInfo"></div>
<div class="gallery-grid" id="galleryGrid"></div> <div class="gallery-grid" id="galleryGrid"></div>
</div> </div>
<!-- Native Raw Printing Engine (Bypass Colour Management) -->
<div class="print-panel hidden" id="rawPrintPanel">
<div class="print-panel-header">
<div class="print-panel-title">
<span class="print-icon">🖨️</span>
<h3>Direct Native Raw Printing</h3>
</div>
<span class="acpu-badge" title="Bypasses OS ICM / ColorSync & CUPS color management, replicating Adobe Color Print Utility">ACPU-Equivalent Mode</span>
</div>
<p class="print-panel-desc">
Send the generated profiling target directly to the printer nozzles with all OS and driver color management strictly bypassed.
</p>
<!-- Notification / Feedback Banner -->
<div id="printNotification" class="notification-banner hidden">
<span class="notification-icon" id="printNotificationIcon"></span>
<div class="notification-text" id="printNotificationText"></div>
</div>
<div class="printer-controls-row">
<div class="form-group printer-select-group">
<label for="printerSelect">Destination Printer</label>
<div class="printer-select-wrapper">
<select id="printerSelect">
<option value="" disabled selected>Detecting printers...</option>
</select>
<button type="button" class="btn-icon" id="btnRefreshPrinters" title="Refresh printer list"></button>
<span id="printerStatusBadge" class="status-badge badge-idle hidden">Idle</span>
</div>
</div>
<div class="form-group print-options-group" id="cupsOptionsGroup">
<label class="checkbox-label" title="Enable for printers that do not accept raw TIFF streams (uses uncorrected CUPS passthrough)">
<input type="checkbox" id="chkPpdFallback">
<span>PPD Uncorrected Passthrough (Fallback)</span>
</label>
</div>
</div>
<div class="print-actions-row">
<button class="primary btn-print" id="btnPrintAll">
<span class="btn-text">🖨️ Print All Pages (Bypass CM)</span>
<span class="btn-spinner hidden"></span>
</button>
<button class="secondary" id="btnAdvanceToStage3">
<span>Proceed to Measurement (Stage 3) →</span>
</button>
</div>
</div>
</section> </section>
<!-- Stage 3: chartread --> <!-- Stage 3: chartread -->
+261 -12
View File
@@ -5,6 +5,8 @@ import { setStage2Result } from './chartread.js';
// Module-level state: set by Stage 1 when it completes // Module-level state: set by Stage 1 when it completes
let stage1Basename = ""; let stage1Basename = "";
let stage1Cwd = ""; let stage1Cwd = "";
let currentManifest = null;
let discoveredPrinters = [];
/** /**
* Called by targen.js (or app.js) after Stage 1 completes. * Called by targen.js (or app.js) after Stage 1 completes.
@@ -30,6 +32,18 @@ export function initPrinttarg() {
const galleryInfo = document.getElementById("galleryInfo"); const galleryInfo = document.getElementById("galleryInfo");
const galleryGrid = document.getElementById("galleryGrid"); const galleryGrid = document.getElementById("galleryGrid");
// Raw Printing UI elements
const rawPrintPanel = document.getElementById("rawPrintPanel");
const printerSelect = document.getElementById("printerSelect");
const btnRefreshPrinters = document.getElementById("btnRefreshPrinters");
const printerStatusBadge = document.getElementById("printerStatusBadge");
const chkPpdFallback = document.getElementById("chkPpdFallback");
const btnPrintAll = document.getElementById("btnPrintAll");
const btnAdvanceToStage3 = document.getElementById("btnAdvanceToStage3");
const printNotification = document.getElementById("printNotification");
const printNotificationIcon = document.getElementById("printNotificationIcon");
const printNotificationText = document.getElementById("printNotificationText");
// Show/hide custom page size inputs // Show/hide custom page size inputs
pageSizeSelect.addEventListener("change", (e) => { pageSizeSelect.addEventListener("change", (e) => {
if (e.target.value === "custom") { if (e.target.value === "custom") {
@@ -39,7 +53,126 @@ export function initPrinttarg() {
} }
}); });
// Create Layout button /**
* Display status feedback banners in the raw printing panel.
*/
function showNotification(type, message) {
if (!printNotification) return;
printNotification.className = `notification-banner ${type}`;
printNotification.classList.remove("hidden");
if (printNotificationIcon) {
if (type === "success") printNotificationIcon.textContent = "✓";
else if (type === "error") printNotificationIcon.textContent = "✕";
else printNotificationIcon.textContent = "️";
}
if (printNotificationText) {
printNotificationText.textContent = message;
}
}
function hideNotification() {
if (printNotification) {
printNotification.classList.add("hidden");
}
}
/**
* Update the badge reflecting current printer's status.
*/
function updatePrinterStatusBadge() {
const selectedName = printerSelect.value;
const printer = discoveredPrinters.find(p => p.name === selectedName);
if (!printer || !printerStatusBadge) {
if (printerStatusBadge) printerStatusBadge.classList.add("hidden");
return;
}
printerStatusBadge.classList.remove("hidden", "badge-idle", "badge-ready", "badge-printing", "badge-stopped", "badge-error", "badge-default");
const statusLower = (printer.status || "").toLowerCase();
printerStatusBadge.textContent = printer.status || "Ready";
if (statusLower.includes("print")) {
printerStatusBadge.classList.add("badge-printing");
} else if (statusLower.includes("stop") || statusLower.includes("disable") || statusLower.includes("error")) {
printerStatusBadge.classList.add("badge-stopped");
} else if (statusLower.includes("idle") || statusLower.includes("ready")) {
printerStatusBadge.classList.add("badge-idle");
} else {
printerStatusBadge.classList.add("badge-default");
}
}
/**
* Discover and enumerate all OS-installed printers via Tauri IPC.
*/
async function loadPrinters() {
if (!printerSelect) return;
printerSelect.disabled = true;
printerSelect.innerHTML = '<option value="" disabled selected>Enumerating printers...</option>';
if (printerStatusBadge) printerStatusBadge.classList.add("hidden");
try {
const printers = await invoke("get_printers");
discoveredPrinters = Array.isArray(printers) ? printers : [];
printerSelect.innerHTML = "";
if (discoveredPrinters.length === 0) {
printerSelect.innerHTML = '<option value="" disabled selected>No printers found</option>';
printerSelect.disabled = true;
if (btnPrintAll) btnPrintAll.disabled = true;
return;
}
printerSelect.disabled = false;
let defaultSelected = false;
discoveredPrinters.forEach((p, idx) => {
const opt = document.createElement("option");
opt.value = p.name;
opt.textContent = `${p.name}${p.is_default ? ' (Default)' : ''} [${p.status || 'Ready'}]`;
if (p.is_default && !defaultSelected) {
opt.selected = true;
defaultSelected = true;
}
printerSelect.appendChild(opt);
});
if (!defaultSelected && discoveredPrinters.length > 0) {
printerSelect.selectedIndex = 0;
}
if (btnPrintAll && currentManifest) btnPrintAll.disabled = false;
updatePrinterStatusBadge();
} catch (err) {
console.error("[ICCery Print] Failed to query printers:", err);
printerSelect.innerHTML = `<option value="" disabled selected>Failed to load printers</option>`;
printerSelect.disabled = true;
if (btnPrintAll) btnPrintAll.disabled = true;
showNotification("error", `Could not enumerate system printers: ${err}`);
}
}
if (printerSelect) {
printerSelect.addEventListener("change", () => {
updatePrinterStatusBadge();
});
}
if (btnRefreshPrinters) {
btnRefreshPrinters.addEventListener("click", () => {
loadPrinters();
});
}
// Load initial printer list on startup
loadPrinters();
// Create Layout button handler
btnCreateLayout.addEventListener("click", async () => { btnCreateLayout.addEventListener("click", async () => {
// Validate that Stage 1 has been completed // Validate that Stage 1 has been completed
if (!stage1Basename) { if (!stage1Basename) {
@@ -51,6 +184,8 @@ export function initPrinttarg() {
logPre.textContent = ""; logPre.textContent = "";
logContainer.classList.remove("hidden"); logContainer.classList.remove("hidden");
tiffGallery.classList.add("hidden"); tiffGallery.classList.add("hidden");
if (rawPrintPanel) rawPrintPanel.classList.add("hidden");
hideNotification();
galleryGrid.innerHTML = ""; galleryGrid.innerHTML = "";
btnCreateLayout.disabled = true; btnCreateLayout.disabled = true;
@@ -83,8 +218,6 @@ export function initPrinttarg() {
}; };
const processId = `printtarg_${stage1Basename}`; const processId = `printtarg_${stage1Basename}`;
// Accumulate all stdout lines to extract JSON manifest at the end
let stdoutAccumulator = ""; let stdoutAccumulator = "";
try { try {
@@ -116,11 +249,14 @@ export function initPrinttarg() {
// Parse the JSON manifest from stdout // Parse the JSON manifest from stdout
const manifest = extractManifest(stdoutAccumulator); const manifest = extractManifest(stdoutAccumulator);
if (manifest && manifest.pages && manifest.pages.length > 0) { if (manifest && manifest.pages && manifest.pages.length > 0) {
currentManifest = manifest;
renderTiffGallery(manifest, config.cwd); renderTiffGallery(manifest, config.cwd);
if (rawPrintPanel) rawPrintPanel.classList.remove("hidden");
if (discoveredPrinters.length > 0 && btnPrintAll) btnPrintAll.disabled = false;
showNotification("info", "Target pages generated. Select your destination printer below and print with color management strictly bypassed.");
} }
setStage2Result(stage1Basename, stage1Cwd); setStage2Result(stage1Basename, stage1Cwd);
advanceToStage3();
} else { } else {
logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`; logPre.textContent += `\n[ERROR] printtarg exited with code ${event.payload.code}.\n`;
btnCreateLayout.disabled = false; btnCreateLayout.disabled = false;
@@ -138,13 +274,115 @@ export function initPrinttarg() {
}); });
/** /**
* Extract the JSON manifest object from the accumulated stdout. * Spool a single TIFF target file with native color management bypass.
* The manifest is emitted by printtarg -u as a JSON block starting with { and ending with }. */
* It always appears at the end of stdout, after all "Creating file..." lines. async function printTargetFile(filePath, label, triggeringButton) {
const printerName = printerSelect ? printerSelect.value : "";
if (!printerName) {
showNotification("error", "Please select a destination printer first.");
return;
}
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
const origBtnContent = triggeringButton ? triggeringButton.innerHTML : "";
if (triggeringButton) {
triggeringButton.disabled = true;
triggeringButton.innerHTML = '<span class="btn-spinner"></span> Spooling...';
}
showNotification("info", `Sending ${label} to '${printerName}' (bypassing color management)...`);
try {
await invoke("print_target_native", {
printerName,
tiffPath: filePath,
ppdUncorrectedPassthrough: ppdFallback,
});
showNotification("success", `✓ Successfully spooled ${label} to '${printerName}' with raw color management bypass.`);
} catch (err) {
console.error("[ICCery Print Error]:", err);
showNotification("error", `Print job failed for ${label}: ${err}`);
} finally {
if (triggeringButton) {
triggeringButton.disabled = false;
triggeringButton.innerHTML = origBtnContent;
}
}
}
/**
* Print all generated TIFF target pages in sequence.
*/
if (btnPrintAll) {
btnPrintAll.addEventListener("click", async () => {
if (!currentManifest || !currentManifest.pages || currentManifest.pages.length === 0) {
showNotification("error", "No generated target pages available to print.");
return;
}
const printerName = printerSelect ? printerSelect.value : "";
if (!printerName) {
showNotification("error", "Please select a destination printer first.");
return;
}
const ppdFallback = chkPpdFallback ? chkPpdFallback.checked : false;
const cwd = stage1Cwd;
const sep = cwd.includes('\\') ? '\\' : '/';
const pages = currentManifest.pages;
btnPrintAll.disabled = true;
const btnText = btnPrintAll.querySelector(".btn-text");
const btnSpinner = btnPrintAll.querySelector(".btn-spinner");
if (btnSpinner) btnSpinner.classList.remove("hidden");
if (btnText) btnText.textContent = "Spooling Targets...";
let errorCount = 0;
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const filePath = cwd ? `${cwd}${sep}${page.filename}` : page.filename;
showNotification("info", `Spooling page ${i + 1} of ${pages.length} (${page.filename}) to '${printerName}'...`);
try {
await invoke("print_target_native", {
printerName,
tiffPath: filePath,
ppdUncorrectedPassthrough: ppdFallback,
});
} catch (err) {
console.error(`[ICCery Print Error] Page ${page.filename}:`, err);
showNotification("error", `Failed on page ${page.filename}: ${err}`);
errorCount++;
break;
}
}
if (errorCount === 0) {
showNotification("success", `✓ All ${pages.length} target page(s) successfully spooled to '${printerName}'!`);
}
btnPrintAll.disabled = false;
if (btnSpinner) btnSpinner.classList.add("hidden");
if (btnText) btnText.textContent = "🖨️ Print All Pages (Bypass CM)";
});
}
// Proceed to Stage 3
if (btnAdvanceToStage3) {
btnAdvanceToStage3.addEventListener("click", () => {
setStage2Result(stage1Basename, stage1Cwd);
advanceToStage3();
});
}
/**
* Extract JSON manifest emitted by printtarg -u.
*/ */
function extractManifest(stdout) { function extractManifest(stdout) {
try { try {
// Find the last JSON object in the output
const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"'); const jsonStart = stdout.lastIndexOf('{\n "event": "manifest"');
if (jsonStart === -1) return null; if (jsonStart === -1) return null;
const jsonEnd = stdout.indexOf('\n}', jsonStart); const jsonEnd = stdout.indexOf('\n}', jsonStart);
@@ -158,9 +396,7 @@ export function initPrinttarg() {
} }
/** /**
* Render TIFF preview gallery using base64-encoded images from the backend. * Render TIFF preview gallery with individual card print actions.
* @param {Object} manifest - The parsed JSON manifest from printtarg -u
* @param {string} cwd - The working directory where TIFFs were generated
*/ */
async function renderTiffGallery(manifest, cwd) { async function renderTiffGallery(manifest, cwd) {
tiffGallery.classList.remove("hidden"); tiffGallery.classList.remove("hidden");
@@ -169,6 +405,7 @@ export function initPrinttarg() {
const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0); const totalPatches = manifest.pages.reduce((sum, p) => sum + p.patches, 0);
const dims = manifest.pages[0]; const dims = manifest.pages[0];
galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`; galleryInfo.textContent = `${pageCount} page(s) · ${totalPatches} patches · ${dims.width_mm} × ${dims.height_mm} mm per page`;
galleryGrid.innerHTML = "";
for (const page of manifest.pages) { for (const page of manifest.pages) {
const sep = cwd.includes('\\') ? '\\' : '/'; const sep = cwd.includes('\\') ? '\\' : '/';
@@ -187,7 +424,6 @@ export function initPrinttarg() {
const img = document.createElement("img"); const img = document.createElement("img");
img.src = `data:image/tiff;base64,${base64Data}`; img.src = `data:image/tiff;base64,${base64Data}`;
img.alt = page.filename; img.alt = page.filename;
// TIFF may not render natively in all browsers — provide a fallback
img.onerror = () => { img.onerror = () => {
img.remove(); img.remove();
const fallback = document.createElement("div"); const fallback = document.createElement("div");
@@ -203,6 +439,19 @@ export function initPrinttarg() {
card.appendChild(fallback); card.appendChild(fallback);
} }
// Individual Card Print Button
const cardActions = document.createElement("div");
cardActions.className = "gallery-card-actions";
const btnPrintCard = document.createElement("button");
btnPrintCard.type = "button";
btnPrintCard.className = "btn-print-page";
btnPrintCard.innerHTML = "<span>🖨️ Print Page</span>";
btnPrintCard.addEventListener("click", () => {
printTargetFile(filePath, page.filename, btnPrintCard);
});
cardActions.appendChild(btnPrintCard);
card.appendChild(cardActions);
galleryGrid.appendChild(card); galleryGrid.appendChild(card);
} }
} }
+226
View File
@@ -847,3 +847,229 @@ button.danger:hover {
border-top: 1px solid var(--border-color); border-top: 1px solid var(--border-color);
margin: 20px 0; margin: 20px 0;
} }
/* ========================================
Stage 2: Native Raw Printing Engine Panel
======================================== */
.print-panel {
margin-top: 24px;
background: var(--panel-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 18px 20px;
animation: fadeIn 0.3s ease;
}
.print-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.print-panel-title {
display: flex;
align-items: center;
gap: 8px;
}
.print-panel-title h3 {
margin: 0;
color: #fff;
font-size: 1.1rem;
}
.acpu-badge {
background: rgba(0, 122, 204, 0.15);
border: 1px solid rgba(0, 122, 204, 0.4);
color: #4fc3f7;
font-size: 0.75rem;
font-weight: 600;
padding: 3px 8px;
border-radius: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.print-panel-desc {
margin: 0 0 16px 0;
font-size: 0.85rem;
color: #aaa;
line-height: 1.4;
}
.printer-controls-row {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: flex-end;
margin-bottom: 18px;
}
.printer-select-group {
flex: 1;
min-width: 280px;
margin-bottom: 0;
}
.printer-select-wrapper {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.printer-select-wrapper select {
flex: 1;
max-width: 100%;
}
.btn-icon {
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
width: 34px;
height: 34px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
flex-shrink: 0;
}
.btn-icon:hover {
border-color: var(--accent-color);
color: #fff;
}
.status-badge {
font-size: 0.75rem;
padding: 4px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.badge-idle, .badge-ready {
background: rgba(76, 175, 80, 0.15);
color: #81c784;
border: 1px solid rgba(76, 175, 80, 0.3);
}
.badge-printing {
background: rgba(33, 150, 243, 0.15);
color: #64b5f6;
border: 1px solid rgba(33, 150, 243, 0.3);
}
.badge-stopped, .badge-error {
background: rgba(244, 67, 54, 0.15);
color: #e57373;
border: 1px solid rgba(244, 67, 54, 0.3);
}
.badge-default {
background: rgba(255, 193, 7, 0.15);
color: #ffd54f;
border: 1px solid rgba(255, 193, 7, 0.3);
}
.print-options-group {
margin-bottom: 0;
display: flex;
align-items: center;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #ccc;
cursor: pointer;
user-select: none;
}
.notification-banner {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 14px;
border-radius: 6px;
font-size: 0.85rem;
margin-bottom: 16px;
animation: fadeIn 0.2s ease;
}
.notification-banner.info {
background: rgba(33, 150, 243, 0.12);
border: 1px solid rgba(33, 150, 243, 0.3);
color: #90caf9;
}
.notification-banner.success {
background: rgba(76, 175, 80, 0.12);
border: 1px solid rgba(76, 175, 80, 0.3);
color: #a5d6a7;
}
.notification-banner.error {
background: rgba(244, 67, 54, 0.12);
border: 1px solid rgba(244, 67, 54, 0.3);
color: #ef9a9a;
}
.print-actions-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
.gallery-card-actions {
padding: 8px 12px;
background: rgba(0, 0, 0, 0.15);
border-top: 1px solid var(--border-color);
display: flex;
justify-content: flex-end;
}
.btn-print-page {
font-size: 0.8rem;
padding: 5px 10px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--border-color);
color: var(--text-color);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.btn-print-page:hover {
background: var(--accent-color);
border-color: var(--accent-color);
color: #fff;
}
.btn-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}