diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index 796833b..393ac23 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -85,6 +85,12 @@ Controls terminology: CSV export reads PN вендора, Описание, and LOT from `data-vendor-pn`, `data-desc`, `data-lot` row attributes to bypass the rowspan cell offset problem. +In the colour-optional indicator mode (`app_settings.indicator_mode = "accessible"`, see +[03-database.md](03-database.md) and [decisions/2026-08-31-accessible-indicator-mode.md](decisions/2026-08-31-accessible-indicator-mode.md)) +both tables gain a narrow leading price-quality meter column (per sub-row), the row background +tint is removed, `world`-fallback cells show a `W` text marker instead of amber, and the footer +total shows a `⚠` prefix instead of red. CSV output is unaffected. + ## Configuration versioning Configuration revisions are append-only snapshots stored in `local_configuration_versions`. diff --git a/bible-local/03-database.md b/bible-local/03-database.md index f52e733..9a338fe 100644 --- a/bible-local/03-database.md +++ b/bible-local/03-database.md @@ -17,7 +17,7 @@ Main tables: | `local_partnumber_book_items` | PN -> LOT catalog payload | | `pending_changes` | sync queue | | `connection_settings` | encrypted MariaDB connection settings | -| `app_settings` | local app state | +| `app_settings` | local app state (key→value); includes `indicator_mode` = `color` \| `accessible`, a per-client display preference set on `/setup` | | `local_schema_migrations` | applied local migration markers | | `local_qt_settings` | server-pushed configurator settings cache (from `qt_settings`) | @@ -261,7 +261,9 @@ PK: lot_name | price | decimal(12,2) NOT NULL | | | price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. | -`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint). +`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The visual scale lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`priceQualityLevel`/`qualityMeterHtml`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown, configurator table (leftmost quality column), pricing tab. + +The module has **two render modes**, chosen per client by `window.QF_INDICATOR_MODE` (injected by `base.html` from `app_settings.indicator_mode`, default `color`): `color` = hue scale red 0 → yellow 5 → green 9; `accessible` = colour-blind-safe 5-step signal-strength meter (`priceQualityLevel`: `0-1/2-3/4-5/6-7/8-9`), single neutral hue. In `accessible` mode the pricing tab drops the row background tint for a leading meter column, the `world`-fallback amber cell tint becomes a `W` text marker, and the "contaminated total" red becomes a `⚠` prefix — see [decisions/2026-08-31-accessible-indicator-mode.md](decisions/2026-08-31-accessible-indicator-mode.md). Any new colour-only signal must add a branch in this module keyed on `isAccessible()`, not a local one. The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items` — **keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need. diff --git a/bible-local/04-api.md b/bible-local/04-api.md index 3aa11c0..5404d7d 100644 --- a/bible-local/04-api.md +++ b/bible-local/04-api.md @@ -29,6 +29,17 @@ `POST /api/restart` exists only in `debug` mode. +## Settings + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/api/settings/ui` | read per-client UI display preferences → `{ "indicator_mode": "color" \| "accessible" }` | +| `PUT` | `/api/settings/ui` | set `indicator_mode` (body `{ "indicator_mode": ... }`); `422` on an unknown value | + +Stored in `app_settings` (SQLite); no restart. Registered in both the normal and the setup-mode +route sets. Consumed by `web/static/price-quality.js` and the pricing tab via a +`window.QF_INDICATOR_MODE` global that `base.html` renders from the same setting. + ## Reference data | Method | Path | Purpose | diff --git a/bible-local/decisions/2026-08-31-accessible-indicator-mode.md b/bible-local/decisions/2026-08-31-accessible-indicator-mode.md new file mode 100644 index 0000000..9b31277 --- /dev/null +++ b/bible-local/decisions/2026-08-31-accessible-indicator-mode.md @@ -0,0 +1,63 @@ +# Decision: `indicator_mode` — a colour-optional rendering mode for UI signals + +**Date:** 2026-08-31 +**Status:** active + +## Context + +Several UI signals were encoded by hue alone and unreadable with a colour-vision deficiency: + +- `price_quality` (0–9) as a red→yellow→green gradient — a dot in the configurator search + dropdown and inline before `lot_name`, a coloured number in the pricelist "Качество" column, + a full-row background tint on the "Ценообразование" tab; +- the amber `world`-fallback price cell tint + ([2026-07-10-world-pricelist-fallback.md](2026-07-10-world-pricelist-fallback.md)); +- the red "contaminated total" in the pricing footer + ([2026-07-24-pricing-total-world-share.md](2026-07-24-pricing-total-world-share.md)). + +The bible has no dedicated accessibility contract, but `table-management` §Icon Semantics, +`controls-selection` §Buttons, `go-code-style` §Business Logic Placement and `build-version-display` +say the same thing: meaning is never carried by one ambiguous channel — back it with +shape / number / explicit `title`+`aria-label`, and keep the indicator visually subordinate. + +## Decision + +One per-client preference, **`app_settings.indicator_mode`** = `color` (default) | `accessible`, +set on `/setup` ("Настройки") via `GET`/`PUT /api/settings/ui` (`internal/handlers/settings.go`) +— no restart. Deliberately named for the concern (how indicators are rendered), not for the +current widget, so future colour-only signals attach to the same switch. + +`internal/handlers/web.go` `render()` and `internal/handlers/setup.go` inject the mode for every +page; `base.html` publishes it as `window.QF_INDICATOR_MODE`. `web/static/price-quality.js` is +still the single source of the price-quality scale and branches on the mode internally, so its +call sites do not change. + +In `accessible` mode: + +- `price_quality` renders as a **5-step signal-strength meter** (`priceQualityLevel` = + `min(4, floor(score/2))` → `0-1 / 2-3 / 4-5 / 6-7 / 8-9`), five bars in one neutral hue, each + instance carrying `title`/`aria-label` = `Качество цены: N/9`. `qualityDotHtml` and + `qualityBadgeHtml` return the meter; +- `qualityRowStyle` returns `''` — the pricing tab instead shows the meter in a narrow leading + column (conditional `` / `colspan` in `index.html`, per-sub-row ``); +- the `world`-fallback amber class is dropped for a `W` superscript text marker on the price + (text, not a colour class — `applyCustomPrice`'s colour-stripping regex is a non-issue); +- `_setPricingTotal` drops `text-red-600` for a leading `⚠` glyph, on the same trigger as the + red (`worldShare > 0`). The hover popup — including the "prices for N of M positions" coverage + line — is unchanged in both modes; per 2026-07-24 there is still no colour-coded coverage-only + signal, and `accessible` mode does not add one. + +`color` mode is byte-for-byte the previous behaviour. + +## Consequences + +- Default unchanged; nothing moves until a user opts in on `/setup`. +- `web/static/price-quality.js` stays the only place that knows the 0–9 scale. Any new + colour-coded quality/price signal adds a branch there keyed on `isAccessible()`, never a local + reimplementation, and its `accessible` form must be shape/text, not another hue. +- The pricing-tab tables have a mode-dependent column count (8 / 9). `index.html` guards the + ``, the empty-state `colspan`, the `tfoot` "Итого:" `colspan` and the JS row template on + `ACCESSIBLE_MODE` / `{{ eq .IndicatorMode "accessible" }}`. CSV export is untouched — it reads + `data-*` row attributes, not cell positions. +- New routes `GET`/`PUT /api/settings/ui`, registered in both the normal and setup-mode route + sets in `cmd/qfs/main.go`. `render()` nil-guards `localDB` for tests. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 1473129..5539d5e 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -581,6 +581,10 @@ func runSetupMode(local *localdb.LocalDB) { router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) + settingsHandler := handlers.NewSettingsHandler(local) + router.GET("/api/settings/ui", settingsHandler.GetUI) + router.PUT("/api/settings/ui", settingsHandler.PutUI) + // Health check router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -892,6 +896,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.POST("/setup/test", setupHandler.TestConnection) router.GET("/setup/status", setupHandler.GetStatus) + // Per-client UI display preferences + settingsHandler := handlers.NewSettingsHandler(local) + router.GET("/api/settings/ui", settingsHandler.GetUI) + router.PUT("/api/settings/ui", settingsHandler.PutUI) + // Web pages router.GET("/", webHandler.Index) router.GET("/configs", webHandler.Configs) diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go new file mode 100644 index 0000000..365f5ed --- /dev/null +++ b/internal/handlers/settings.go @@ -0,0 +1,47 @@ +package handlers + +import ( + "net/http" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "github.com/gin-gonic/gin" +) + +// SettingsHandler serves per-client UI display preferences stored in app_settings. +type SettingsHandler struct { + localDB *localdb.LocalDB +} + +func NewSettingsHandler(localDB *localdb.LocalDB) *SettingsHandler { + return &SettingsHandler{localDB: localDB} +} + +type uiSettingsResponse struct { + IndicatorMode string `json:"indicator_mode"` +} + +// GetUI returns the current UI display preferences. +func (h *SettingsHandler) GetUI(c *gin.Context) { + c.JSON(http.StatusOK, uiSettingsResponse{ + IndicatorMode: h.localDB.GetIndicatorMode(), + }) +} + +// PutUI updates UI display preferences. Currently only indicator_mode. +func (h *SettingsHandler) PutUI(c *gin.Context) { + req, ok := BindJSON[struct { + IndicatorMode string `json:"indicator_mode"` + }](c) + if !ok { + return + } + + if err := h.localDB.SetIndicatorMode(req.IndicatorMode); err != nil { + RespondError(c, http.StatusUnprocessableEntity, "invalid indicator_mode", err) + return + } + + c.JSON(http.StatusOK, uiSettingsResponse{ + IndicatorMode: h.localDB.GetIndicatorMode(), + }) +} diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index af6afdc..dec1f4a 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -10,6 +10,7 @@ import ( "time" qfassets "git.mchus.pro/mchus/quoteforge" + "git.mchus.pro/mchus/quoteforge/internal/appmeta" "git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/localdb" "github.com/gin-gonic/gin" @@ -56,7 +57,9 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) { settings, _ := h.localDB.GetSettings() data := gin.H{ - "Settings": settings, + "Settings": settings, + "IndicatorMode": h.localDB.GetIndicatorMode(), + "AppVersion": appmeta.Version(), } tmpl := h.templates["setup.html"] @@ -207,4 +210,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo } return cfg.FormatDSN() } - diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 81ce918..c9652de 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -114,6 +114,10 @@ func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) { func (h *WebHandler) render(c *gin.Context, name string, data gin.H) { data["AppVersion"] = appmeta.Version() + data["IndicatorMode"] = localdb.IndicatorModeColor + if h.localDB != nil { + data["IndicatorMode"] = h.localDB.GetIndicatorMode() + } c.Header("Content-Type", "text/html; charset=utf-8") tmpl, ok := h.templates[name] if !ok { diff --git a/internal/localdb/indicator_mode_test.go b/internal/localdb/indicator_mode_test.go new file mode 100644 index 0000000..932d980 --- /dev/null +++ b/internal/localdb/indicator_mode_test.go @@ -0,0 +1,45 @@ +package localdb + +import ( + "path/filepath" + "testing" +) + +func newModeDB(t *testing.T) *LocalDB { + t.Helper() + local, err := New(filepath.Join(t.TempDir(), "mode.db")) + if err != nil { + t.Fatalf("open localdb: %v", err) + } + t.Cleanup(func() { _ = local.Close() }) + return local +} + +func TestIndicatorMode(t *testing.T) { + local := newModeDB(t) + + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("unset: got %q, want %q", got, IndicatorModeColor) + } + + if err := local.SetIndicatorMode(IndicatorModeAccessible); err != nil { + t.Fatalf("set accessible: %v", err) + } + if got := local.GetIndicatorMode(); got != IndicatorModeAccessible { + t.Fatalf("after set accessible: got %q, want %q", got, IndicatorModeAccessible) + } + + if err := local.SetIndicatorMode(IndicatorModeColor); err != nil { + t.Fatalf("set color: %v", err) + } + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("after set color: got %q, want %q", got, IndicatorModeColor) + } + + if err := local.SetIndicatorMode("rainbow"); err == nil { + t.Fatal("expected error for invalid mode, got nil") + } + if got := local.GetIndicatorMode(); got != IndicatorModeColor { + t.Fatalf("after rejected set: got %q, want %q", got, IndicatorModeColor) + } +} diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index a9844ca..8077b8b 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1130,6 +1130,43 @@ func (l *LocalDB) upsertAppSetting(tx *gorm.DB, key, value string, updatedAt tim `, key, value, updatedAt.Format(time.RFC3339)).Error } +// Indicator display mode: how the UI renders signals that are otherwise carried +// by colour alone (price-quality scale, world-fallback price cells, incomplete +// pricing totals). "color" (default) keeps the hue coding; "accessible" swaps it +// for shape/text so it stays readable with a colour-vision deficiency. Per-client +// preference stored in app_settings; consumed by web/static/price-quality.js and +// the pricing tab via base.html's window.QF_INDICATOR_MODE. +const ( + IndicatorModeColor = "color" + IndicatorModeAccessible = "accessible" +) + +// GetIndicatorMode returns the stored indicator display mode, falling back to +// "color" when unset or invalid. +func (l *LocalDB) GetIndicatorMode() string { + value, ok := l.getAppSettingValue("indicator_mode") + if !ok { + return IndicatorModeColor + } + switch strings.TrimSpace(value) { + case IndicatorModeAccessible: + return IndicatorModeAccessible + default: + return IndicatorModeColor + } +} + +// SetIndicatorMode stores the indicator display mode. Only the two known +// literals are accepted. +func (l *LocalDB) SetIndicatorMode(mode string) error { + mode = strings.TrimSpace(mode) + if mode != IndicatorModeColor && mode != IndicatorModeAccessible { + return fmt.Errorf("invalid indicator mode %q", mode) + } + now := time.Now() + return l.upsertAppSetting(l.db, "indicator_mode", mode, now) +} + // SetLastSyncTime sets the last sync timestamp func (l *LocalDB) SetLastSyncTime(t time.Time) error { // Using raw SQL for upsert since SQLite doesn't have native UPSERT in all versions diff --git a/web/static/app.css b/web/static/app.css index a08d7eb..01fcaf8 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -44,3 +44,42 @@ visibility: visible; opacity: 1; } + +/* Colour-blind-safe price-quality indicator (mono mode). 5-step signal meter, + single neutral hue — magnitude reads from the number of lit bars, not colour. */ +.qf-quality-meter { + display: inline-flex; + align-items: flex-end; + gap: 1px; + height: 12px; + vertical-align: middle; + line-height: 0; +} +.qf-quality-meter > i { + display: inline-block; + width: 3px; + background-color: #d1d5db; + border-radius: 1px; +} +.qf-quality-meter > i.on { + background-color: #374151; +} +.qf-quality-meter > i:nth-child(1) { height: 30%; } +.qf-quality-meter > i:nth-child(2) { height: 45%; } +.qf-quality-meter > i:nth-child(3) { height: 62%; } +.qf-quality-meter > i:nth-child(4) { height: 80%; } +.qf-quality-meter > i:nth-child(5) { height: 100%; } +.qf-quality-meter[data-size="sm"] { + height: 10px; + gap: 1px; +} +.qf-quality-meter[data-size="sm"] > i { + width: 2px; +} + +/* Leading meter column on the pricing tab tables (mono mode). */ +.pricing-quality-meter { + width: 1%; + white-space: nowrap; + text-align: center; +} diff --git a/web/static/price-quality.js b/web/static/price-quality.js index 7c9fa19..f0510a0 100644 --- a/web/static/price-quality.js +++ b/web/static/price-quality.js @@ -1,16 +1,25 @@ -// Shared LOT price-quality color scale, used everywhere price_quality is +// Shared LOT price-quality indicator, used everywhere price_quality is // displayed (pricelist detail page, configurator search dropdown, configurator -// table, pricing tab). price_quality is a 0-9 score set by the external -// pricing tool (see bible-local/03-database.md, qt_pricelist_items.price_quality) -// — QF only displays it, never computes it. Single source of the color scale -// so every view stays visually consistent. +// table, pricing tab). price_quality is a 0-9 score set by the external pricing +// tool (see bible-local/03-database.md, qt_pricelist_items.price_quality) — QF +// only displays it, never computes it. Single source of the visual scale so +// every view stays consistent. // -// Gradient: 0 = red, 5 = yellow, 9 = green. +// Two render modes, chosen per client via window.QF_INDICATOR_MODE (injected by +// base.html from app_settings, key indicator_mode): +// "color" (default) — hue scale, 0 = red, 5 = yellow, 9 = green. +// "accessible" — colour-blind-safe 5-step signal-strength meter, +// single neutral hue, magnitude encoded by bar count. +// See bible-local/decisions/2026-08-31-accessible-indicator-mode.md. (function () { const RED = [220, 38, 38]; const YELLOW = [234, 179, 8]; const GREEN = [22, 163, 74]; + function isAccessible() { + return window.QF_INDICATOR_MODE === 'accessible'; + } + function lerp(c1, c2, t) { return c1.map((v, i) => Math.round(v + (c2[i] - v) * t)); } @@ -28,27 +37,53 @@ return rgb ? `rgb(${rgb})` : null; } - // Small colored dot for compact contexts (search dropdown, configurator table cell). + // 0-9 score -> 0-4 meter level. 0-1 / 2-3 / 4-5 / 6-7 / 8-9. + function priceQualityLevel(quality) { + if (typeof quality !== 'number' || Number.isNaN(quality)) return null; + const q = Math.max(0, Math.min(9, quality)); + return Math.min(4, Math.floor(q / 2)); + } + + // Monochrome 5-step signal-strength meter. size: undefined | 'sm'. + function qualityMeterHtml(quality, opts) { + const level = priceQualityLevel(quality); + if (level === null) return ''; + const size = opts && opts.size === 'sm' ? ' data-size="sm"' : ''; + const title = `Качество цены: ${quality}/9`; + let bars = ''; + for (let i = 0; i < 5; i++) { + bars += ``; + } + return `${bars}`; + } + + // Small indicator for compact contexts (search dropdown, configurator table cell). function qualityDotHtml(quality) { + if (isAccessible()) return qualityMeterHtml(quality, { size: 'sm' }); const color = priceQualityColor(quality); if (!color) return ''; return ``; } - // Colored number badge for a dedicated "quality" column/cell. + // Indicator for a dedicated "quality" column/cell. function qualityBadgeHtml(quality) { if (typeof quality !== 'number') return '-'; + if (isAccessible()) return qualityMeterHtml(quality, { size: 'sm' }); const color = priceQualityColor(quality); return `${quality}`; } - // Light row-tint background for table rows, e.g. the pricing tab. + // Light row-tint background for table rows, e.g. the pricing tab. Suppressed + // in accessible mode — the pricing tab carries its own leading meter column there. function qualityRowStyle(quality) { + if (isAccessible()) return ''; const rgb = priceQualityRgb(quality); return rgb ? `background-color: rgba(${rgb}, 0.10)` : ''; } window.priceQualityColor = priceQualityColor; + window.priceQualityLevel = priceQualityLevel; + window.qualityMeterHtml = qualityMeterHtml; window.qualityDotHtml = qualityDotHtml; window.qualityBadgeHtml = qualityBadgeHtml; window.qualityRowStyle = qualityRowStyle; diff --git a/web/templates/base.html b/web/templates/base.html index 3fe4878..0253e5f 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -8,6 +8,7 @@ +