UX/UI Audit — Internal

Moddex Configurator

Product being audited: Moddex Configurator (Module Access — Stair and Platforms)

Auditor(s): Claude Code (automated) — review with Dallas Peters

Date: 2026-06-03

Tech stack: Rails 8.0.3 + React 18 + MobX 5 + Three.js 0.162 + Stimulus 3.2 + Webpack 5

Design system target: @rolemodel/optics 2.2.0 (already installed)

Live URL: http://localhost:3000

Figma file: Not provided

Severity legend

All findings from static code analysis of 54 SCSS/CSS files and 40+ React/view components.

Critical — WCAG violation or data loss risk
High — UX quality degradation
Medium — consistency/maintainability
Pattern — systemic, 3+ locations
Executive Summary
3
Critical (WCAG)
14
High findings
16
Medium findings
2
Pattern findings
46
Hardcoded hex colors
613
Optics token usages
The Moddex Configurator is in a healthy mid-migration state: Optics is installed (2.2.0) and widely used (613 token references across 54 files), but three overlapping custom property systems (--sk-*, --op-*, --color-*) still coexist, and the legacy flash/alert styles use hardcoded Bootstrap-style hex values. The most impactful single fix is adding a loading indicator when a project loads — the LoadProjectView component currently returns null for 2–5 seconds, leaving users on a blank screen during the app's primary flow. Three WCAG violations exist around outline: 0 on focusable elements without a :focus-visible replacement, and the confirm dialog lacks role="dialog" and focus trap. Completing the Optics migration (replacing --color-* and --sk-* variables with Optics tokens) would eliminate approximately 46 hardcoded hex values and consolidate the token system — estimated 1–2 days of focused cleanup.

Section 1: First Impressions & Visual Coherence

Visual coherence across screens; brand clarity; heading hierarchy; color semantics.

