v0.2.12-pre.007

This commit is contained in:
2026-08-27 14:35:18 +02:00
parent 7d0fe8ac5f
commit 2042482592
13 changed files with 764 additions and 95 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml
# version: 293
# version: 294
[workspace]
resolver = "3"
members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-wallet-lib"]
[workspace.package]
version = "0.2.12-pre.6"
version = "0.2.12-pre.7"
edition = "2024"
license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"

View File

@@ -1,5 +1,5 @@
<!-- file: crates/ksp-app-solprices-desk/frontend/main.html -->
<!-- version: 7 -->
<!-- version: 8 -->
<!DOCTYPE html>
<html lang="fr">
@@ -44,13 +44,16 @@
<div class="card app-shell-card mx-auto shadow-sm border-0">
<div class="card-body p-4">
<section data-view-panel="prices">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-4">
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap mb-3">
<div>
<h1 class="h3 mb-1">Prices</h1>
<p class="text-body-secondary mb-0">Inventaire provider-neutral du registry Off-chain. Rafraîchissement individuel, sélectionné ou global, sans polling ni fallback automatique.</p>
<p class="text-body-secondary mb-0">Inventaire provider-neutral SOL/USD. Les observations restent distinctes : aucune moyenne, aucun fallback automatique et aucun polling.</p>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span id="selectedProviderCount" class="small text-body-secondary">0 selected</span>
<div class="d-flex align-items-center gap-2 flex-wrap app-market-toolbar">
<span id="selectedProviderCount" class="small text-body-secondary" aria-live="polite">0 selected</span>
<button id="clearSelectionButton" class="btn btn-sm btn-outline-secondary" type="button" disabled>
<i class="fa-solid fa-xmark me-1" aria-hidden="true"></i>Clear selection
</button>
<button id="refreshSelectedButton" class="btn btn-sm btn-outline-primary" type="button" disabled>
<i class="fa-solid fa-list-check me-1" aria-hidden="true"></i>Refresh selected
</button>
@@ -59,6 +62,40 @@
</button>
</div>
</div>
<div id="pricesSummary" class="row g-2 mb-3" aria-label="Résumé des providers">
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Providers</span>
<strong id="pricesSummaryProviders">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Ready</span>
<strong id="pricesSummaryReady">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Observed</span>
<strong id="pricesSummaryObserved">0</strong>
</div>
</div>
<div class="col-6 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Attention</span>
<strong id="pricesSummaryAttention">0</strong>
</div>
</div>
<div class="col-12 col-lg">
<div class="app-market-summary-card">
<span class="app-market-summary-label">Refreshing</span>
<strong id="pricesSummaryLoading">0</strong>
</div>
</div>
</div>
<div id="pricesLoading" class="app-placeholder">
<div>
<i class="fa-solid fa-spinner fa-spin text-body-secondary mb-3" aria-hidden="true"></i>
@@ -73,22 +110,24 @@
<p class="text-body-secondary mb-0">Le runtime Off-chain ne contient actuellement aucune ligne provider.</p>
</div>
</div>
<div id="pricesDiagnostic" class="alert alert-warning mb-3" hidden></div>
<div id="pricesTableContainer" class="table-responsive" hidden>
<table id="marketPriceTable" class="table table-hover align-middle mb-0">
<div id="pricesDiagnostic" class="alert alert-warning mb-3" role="status" aria-live="polite" hidden></div>
<div id="pricesTableContainer" class="table-responsive app-market-table-container" hidden>
<table id="marketPriceTable" class="table table-hover align-middle mb-0 app-market-table">
<caption class="visually-hidden">Providers SOL/USD, disponibilité, prix exacts, fraîcheur et actions de refresh.</caption>
<thead>
<tr>
<th scope="col"><span class="visually-hidden">Select</span></th>
<th scope="col" class="app-market-selection-column">
<input id="selectAllProviders" class="form-check-input" type="checkbox" aria-label="Select all providers" disabled>
</th>
<th scope="col">Provider</th>
<th scope="col">Pair</th>
<th scope="col">Semantics</th>
<th scope="col">Auth</th>
<th scope="col" class="d-none d-xl-table-cell">Semantics</th>
<th scope="col" class="d-none d-xl-table-cell">Auth</th>
<th scope="col">Availability</th>
<th scope="col">Exact price</th>
<th scope="col">Provider timestamp (ms)</th>
<th scope="col">Received at (ms)</th>
<th scope="col">Retry at (ms)</th>
<th scope="col">Action</th>
<th scope="col" class="app-market-time-column">Observation times</th>
<th scope="col" class="app-market-time-column">Retry</th>
<th scope="col" class="text-end">Action</th>
</tr>
</thead>
<tbody id="marketPriceRows"></tbody>
@@ -127,7 +166,7 @@
<dt class="col-sm-5">Fallback Logging</dt>
<dd id="runtimeLoggingFallback" class="col-sm-7"></dd>
</dl>
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" hidden></div>
<div id="runtimeDiagnostic" class="alert alert-warning mt-3 mb-0" role="status" aria-live="polite" hidden></div>
</div>
</div>
</section>

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/frontend/sass/_app.scss
// version: 3
// version: 4
$app-header-height: 72px;
$app-footer-height: 42px;
@@ -61,7 +61,10 @@ body {
max-width: 1320px;
}
.app-runtime-list dd {
.app-runtime-list dd,
.app-market-code,
.app-market-price,
.app-market-time-exact {
font-family: var(--bs-font-monospace);
}
@@ -103,3 +106,130 @@ body {
.app-placeholder i {
font-size: 2.5rem;
}
.app-market-toolbar {
justify-content: flex-end;
}
.app-market-summary-card {
display: flex;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 0.8rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
background: var(--bs-tertiary-bg);
}
.app-market-summary-card strong {
min-width: 2ch;
font-size: 1.15rem;
text-align: right;
}
.app-market-summary-label {
color: var(--bs-secondary-color);
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.app-market-table-container {
max-height: min(62vh, 720px);
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius);
}
.app-market-table {
min-width: 1080px;
}
.app-market-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-tertiary-bg);
box-shadow: inset 0 -1px 0 var(--bs-border-color);
white-space: nowrap;
}
.app-market-table tbody tr[data-availability="ready"] {
--bs-table-accent-bg: rgba($success, 0.025);
}
.app-market-table tbody tr[data-availability="cooling_down"],
.app-market-table tbody tr[data-availability="temporarily_unavailable"] {
--bs-table-accent-bg: rgba($warning, 0.04);
}
.app-market-table tbody tr[data-availability="authentication_unavailable"],
.app-market-table tbody tr[data-availability="misconfigured"] {
--bs-table-accent-bg: rgba($danger, 0.035);
}
.app-market-selection-column {
width: 42px;
text-align: center;
}
.app-market-time-column {
min-width: 220px;
}
.app-market-provider-name,
.app-market-price {
font-weight: 600;
}
.app-market-code,
.app-market-time-exact {
color: var(--bs-secondary-color);
font-size: 0.75rem;
}
.app-market-price {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.app-market-time {
display: grid;
gap: 0.25rem;
}
.app-market-time-line {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.35rem;
align-items: baseline;
}
.app-market-time-label {
color: var(--bs-secondary-color);
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
}
.app-market-time-value {
min-width: 0;
}
.app-market-row-loading {
opacity: 0.72;
}
@media (max-width: 991.98px) {
.app-sidebar {
width: 190px;
min-width: 190px;
max-width: 190px;
}
.app-market-toolbar {
width: 100%;
justify-content: flex-start;
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/frontend/ts/main.ts
// version: 6
// version: 7
import "bootstrap";
import ResizeObserver from "resize-observer-polyfill";
@@ -17,11 +17,46 @@ installFrontendConsoleBridge("main");
type ViewId = "prices" | "diagnostics";
type AvailabilityPresentation = {
badgeClass: string;
iconClass: string;
label: string;
};
const viewTitles: Record<ViewId, string> = {
prices: "Prices",
diagnostics: "Diagnostics",
};
const availabilityPresentations: Record<string, AvailabilityPresentation> = {
authentication_unavailable: { badgeClass: "text-bg-danger", iconClass: "fa-key", label: "Authentication unavailable" },
cooling_down: { badgeClass: "text-bg-warning", iconClass: "fa-clock", label: "Cooling down" },
disabled: { badgeClass: "text-bg-secondary", iconClass: "fa-ban", label: "Disabled" },
misconfigured: { badgeClass: "text-bg-danger", iconClass: "fa-triangle-exclamation", label: "Misconfigured" },
quota_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-gauge-high", label: "Quota unavailable" },
ready: { badgeClass: "text-bg-success", iconClass: "fa-circle-check", label: "Ready" },
temporarily_unavailable: { badgeClass: "text-bg-warning", iconClass: "fa-cloud-arrow-down", label: "Temporarily unavailable" },
unknown: { badgeClass: "text-bg-secondary", iconClass: "fa-circle-question", label: "Unknown" },
};
const authLabels: Record<string, string> = {
none: "Keyless",
optional_api_key: "Optional API key",
required_api_key: "API key required",
unknown: "Unknown",
};
const semanticsLabels: Record<string, string> = {
aggregated_market: "Aggregated market",
dex_pair_usd: "DEX pair USD",
exchange_last_trade: "Exchange last trade",
solana_heuristic: "Solana heuristic",
solana_spot: "Solana spot",
unknown: "Unknown",
};
const jsDateMaxUnixMillis = 8_640_000_000_000_000n;
let currentMarketPriceRows: MarketPriceProviderRowDto[] = [];
const selectedProviderIds = new Set<string>();
@@ -59,10 +94,14 @@ function bindNavigation(): void {
frontendTrace("main", "SOL Prices Desk navigation handlers installed");
}
function appendMarketPriceCell(row: HTMLTableRowElement, value: string): void {
function appendMarketPriceCell(row: HTMLTableRowElement, value: string, className?: string): HTMLTableCellElement {
const cell = document.createElement("td");
cell.textContent = value;
if (className) {
cell.className = className;
}
row.append(cell);
return cell;
}
function clearPricesDiagnostic(): void {
@@ -94,21 +133,49 @@ function synchronizeSelectedProviders(): void {
}
}
function updateSummary(): void {
const summaryValues: Record<string, number> = {
pricesSummaryProviders: currentMarketPriceRows.length,
pricesSummaryReady: currentMarketPriceRows.filter(provider => provider.availability === "ready" && !provider.loading).length,
pricesSummaryObserved: currentMarketPriceRows.filter(provider => provider.refreshed).length,
pricesSummaryAttention: currentMarketPriceRows.filter(provider => provider.availability !== "ready").length,
pricesSummaryLoading: currentMarketPriceRows.filter(provider => provider.loading).length,
};
for (const [elementId, value] of Object.entries(summaryValues)) {
const element = document.querySelector<HTMLElement>(`#${elementId}`);
if (element) {
element.textContent = value.toString();
}
}
}
function synchronizeBatchControls(): void {
const selectedCount = selectedProviderIdsInRowOrder().length;
const providerCount = currentMarketPriceRows.length;
const anyLoading = currentMarketPriceRows.some(provider => provider.loading);
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
const selectedCountLabel = document.querySelector<HTMLElement>("#selectedProviderCount");
if (selectedButton) {
selectedButton.disabled = selectedCount === 0 || anyLoading;
}
if (allButton) {
allButton.disabled = currentMarketPriceRows.length === 0 || anyLoading;
allButton.disabled = providerCount === 0 || anyLoading;
}
if (clearButton) {
clearButton.disabled = selectedCount === 0 || anyLoading;
}
if (selectAll) {
selectAll.disabled = providerCount === 0 || anyLoading;
selectAll.checked = providerCount !== 0 && selectedCount === providerCount;
selectAll.indeterminate = selectedCount > 0 && selectedCount < providerCount;
}
if (selectedCountLabel) {
selectedCountLabel.textContent = `${selectedCount} selected`;
}
updateSummary();
}
function markProvidersLoading(providerIds: string[]): void {
@@ -198,6 +265,7 @@ async function refreshAllMarketPrices(): Promise<void> {
function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-selection-column";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "form-check-input";
@@ -220,13 +288,152 @@ function appendSelectionCell(row: HTMLTableRowElement, provider: MarketPriceProv
row.append(cell);
}
function appendProviderCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
const name = document.createElement("div");
name.className = "app-market-provider-name";
name.textContent = provider.displayName;
const code = document.createElement("div");
code.className = "app-market-code";
code.textContent = provider.providerId;
cell.append(name, code);
row.append(cell);
}
function appendBadgeCell(row: HTMLTableRowElement, label: string, badgeClass = "text-bg-light", className?: string): void {
const cell = document.createElement("td");
if (className) {
cell.className = className;
}
const badge = document.createElement("span");
badge.className = `badge ${badgeClass}`;
badge.textContent = label;
cell.append(badge);
row.append(cell);
}
function availabilityPresentation(provider: MarketPriceProviderRowDto): AvailabilityPresentation {
if (provider.loading) {
return { badgeClass: "text-bg-info", iconClass: "fa-spinner fa-spin", label: "Refreshing" };
}
return availabilityPresentations[provider.availability] ?? availabilityPresentations.unknown;
}
function appendAvailabilityCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
const presentation = availabilityPresentation(provider);
const badge = document.createElement("span");
badge.className = `badge ${presentation.badgeClass}`;
const icon = document.createElement("i");
icon.className = `fa-solid ${presentation.iconClass} me-1`;
icon.setAttribute("aria-hidden", "true");
badge.append(icon, document.createTextNode(presentation.label));
cell.append(badge);
row.append(cell);
}
function appendPriceCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
if (provider.price === null) {
const empty = document.createElement("span");
empty.className = "text-body-secondary";
empty.textContent = "No observation";
cell.append(empty);
} else {
const price = document.createElement("span");
price.className = "app-market-price";
price.textContent = provider.price;
const currency = document.createElement("small");
currency.className = "text-body-secondary ms-1";
currency.textContent = "USD";
cell.append(price, currency);
}
row.append(cell);
}
function formatUnixMillis(value: string): { exact: string; iso: string; local: string } | null {
try {
const exact = BigInt(value);
if (exact < -jsDateMaxUnixMillis || exact > jsDateMaxUnixMillis) {
return null;
}
const date = new Date(Number(exact));
if (Number.isNaN(date.getTime())) {
return null;
}
return {
exact: value,
iso: date.toISOString(),
local: date.toLocaleString("fr-CH", { dateStyle: "short", timeStyle: "medium", hour12: false }),
};
} catch {
return null;
}
}
function appendTimestampValue(container: HTMLElement, label: string, value: string | null, emptyLabel: string): void {
const line = document.createElement("div");
line.className = "app-market-time-line";
const prefix = document.createElement("span");
prefix.className = "app-market-time-label";
prefix.textContent = label;
const valueContainer = document.createElement("span");
valueContainer.className = "app-market-time-value";
if (value === null) {
valueContainer.classList.add("text-body-secondary");
valueContainer.textContent = emptyLabel;
} else {
const formatted = formatUnixMillis(value);
if (formatted === null) {
const exact = document.createElement("span");
exact.className = "app-market-time-exact";
exact.textContent = `${value} ms`;
valueContainer.append(exact);
} else {
const time = document.createElement("time");
time.dateTime = formatted.iso;
time.textContent = formatted.local;
time.title = formatted.iso;
const exact = document.createElement("span");
exact.className = "app-market-time-exact d-block";
exact.textContent = `${formatted.exact} ms`;
valueContainer.append(time, exact);
}
}
line.append(prefix, valueContainer);
container.append(line);
}
function appendObservationTimeCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-time-column";
const time = document.createElement("div");
time.className = "app-market-time";
appendTimestampValue(time, "Provider", provider.providerTimestampUnixMillis, "Not supplied");
appendTimestampValue(time, "KSP", provider.receivedAtUnixMillis, "—");
cell.append(time);
row.append(cell);
}
function appendRetryCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "app-market-time-column";
const time = document.createElement("div");
time.className = "app-market-time";
appendTimestampValue(time, "Retry", provider.retryAtUnixMillis, "—");
cell.append(time);
row.append(cell);
}
function appendRefreshCell(row: HTMLTableRowElement, provider: MarketPriceProviderRowDto): void {
const cell = document.createElement("td");
cell.className = "text-end";
const button = document.createElement("button");
button.type = "button";
button.className = "btn btn-sm btn-outline-primary text-nowrap";
button.disabled = provider.loading;
button.dataset.providerId = provider.providerId;
button.setAttribute("aria-label", `Refresh ${provider.displayName}`);
button.innerHTML = provider.loading
? '<i class="fa-solid fa-spinner fa-spin me-1" aria-hidden="true"></i>Refreshing…'
: '<i class="fa-solid fa-rotate me-1" aria-hidden="true"></i>Refresh';
@@ -257,34 +464,68 @@ function renderMarketPriceRows(rows: MarketPriceProviderRowDto[]): void {
for (const provider of rows) {
const row = document.createElement("tr");
row.dataset.providerId = provider.providerId;
row.dataset.availability = provider.availability;
row.classList.toggle("app-market-row-loading", provider.loading);
appendSelectionCell(row, provider);
appendMarketPriceCell(row, provider.displayName);
appendMarketPriceCell(row, provider.pair);
appendMarketPriceCell(row, provider.semantics);
appendMarketPriceCell(row, provider.authMode);
appendMarketPriceCell(row, provider.loading ? "loading" : provider.availability);
appendMarketPriceCell(row, provider.price ?? "—");
appendMarketPriceCell(row, provider.providerTimestampUnixMillis ?? "—");
appendMarketPriceCell(row, provider.receivedAtUnixMillis ?? "—");
appendMarketPriceCell(row, provider.retryAtUnixMillis ?? "—");
appendProviderCell(row, provider);
appendBadgeCell(row, provider.pair, "text-bg-primary");
appendBadgeCell(row, semanticsLabels[provider.semantics] ?? semanticsLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
appendBadgeCell(row, authLabels[provider.authMode] ?? authLabels.unknown, "text-bg-light", "d-none d-xl-table-cell");
appendAvailabilityCell(row, provider);
appendPriceCell(row, provider);
appendObservationTimeCell(row, provider);
appendRetryCell(row, provider);
appendRefreshCell(row, provider);
tableBody.append(row);
}
synchronizeBatchControls();
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", { rowCount: rows.length });
frontendTrace("main", "SOL Prices Desk market-price registry rows rendered", {
rowCount: rows.length,
observedCount: rows.filter(provider => provider.refreshed).length,
attentionCount: rows.filter(provider => provider.availability !== "ready").length,
});
}
function selectAllProviders(selected: boolean): void {
selectedProviderIds.clear();
if (selected) {
for (const provider of currentMarketPriceRows) {
selectedProviderIds.add(provider.providerId);
}
}
frontendDebug("main", "SOL Prices Desk select-all providers control changed", {
selected,
selectedCount: selectedProviderIds.size,
});
renderMarketPriceRows(currentMarketPriceRows);
}
function clearProviderSelection(): void {
const selectedCount = selectedProviderIds.size;
frontendDebug("main", "SOL Prices Desk clear provider selection control clicked", { selectedCount });
selectedProviderIds.clear();
renderMarketPriceRows(currentMarketPriceRows);
}
function bindMarketPriceControls(): void {
const selectedButton = document.querySelector<HTMLButtonElement>("#refreshSelectedButton");
const allButton = document.querySelector<HTMLButtonElement>("#refreshAllButton");
const clearButton = document.querySelector<HTMLButtonElement>("#clearSelectionButton");
const selectAll = document.querySelector<HTMLInputElement>("#selectAllProviders");
selectedButton?.addEventListener("click", () => {
void refreshSelectedMarketPrices();
});
allButton?.addEventListener("click", () => {
void refreshAllMarketPrices();
});
clearButton?.addEventListener("click", () => {
clearProviderSelection();
});
selectAll?.addEventListener("change", () => {
selectAllProviders(selectAll.checked);
});
synchronizeBatchControls();
frontendTrace("main", "SOL Prices Desk market-price batch controls installed");
frontendTrace("main", "SOL Prices Desk market-price controls installed");
}
function renderRuntimeStatus(status: MarketPriceRuntimeStatusDto): void {

View File

@@ -1,7 +1,7 @@
{
"name": "ksp-app-solprices-desk",
"private": true,
"version": "0.2.12-pre.6",
"version": "0.2.12-pre.7",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/app_state.rs
// version: 5
// version: 6
//! Shared backend state owned by the SOL Prices Desk Tauri application.
@@ -129,7 +129,7 @@ impl crate::AppState {
fallback_logging_active: runtime.fallback_active,
provider_count,
ready_provider_count,
shell_phase: "pre.006-multi-refresh".to_owned(),
shell_phase: "pre.007-ux-instrumentation".to_owned(),
startup_diagnostic: runtime.startup_diagnostic.clone(),
unavailable_provider_count,
});

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/src/tauri.rs
// version: 5
// version: 6
//! Tauri runtime assembly for the KSP SOL prices desktop application.
@@ -98,12 +98,24 @@ fn configure_setup(builder: tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri:
});
}
fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto {
ksp_logging_lib::warn!(
target: crate::TRACING_TARGET,
domain = domain,
command = command,
error_domain = error.code().domain(),
error_code = error.code().code(),
"SOL Prices Desk Tauri command failed"
);
return crate::CommandErrorDto::from_error(error);
}
#[tauri::command]
fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> {
let result = crate::emit_frontend_log_event(payload);
return match result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("emit_frontend_log", crate::TRACING_DOMAIN_FRONTEND, &error)),
};
}
@@ -112,7 +124,7 @@ fn get_runtime_status(state: tauri::State<'_, crate::AppState>) -> std::result::
let result = state.runtime_status();
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("get_runtime_status", crate::TRACING_DOMAIN_SHELL, &error)),
};
}
@@ -123,10 +135,17 @@ fn list_market_prices(
let result = state.list_market_prices();
return match result {
std::result::Result::Ok(value) => {
ksp_logging_lib::trace!(target: crate::TRACING_TARGET, domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, row_count = value.len(), "projected SOL Prices Desk market-price registry rows");
ksp_logging_lib::trace!(
target: crate::TRACING_TARGET,
domain = crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT,
row_count = value.len(),
"projected SOL Prices Desk market-price registry rows"
);
std::result::Result::Ok(value)
},
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("list_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
@@ -137,7 +156,9 @@ async fn refresh_all_market_prices(
let result = state.refresh_all_market_prices().await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_all_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
@@ -149,7 +170,9 @@ async fn refresh_market_price(
let result = state.refresh_market_price(provider_id).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_market_price", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
@@ -161,7 +184,9 @@ async fn refresh_market_prices(
let result = state.refresh_market_prices(request).await;
return match result {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => {
std::result::Result::Err(project_command_error("refresh_market_prices", crate::TRACING_DOMAIN_OFFCHAIN_TRANSPORT, &error))
},
};
}
@@ -174,6 +199,6 @@ async fn splash_frontend_ready(
let result = crate::splash_frontend_ready_service(app, webview_window, &state).await;
return match result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(error) => std::result::Result::Err(crate::CommandErrorDto::from_error(&error)),
std::result::Result::Err(error) => std::result::Result::Err(project_command_error("splash_frontend_ready", crate::TRACING_DOMAIN_WINDOWS, &error)),
};
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "KSP SOL Prices Desk",
"version": "0.2.12-pre.6",
"version": "0.2.12-pre.7",
"identifier": "com.sasedev.ksp-app-solprices-desk",
"build": {
"beforeDevCommand": {

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs
// version: 6
// version: 7
//! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`.
@@ -246,3 +246,46 @@ fn pre_006_market_price_runtime_adds_selected_and_global_refresh_without_polling
assert!(!main_typescript.contains(forbidden), "pre.006 must not add consumer scheduling: {forbidden}");
}
}
#[test]
fn pre_007_prices_view_is_responsive_and_projects_generic_states_without_price_coercion() {
let root = app_root();
let main_html = read_text(root.join("frontend/main.html").as_path());
for required in [
r#"id="pricesSummaryProviders""#,
r#"id="pricesSummaryReady""#,
r#"id="pricesSummaryObserved""#,
r#"id="pricesSummaryAttention""#,
r#"id="pricesSummaryLoading""#,
r#"id="selectAllProviders""#,
r#"id="clearSelectionButton""#,
"Observation times",
"app-market-table-container",
"visually-hidden",
] {
assert!(main_html.contains(required), "pre.007 Prices UX is missing {required}");
}
let main_typescript = read_text(root.join("frontend/ts/main.ts").as_path());
for required in [
"availabilityPresentations",
"Authentication unavailable",
"Cooling down",
"Quota unavailable",
"Temporarily unavailable",
"formatUnixMillis",
"BigInt(value)",
"Not supplied",
"selectAllProviders",
"clearProviderSelection",
"pricesSummaryObserved",
"app-market-row-loading",
] {
assert!(main_typescript.contains(required), "pre.007 Prices UX is missing {required}");
}
assert!(!main_typescript.contains("parseFloat("));
assert!(!main_typescript.contains("Number(provider.price"));
let app_scss = read_text(root.join("frontend/sass/_app.scss").as_path());
for required in ["position: sticky", "font-variant-numeric: tabular-nums", "app-market-summary-card", "app-market-table-container"] {
assert!(app_scss.contains(required), "pre.007 responsive styling is missing {required}");
}
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-app-solprices-desk/tests/desktop_security.rs
// version: 6
// version: 7
//! Security, ownership and frontend instrumentation canaries for SOL Prices Desk.
@@ -142,3 +142,47 @@ fn pre_006_frontend_batch_controls_are_logged_without_price_or_provider_payloads
assert!(!main.contains(forbidden), "batch frontend logging must not serialize price/provider/secret values: {forbidden}");
}
}
#[test]
fn pre_007_all_frontend_controls_and_backend_command_failures_are_instrumented_safely() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
for required in [
"SOL Prices Desk navigation control clicked",
"SOL Prices Desk market-price selection control changed",
"SOL Prices Desk select-all providers control changed",
"SOL Prices Desk clear provider selection control clicked",
"SOL Prices Desk market-price refresh control clicked",
"SOL Prices Desk selected market-price refresh control clicked",
"SOL Prices Desk global market-price refresh control clicked",
] {
assert!(main.contains(required), "pre.007 frontend control instrumentation is missing {required}");
}
for forbidden in [
"JSON.stringify(currentMarketPriceRows)",
"JSON.stringify(result)",
"price: provider.price",
"providerTimestampUnixMillis:",
"receivedAtUnixMillis:",
"retryAtUnixMillis:",
"apiKey",
"authorization",
] {
assert!(!main.contains(forbidden), "pre.007 frontend instrumentation must not log business/secret values: {forbidden}");
}
let tauri = read_text(root.join("src/tauri.rs").as_path());
assert!(tauri.contains("fn project_command_error"));
assert!(tauri.contains("error_domain = error.code().domain()"));
assert!(tauri.contains("error_code = error.code().code()"));
assert!(tauri.contains("SOL Prices Desk Tauri command failed"));
assert!(!tauri.contains("error_message = error"));
}
#[test]
fn pre_007_remains_manual_without_browser_persistence_or_price_scheduler() {
let root = app_root();
let main = read_text(root.join("frontend/ts/main.ts").as_path());
for forbidden in ["setInterval(", "setTimeout(", "requestAnimationFrame(", "localStorage", "sessionStorage", "fetch(", "XMLHttpRequest"] {
assert!(!main.contains(forbidden), "pre.007 must remain manual and backend-owned: {forbidden}");
}
}

112
deltas/0.2.12/pre.007.md Normal file
View File

@@ -0,0 +1,112 @@
# Delta `0.2.12-pre.007` — UX et instrumentation SOL Prices Desk
## 1. Identité
```text
release : 0.2.12
tranche : pre.007
version Cargo : 0.2.12-pre.7
base : 0.2.12-pre.6
```
Le gate opérateur de `pre.006` est intégralement propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `cargo test -p ksp-app-solprices-desk`, `cargo test --workspace` et lancement `cargo tauri dev` passent. Le parcours manuel confirme également le fonctionnement du multi-refresh.
## 2. Scope réalisé
`pre.007` finalise l'ergonomie du SOL Prices Desk sans modifier les contrats de dispatch Off-chain :
```text
résumé états providers Providers / Ready / Observed / Attention / Refreshing
table responsive + header sticky + scroll horizontal borné
colonnes secondaires Semantics/Auth adaptatives
availability badges génériques provider-neutral
prix String exacte inchangée
timestamps rendu humain + millisecondes exactes
provider timestamp absent Not supplied, jamais remplacé par received_at
sélection row + select all + clear selection
refresh row / selected / all inchangés
nouvelle commande IPC aucune
```
## 3. Lisibilité et fraîcheur
La table ne transforme pas la vérité métier :
- le prix exact reste la `String` canonique fournie par `MarketPriceDecimal` ;
- aucun `parseFloat` ni `Number(provider.price)` n'est introduit ;
- les millisecondes exactes restent visibles pour les timestamps ;
- une présentation locale/ISO est calculée uniquement pour l'affichage temporel, après validation d'une valeur entière compatible `Date` ;
- `provider_timestamp = None` est rendu comme `Not supplied` ;
- `received_at` reste une ligne KSP distincte ;
- `retry_at` est affiché séparément lorsqu'il existe ;
- `loading` ne remplace pas l'availability persistée : il devient uniquement le badge visuel temporaire `Refreshing`.
## 4. Interactions frontend
La sélection gagne deux contrôles purement locaux :
```text
select all providers
clear selection
```
Ils ne déclenchent aucun appel réseau et ne créent aucun état persistant navigateur. Le master checkbox expose les états `checked`/`indeterminate` à partir de la sélection courante. Les boutons batch restent désactivés pendant une opération visible `loading`.
## 5. Instrumentation
Le bridge Logging couvre désormais l'ensemble des contrôles :
```text
navigation/view activation debug
sélection row debug + provider_id opaque + bool
select all debug + selectedCount
clear selection debug + selectedCount
Refresh row debug + provider_id opaque
Refresh selected debug + selectedCount
Refresh all debug + providerCount
IPC request/completion debug / trace
rendu table trace + row/observed/attention counts
échec frontend warn sans payload métier
échec command backend warn + command/error_domain/error_code
```
Aucun prix, timestamp, body provider, URL, header, API key, credential ou DTO complet n'est journalisé.
## 6. Canaris
Les canaris `pre.007` vérifient :
- présence du résumé, du master select, de `Clear selection`, du header sticky et des styles responsive ;
- projection des états génériques actuels sans branche provider spécifique ;
- absence de coercition flottante du prix ;
- distinction explicite entre provider timestamp et KSP receipt timestamp ;
- instrumentation de tous les contrôles utilisateur ;
- projection backend des erreurs limitée à `command`, `error_domain` et `error_code` ;
- absence de `fetch`, `XMLHttpRequest`, `localStorage`, `sessionStorage`, `setInterval`, `setTimeout` et `requestAnimationFrame` dans le frontend prix.
## 7. Hors scope confirmé
```text
hardening hostile batch/package final pre.008
Wallet Desk Off-chain runtime pre.009
Wallet Desk price DTO/commands pre.010
Wallet Desk price UI pre.011
auto-refresh / polling OUT 0.2.12
prix canonique / fallback / moyenne OUT 0.2.12
valorisation USD Wallet OUT 0.2.12
```
## 8. Gate
À exécuter par l'opérateur :
```bash
cargo fmt --all
python3 scripts/audit_rust_workspace_rules.py
python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.2.12
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-app-solprices-desk
cargo test --workspace
(cd crates/ksp-app-solprices-desk && cargo tauri dev)
```

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/019-V0_2_12_SOL_PRICES_DESK_PLAN.md -->
<!-- version: 12 -->
<!-- version: 13 -->
# Plan `0.2.12` — SOL Prices Desk + intégration prix Wallet Desk
@@ -526,15 +526,15 @@ Le gate de `pre.005` confirme que le refresh individuel fonctionne et que le run
### `pre.006` — Refresh multiple et global
**Statut : implémenté ; gate opérateur à confirmer.**
**Statut : réalisé ; gate opérateur Cargo/workspace et parcours manuel multi-refresh intégralement PASS.**
Ajout de `refresh_market_prices` et `refresh_all_market_prices` sur les APIs génériques `MarketPriceService::refresh_many`/`refresh_all`. La sélection frontend est reconstruite dans l'ordre courant des rows, le backend valide atomiquement doublons/IDs inconnus/conflits `in_flight`, marque toutes les lignes concernées `loading`, relâche le mutex de présentation pendant l'`await`, puis applique les outcomes dans leur ordre déterministe. `MarketPriceRefreshManyRequestDto` transporte uniquement les IDs opaques et `MarketPriceRefreshResultDto` retourne les rows affectées avec `requested_count`/`refreshed_count`. Les cooldown/retry deadlines restent les projections Off-chain existantes ; aucun polling, sleep consommateur, fallback automatique ou consensus provider n'est introduit.
### `pre.007` — UX et instrumentation SOL Prices Desk
**Statut : planifié.**
**Statut : implémenté ; gate opérateur à confirmer.**
Finalisation de la table responsive, interactions utilisateur, états visuels, instrumentation frontend/backend et polish du Desk. La tranche reste manuelle : aucun auto-refresh ni persistance navigateur n'est ajouté.
Finalisation de la table responsive sans changer le contrat de dispatch : résumé providers/ready/observed/attention/refreshing, header sticky, colonnes secondaires adaptatives, badges génériques d'auth/semantics/availability, prix exact conservé en String et timestamps présentés à la fois sous forme humaine et en millisecondes exactes. `provider_timestamp = None` est rendu explicitement comme `Not supplied`, sans substitution par `received_at`. La sélection gagne un contrôle global et `Clear selection`, uniquement frontend. Chaque nouveau contrôle est journalisé en `debug`; les rendus et comptes sûrs restent en `trace`. Les wrappers Tauri journalisent les échecs avec seulement `command`, `error_domain` et `error_code`. Aucun prix, timestamp, payload provider, URL, header ou credential n'entre dans les logs. La tranche reste manuelle : aucune nouvelle commande métier, aucun auto-refresh, scheduler ou persistance navigateur n'est ajouté.
### `pre.008` — Hardening SOL Prices Desk
@@ -590,7 +590,7 @@ Préparation du prompt de la release suivante, de `CHANGELOG.md` et de `ROADMAP.
Mécanique de publication stable uniquement, après validation de la dernière prerelease et vérification de la cohérence de release. Aucun défaut fonctionnel ou documentaire ne doit être absorbé par `rel.001`.
Le point de packaging transversal de `pre.003` est désormais fermé. Le prochain risque fonctionnel est borné aux refresh `pre.005`/`pre.006`, sans remettre en cause le découpage souple ni les responsabilités documentaires de clôture.
Le packaging transversal et les refresh manuels du Price Desk sont désormais fermés par les gates opérateur jusquà `pre.006`. Après le polish `pre.007`, le prochain risque fonctionnel est borné au hardening `pre.008` avant louverture du couloir Wallet Desk `pre.009+`, sans remettre en cause le découpage souple ni les responsabilités documentaires de clôture.
## 20. Critères de sortie `pre.001`

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/015-V0_2_12_SOL_PRICES_DESK.md -->
<!-- version: 11 -->
<!-- version: 12 -->
# Validation `0.2.12` — SOL Prices Desk + intégration Wallet Desk
@@ -51,11 +51,12 @@ NOT RUN commande non exécutée dans le contexte indiqué
| splash Price Desk | lifecycle KSP commun | PASS |
| main shell Price Desk | header/navigation KSP | PASS |
| table providers | registry provider-neutral, ordre stable | PASS |
| table responsive UX | sticky/badges/timestamps distincts | PASS |
| prix exact | String issue de `MarketPriceDecimal` | PASS |
| availability/fraîcheur | état générique + timestamps réels | PASS |
| refresh row | `MarketPriceService::refresh` | PASS |
| refresh selected/many | `MarketPriceService::refresh_many` | PENDING |
| refresh all | `MarketPriceService::refresh_all` | PENDING |
| refresh selected/many | `MarketPriceService::refresh_many` | PASS |
| refresh all | `MarketPriceService::refresh_all` | PASS |
| Diagnostics | profils/counts sûrs seulement | PASS |
| Wallet price panel | Dashboard compact provider-neutral | PENDING |
| auto-refresh | aucun scheduler/polling | OUT |
@@ -63,17 +64,17 @@ NOT RUN commande non exécutée dans le contexte indiqué
## 4. DTO/command matrix
| Contrat | Entrée/sortie sûre | Statut |
|----------------------------------------|--------------------------------------------------------------------|---------|
| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PASS |
| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PASS |
| single refresh direct | provider_id opaque seulement | PASS |
| `MarketPriceRefreshManyRequestDto` | liste bornée dIDs opaques | PENDING |
| `MarketPriceRefreshResultDto` | rows/outcome classifiés | PENDING |
| runtime/list/refresh/many/all commands | aucun URL/header/API key/body | PENDING |
| TS-RS exports | bindings générés non versionnés | PASS |
| provider-specific type dans IPC | interdit | OUT |
| prix `f64` comme vérité IPC | interdit | OUT |
| Contrat | Entrée/sortie sûre | Statut |
|----------------------------------------|--------------------------------------------------------------------|--------|
| `MarketPriceRuntimeStatusDto` | profiles/counts sans secret | PASS |
| `MarketPriceProviderRowDto` | ID/display/pair/semantics/auth/availability/price/timestamps/retry | PASS |
| single refresh direct | provider_id opaque seulement | PASS |
| `MarketPriceRefreshManyRequestDto` | liste dIDs opaques ; borne hostile à durcir en `pre.008` | PASS |
| `MarketPriceRefreshResultDto` | rows/outcome classifiés | PASS |
| runtime/list/refresh/many/all commands | aucun URL/header/API key/body | PASS |
| TS-RS exports | bindings générés non versionnés | PASS |
| provider-specific type dans IPC | interdit | OUT |
| prix `f64` comme vérité IPC | interdit | OUT |
## 5. Config/composite matrix
@@ -94,35 +95,36 @@ NOT RUN commande non exécutée dans le contexte indiqué
## 6. Provider-agnostic boundary matrix
| Canari | Attendu | Statut |
|--------------------------------------------|------------------|---------|
| app dépend du service/registry générique | oui | PASS |
| branche `coingecko`/`kraken`/etc. dans app | aucune | PASS |
| SDK provider dans app | aucun | PASS |
| reqwest/provider fetch frontend | aucun | PASS |
| endpoint/header/API key dans DTO | aucun | PASS |
| rate limiter/sleep/retry provider dans app | aucun | PASS |
| refresh many ordre service | préservé | PENDING |
| semantics provider-neutral visibles | oui, sans fusion | PASS |
| prix canonique/fallback/moyenne | aucun | OUT |
| Canari | Attendu | Statut |
|--------------------------------------------|------------------|--------|
| app dépend du service/registry générique | oui | PASS |
| branche `coingecko`/`kraken`/etc. dans app | aucune | PASS |
| SDK provider dans app | aucun | PASS |
| reqwest/provider fetch frontend | aucun | PASS |
| endpoint/header/API key dans DTO | aucun | PASS |
| rate limiter/sleep/retry provider dans app | aucun | PASS |
| refresh many ordre service | préservé | PASS |
| semantics provider-neutral visibles | oui, sans fusion | PASS |
| prix canonique/fallback/moyenne | aucun | OUT |
## 7. Security matrix
| Risque/canari | Attendu | Statut |
|--------------------------------|----------------------------------------------|---------|
| URL arbitraire depuis frontend | impossible par contrat command | PASS |
| extraction API key/header | aucune projection/log | PASS |
| provider_id inconnu | erreur KSP sûre | PASS |
| batch duplicate/oversized | rejet avant dispatch | PENDING |
| refresh spam | limites/cooldown Off-chain toujours actifs | PASS |
| stale observation | timestamps/état distingués | PASS |
| double-click race | in-flight/order contrôlés | PENDING |
| close pendant refresh | aucune persistance/sûreté frontend requise | PENDING |
| CWD/resource confusion | canaris propres à chaque app | PENDING |
| Price Desk capabilities | `core:default`, `tracing:default` uniquement | PASS |
| plugin dialog Price Desk | absent | PASS |
| frontend sensitive logging | action/ID/count/code seulement | PASS |
| Wallet VIEW/OWNER/secrets | non-régression | PENDING |
| Risque/canari | Attendu | Statut |
|--------------------------------|-------------------------------------------------|---------|
| URL arbitraire depuis frontend | impossible par contrat command | PASS |
| extraction API key/header | aucune projection/log | PASS |
| provider_id inconnu | erreur KSP sûre | PASS |
| batch duplicate | rejet avant dispatch | PASS |
| batch oversized | borne hostile applicative à durcir en `pre.008` | PENDING |
| refresh spam | limites/cooldown Off-chain toujours actifs | PASS |
| stale observation | timestamps/état distingués | PASS |
| double-click race | in-flight/order contrôlés | PASS |
| close pendant refresh | aucune persistance/sûreté frontend requise | PENDING |
| CWD/resource confusion | canaris propres à chaque app | PENDING |
| Price Desk capabilities | `core:default`, `tracing:default` uniquement | PASS |
| plugin dialog Price Desk | absent | PASS |
| frontend sensitive logging | action/ID/count/code seulement | PASS |
| Wallet VIEW/OWNER/secrets | non-régression | PENDING |
## 8. Wallet integration matrix
@@ -529,4 +531,37 @@ python3 scripts/audit_markdown_tables.py README.md ... deltas PASS
cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox
```
Les lignes `refresh selected/many`, `refresh all`, `MarketPriceRefreshManyRequestDto`, `MarketPriceRefreshResultDto`, ordre many, batch duplicate/oversized et double-click race de la matrice générale restent `PENDING` jusqu'au gate opérateur de cette tranche.
Le gate opérateur de `pre.006` fourni le 2026-08-27 ferme ensuite cette tranche : audits, `cargo check`, Clippy, test ciblé et `cargo test --workspace` passent, `cargo tauri dev` démarre correctement et le parcours manuel confirme que le multi-refresh fonctionne. Les lignes `refresh selected/many`, `refresh all`, les deux DTOs batch, l'ordre many, le rejet des doublons et le conflit `in_flight` passent donc à `PASS`. La borne hostile d'une requête batch artificiellement surdimensionnée reste volontairement `PENDING` pour le hardening `pre.008`.
## 23. Gate `0.2.12-pre.007`
La tranche finalise l'UX et l'instrumentation sans modifier le moteur Off-chain ni ajouter de nouvelle commande métier :
```text
résumé Providers / Ready / Observed / Attention / Refreshing implémenté
header table sticky + scroll horizontal borné implémenté
Semantics/Auth secondaires adaptatifs implémenté
availability générique par badge implémenté
prix exact String inchangée, aucune coercition float
timestamp provider absent Not supplied, jamais remplacé par received_at
timestamps présents local/ISO + millisecondes exactes visibles
select all / clear selection frontend uniquement
clics select-all / clear / row / selected / all / navigation debug
rendu table + comptes sûrs trace
échec command backend command + error_domain + error_code seulement
nouvelle command IPC aucune
prix/timestamp/payload/URL/header/API key dans les logs aucun
auto-refresh / setInterval / setTimeout / requestAnimationFrame absent
localStorage / sessionStorage / fetch / XMLHttpRequest absent
```
Canaris statiques ajoutés : table responsive et états génériques, absence de coercition du prix, instrumentation de tous les contrôles, projection sûre des erreurs backend et maintien du fonctionnement exclusivement manuel.
Validations sandbox après matérialisation :
```text
python3 scripts/audit_rust_workspace_rules.py PASS
python3 scripts/audit_markdown_tables.py README.md ... deltas PASS
TypeScript syntax transpile main.ts PASS
cargo fmt/check/clippy/test NOT RUN — cargo indisponible dans le sandbox
```