diff --git a/CHANGELOG.md b/CHANGELOG.md index b003c73..a709311 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [2.1.0] — 2026-08-07 — Icon Rail Sidebar + +### Added +- Icon Rail sidebar layout (Theme Studio → Navbar & Sidebar → Sidebar Layout), on by default: a slim, always-visible column topped with the company logo, with one compact icon per installed app (e.g. ERPNext, CRM, Frappe HR) below it, each using that app's own icon in a theme-colored tile that follows the site's brand/accent color automatically. Beside it, Frappe's own workspace sidebar list still renders normally. New apps/workspaces appear automatically; the rail can also collapse to icon-only. Administrators can switch back to the classic single-column Tree layout at any time. +- Theme Studio Chart System with schema-driven global defaults and permission-filtered individual editors for Dashboard Charts, Dashboard Graphs, Query/Script Report charts, and Number Card sparklines +- Visual, structural, and behavioral chart controls for surfaces, palettes, individual series, axes, legends, labels, tooltips, animation, interaction, and safe advanced options +- Layered chart reset behavior: property and chart resets inherit global values, while the global reset restores built-in system defaults + +### Changed +- Theme Studio is now the single user-facing configuration experience for visual themes, branding, Smart Home, and Command Palette settings +- Opening Theme Settings redirects to Theme Studio; administrators retain an explicit raw-settings maintenance action +- Branding image controls in Theme Studio now use Frappe attachment fields instead of requiring manually entered URLs +- Theme Studio now uses a readable control and preview typography scale instead of 6–10px interface text +- Theme Studio's login scene now mirrors the public login card, branding, image background, fields, links, and light/dark token behavior +- Dashboard, Form, Table, and Login previews now support click-to-select property editing in a floating inspector beside the selected item +- Published profile and realtime theme refreshes now update chart configuration atomically without changing chart data or callbacks + +### Fixed +- The classic sidebar's own branding header (`#st-company-header`, shown beside the Icon Rail) no longer shows the company logo a second time next to the company name — it's text-only by design now, since the Icon Rail's own brand tile is the logo's one dedicated home in the sidebar chrome +- Icon Rail app tiles and the collapse chevron are now actually visible — `frappe.utils.icon()`'s svg renders via the `--icon-fill`/`--icon-stroke` custom properties Frappe's own CSS declares, not `color`/currentColor, so setting `color` on the tile had no effect: every icon silently rendered with whichever `--icon-stroke` happened to be ambient on the page, invisible against the tile's own accent-colored background — every tile appeared as a blank color swatch +- `publish_theme_config` (the whitelisted API Theme Studio's Publish action calls) now also protects the site's company logo/name/favicon/tagline when switching profiles, independent of the editor's own safeguard above — closing the same class of bug for any direct API call, script, or future code path that doesn't go through Theme Studio's UI, without overriding a deliberate clear on an already-active profile +- The Icon Rail's own logo tile no longer goes stale after a live theme update — publishing a new company logo/name previously refreshed the classic sidebar's header on every open Desk tab but left the rail showing the old logo until a full reload +- The Icon Rail's list column no longer gets forced open on mobile (≤767px) on every page change — that forced Frappe's own `.expanded` class into a full-screen overlay with a dimming scrim that reappeared immediately after being tapped shut, permanently covering Desk content; mobile now keeps its native collapsed/overlay toggle untouched +- Icon Rail background/active-color field descriptions in Theme Settings now correctly describe the automatic fallback (the site's accent color) instead of a color that was never actually used +- Loading a theme profile (built-in or custom) in Theme Studio no longer silently erases the site's company logo/name/favicon/tagline when published - every profile's stored config carries these as blank unless it explicitly sets them, since a profile is a reusable visual theme, not a site's identity; they're now always preserved from the current site config unless the profile deliberately overrides them +- The active workspace's label in the Icon Rail no longer appears blank; padding is tighter throughout, and the rail can now collapse to dots-only via a toggle at its base, matching the classic sidebar's own collapse behavior +- The classic sidebar's own "Workspaces" quick-list no longer duplicates the new Icon Rail column — it now only shows when Sidebar Layout is Tree, and updates immediately if the layout is changed live +- Icon Rail's list column no longer renders broken (item labels bleeding out of a zero-width collapsed container) — it now stays fully expanded via Frappe's own `.expanded` sidebar state instead of a custom collapse/hide CSS rule +- Sidebar layout changes (Tree ↔ Icon Rail) now apply live to every open Desk tab the moment a theme is published, matching every other Theme Studio setting — previously this required a full page reload, since it's the only setting read from boot data rather than applied via CSS variables +- Icon rail no longer renders partly underneath the fixed top toolbar +- Icon rail width no longer shows "undefined" or silently clamps to its minimum after a Theme Settings document is saved for the first time — Frappe's Int-field save serialization writes an unset field as 0 (not None), which was previously misread as a deliberate override + ## [2.0.1] — 2026-08-03 ### Fixed @@ -10,9 +42,6 @@ ## [2.0.0] — 2026-07-29 — Complete Theme Studio ### Added -- Theme Studio Chart System with schema-driven global defaults and permission-filtered individual editors for Dashboard Charts, Dashboard Graphs, Query/Script Report charts, and Number Card sparklines -- Visual, structural, and behavioral chart controls for surfaces, palettes, individual series, axes, legends, labels, tooltips, animation, interaction, and safe advanced options -- Layered chart reset behavior: property and chart resets inherit global values, while the global reset restores built-in system defaults - A complete searchable visual editor spanning colours, navigation, form controls, typography, cards, tables, dashboards, login branding, layout, accessibility, and advanced overrides - Frappe Default, Light, Dark, High Contrast, Solvronix, and Forest profiles, plus custom profile create, update, duplicate, rename, delete, import, and export workflows - Live Dashboard, Form, Table, and Login preview scenes with desktop/tablet/mobile modes, Frappe comparison, drag-and-drop layout, undo/redo, draft saving, and section reset diff --git a/README.md b/README.md index cba5a42..45bd27d 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ Four one-click starting points appear in Theme Studio. Pick one and the brand an ### Auto Color System You set one brand color. The system automatically generates your complete color palette — backgrounds, hover states, borders, shadows — using CSS `color-mix()`. Change your brand color and everything updates instantly. No developer needed. -### Slim Icon Sidebar -A 64px icon rail instead of the default wide sidebar. Expands to 240px on click. Saves screen space, reduces visual clutter. State is saved per user. +### Icon Rail Sidebar +A slim, always-visible app-icon column sits beside the classic workspace list — one compact tile per installed app (ERPNext, CRM, Frappe HR, and so on), each using that app's own icon in a theme-colored tile that follows your brand/accent color automatically. New apps and workspaces appear on the rail automatically. Collapses to icon-only via its own toggle, independent of the classic sidebar. Switch back to the classic single-column Tree layout at any time from Theme Studio → Navbar & Sidebar → Sidebar Layout. ### Dark Mode — Light / Dark / Auto One click cycles between light, dark, and auto. Auto follows your operating system theme and switches live when the OS does. Both modes respect your brand colors. Works on every page. @@ -133,13 +133,15 @@ On first launch, a checklist banner guides System Manager users through the init |:---:|:---:| | ![Login Page](docs/screenshots/login.png) | ![Today's View](docs/screenshots/smart-home.png) | -| Theme Studio / Settings | Dark Mode | +| Theme Studio | Dark Mode | |:---:|:---:| -| ![Theme Settings](docs/screenshots/theme-settings.png) | ![Dark Mode](docs/screenshots/dark-mode.png) | +| ![Theme Studio](docs/screenshots/theme-studio.png) | ![Dark Mode](docs/screenshots/dark-mode.png) | -| Command Palette | Slim Sidebar | +| Command Palette | Icon Rail Sidebar | |:---:|:---:| -| ![Command Palette](docs/screenshots/command-palette.png) | ![Slim Sidebar](docs/screenshots/sidebar.png) | +| ![Command Palette](docs/screenshots/command-palette.png) | ![Icon Rail Sidebar](docs/screenshots/sidebar.png) | + +![All Apps](docs/screenshots/All-apps.png) --- diff --git a/docs/screenshots/All-apps.png b/docs/screenshots/All-apps.png new file mode 100644 index 0000000..31eaece Binary files /dev/null and b/docs/screenshots/All-apps.png differ diff --git a/docs/screenshots/command-palette.png b/docs/screenshots/command-palette.png index 0a8222a..f5df34d 100644 Binary files a/docs/screenshots/command-palette.png and b/docs/screenshots/command-palette.png differ diff --git a/docs/screenshots/dark-mode.png b/docs/screenshots/dark-mode.png index 89f9f8f..2559652 100644 Binary files a/docs/screenshots/dark-mode.png and b/docs/screenshots/dark-mode.png differ diff --git a/docs/screenshots/hero.png b/docs/screenshots/hero.png deleted file mode 100644 index 53fa292..0000000 Binary files a/docs/screenshots/hero.png and /dev/null differ diff --git a/docs/screenshots/login-2.png b/docs/screenshots/login-2.png deleted file mode 100644 index 4a9b0be..0000000 Binary files a/docs/screenshots/login-2.png and /dev/null differ diff --git a/docs/screenshots/login.png b/docs/screenshots/login.png index 4a9b0be..49a697d 100644 Binary files a/docs/screenshots/login.png and b/docs/screenshots/login.png differ diff --git a/docs/screenshots/sidebar.png b/docs/screenshots/sidebar.png index 83dd6c4..07c0914 100644 Binary files a/docs/screenshots/sidebar.png and b/docs/screenshots/sidebar.png differ diff --git a/docs/screenshots/smart-home.png b/docs/screenshots/smart-home.png index b0368ab..4e5e8c8 100644 Binary files a/docs/screenshots/smart-home.png and b/docs/screenshots/smart-home.png differ diff --git a/docs/screenshots/theme-settings.png b/docs/screenshots/theme-settings.png deleted file mode 100644 index b74e663..0000000 Binary files a/docs/screenshots/theme-settings.png and /dev/null differ diff --git a/docs/screenshots/theme-studio.png b/docs/screenshots/theme-studio.png new file mode 100644 index 0000000..75c0c9c Binary files /dev/null and b/docs/screenshots/theme-studio.png differ diff --git a/docs/screenshots/todays-view.png b/docs/screenshots/todays-view.png new file mode 100644 index 0000000..4e5e8c8 Binary files /dev/null and b/docs/screenshots/todays-view.png differ diff --git a/solvronix_desk/__init__.py b/solvronix_desk/__init__.py index 159d48b..9aa3f90 100644 --- a/solvronix_desk/__init__.py +++ b/solvronix_desk/__init__.py @@ -1 +1 @@ -__version__ = "2.0.1" +__version__ = "2.1.0" diff --git a/solvronix_desk/hooks.py b/solvronix_desk/hooks.py index f1b1355..2ad6b7b 100644 --- a/solvronix_desk/hooks.py +++ b/solvronix_desk/hooks.py @@ -7,7 +7,7 @@ app_license = "MIT" app_color = "#E8610A" app_icon = "octicon octicon-paintcan" -app_version = "2.0.1" +app_version = "2.1.0" required_apps = [] @@ -19,7 +19,7 @@ # Query versions are bumped whenever an asset changes to invalidate browser cache. app_include_css = [ "/assets/solvronix_desk/css/solvronix_desk.css?v=52", - "/assets/solvronix_desk/css/sidebar.css?v=21", + "/assets/solvronix_desk/css/sidebar.css?v=31", "/assets/solvronix_desk/css/command_palette.css?v=4", "/assets/solvronix_desk/css/smart_home.css?v=7", "/assets/solvronix_desk/css/progressive_forms.css?v=3", @@ -28,12 +28,12 @@ "/assets/solvronix_desk/css/dark_mode.css?v=14", "/assets/solvronix_desk/css/module_cards.css?v=2", "/assets/solvronix_desk/css/density.css?v=2", - "/assets/solvronix_desk/css/theme_studio.css?v=16", + "/assets/solvronix_desk/css/theme_studio.css?v=19", ] app_include_js = [ "/assets/solvronix_desk/js/dark_mode.js?v=12", "/assets/solvronix_desk/js/personalization.js?v=1", - "/assets/solvronix_desk/js/solvronix_desk.js?v=51", + "/assets/solvronix_desk/js/solvronix_desk.js?v=63", "/assets/solvronix_desk/js/sidebar.js?v=3", "/assets/solvronix_desk/js/command_palette.js?v=9", "/assets/solvronix_desk/js/progressive_forms.js?v=4", diff --git a/solvronix_desk/public/css/sidebar.css b/solvronix_desk/public/css/sidebar.css index 173daf8..f7c905e 100644 --- a/solvronix_desk/public/css/sidebar.css +++ b/solvronix_desk/public/css/sidebar.css @@ -32,24 +32,14 @@ overflow: hidden; } -/* In collapsed mode: center the logo, hide the name */ +/* Text-only header (no icon — the Icon Rail's own brand tile already + covers that at the far-left edge). A collapsed 64px classic sidebar has + no room to show a readable name, so hide the whole header rather than + leave an empty bordered strip. */ .body-sidebar-container:not(.expanded) #st-company-header { - justify-content: center; - padding: 0; -} - -.body-sidebar-container:not(.expanded) .st-company-name { display: none; } -#st-company-header img { - height: 28px; - width: auto; - max-width: 100%; - object-fit: contain; - flex-shrink: 0; -} - .st-company-name { font-size: 14px; font-weight: 600; @@ -314,125 +304,7 @@ opacity: 1; } -/* ---- Today / New / Workspaces start rail ---- */ -#st-workspace-rail { - margin: 8px 8px 12px; - padding-bottom: 10px; - border-bottom: 1px solid color-mix(in srgb, var(--st-sidebar-text, #374151) 12%, transparent); -} - -.st-rail-primary, -.st-rail-workspaces { - display: grid; - gap: 3px; -} - -.st-rail-link, -.st-rail-workspace { - display: flex; - align-items: center; - gap: 10px; - width: 100%; - min-height: 36px; - padding: 6px 9px; - border: 0; - border-radius: 9px; - background: transparent; - color: var(--st-sidebar-text, #374151); - font-size: 12px; - font-weight: 550; - text-align: left; - text-decoration: none; - white-space: nowrap; -} - -.st-rail-link:hover, -.st-rail-workspace:hover { - background: var(--st-sidebar-hover, rgba(0,0,0,.05)); - color: var(--st-sidebar-hover-text, #111827); - text-decoration: none; -} - -.st-rail-today { - background: color-mix(in srgb, var(--st-accent, #e8610a) 10%, transparent); - color: var(--st-accent, #e8610a); -} - -.st-rail-icon { - display: grid; - place-items: center; - width: 18px; - flex: 0 0 18px; -} -.st-rail-icon svg { width: 16px; height: 16px; } .st-rail-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; } -.st-rail-new-plus { margin-left: auto; color: var(--st-accent, #e8610a); font-size: 17px; } - -.st-rail-section-title { - display: flex; - align-items: center; - justify-content: space-between; - margin: 14px 9px 5px; - color: var(--st-sidebar-text-muted, #9ca3af); - font-size: 9px; - font-weight: 800; - letter-spacing: .1em; - text-transform: uppercase; -} -.st-rail-section-title a { color: inherit; font-size: 14px; text-decoration: none; } - -.st-rail-dot { - width: 7px; - height: 7px; - margin-left: 5px; - flex: 0 0 7px; - border-radius: 50%; - background: #e8610a; -} -.st-rail-dot-1 { background: #2563eb; } -.st-rail-dot-2 { background: #15915e; } -.st-rail-dot-3 { background: #d97706; } -.st-rail-dot-4 { background: #d64f4f; } - -.st-rail-create { - margin: 5px 0 8px 28px; - padding: 6px; - border: 1px solid color-mix(in srgb, var(--st-sidebar-text, #374151) 12%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--st-sidebar-bg, #fff) 92%, var(--st-sidebar-text, #374151)); -} -.st-rail-create[hidden] { display: none; } -.st-rail-create button { - display: flex; - gap: 7px; - width: 100%; - padding: 7px 8px; - border: 0; - border-radius: 7px; - background: transparent; - color: var(--st-sidebar-text, #374151); - font-size: 11px; - text-align: left; -} -.st-rail-create button:hover { background: var(--st-sidebar-hover, rgba(0,0,0,.05)); } -.st-rail-create button span { color: var(--st-accent, #e8610a); } -.st-rail-create small { display: block; padding: 7px; color: var(--st-sidebar-text-muted, #9ca3af); } - -.body-sidebar-container:not(.expanded) #st-workspace-rail { - margin-inline: 6px; -} -.body-sidebar-container:not(.expanded) .st-rail-link, -.body-sidebar-container:not(.expanded) .st-rail-workspace { - justify-content: center; - padding-inline: 0; -} -.body-sidebar-container:not(.expanded) .st-rail-label, -.body-sidebar-container:not(.expanded) .st-rail-new-plus, -.body-sidebar-container:not(.expanded) .st-rail-section-title, -.body-sidebar-container:not(.expanded) .st-rail-create { - display: none !important; -} -.body-sidebar-container:not(.expanded) .st-rail-dot { margin-left: 0; width: 9px; height: 9px; flex-basis: 9px; } /* ---- Chevron toggle button rotation ---- */ .body-sidebar .collapse-sidebar-link svg, @@ -523,3 +395,174 @@ html[data-theme="dark"] .body-sidebar .standard-sidebar-item.active-sidebar .sid color: var(--st-accent) !important; fill: var(--st-accent) !important; } + +/* ══════════════════════════════════════════════════════════════════════════ + ICON RAIL — opt-in second sidebar column (Theme Studio: Sidebar Layout) + Everything below is additive and scoped under body.st-sidebar-layout-rail, + so the default Tree layout above is completely unaffected. + ═════════════════════════════════════════════════════════════════════════ */ +#st-icon-rail { + display: flex; + flex-direction: column; + width: var(--st-rail-width, 72px); + height: 100vh; + background: var(--st-sidebar-bg, #FFFFFF); + border-right: 1px solid var(--sidebar-border-color, #ededed); + flex-shrink: 0; + transition: width 0.2s ease; +} +/* Mirrors the equivalent .body-sidebar rule below — keeps the rail clear of + the fixed top toolbar this theme always adds via .st-has-toolbar. */ +body.st-has-toolbar #st-icon-rail { + margin-top: var(--st-header-height) !important; + height: calc(100vh - var(--st-header-height)) !important; +} +/* Company logo/icon, pinned at the top — its one dedicated home in the + entire sidebar chrome; the classic sidebar's own #st-company-header is + text-only by design so the logo is never shown twice. */ +#st-icon-rail .st-rail-brand { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 100%; + height: 52px; + border-bottom: 1px solid var(--sidebar-border-color, #ededed); +} +#st-icon-rail .st-rail-brand img { + width: 28px; + height: 28px; + object-fit: contain; + border-radius: 6px; +} +#st-icon-rail .st-rail-brand-fallback { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + background: var(--st-rail-bg, var(--st-accent)); + color: var(--st-rail-icon-color, #FFFFFF); + font-size: 13px; + font-weight: 700; +} +/* Scrollable item list above a pinned collapse toggle, not the whole rail — + so the toggle stays reachable regardless of list length. */ +#st-icon-rail .st-rail-items { + flex: 1 1 auto; + overflow-y: auto; + padding: 10px 6px; + display: flex; + flex-direction: column; + gap: 4px; +} +/* One icon per installed app — icon sits in a theme-colored tile (driven by + --st-rail-bg, which defaults to the theme's own accent color, so it + updates automatically if the admin changes brand/accent colors), with the + app's friendly name below. The active app gets a ring around its tile + rather than an inverted color scheme, so label/tile contrast never has to + be recomputed per state. */ +#st-icon-rail .st-rail-app { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 5px 4px; + border-radius: 9px; + text-decoration: none !important; + cursor: pointer; +} +#st-icon-rail .st-rail-app:hover { + background: var(--sidebar-hover-color, #f3f3f3); + text-decoration: none !important; +} +#st-icon-rail .st-rail-app-icon { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + background: var(--st-rail-bg, var(--st-accent)); + color: var(--st-rail-icon-color, #FFFFFF); + /* frappe.utils.icon()'s renders via the fill/stroke + custom properties Frappe's own icons.scss declares (--icon-fill/ + --icon-stroke) — NOT via `color`/currentColor. Without this override + each tile's glyph silently inherits whatever --icon-stroke the page + happens to have ambient (a neutral gray meant for icons sitting + directly on the page background), uncoordinated with this tile's own + accent-colored background, making icons vanish rather than merely + look off. --icon-fill stays untouched: Frappe's icon set is already + transparent-fill/stroke-only in both light and dark mode. */ + --icon-stroke: var(--st-rail-icon-color, #FFFFFF); +} +#st-icon-rail .st-rail-app-icon svg { width: 16px; height: 16px; } +#st-icon-rail .st-rail-app-fallback { font-size: 13px; font-weight: 700; } +#st-icon-rail .st-rail-app.st-rail-active .st-rail-app-icon { + box-shadow: 0 0 0 2px var(--st-sidebar-bg, #FFFFFF), 0 0 0 3px var(--st-rail-active, var(--st-accent)); +} +#st-icon-rail .st-rail-app-label { + font-size: 11px; + font-weight: 600; + line-height: 1.2; + color: var(--st-sidebar-text, #374151); + text-align: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +#st-icon-rail .st-rail-app.st-rail-active .st-rail-app-label { + color: var(--st-rail-active, var(--st-accent)); +} + +/* Collapse toggle — pinned at the bottom, mirrors Frappe core's own + .collapse-sidebar-link (chevron icon, same interaction pattern). */ +#st-icon-rail .st-rail-collapse { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 32px; + padding: 6px 12px; + border: 0; + border-top: 1px solid var(--sidebar-border-color, #ededed); + background: transparent; + color: var(--st-sidebar-text-muted, #9ca3af); + font-size: 11px; + font-weight: 600; + cursor: pointer; + /* See .st-rail-app-icon's --icon-stroke comment: frappe.utils.icon()'s + svg reads --icon-stroke, not `color`, so the chevron needs its own + explicit value to actually be visible. */ + --icon-stroke: var(--st-sidebar-text-muted, #9ca3af); +} +#st-icon-rail .st-rail-collapse:hover { + background: var(--sidebar-hover-color, #f3f3f3); +} +#st-icon-rail .st-rail-collapse-icon svg { width: 14px; height: 14px; } + +/* Collapsed: icon tiles only, no labels, narrow fixed width — matching the + classic sidebar's own icon-only collapsed state. */ +#st-icon-rail.is-collapsed { + width: 60px; +} +#st-icon-rail.is-collapsed .st-rail-label, +#st-icon-rail.is-collapsed .st-rail-collapse .st-rail-label { + display: none; +} +#st-icon-rail.is-collapsed .st-rail-collapse { + justify-content: center; +} + +html[data-theme="dark"] #st-icon-rail { + border-right-color: rgba(255,255,255,0.08); +} +html[data-theme="dark"] #st-icon-rail .st-rail-app:hover { + background: rgba(255,255,255,0.06); +} +html[data-theme="dark"] #st-icon-rail .st-rail-collapse:hover { + background: rgba(255,255,255,0.06); +} diff --git a/solvronix_desk/public/css/theme_studio.css b/solvronix_desk/public/css/theme_studio.css index 12db60d..1910010 100644 --- a/solvronix_desk/public/css/theme_studio.css +++ b/solvronix_desk/public/css/theme_studio.css @@ -266,6 +266,14 @@ .sts-preview-collapse { width: 100%; height: 30px; display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: auto; padding: 0; border: 0; border-top: 1px solid color-mix(in srgb, var(--studio-sidebar-text) 12%, transparent); background: transparent; color: color-mix(in srgb, var(--studio-sidebar-text) 62%, transparent); font-size: 8px; } .sts-preview-sidebar.is-expanded .sts-preview-collapse { justify-content: flex-start; padding-left: 10px; } .sts-preview-sidebar.is-expanded .sts-preview-collapse svg { transform: rotate(180deg); } +/* Fake workspace rail — cosmetic preview only, mirrors solvronix_desk.js's real #st-icon-rail */ +.sts-preview-rail { display: none; width: 46px; min-width: 46px; flex-direction: column; align-items: center; gap: 6px; padding: 8px 4px; background: var(--studio-sidebar); border-right: 1px solid color-mix(in srgb, var(--studio-sidebar-text) 12%, transparent); } +.sts-preview-rail.is-visible { display: flex; } +.sts-preview-rail a { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 4px 2px; border-radius: calc(var(--studio-radius) * .65); color: color-mix(in srgb, var(--studio-sidebar-text) 78%, transparent); font-size: 6px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sts-preview-rail-icon { width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; border-radius: 6px; background: var(--studio-rail-bg, var(--studio-accent)); color: #fff; } +.sts-preview-rail-icon svg { width: 12px; height: 12px; } +.sts-preview-rail a.active .sts-preview-rail-icon { box-shadow: 0 0 0 1px var(--studio-sidebar), 0 0 0 2px var(--studio-rail-active, var(--studio-accent)); } +.sts-preview-rail a.active { color: var(--studio-rail-active, var(--studio-accent)); } .sts-preview-main { min-width: 0; flex: 1; display: flex; flex-direction: column; } .sts-preview-nav { height: var(--studio-header-height,36px); display: flex; align-items: center; padding: 0 12px; background: var(--studio-navbar); color: var(--studio-toolbar-text); font-family: 'Segoe UI', sans-serif; } .sts-toolbar-left { display: flex; align-items: center; gap: 8px; font-size: 7px; } diff --git a/solvronix_desk/public/js/solvronix_desk.js b/solvronix_desk/public/js/solvronix_desk.js index dd34d5b..7493a5c 100644 --- a/solvronix_desk/public/js/solvronix_desk.js +++ b/solvronix_desk/public/js/solvronix_desk.js @@ -140,18 +140,16 @@ var b = ST._branding; if (!b) return; /* branding not fetched yet — injectBranding() will retry */ - /* Nothing set → show nothing (blank space avoided by keeping header absent) */ - if (!b.logo && !b.company_name) return; + /* Text-only by design: the logo icon already has its own dedicated, + permanent home in the Icon Rail's brand tile (.st-rail-brand) at the + far-left edge — showing it a second time here, next to the company + name, duplicated it. This header is the company NAME only, nothing + else, regardless of layout. */ + if (!b.company_name) return; /* Nothing set → show nothing */ - var html = '
'; - if (b.logo) { - html += '' + (b.company_name || '; - } - if (b.company_name) { - html += '' + - frappe.utils.escape_html(b.company_name) + ''; - } - html += '
'; + var html = '
' + + '' + frappe.utils.escape_html(b.company_name) + '' + + '
'; /* Prepend before .body-sidebar-top (the items list) */ var top = sidebar.querySelector(".body-sidebar-top"); @@ -182,82 +180,204 @@ /* Frappe v16 handles sidebar collapse natively. */ } - /* A focused start section keeps Today, New, and accessible workspaces together. */ - function injectWorkspaceRail() { + /* ──────────────────────────────────────────────────────────────────────────── + 4b. ICON RAIL — opt-in second sidebar column (Theme Studio: Sidebar Layout) + Frappe's own frappe.ui.Sidebar still renders the workspace list column; + this only adds a slim, always-visible app-icon rail beside it and swaps + the list via normal frappe.set_route() routing — no second list renderer. + ──────────────────────────────────────────────────────────────────────────── */ + function railEnabled() { + var cfg = frappe.boot && frappe.boot.st_theme_config; + return !!(cfg && cfg.sidebar_layout === "Icon Rail"); + } + + function removeIconRail() { + document.body.classList.remove("st-sidebar-layout-rail"); + var el = document.getElementById("st-icon-rail"); + if (el && el.parentNode) el.parentNode.removeChild(el); + } + + /* frappe.ui.Sidebar.setup() stamps data-title on .body-sidebar with the + resolved workspace title (frappe/public/js/frappe/ui/sidebar/sidebar.js) — + reading the DOM attribute avoids depending on the frappe.app.sidebar + instance directly and matches this file's existing DOM-read approach + (see patchNativeSidebar). */ + function activeSidebarTitle() { var $sidebar = $(".body-sidebar").first(); - if (!$sidebar.length || $sidebar.find("#st-workspace-rail").length || $sidebar.data("st-rail-loading")) return; - $sidebar.data("st-rail-loading", true); + return $sidebar.length ? String($sidebar.attr("data-title") || "").trim().toLowerCase() : ""; + } + + function syncRailHighlight() { + var $rail = $("#st-icon-rail"); + if (!$rail.length) return; + /* Smart Home / Today's View is a cross-app dashboard, not a real + Workspace — Frappe's own sidebar has nothing to resolve for it and + keeps showing whichever workspace's list was last active (e.g. the + "Home" workspace's own Dashboard/Stock Entry/etc. items), which made + the rail misleadingly highlight that workspace's app as if Today's + View belonged to it. Show no active app here instead. */ + var route = frappe.get_route ? frappe.get_route() : []; + var onSmartHome = route && route[0] === "smart-home"; + var active = onSmartHome ? "" : activeSidebarTitle(); + var titleToApp = ST._railTitleToApp || {}; + var activeApp = titleToApp[active] || ""; + $rail.find(".st-rail-app").each(function () { + var appKey = String($(this).attr("data-app") || ""); + $(this).toggleClass("st-rail-active", !!appKey && appKey === activeApp); + }); + } + + /* Sole place that renders the rail's logo tile — called at initial build + AND whenever ST._branding changes live, so a published logo/name change + never leaves the rail showing stale artwork the way a one-shot render + would (the rest of injectIconRail() only runs once per page load). */ + function renderRailBrand() { + var $brand = $("#st-icon-rail .st-rail-brand"); + if (!$brand.length) return; + function esc(value) { + return frappe.utils.escape_html(String(value || "")); + } + var branding = ST._branding || {}; + var brandInner = branding.logo + ? '' + esc(branding.company_name || ' + : '' + esc(String(branding.company_name || "S").charAt(0)) + ''; + $brand.attr("title", branding.company_name || __("Home")).html(brandInner); + } + + function keepListColumnExpanded() { + /* The list column always shows fully expanded alongside the rail + (matching the two-column reference layout) — reuse Frappe's own + .expanded class/state rather than inventing new width/label CSS, since + that's the exact mechanism classic Tree mode already uses correctly + today. Re-applied on every injectIconRail() call (boot, page-change, + live theme refresh) since it's harmless to repeat and cheap to check. + + Below Frappe's own sidebar breakpoint (frappe/public/scss/desk/ + sidebar.scss's media-breakpoint-down(sm), 768px) core CSS repurposes + ".expanded" into a full-screen overlay + dimming scrim toggled by the + user tapping the navbar brand. Forcing it here on every page-change + would re-open that overlay right after the user taps it closed — + leaving mobile Desk permanently covered by the list column and scrim + under Icon Rail layout. Desktop/tablet-landscape keep the original + always-expanded behavior; mobile keeps its native collapsed/overlay + toggle untouched. */ + if (window.matchMedia && window.matchMedia("(max-width: 767px)").matches) return; + var $container = $(".body-sidebar-container").first(); + if (!$container.length) return; + $container.addClass("expanded"); + try { localStorage.setItem("sidebar-expanded", "true"); } catch (err) {} + } + + function injectIconRail() { + if (!railEnabled()) { + if (document.getElementById("st-icon-rail")) removeIconRail(); + return; + } + document.body.classList.add("st-sidebar-layout-rail"); + keepListColumnExpanded(); + if (document.getElementById("st-icon-rail")) { + syncRailHighlight(); + return; + } + + var $container = $(".body-sidebar-container").first(); + if (!$container.length) return; /* sidebarReady() retries until it exists */ frappe.call({ method: "solvronix_desk.api.get_workspaces", - callback: function (response) { - $sidebar.removeData("st-rail-loading"); - if (!$sidebar.closest("body").length || $sidebar.find("#st-workspace-rail").length) return; - var message = (response && response.message) || {}; - var pages = (message.pages || []).concat(message.private_pages || []); - var seen = {}; - pages = pages.filter(function (page) { - var title = String(page.title || page.name || "").trim(); - if (!title || seen[title]) return false; - seen[title] = true; - return true; - }).slice(0, 9); + callback: function (r) { + if (document.getElementById("st-icon-rail")) return; + var message = (r && r.message) || {}; + /* Rail = one icon per installed app (not per workspace) — ERPNext + alone can contribute a dozen top-level workspaces, which is too + many to show as separate rail entries. get_workspaces() already + tags each page with its owning app; group by that instead. */ + var pages = (message.pages || []).filter(function (page) { + return !page.parent_page; + }); + if (!pages.length) return; function esc(value) { return frappe.utils.escape_html(String(value || "")); } function routeFor(page) { - var route = page.route || String(page.title || page.name || "") - .toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); - return "/desk/" + encodeURIComponent(route).replace(/%2F/gi, "/"); + return page.route || + ((frappe.router && frappe.router.slug) ? frappe.router.slug(page.name || page.title || "") : ""); } - var createOrder = ["Sales Invoice", "Quotation", "Sales Order", "Purchase Order", "Customer", "Supplier", "Task", "ToDo"]; - var canCreate = ((frappe.boot && frappe.boot.user && frappe.boot.user.can_create) || []); - var createItems = createOrder.filter(function (doctype) { - return canCreate.indexOf(doctype) !== -1; - }).slice(0, 6); - - var html = - ''; - - var $top = $sidebar.find(".body-sidebar-top").first(); - ($top.length ? $top : $sidebar).prepend(html); - var $rail = $sidebar.find("#st-workspace-rail"); - $rail.on("click", ".st-rail-new", function () { - var $menu = $rail.find(".st-rail-create"); - var open = $menu.prop("hidden"); - $menu.prop("hidden", !open); - $(this).attr("aria-expanded", open ? "true" : "false"); + /* First page per app (in the order the admin already arranged + workspaces in) supplies the representative icon + fallback route; + every page's title maps back to its app for highlight-sync. */ + var appOrder = []; + var appReps = {}; + var titleToApp = {}; + pages.forEach(function (page) { + var appKey = String(page.app || ""); + titleToApp[String(page.title || page.name || "").trim().toLowerCase()] = appKey; + if (!appReps[appKey]) { + appReps[appKey] = page; + appOrder.push(appKey); + } + }); + ST._railTitleToApp = titleToApp; + + var appMeta = {}; + ((frappe.boot && frappe.boot.app_data) || []).forEach(function (a) { + appMeta[a.app_name] = a; + }); + + var items = appOrder.map(function (appKey) { + var rep = appReps[appKey]; + var meta = appMeta[appKey]; + var title = (meta && meta.app_title) || rep.title || rep.name; + var route = routeFor(rep); + return { appKey: appKey, title: title, route: route, icon: rep.icon }; + }).filter(function (item) { return item.route; }); + if (!items.length) return; + + var isCollapsed = false; + try { isCollapsed = localStorage.getItem("st-rail-collapsed") === "true"; } catch (err) {} + + var html = ''; + + $container.before(html); + var $rail = $("#st-icon-rail"); + renderRailBrand(); + + $rail.on("click", ".st-rail-app", function (e) { + e.preventDefault(); + var route = $(this).attr("data-route"); + if (!route) return; + frappe.set_route(route); + setTimeout(syncRailHighlight, 0); }); - $rail.on("click", "[data-new-doctype]", function () { - frappe.new_doc(this.dataset.newDoctype); + + $rail.on("click", ".st-rail-collapse", function () { + var collapsed = $rail.toggleClass("is-collapsed").hasClass("is-collapsed"); + try { localStorage.setItem("st-rail-collapsed", collapsed ? "true" : "false"); } catch (err) {} + $(this).find(".st-rail-collapse-icon").html(frappe.utils.icon(collapsed ? "chevron-right" : "chevron-left", "sm")); }); + + syncRailHighlight(); }, - error: function () { - $sidebar.removeData("st-rail-loading"); - } }); } @@ -1377,21 +1497,26 @@ sidebarReady(function () { injectSidebarCollapseToggle(); injectSidebarBrandingHeader(); /* retry — branding may already be cached */ - injectWorkspaceRail(); + injectIconRail(); patchNativeSidebar(); injectPoweredBy(); }); injectSetupGuide(); + if (frappe.router && frappe.router.on) { + frappe.router.on("change", syncRailHighlight); + } + /* Move Frappe's native notification bell into toolbar after desktop page renders */ setTimeout(moveNativeBell, 800); $(document).on("page-change", function () { - injectWorkspaceRail(); + injectIconRail(); patchNativeSidebar(); injectPoweredBy(); injectSetupGuide(); setTimeout(moveNativeBell, 400); + setTimeout(syncRailHighlight, 0); }); /* bootinfo.home_page owns the initial Smart Home redirect. Redirecting here @@ -1438,6 +1563,7 @@ var old = document.getElementById("st-company-header"); if (old) old.parentNode.removeChild(old); injectSidebarBrandingHeader(); + renderRailBrand(); } /* 3. Subtle confirmation */ @@ -1445,6 +1571,40 @@ }); }); + /* ── ICON RAIL: APPLY LAYOUT CHANGES LIVE, NO RELOAD ──────────────────────── + frappe.boot.st_theme_config is only embedded once, at the last full page + load — it never refreshes on its own during SPA navigation. Every other + Theme Studio setting (colors, fonts) already applies live via CSS + variables; sidebar layout needs its own patch since railEnabled() reads + boot data, not CSS. Reuses the same "st-theme-runtime-refresh" event + theme_runtime.js/chart_runtime.js already listen for — runtime.config + already carries sidebar_layout/icon_rail_* since they're part of + DEFAULT_CONFIG. */ + window.addEventListener("st-theme-runtime-refresh", function (event) { + var runtime = event.detail; + var config = runtime && runtime.config; + if (!config) return; + var cfg = (frappe.boot.st_theme_config = frappe.boot.st_theme_config || {}); + cfg.sidebar_layout = config.sidebar_layout; + cfg.icon_rail_width = config.icon_rail_width; + cfg.icon_rail_background = config.icon_rail_background; + cfg.icon_rail_active_color = config.icon_rail_active_color; + /* config is the fully resolved theme (theme_engine.resolve_config), + so its identity fields are already protected against the profile- + blanking bug — a more reliable branding source than relying solely + on the ad-hoc "branding" payload the st_theme_changed socket event + carries, which no other code path (schedule/user/role switches) sends. */ + ST._branding = { + company_name: config.app_title || "", + logo: config.company_logo || "", + favicon: config.favicon || "", + tagline: config.tagline || "", + }; + injectIconRail(); + renderRailBrand(); + syncRailHighlight(); + }); + /* ── THEME SETTINGS FORM: LIVE APPLY ON SAVE ──────────────────────────────── frappe.realtime can miss the event if the socket reconnects right as the user saves. This form hook fires synchronously on the same page — guaranteed. @@ -1469,6 +1629,7 @@ var old = document.getElementById("st-company-header"); if (old) old.parentNode.removeChild(old); injectSidebarBrandingHeader(); + renderRailBrand(); if (frm.doc.favicon) { $('link[rel="shortcut icon"], link[rel="icon"]').attr("href", frm.doc.favicon); } diff --git a/solvronix_desk/setup.py b/solvronix_desk/setup.py index 67febfd..278fde7 100644 --- a/solvronix_desk/setup.py +++ b/solvronix_desk/setup.py @@ -20,6 +20,8 @@ def after_install(): "corner_radius": 8, "shadow_style": "Soft", "sidebar_width": 240, + "sidebar_layout": "Icon Rail", + "icon_rail_width": 72, "studio_layout": '["metrics","chart","activity","quick_actions"]', "theme_enabled": 1, "allow_user_theme": 1, diff --git a/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.json b/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.json index 983ca05..6ed9d96 100644 --- a/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.json +++ b/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.json @@ -32,6 +32,11 @@ "column_break_visual_shape", "shadow_style", "sidebar_width", + "sidebar_layout", + "icon_rail_width", + "column_break_icon_rail", + "icon_rail_background", + "icon_rail_active_color", "studio_layout", "section_theme_engine", "theme_enabled", @@ -216,6 +221,40 @@ "fieldtype": "Int", "label": "Sidebar Width" }, + { + "default": "Icon Rail", + "description": "Icon Rail shows a slim app-icon column beside the workspace list, with each workspace's own items shown when its icon is selected. Tree reverts to Frappe's classic single-column sidebar.", + "fieldname": "sidebar_layout", + "fieldtype": "Select", + "label": "Sidebar Layout", + "options": "Tree\nIcon Rail" + }, + { + "default": "72", + "depends_on": "eval:doc.sidebar_layout == \"Icon Rail\"", + "description": "Icon rail width in pixels (60–120).", + "fieldname": "icon_rail_width", + "fieldtype": "Int", + "label": "Icon Rail Width" + }, + { + "fieldname": "column_break_icon_rail", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval:doc.sidebar_layout == \"Icon Rail\"", + "description": "Optional override. Leave empty to reuse the site's accent color.", + "fieldname": "icon_rail_background", + "fieldtype": "Color", + "label": "Icon Rail Background" + }, + { + "depends_on": "eval:doc.sidebar_layout == \"Icon Rail\"", + "description": "Optional override. Leave empty to reuse the site's accent color.", + "fieldname": "icon_rail_active_color", + "fieldtype": "Color", + "label": "Icon Rail Active Color" + }, { "default": "[\"metrics\",\"chart\",\"activity\",\"quick_actions\"]", "fieldname": "studio_layout", diff --git a/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.py b/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.py index 358c462..6d7e09a 100644 --- a/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.py +++ b/solvronix_desk/solvronix_desk/doctype/theme_settings/theme_settings.py @@ -29,9 +29,11 @@ def get_theme_settings(): "enable_command_palette": s.enable_command_palette, "enable_smart_home": s.enable_smart_home, "dark_mode_default": s.dark_mode_default, + "sidebar_layout": s.sidebar_layout or "Icon Rail", } except Exception: return { - "brand_color": "#1B3F7E", - "accent_color": "#F57C00", + "brand_color": "#1B3F7E", + "accent_color": "#F57C00", + "sidebar_layout": "Icon Rail", } diff --git a/solvronix_desk/solvronix_desk/page/theme_studio/theme_studio.js b/solvronix_desk/solvronix_desk/page/theme_studio/theme_studio.js index 1745fe9..cc07245 100644 --- a/solvronix_desk/solvronix_desk/page/theme_studio/theme_studio.js +++ b/solvronix_desk/solvronix_desk/page/theme_studio/theme_studio.js @@ -31,6 +31,10 @@ solvronix_desk.theme_studio_sections = [ ["sidebar_hover_color", "Menu hover", "color"], ["sidebar_width", "Expanded width", "range", 200, 360, "px"], ["sidebar_mode", "Initial sidebar", "select", ["Compact", "Expanded"]], ["sidebar_auto_collapse", "Auto-collapse on mouse leave", "check"], + ["sidebar_layout", "Sidebar layout", "select", ["Tree", "Icon Rail"]], + ["icon_rail_width", "Icon rail width", "range", 60, 120, "px"], + ["icon_rail_background", "Icon rail background (auto if empty)", "optional-color"], + ["icon_rail_active_color", "Icon rail active colour (auto if empty)", "optional-color"], ["logo_size", "Logo size", "range", 16, 64, "px"], ["logo_position", "Logo position", "select", ["Left", "Center"]], ], }, @@ -406,7 +410,7 @@ solvronix_desk.ThemeStudio = class ThemeStudio { _inspector_catalog() { var catalog = { "navigation.toolbar": { title: "Top toolbar", scene: "Global", keys: ["navbar_background", "toolbar_text_color", "header_height", "sticky_navbar"] }, - "navigation.sidebar": { title: "Sidebar", scene: "Global", keys: ["sidebar_background", "sidebar_text_color", "sidebar_icon_color", "sidebar_active_color", "sidebar_hover_color", "sidebar_width", "sidebar_mode"] }, + "navigation.sidebar": { title: "Sidebar", scene: "Global", keys: ["sidebar_background", "sidebar_text_color", "sidebar_icon_color", "sidebar_active_color", "sidebar_hover_color", "sidebar_width", "sidebar_mode", "sidebar_layout", "icon_rail_width", "icon_rail_background", "icon_rail_active_color"] }, "dashboard.heading": { title: "Dashboard heading", scene: "Dashboard", keys: ["page_background", "text_color", "heading_scale", "primary_button_color", "button_radius"] }, "dashboard.metrics": { title: "Number cards", scene: "Dashboard", keys: ["number_card_color", "text_color", "muted_text_color", "card_radius", "shadow_style"] }, "dashboard.chart": { title: "Chart card", scene: "Dashboard", keys: ["chart_background", "chart_palette", "text_color", "card_radius", "shadow_style"] }, @@ -694,6 +698,8 @@ solvronix_desk.ThemeStudio = class ThemeStudio { sidebar_active_color: c.sidebar_active_color || c.accent_color, sidebar_active_text_color: c.sidebar_active_text_color || this._contrast(c.sidebar_active_color), sidebar_hover_color: c.sidebar_hover_color || this._mix_hex(c.sidebar_background, c.text_color, 0.88), + icon_rail_background: c.icon_rail_background || c.accent_color, + icon_rail_active_color: c.icon_rail_active_color || c.accent_color, page_background: c.page_background || "#F3F5F7", card_background: c.card_background || "#FFFFFF", workspace_card_color: c.workspace_card_color || c.card_background || "#FFFFFF", @@ -1127,14 +1133,31 @@ solvronix_desk.ThemeStudio = class ThemeStudio { _range_control(key, label, min, max, unit, scope) { var inputId = "sts-" + (scope ? scope + "-" : "") + key; + /* A stale stored config predating a newly-added field would otherwise + render "undefined" here and, worse, feed that back into config on + the next input/change event — fall back to min rather than trust + this.config[key] blindly. */ + var raw = this.config[key]; + var value = (raw === undefined || raw === null || isNaN(raw)) ? min : raw; return '
' + this.config[key] + unit + "
" + + '' + value + unit + "
" + ''; + '" value="' + value + '" data-setting="' + key + '">'; } _sidebar_html() { - return '