Medium Three overlapping custom property systems coexist
The codebase defines tokens under three distinct namespaces: --sk-* (legacy Sayfa, ~15 tokens in variables.scss), --op-* (Optics, 204 declarations), and --color-* (old legacy, ~20 variables for alert colors). Components pick from all three, making the token contract unclear. The --sk-* system partially overrides --op-* values in theme files (e.g. --op-color-primary-plus-six: var(--sk-navbar-button-background-color)), which is the correct migration pattern but leaves old --sk-* consumers unfixed.
app/assets/stylesheets/styles/variables.scss, app/assets/stylesheets/core/theme/moddex-theme-core.scss
Medium Flash notification styles use hardcoded Bootstrap-era hex colors
The flash component uses raw hex values (#fdf3d1, #856404, #f3d8da, #721c24) instead of Optics alert tokens. These are Bootstrap 4-era color values. Optics provides --op-color-alerts-warning-* and --op-color-alerts-danger-* scales that would replace all 6 hardcoded values in this file with a single token each.
app/assets/stylesheets/styles/flash.scss:20–28
→ --op-color-alerts-warning-plus-seven (notice bg), --op-color-alerts-danger-plus-seven (alert bg)
Medium Inter font imported but never used
moddex-theme-core.scss imports Inter and Rubik from Google Fonts. The --op-font-family is set to 'Rubik', 'Roboto', and variables.scss sets --font-primary: 'Helvetica' (commented note). Inter is not referenced anywhere in the codebase. This is a ~200KB unused network request per page load.
app/assets/stylesheets/core/theme/moddex-theme-core.scss:1
Medium Unresolved TODO in base styles
A TODO comment in base.scss reads: "Re-enable this? Some parts of the UI have too little contrast on a near-white background (they're fine on true white)." The commented-out rule sets the body background to --color-contrast-lower (#f9f9f9), a subtle off-white that would improve perceived depth. This suggests a known visual issue that was deferred rather than resolved.
app/assets/stylesheets/styles/base.scss:16–19

Section 2: Navigation & Wayfinding

Structure, active states, landmarks, skip navigation, and orientation cues.

High Hamburger menu button missing aria-expanded
Menu.jsx's toggle button has title="Menu" but no aria-expanded attribute. Screen readers announce the button but cannot communicate whether the menu is currently open or closed. The fix is one line: aria-expanded={this.state.visible} on the button element.
app/javascript/components/Menu.jsx:26
High No active/current state on navigation items
The hamburger menu renders links from a menuLinks prop but applies no active/current class based on the current route. Users navigating via keyboard or screen reader have no programmatic way to determine where they are. The action bar page title (@action_bar_title) provides visual context, but there's no aria-current="page" on any nav item.
app/javascript/components/Menu.jsx:38–49, app/views/shared/_header.html.slim:5
High No <main> landmark in the main application layout
The application.html.slim layout uses = yield directly after the header with no wrapping <main> element. The login view correctly uses main.app-body, but every other page omits it. Without a <main> landmark, screen reader users cannot jump directly to content. WCAG 2.4.1 (Level A) requires a mechanism to bypass repeated navigation.
app/views/layouts/application.html.slim:4–7
WCAG 2.4.1 Bypass Blocks (Level A)
High No skip navigation link
No skip-to-content link exists anywhere in the application layout. Keyboard-only users must tab through the full action bar (hamburger menu, undo, redo, zoom controls, help link, share menu, download menu, dot menu) on every page before reaching main content. This is particularly painful in the drawing editor where the action bar has 8+ interactive items.
app/views/layouts/application.html.slim, app/views/shared/_header.html.slim
WCAG 2.4.1 Bypass Blocks (Level A)

Section 3: Cognitive Load & Complexity

Decision density, chunking, form complexity, and progressive disclosure.

High Project list table has 9 columns — exceeds Miller's Law
The projects table renders 9 columns: DWG #, Project Name, Company, Client, Job #, RRP (AUD), Date Created, Status, Last Opened. This is at the upper bound of Miller's Law (7 ± 2). "DWG #" and "Job #" and project "number" are three separate identifiers — their distinction is non-obvious to new users. Consider whether DWG # and Job # can be combined or moved to a detail view. Status and Last Opened could be a single "Status" chip column.
app/views/projects/_list.html.slim:5–18
Medium Organization settings form mixes structural sections without clear visual hierarchy
The organization settings page contains 4 distinct sections (basic info, export options, PDF options, user options) in a single form. Each section uses .card.form__section but the sections aren't clearly delineated with headings — just <p class="form__label"> used as section labels. This is a <p> masquerading as an <h3>, both semantically incorrect and visually understated.
app/views/organizations/edit.html.slim:14–17

Section 4: Key Flows & Task Completion

Core tasks evaluated: (1) Load a project and edit it, (2) Create a new project, (3) Export documentation.

High Project load shows blank screen — LoadProjectView returns null while loading
withLoadedProject.jsx's LoadProjectView returns null while status === 'pending'. For a complex 3D drawing, this load can take 2–5 seconds. Users see a white void after clicking a project, with no indication that anything is happening. The error state ('rejected') renders only <div>Error loading project</div> with no guidance, retry action, or back button.
app/javascript/components/withLoadedProject.jsx:43–48
High "Reset Canvas" has no confirmation dialog
The "Reset Canvas" action in DotMenuItems.jsx calls this._drawingEditor().clearProjectDrawing() immediately on click with no confirmation. The adjacent "Delete Project" correctly uses TurboConfirm. A misclick on "Reset Canvas" destroys the user's current drawing state without any safety net. This is especially risky because the dot menu is small and the items are close together.
app/javascript/components/drawing-editor-view/DotMenuItems.jsx:57–62
Medium Non-standard autocomplete values on profile form fields
The registration and account edit forms use autocomplete="first_name", autocomplete="last_name", and autocomplete="organization_name". The WHATWG HTML spec and all major password managers recognize "given-name", "family-name", and "organization" instead. With the non-standard values, browsers cannot auto-fill these fields, increasing form friction for returning users.
app/views/devise/registrations/edit.html.slim:4–7, app/views/devise/invitations/new.html.slim:11

Section 5: Feedback & System Communication

Loading states, success/error feedback, autosave indicators, and flash messaging.

High Flash messages auto-dismiss after 5s with no manual dismiss button
The flash partial (_flash.html.slim) renders the flash message text but includes no close button in the markup — only a .flash__close class exists in the CSS with no associated element. The 5-second auto-dismiss via CSS animation is the only way messages disappear. Users with slower reading speed or cognitive load may miss critical feedback. The animation uses -webkit-animation (vendor-prefixed) which is not needed for a Chrome-only app.
app/views/shared/_flash.html.slim, app/assets/stylesheets/styles/flash.scss
High Flash messages not announced to screen readers
Flash messages have no role="status" (for notices) or role="alert" (for errors). Screen readers will not automatically announce these messages when they appear. This means authentication feedback, save confirmations, and error messages are invisible to assistive technology users.
app/views/shared/_flash.html.slim
WCAG 4.1.3 Status Messages (Level AA)
Medium Error state for project load provides no guidance or action
When LoadProjectView fails (status === 'rejected'), it renders only <div>Error loading project</div>. There is no error message, no retry button, no back navigation, and no log of what went wrong. The console.error(error) call captures the error in dev tools but users see only a generic message.
app/javascript/components/withLoadedProject.jsx:45–47

Section 6: Consistency & Standards

Component reuse, behavioral consistency, and platform convention alignment.

Pattern Two independent hamburger menu implementations with different toggle strategies
Menu.jsx (page nav) and MoreMenu.jsx (project row actions) solve the same problem but diverge in implementation: Menu conditionally renders _renderMenu() (null when closed), while MoreMenu always renders the dropdown div and toggles visibility via CSS class (hamburger-menu__list--active). They share the same CSS classes and visual appearance but have different DOM behavior, different accessibility implications, and different close-on-outside-click handling. A single DropdownMenu component would eliminate the duplication.
app/javascript/components/Menu.jsx, app/javascript/components/MoreMenu.jsx
Pattern Reset Canvas bypasses the confirmation pattern used everywhere else
The codebase uses TurboConfirm for destructive actions (Delete Project uses it via tc.confirm()). "Reset Canvas" is a destructive action that clears the current drawing but skips this pattern entirely — no confirmation, no undo, no feedback. This is inconsistent with both the project's own patterns and user expectations for irreversible operations. The fix is a 4-line addition of TurboConfirm matching the Delete Project implementation.
app/javascript/components/drawing-editor-view/DotMenuItems.jsx:57–62 (contrast with 63–80)
Medium Action bar dropdown box-shadow uses hardcoded color
The @mixin action-bar__dropdown-menu applies box-shadow: 0px 5px 15px #232323 — a hardcoded dark gray shadow rather than using the --shadow token (0px 8px 16px rgba(0, 0, 0, 0.08)) defined in variables.scss. This inconsistency means the dropdown shadow will not respond to theme changes and uses an opaque color instead of a transparent one.
app/assets/stylesheets/styles/action-bar.scss:5
→ var(--shadow)
Medium Section labels in forms use <p> instead of <h3>
In the organization settings form, section labels like "PDF Options", "User Options", and "Export Options" are rendered as <p class="form__label">. This is semantically incorrect — these are section headers for groups of related form fields. Using <h3> (or <legend> inside <fieldset>) would correctly express the document structure and allow screen reader users to navigate by heading.
app/views/organizations/edit.html.slim:14, 20, 44

Section 7: Accessibility

WCAG 2.1 AA compliance, ARIA usage, focus management, and semantic HTML.

Critical Form inputs remove outline with only border-color as focus substitute
.form__input:focus sets outline: 0 and substitutes a border-color change to --color-contrast-high. WCAG 2.4.11 (Focus Appearance, Level AA) requires focus indicators to have a minimum contrast ratio of 3:1 between focused and unfocused states, and a minimum area. A border-color change alone — especially a subtle gray-to-darker-gray shift — almost certainly fails this. A visible focus ring (e.g. box-shadow: 0 0 0 2px var(--op-color-primary-base)) is needed as the replacement.
app/assets/stylesheets/components/optics-overrides/form.scss:77–80
WCAG 2.4.7 Focus Visible (Level AA), WCAG 2.4.11 Focus Appearance (Level AA)
Critical Confirm dialog missing role="dialog", aria-modal, and focus trap
The global confirm dialog in _confirm.html.slim and its styles in confirm.scss have no role="dialog", no aria-modal="true", no aria-labelledby pointing to the title, and no focus trap. When the dialog opens, keyboard users can tab behind it to the page content. Screen readers don't announce it as a modal. The backdrop also has outline: 0. The dialog is visually modal but functionally not — this is a Level A accessibility failure for a dialog that appears before destructive actions.
app/views/application/_confirm.html.slim, app/assets/stylesheets/components/optics-overrides/confirm.scss:9
WCAG 1.3.1 Info and Relationships (Level A), WCAG 4.1.2 Name, Role, Value (Level A)
Critical Modal wrapper sets outline: 0 without replacement
%modal-wrapper-global sets outline: 0 on the modal wrapper element. While the modal dialog itself should trap focus rather than receive focus, removing the outline on the wrapper can interfere with certain focus management approaches and assistive technology behavior. Combined with no explicit role="dialog" or aria-modal on the modals rendered via this pattern, modals may not be properly announced.
app/assets/stylesheets/components/optics-overrides/modal.scss:12
WCAG 2.4.7 Focus Visible (Level AA)
High MoreMenu button missing aria-expanded
MoreMenu.jsx's icon button has only a title="More Menu" attribute. Like the hamburger menu, it needs aria-expanded={this.state.visible} and ideally aria-haspopup="menu" to correctly communicate its state to screen readers.
app/javascript/components/MoreMenu.jsx:22–31
High Flash messages not announced to screen readers
See H9 above. Flash messages require role="status" for notices and role="alert" for errors to trigger screen reader announcements when inserted into the DOM.
app/views/shared/_flash.html.slim
WCAG 4.1.3 Status Messages (Level AA)
Medium Autocomplete values are non-standard — browsers and password managers ignore them
autocomplete="first_name" and autocomplete="last_name" are not WHATWG-recognized token values. The correct values are "given-name" and "family-name". autocomplete="organization_name" should be "organization". These are minor fixes (3 string changes) with meaningful UX impact for returning users.
app/views/devise/registrations/edit.html.slim:4–7, app/views/devise/invitations/new.html.slim:11
Medium Organization settings form section labels use <p> instead of semantic headings
See M9 in Section 6. The <p class="form__label"> elements used as section headers within the organization settings form are not announced as headings by screen readers, breaking the document's heading hierarchy.
app/views/organizations/edit.html.slim:14–44
WCAG 1.3.1 Info and Relationships (Level A)

Section 8: Mobile & Responsive Behavior

Breakpoint coverage, touch targets, layout adaptation. Note: this is a CAD tool — desktop-primary is expected.

Medium Flash message uses absolute pixel positioning that overflows on small screens
.flash is positioned with top: 72px; left: calc(50% - 250px); width: 500px. On a 375px-wide screen, this results in left: -62.5px — the notification bleeds off the left edge. The login and account pages are the most likely mobile views, and both trigger flash messages (login errors, password changes).
app/assets/stylesheets/styles/flash.scss:7–11
Medium Only 2 @media queries in the entire stylesheet — login/auth views have no responsive adaptation
The entire stylesheet (54 files) contains only 2 @media queries. While the drawing editor is intentionally desktop-only, auth pages (login, invitation acceptance, password reset) and the account settings page have no responsive adaptation. The login card and forms would benefit from responsive padding/width adjustments for mobile users.
app/assets/stylesheets (full scan)

Section 9: Performance Perception

Observable performance signals from static analysis only — the app could not be started due to missing node_modules.

Medium Unused Inter font adds unnecessary network request
See M3. The Google Fonts import for Inter (variable font with full weight range 100..900) loads a CSS file and potentially multiple font files on every page, despite Inter never being used. For a Chrome-only app with a defined font stack (Rubik → Roboto → sans-serif), this is pure overhead.
app/assets/stylesheets/core/theme/moddex-theme-core.scss:1
Medium Both Roboto and Rubik imported — only Rubik is actually used
moddex-theme-core.scss imports Roboto as a fallback in the Google Fonts URL. --op-font-family: 'Rubik', 'Roboto', sans-serif lists both. Roboto is a system-available font on Android and Chrome OS, so loading it from Google Fonts is unnecessary when it's already the OS fallback. The Google Fonts import for Roboto can be removed; the CSS fallback in --op-font-family can stay.
app/assets/stylesheets/core/theme/moddex-theme-core.scss:2

Section 10: Strategic & Forward-Looking Notes

High The three-token-system migration is the highest-leverage cleanup available
The project's strongest current state: Optics is installed (2.2.0), 613 token references are already using the --op-* namespace, and the theme file correctly overrides Optics variables with brand values. The remaining cleanup is mechanical: replace --color-notice, --color-error, --color-warning, and --color-info in variables.scss with Optics alert tokens; replace --sk-* references in non-theme files with their --op-* equivalents. This is achievable in 1–2 focused days and would eliminate ~46 hardcoded hex values and all three parallel token systems.
app/assets/stylesheets/styles/variables.scss, app/assets/stylesheets/styles/flash.scss
High Loading state gap is the top user-facing fix
Adding a loading skeleton or spinner to withLoadedProject.jsx when status === 'pending' is the single change with the highest user-visible impact. The fix is ~10 lines of JSX. A simple spinner inside the full-page canvas area would prevent the blank-screen confusion during what is the app's primary flow (opening a project).
app/javascript/components/withLoadedProject.jsx:43–48
High Consolidate Menu and MoreMenu into a single reusable component
The pattern (icon button → dropdown list of links/actions) appears twice with diverging implementations. A single DropdownMenu component that accepts trigger, items, and handles open/close state, aria-expanded, and focus management would make both instances correct simultaneously. This is the right abstraction because the existing duplication has already diverged in accessibility behavior.
app/javascript/components/Menu.jsx, app/javascript/components/MoreMenu.jsx

Phase 2: Design Token Mapping

Mapping observed hardcoded values to @rolemodel/optics 2.2.0 tokens. 613 usages already reference --op-* tokens — the project is in good shape. The gaps below are the remaining legacy values.

Colors — Hardcoded Hex → Optics Token

46 hardcoded hex values across 11 files. Top concentration: variables.scss (20), flash.scss (6), PartsListView.scss (5).

Hardcoded Value Used In Optics Token Fit
#155724 variables.scss (--color-notice) --op-color-alerts-notice-base Close
#c3e6cb variables.scss (--color-notice-light) --op-color-alerts-notice-plus-five Close
#d4edda variables.scss (--color-notice-lighter) --op-color-alerts-notice-plus-seven Close
#721c24 variables.scss (--color-error), flash.scss --op-color-alerts-danger-minus-seven Close
#f5c6cb variables.scss (--color-error-light), flash.scss --op-color-alerts-danger-plus-five Close
#f8d7da variables.scss (--color-error-lighter), flash.scss --op-color-alerts-danger-plus-seven Close
#856404 variables.scss (--color-warning), flash.scss --op-color-alerts-warning-minus-five Close
#fff3cd variables.scss (--color-warning-lighter) --op-color-alerts-warning-plus-seven Close
#ffeeba variables.scss (--color-warning-light), flash.scss --op-color-alerts-warning-plus-five Close
#f9f9f9 variables.scss (--color-contrast-lower) --op-color-neutral-plus-eight Close
#ebebeb variables.scss (--color-contrast-low) --op-color-neutral-plus-seven Close
#e0e0e0 variables.scss (--color-contrast-medium) --op-color-neutral-plus-five Close
#b3b3b3 variables.scss (--color-contrast-medium-high) --op-color-neutral-plus-two Miss
#5e6168 variables.scss (--color-contrast-high) --op-color-neutral-minus-five Close
#ccc variables.scss (--input-border-color) --op-color-neutral-plus-four Close
#232323 action-bar.scss (box-shadow) Use rgba(0,0,0,0.3) or var(--shadow) Miss
#003dff form.scss (radio/checkbox active) --op-color-primary-base Close

Border Radius — Hardcoded → Token

ValueLocationTokenFit
2pxDocumentsDownloadModal, PropertiesPanel, DrawingEditorActionBar, StairAngleControl--op-radius-smallClose
3pxflash.scss, StairAngleModal, form.scss--op-radius-medium (4px)Close
50%DocumentsDownloadModal (circular elements)--op-radius-circleExact
8pxform.scss:251--op-radius-largeClose
0.2remform.scss (plain input)--op-radius-smallClose

Hardcoded Colors Per File (Bar Chart)

styles/variables.scss
20
styles/flash.scss
6
drawing-editor/material-list/PartsListView.scss
5
drawing-editor/property-panels/StairAngleModal.scss
4
drawing-editor/DocumentsDownloadModal.scss
3
drawing-editor/ProjectComponentsPanel.scss
2
components/optics-overrides/form.scss
2
styles/login.scss, styles/action-bar.scss, styles/account.scss, drawing-editor/PropertiesPanel.scss
1 each

All Findings Summary

ID Title Section Severity WCAG
C1Form inputs remove outline with only border-color as substituteAccessibilityCritical2.4.7, 2.4.11
C2Confirm dialog missing role, aria-modal, focus trapAccessibilityCritical1.3.1, 4.1.2
C3Modal wrapper sets outline: 0 without replacementAccessibilityCritical2.4.7
H1Hamburger menu button missing aria-expandedNavigationHigh4.1.2
H2No active/current state on navigation itemsNavigationHigh2.4.8
H3No <main> landmark in application layoutNavigationHigh2.4.1
H4No skip navigation linkNavigationHigh2.4.1
H5Project list table has 9 columns — exceeds Miller's LawCognitive LoadHigh—
H6Project load shows blank screenKey FlowsHigh—
H7Reset Canvas has no confirmation dialogKey FlowsHigh—
H8Flash messages auto-dismiss with no dismiss buttonFeedbackHigh—
H9Flash messages not announced to screen readersFeedbackHigh4.1.3
H10MoreMenu button missing aria-expandedAccessibilityHigh4.1.2
H11Flash messages not announced to screen readers (accessibility section)AccessibilityHigh4.1.3
H12Three-token-system migration (strategic)StrategicHigh—
H13Loading state gap — top user-facing fix (strategic)StrategicHigh—
H14Consolidate Menu and MoreMenu (strategic)StrategicHigh—
M1Three overlapping custom property systemsVisual CoherenceMedium—
M2Flash styles use hardcoded Bootstrap hex colorsVisual CoherenceMedium—
M3Inter font imported but never usedVisual CoherenceMedium—
M4Unresolved TODO in base stylesVisual CoherenceMedium—
M5Org settings form lacks visual hierarchyCognitive LoadMedium—
M6Non-standard autocomplete values on profile fieldsKey FlowsMedium—
M7Error state for project load provides no guidanceFeedbackMedium—
M8Action bar dropdown uses hardcoded box-shadow colorConsistencyMedium—
M9Section labels in forms use <p> instead of <h3>ConsistencyMedium1.3.1
M10Autocomplete values are non-standardAccessibilityMedium—
M11Form section labels use <p> not headings (accessibility)AccessibilityMedium1.3.1
M12Flash message overflows small screensMobileMedium—
M13Only 2 @media queries — auth pages have no responsive adaptationMobileMedium—
M14Unused Inter font adds network overheadPerformanceMedium—
M15Roboto imported from Google Fonts unnecessarilyPerformanceMedium—
P1Two diverging hamburger menu implementationsConsistencyPattern—
P2Reset Canvas bypasses confirmation patternConsistencyPattern—