jey_theme/CLAUDE.md

13 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this app is

Frappe/ERPNext v15 app that does three things:

  1. Overrides the desk UI with a multi-theme visual system (CSS + JS bundle).
  2. Hides the Quality Management and Subcontracting modules app-wide (URL/API/bootinfo/permissions).
  3. Maintains a custom Taxes Az workspace on every migrate.

Installed into frappe-bench as a normal app. Python code is at jey_theme/ (Frappe app package). Frontend assets live at jey_theme/public/{css,js}.

Commands

Run from frappe-bench/ (the bench root), not this app dir.

  • bench build --app jey_theme — required after CSS edits (jey_theme.bundle.css → hashed file in public/dist/css/). JS is served raw and needs only a hard reload.
  • bench migrate — runs the after_migrate hook that rebuilds the Taxes Az workspace from setup/taxes_workspace.py.
  • bench execute jey_theme.setup.taxes_workspace.setup — re-run the workspace setup without a full migrate.
  • bench restart — reload Python workers after editing access_control.py or hooks.py.
  • No test suite, no linter config in this repo.

Architecture

Python: access control (jey_theme/access_control.py + hooks.py)

Goal: make blocked modules invisible and unreachable for every user, including Administrator.

  • BLOCKED_MODULES / BLOCKED_DOCTYPES_EXTRA — edit these frozensets to change what's blocked.
  • Three defense layers wired in hooks.py:
    • before_request — blocks /api/resource/<Doctype>, /app/<slug>, and form_dict-based calls at the HTTP boundary (catches Administrator, who bypasses has_permission).
    • has_permission = {"*": ...} — per-doc deny for non-admin users who have role access.
    • extend_bootinfo — strips blocked items from allowed_modules, modules, user.can_*, workspaces.pages, workspace_sidebar_item, module_wise_workspaces, app_data, desktop_icons, etc., so the client router/sidebar never sees them.
  • Slug/doctype/workspace sets are cached on frappe.local per-request.
  • When adding a new block vector: extend extend_bootinfo rather than relying only on has_permission, because the client has many independent boot-derived UIs.

Python: desk layout engine (jey_theme/desk_layout.py + desk_overrides.py)

Configurable, non-destructive (variant A) control of desk workspaces/cards/links, driven by a JSON config. Lets a System Manager, inline on /desk, hide, block, add and reorder links. The companion editor is the separate jey_layout app (install on the design machine only); the engine here is enforced on every site. The Workspace DB records are never modified — fully reversible, survives ERPNext migrations (everything is applied at request time, server-side → no client flash).

Config (jey_theme/config/desk_layout.json = shipped snapshot; per machine the editor writes a working copy in jey_layout/config/desk_layout.json):

{ "workspaces": { "<ws>": {
    "state": "visible|hidden|blocked",
    "cards": { "<untranslated card label>": {"state": "visible|hidden"} },
    "links": { "<Type>::<link_to>": {"state": "visible|hidden|blocked"} },  // hide/block of any link
    "added": { "<Type>::<link_to>": {"link_type","link_to","label","card"} },// injected links
    "order": { "<card label>": ["<key>", ...] }                              // per-card link order
} } }

Key for every link = link_type::link_to (Type ∈ DocType/Report/Page).

  • hide = the item is filtered OUT of the data the server sends (sidebar / get_desktop_page cards+links) → absent in the DOM. URL still works.
  • block = ALSO denied at the access layer, gated to PRODUCTION only (where jey_layout is absent / jey_design_mode is 0; the design machine keeps access so admins can manage). Two enforcement paths:
    • doctypespermission_query_conditions (* → returns 1=0, so the list is EMPTY for everyone incl. System Manager) + has_permission (per-doc deny, every ptype: read/create/write/delete) + extend_bootinfo (stripped from can_*). NOT before_request: a blunt URL/API block on a doctype breaks search_link/dynamic links, whereas permission hooks are bypassed by ignore_permissions so those internal reads keep working. Code-enforced ⇒ SM-proof (lives in code+config, not DocPerm, so a System Manager can't undo it from the UI). Administrator is the only break-glass bypass.
    • reports / pages / workspacesbefore_request (HTTP/URL/API, catches Administrator too) + has_permission (per-doc). Truly unreachable for everyone.
  • add / reorder = structural overlay: get_desktop_page injects added links into their card and reorders per order. Applied ALWAYS (even in edit mode); only hide/block is gated by enforcement.

Files

  • desk_layout.py — loads config; _cache()/_build() parse it ONCE per request (ungated, fail-open) into hide/block sets on frappe.local. Two accessor families with DIFFERENT gates: VISUAL overlay (should_hide_*, hidden_or_blocked_workspaces) is gated on is_enforcing(); ACCESS-block (prod_blocked_doctypes/_reports/ _pages/_workspaces) is gated on not is_design_mode() (block bites on prod only). _ws_entry() + added_links()/order_map()/apply_order() are the structural (always-on) overlay. is_enforcing(): production → always; design machine → not is_editing() (Redis key jey_layout_editing). is_design_mode() defaults to ON exactly when the jey_layout app is installed (test machines); jey_design_mode in site_config overrides (0/1). Tolerates missing refs.
  • desk_overrides.py — wraps get_workspace_sidebar_items/get_desktop_page via override_whitelisted_methods. get_desktop_page does inject → reorder (always) → hide/block filter (only when enforcing). _build_link_item shapes an injected link like frappe's so the client renders it natively. Card labels in output are translated, so matching forward-translates config labels (_(label)).
  • access_control.py — enforcement. Static BLOCKED_MODULES (Quality/Subcontracting)
    • config reports/pages/workspaces are denied at before_request (HTTP, catches Administrator) and has_permission. Config-blocked doctypes are denied via permission_query_conditions (*1=0) + has_permission (per-doc) ONLY — not before_request (keeps search_link/dynamic links working). extend_bootinfo strips blocked modules/doctypes (can_*) and hidden/blocked workspaces from boot, and sets bootinfo.jey_design_mode. All hooks are wrapped fail-open (never break the desk; has_permission returns True on error, before_request re-raises only the intended PermissionError; errors → Error Log).

Edit vs applied model (no "preview"): on the design machine, the normal desk is the applied result; "✎ Edit layout" flips jey_layout_editing and reloads to show all items + controls; "✓ Done editing" reloads back to applied. Export copies the working file into this snapshot for shipping.

⚠️ Edge gotcha — DO NOT load the editor via app_include. app_include_js/css assets are added to the HTTP Link: rel=preload response header. On this deployment the desk's total response headers are ~4 KB and the edge proxy's proxy_buffer_size is 4 KB; two extra app_include entries pushed it over → 502 "upstream sent too big header" (local nginx has a bigger buffer, so it only failed through the edge). Instead jey_theme.js loads jey_layout's JS/CSS dynamically (design mode only, via bootinfo.jey_design_mode), adding nothing to the header. Proper infra fix (not required by the app): raise the edge's proxy_buffer_size/large_client_header_buffers.

Python: Taxes Az workspace (jey_theme/setup/taxes_workspace.py)

Idempotent setup run via after_migrate.

  • Builds a Workspace named Taxes Az with six cards (Declarations, Setup, Tools, E-Taxes Setup, E-Taxes Reference, E-Taxes Documents), a Workspace Sidebar listing every doctype, and a Desktop Icon that routes via the sidebar.
  • The doctype list is a static snapshot in DECLARATIONS_DOCTYPES / SETUP_DOCTYPES / TOOLS_DOCTYPES (taxes_az) and ETAXES_SETUP_DOCTYPES / ETAXES_REFERENCE_DOCTYPES / ETAXES_DOCUMENT_DOCTYPES (invoice_az). Adding a DocType to either app does NOT auto-include it — append to the tuple and re-migrate.
  • _cleanup_obsolete() removes legacy docs owned by this app (currently "Taxes"). Only touches records where app == "jey_theme" — never deletes ERPNext/other-app docs.

Frontend: multi-theme system

JS entry: jey_theme/public/js/jey_theme.js (served raw via app_include_js, not bundled). CSS: one file, jey_theme/public/css/jey_theme.bundle.css — the former shared.css / theme_chrome.css / theme_modern.css were concatenated into it (in that order, still marked by /* ===== <name>.css ===== */ banners) so the desk's preload header stays under the edge proxy's 4 KB buffer. Edit the bundle directly; there are no separate source files to regenerate it from.

  • CSS changes need bench build --app jey_theme. app_include_css names the bundle, and esbuild emits the hashed public/dist/css/jey_theme.bundle.<HASH>.css that sites/assets/assets.json actually points the browser at. Editing the bundle alone changes nothing in the browser.
  • JS changes need only a hard reload — but /assets/jey_theme/js/jey_theme.js is a hashless path under a 1-year nginx cache, so it really does have to be a hard reload (or a cache-disabled session).
  • Theme selector lives in data-jey-theme on <html>, stored in localStorage("jey-theme"). Default: chrome. Set before first paint by an IIFE at the top of jey_theme.js.
  • Themes supported: chrome (metallic rings), silk (warm ivory & champagne-gold glass-neumorphism, ported from public/silk_dashboard_crisp.html — that mockup is silk's design reference; keep the two in sync). No dark/light variants — each theme is self-contained.
  • Theme-specific CSS MUST be wrapped in [data-jey-theme="themename"]. Keyframes MUST be prefixed jey-<theme>-* to avoid cross-theme name collisions.
  • Shared CSS (checkboxes, icon base sizing, switcher UI, column-resize rules, the card icon-tile defaults) lives in the shared.css section without theme selectors.

Frosted glass (silk, and any future glass theme) — two rules that cost real debugging time:

  • One backdrop-filter per stack. A frosted element inside another frosted element paints as an opaque white block on real GPUs; headless Chrome (SwiftShader) renders it correctly, so it passes every test render. Only the outermost surface gets the filter — currently .widget, .form-page, .frappe-list .result-container, .navbar-search-bar, .dropdown-navbar-user, .desktop-navbar-modal-search, .jey-chrome. Everything nested (icon tiles, chips, count pills, buttons) gets a translucent fill and no filter. There's a backstop rule near the Modals section, but don't rely on it — check with the live-DOM audit (walk every element, flag any whose ancestor also has a non-none computed backdropFilter).
  • On silk it's the translucency, not the blur, that reads as glass. The fabric is near-uniform, so a strong blur averages it into a flat opaque panel. Light blur (6px) over a very translucent warm fill is what lets it show through.

Frontend: per-theme icons

  • THEME_ICONS[themeName] in jey_theme.js maps icon keys to inline SVG path strings. Missing keys fall back to THEME_ICONS.chrome, then _fallback.
  • JS replaces <img class="app-icon"> / .folder-icon with inline <svg class="jey-icon"> per current theme.
  • window.jeyRebuildIcons() tears down and rebuilds all icon DOM — called on theme switch.
  • SVGs may carry a cls (e.g. ico-buying) for shared CSS animations.

Frontend: early-paint tricks in jey_theme.js

The top IIFE runs before paint to avoid FOUC:

  • Sets data-jey-theme immediately.
  • Defaults container_fullwidth = "true" on fresh install.
  • Reads all jey-col-widths:<Doctype>:<field> localStorage keys and injects a <style id="jey-col-resize-styles"> with per-grid column widths, plus a MutationObserver that stamps data-jey-grid on grids as they appear.

Keep this IIFE minimal and synchronous — anything slow here delays first paint.

Adding a new theme

  1. Append a /* ===== theme_mytheme.css ===== */ section to jey_theme/public/css/jey_theme.bundle.css, with every rule wrapped in [data-jey-theme="mytheme"]. Prefix keyframes jey-mytheme-*. Do NOT add a new file/app_include_css entry — extra preload-header lines are what the single bundle exists to avoid.
  2. Add THEME_ICONS.mytheme = { ... } in jey_theme.js (only override keys you care about).
  3. Add an entry to the JEY_THEMES array in the switcher section of jey_theme.js, and a .jey-theme-preview--mytheme swatch in the bundle's shared section.
  4. bench build --app jey_theme.

Rules

  • Themes can change anything (layout, positioning, colors, fonts, visibility, animations, DOM structure) — not just icons. Use JS (jeyRebuildIcons / theme-switch hooks) for DOM restructuring.
  • Never add [data-theme="dark"] or light/dark variant rules — themes are self-contained (no inherited dark/light axis).
  • Don't touch ERPNext-owned records in setup scripts; gate deletes on app == "jey_theme".