UX/UI Audit · Internal

PropertyIntel

Product being audited: PropertyIntel

Auditor(s): Theo Luciano

Date: June 2, 2026

Tech stack: Rails 8.1 + React 18 (Stimulus / Turbo, Lit web components, Shoelace) · SCSS + PostCSS · Slim templating · Vite 8

Design system target: Optics 2.2.0 (already in use)

Live URL: http://localhost:3000 — not walked live; findings are from static code analysis

Figma file: —

How to use this document:

Work through each section during the heuristic walkthrough. For each item, check it off when evaluated and record observations below the section. Rate severity:

Holding up well
Opportunity
Significant gap

Not every item will apply to every product — use judgment. The goal is a rich findings list, not a complete score. Code scan data (hardcoded values, token mapping) is appended at the end.

Executive Summary
17
Confirmed findings
5
WCAG gaps (1 Critical)
97%
Stylesheets using tokens
6,187
Optics token uses
0
Hex colors in source
This is a mature, well-disciplined codebase — the usual UX-audit headline of "hundreds of hardcoded values and no design system" does not apply. Optics token adoption is exceptional (6,187 token uses across 114 of 118 stylesheets; border-radius and typography are fully tokenized; zero hex colors in source), modals and forms are properly encapsulated behind shared wrappers, and touch targets and the responsive strategy are sound. The real, verified opportunities cluster in accessibility and feedback: 17 findings, of which 1 is Critical (pinch-zoom is disabled app-wide — a one-line fix) and 4 are High (icon-button names, focus outlines, keyboard-inaccessible divs, generic error messaging). The single most valuable move is a focused accessibility pass, because several of the strongest fixes are centralized — wiring aria-label into the Icon/Button component, aria-current into the active_link_to helper, and a clearer error path into the shared ConfirmButton each propagate across the whole app from a single change. Effort is modest: the Critical and most High items are low-effort, high-leverage edits rather than a redesign.

Section 1: First Impressions & Visual Coherence

What a user feels in the first 30 seconds — and whether the product looks and feels like one thing.

Aesthetic-Usability Effect: Users perceive aesthetically pleasing design as more usable. A polished first impression raises the user's confidence before they've done anything.

Law of Similarity / Law of Uniform Connectedness: Inconsistent visual treatment signals inconsistent relationships and creates cognitive friction.

The Von Restorff Effect suggests that key actions and information should stand out from surrounding content. If everything competes for attention, nothing wins.

Observations:
Holding up well. Visual coherence is a genuine strength. Color, spacing, radius and typography decisions run through Optics tokens (6,187 --op-* uses across 114/118 stylesheets), so the product reads as one consistent system. The brand palette is defined in one place as HSL primitives — themes/landone_theme.scss (primary H:201 S:67% L:29%, a deep teal-blue) — rather than scattered hex values.

[M9] Decorative chip colors hardcoded and duplicated. The orange/yellow profile-chip colors are raw rgba(…,1) values defined twice — components/mobile-profile-sheet.scss:59-67 and components/profile-menu.scss:159-167. They sit outside the Optics palette so they don't theme, and the duplication risks drift. (Medium)

Section 2: Navigation & Wayfinding

Can users find what they need, understand where they are, and get back if they get lost?

Jakob's Law: Users spend most of their time on other sites and expect your product to work like the ones they already know. Deviation has a cost.

Nielsen's principle: Users shouldn't have to remember where they are or how they got there.

Nielsen's principle: Users need a clearly marked "emergency exit" from unwanted states.

Observations:
Holding up well. Navigation is well-structured: a NavLink model abstracts the nav, active_link_to drives a clear visual active state (primary-tinted background + indicator bar — layouts/sidebar.scss:100-115, layouts/bottom_nav_rail.scss:45-63), breadcrumbs collapse responsively, and the sidebar swaps to a bottom rail on mobile.

[M2] Active nav state is visual-only — no aria-current. The active page is obvious visually but carries no aria-current="page"; there are zero occurrences anywhere in the codebase, so screen-reader users don't get the "current page" semantic. Best fixed centrally in the active_link_to helper output. (Medium)

[L1] Mobile "More" overflow gives no preview of what's hidden. The bottom-nav "More" control (shared/navigation/_bottom_nav_rail.html.slim:9-32) collapses overflow items into a drawer with no count or label hint. (Low)

Section 3: Cognitive Load & Complexity

How hard is the product making users think? Are we asking for more mental effort than necessary?

Hick's Law: The time it takes to make a decision increases with the number and complexity of choices. Miller's Law: The average person can hold 7 (±2) items in working memory.

Law of Common Region and Law of Proximity: Grouping related elements reduces cognitive effort and helps users build accurate mental models.

Occam's Razor: The simplest solution that accomplishes the goal is usually the right one. Every extra field is a cost to the user.

Nielsen's principle: The product should speak the user's language, not the system's.

Goal-Gradient Effect: People move faster toward a goal as they perceive themselves getting closer. Zeigarnik Effect: People remember and feel pulled toward incomplete tasks.

Observations:
[M3] Multi-step estimate wizard has no progress indicator. The estimate flow is 4 steps (boundaries → features → details → review — estimate_request/EstimateRequestLeftPanelWizard.jsx:40-45) but renders no "Step 2 of 4" stepper; progress is inferred from headings and the back/forward buttons alone. (Medium — Goal-Gradient / Zeigarnik)

[M4] Required fields rely on label text alone. Required fields (e.g. Project Name, Property Type) are signaled only by wording — no asterisk or visual marker (EstimateRequestStep3.jsx:68-101; sections/new.html.slim:7 renders "Name (Required)" as plain text). The forward button gates on completion (EstimateRequestLeftPanelWizard.jsx:291-299), so users discover requirements by being blocked rather than by scanning. (Medium)

[L2] Long material form isn't visually sectioned. master_materials/MasterMaterialForm.jsx presents ~15 inputs in a flat list with no grouping; inline error display is wired only for the name field (lines 192-202, 238-245). (Low)

Section 4: Key Flows & Task Completion

Walk through the most critical user journeys. Can users accomplish what they came to do? Identify the top 2–3 core user tasks before starting this section and test each one.

Core tasks being evaluated:

1. Create an estimate request — 4-step wizard (boundaries → features → details → review).
2. Bulk order upload & process — file upload → address verification → parcel-boundary retrieval.
3. Create / edit a master material (catalog item) via a multi-field form.

Fitts's Law: The time to acquire a target is a function of its distance and size. Small or distant targets increase error rates and friction.

Mental Model principle: When a product's structure doesn't match how users think about the task, they make more errors and feel less confident.

Nielsen's principle: Error messages should be in plain language, identify the problem, and suggest a solution.

Postel's Law: Products should gracefully handle unexpected input rather than failing.

Peak-End Rule: Users judge an experience by how they felt at its most intense moment and at its end — not by the sum of the whole.

Observations:
Holding up well. The bulk-order flow has strong staged feedback — estimate_request/BulkOrderProcessingProgress.jsx:44-82 shows a step-by-step progress ring with per-stage icons and surfaces unverified addresses explicitly. Forward buttons disable when required data is missing, preventing invalid submissions.

[H4] Generic failure message gives users nothing to act on. When an async save/submit fails, the shared common/ConfirmButton.jsx:69-76 surfaces "Something went wrong!" with no cause and no recovery step. Because ConfirmButton is shared, surfacing the real error reason + an explicit retry path is a single centralized win. (High — Nielsen: error recovery)

[M6] No unsaved-changes warning on most forms/modals. Closing a form/modal generally discards in-progress input silently. Only the estimate-request-initialized path warns (EstimateRequestLeftPanelWizard.jsx:328-344); others such as MasterMaterialForm.jsx close without confirmation. (Medium — user control & freedom)

Section 5: Feedback & System Communication

Does the product keep users informed and in control?

Nielsen's principle (Visibility of System Status): Keep users informed about what's going on through appropriate feedback within a reasonable time. Doherty Threshold: System responses faster than 400ms feel immediate.

Nielsen's principle: User control and freedom.

Observations:
Holding up well. markup/DynamicToolToast.jsx:48-78 implements clean three-state feedback (searching spinner → success → error) with icon + text and auto-dismiss. Action acknowledgement and state-dependent button disabling are consistent across flows.

[M5] Flash messages lack visual hierarchy by type. Server flash notices render as simple alert divs without clear info / success / warning / error differentiation (shared/_flash.html.slim:1-6), so a message's severity isn't legible at a glance. The data-turbo-cache=false hint also suggests past flash/Turbo race issues. Style by type using Optics --op-color-alerts-* tokens with a distinct icon + color per level. (Medium)

Section 6: Consistency & Standards

Does the product behave predictably, and does it follow platform conventions?

Nielsen's principle: Users should not have to wonder whether different words, situations, or actions mean the same thing.

Jakob's Law: Familiar patterns reduce learning curves.

Law of Uniform Connectedness: Consistent visual treatment of interactive elements prevents confusion.

Observations:
Holding up well. Modal behavior is consistent, not fragmented: chunks/application/components/LandOneModal.jsx is a shared wrapper over Shoelace SlDialog/SlDrawer (focus-trap and aria-modal handled), and 28 of 32 *Modal*.jsx files compose it. Form labels are properly associated via htmlFor, and status badges render human-readable text (not color-only).

[P1] Three button patterns coexist across the stack. The button concept is expressed three ways — Shoelace <sl-button> (~61 uses), the Optics .btn--small class (~284 uses), and raw <button> tags in Slim views. This is maintainability/consistency drift rather than a user-facing bug, but button behavior and styling have no single source of truth. Pick one canonical wrapper and migrate incrementally. (Pattern)

Section 7: Accessibility

Who is being left out — and what's the effort to fix it? Includes WCAG contrast, ARIA issues, keyboard navigation, and semantic HTML findings from the code scan.

Fitts's Law: Target size directly affects accuracy and ease of interaction.

Observations:
Accessibility is where the highest-leverage work sits. Several fixes are centralized — one change propagates app-wide.

[C1] Pinch-to-zoom is disabled app-wide. shared/_head.html.slim:5 sets the viewport to maximum-scale=1, user-scalable=no, so low-vision users on mobile cannot zoom in. WCAG 1.4.4 (Resize Text). It's also internally inconsistent — visual_diffs.html.slim uses an unrestricted viewport. Fix: drop maximum-scale=1, user-scalable=no (one line). (Critical)

[H1] Icon-only buttons have weak or missing accessible names. The icon factory (chunks/application/components/LandOneIconFactory.jsx:96,104) sets a title from hoverText, defaulting to the raw icon name — so a screen reader may announce a machine string like "keyboard_backspace", and title is an unreliable accessible-name source. Some call sites pass hoverText="", leaving no accessible name at all (e.g. EstimateRequestModal.jsx:157). WCAG 4.1.2 / 1.1.1. Fix by wiring an explicit aria-label on icon-only buttons, centralized in the Icon/Button component. (High)

[H2] Focus outline removed without a visible replacement (2 spots). Most outline:none uses correctly pair with a :focus-visible box-shadow ring (base/base.scss:13 and others — safe). Two component stylesheets strip the outline with no replacement: layouts/markup_left_panel.scss and components/material_list.scss, removing the keyboard focus indicator there. WCAG 2.4.7. (High)

[H3] Two clickable <div>s have no keyboard equivalent. lightning-cad-skin/views/ToolView.jsx:54 and markup/GraphicalScaleControl.jsx:185 use <div onClick> with no role/tabIndex/onKeyDown. WCAG 2.1.1. (The other 489 onClick handlers are on real buttons/components — this is not endemic. The GraphicalScaleControl case may be a propagation-only wrapper, i.e. a possible false positive — verify when fixing.) (High)

[M1] No skip-to-content link. Keyboard/SR users must tab through sidebar, breadcrumbs and bottom nav on every page; 48 <main> landmarks exist server-side but no skip link targets them. WCAG 2.4.1. (Medium)

Section 8: Mobile & Responsive Behavior

Does the layout adapt gracefully? Are touch targets appropriately sized? Can mobile users complete core tasks?

Observations:
Holding up well. Touch targets meet/exceed spec — sheet items 56px (components/sheet.scss:80), primary sheet actions 48px, buttons upsize on mobile (components/button.scss:122-129). Mobile detection is CSS-first via a matchMedia React context (recent removal of the hardcoded isMobile prop, commit 72df7657f). Adaptive reflow is used in places (flex → grid, thumbnail-card.scss:384-434).

[M7] Breakpoints are scattered and undocumented. 230 @media queries use inconsistent hardcoded breakpoints (768 / 1024 / 1280 / 390 / 512 / 1140 / 1225 / 1420) with no central variable set; thumbnail-card.scss:45-46 defines its own local breakpoint vars, and Shoelace overrides use a different syntax. Optics exposes --op-breakpoint-* (referenced in comments but not used as values). (Medium)

[M8] Some mobile rules hide content rather than adapt it. Alongside the good reflow, a .hide-on-mobile { display:none !important; } utility (base/utilities.scss:68) and a hidden filter header (base/mobile.scss:82) remove functionality on small screens instead of relocating it (e.g. into a filters sheet). (Medium)

Section 9: Performance Perception

We're not doing a technical performance audit — but we note what we can observe.

Doherty Threshold: Productivity and engagement drop when users have to wait. Perceived performance matters even when actual speed isn't our scope.

Observations:
Not walked live, so this is observable-from-code only. One positive: LandOneModal guards rendering with a renderModal flag (LandOneModal.jsx:120) so modal contents aren't mounted until visible — good for perceived performance. Minor: components/leaflet_overrides.scss:35 uses font-size: 2rem !important, a heavy override worth a glance. No deeper performance claims made without a running app. (No findings raised.)

Section 10: Strategic & Forward-Looking Notes

Step back from the individual findings. What's the bigger picture?

Observations:
Strongest moment — protect it. The design-token discipline and component encapsulation (Optics tokens + shared LandOneModal/Shoelace) are a real asset. This foundation means UX and accessibility improvements can be made centrally rather than screen-by-screen.

Where it's most constrained. Accessibility was clearly not a first-class concern when this was built — viewport zoom blocking, icon naming via title, missing aria-current, occasional focus-outline removal, and no skip link. None are architectural; all are addressable.

Highest impact, moderate effort (lead opportunities). (1) Remove the viewport zoom block — one line, Critical. (2) Wire aria-label into the Icon/Button component so every icon-only button gets a real accessible name from one change. (3) Add aria-current="page" in the active_link_to helper. (4) Improve the shared ConfirmButton error path. Three of these four are single-source fixes that propagate app-wide.

What "next" looks like. A focused accessibility pass (a sprint, not a redesign) would move PropertyIntel from "visually polished" to "polished and inclusive," then a consistency cleanup (one button component, a breakpoint token set) pays down the remaining maintainability drift.

Token Mapping: Current → Optics

PropertyIntel is already on Optics — this table maps the few values that escape the token system, not a migration. The brand palette lives correctly in themes/landone_theme.scss as HSL primitives feeding the --op-color-* scales; those are the token source, not drift.

Colors

Current valueWhereOptics tokenFit
Brand HSL primitives (primary H:201 S:67% L:29%, neutral, warning, danger, aspire)themes/landone_theme.scss--op-color-{primary,neutral,…}-h/s/lExact — token source
rgba(255,239,224,1) / rgba(82,39,0,1) (orange chip), yellow pairmobile-profile-sheet.scss, profile-menu.scssNo Optics equivalent (decorative); define once or map to --op-color-alerts-warning-*Miss — duplicated
radial-gradient(rgba(187,222,242,1) → rgba(246,251,253,1))layouts/login.scssCould derive from --op-color-primary-plus-*Close
21 × rgba(0,0,0,0.0x) shadow-color alphasvarious (box-shadow)Roll into --op-shadow-* tokensClose

Spacing

Current valueCountOptics tokenFit
px inside named custom properties (e.g. --attachments-border-radius: calc(var(--op-size-unit) * 8))~120Allowed per project CSS conventions (non-grid / sub-pixel values in block-scoped props)Exact — by convention
1px borders23n/a (hairline borders)Exact
Other raw px in spacing/sizing props~190calc(N * var(--op-size-unit)) where divisible by 4Close — case-by-case

Border Radius

Current valueWhereOptics tokenFit
All radii tokenizedcodebase-wide--op-radius-small … --op-radius-pillExact
border-radius: unset / none (resets) + one 5000px pill hack (commented)~11 spotsn/a (intentional resets)Exact

Typography

Current valueWhereOptics tokenFit
Font sizes tokenized; scale defined in theme (--op-font-*)codebase-wide--op-font-*Exact
3 literal font-size (0, leaflet 2rem !important override, 1 comment)sidebar.scss, leaflet_overrides.scssn/a (legit / 3rd-party override)Exact

Shadows

Current valueCountOptics tokenFit
Literal box-shadow (px offsets + rgba(0,0,0,0.x) colors)~122 declarations--op-shadow-x-small … --op-shadow-x-largeClose — mappable

Hardcoded Values by File

49 non-tokenized color literals (hex + rgb/rgba/hsl not wrapping an --op token) across 16 files — the meaningful "hardcoded" metric here, since spacing/radius/typography are essentially fully tokenized. The largest entry is the theme source file, where raw HSL values are correct. Bar color: red >20, orange 10–20, green <10.

themes/landone_theme.scss · token source (expected)
12
layouts/login.scss
6
layouts/markup_view.scss
4
components/profile-menu.scss
4
components/mobile-profile-sheet.scss
4
layouts/text_search.scss
2
layouts/project.scss
2
components/pin-preview.scss
2
base/base.scss
2
+ 7 files with 1 each (visual_diffs, sl_tokens_shared, sl_dialog, subscription, promaps, leaflet_overrides, scrollbar)
7

Component Mapping: Custom → Optics

Most of the app already composes Optics/Shoelace. The opportunities below are about consolidating competing patterns onto one canonical component, not introducing the design system.

sl-button / .btn--small / <button>
→
One Button wrapper
~345 call sites · consolidate [P1]
_flash.html.slim alert divs
→
Optics Alert (by type)
--op-color-alerts-* · [M5]
Icon (title-only naming)
→
Icon + enforced aria-label
410 uses · centralize [H1]
LandOneModal
→
Shoelace Dialog/Drawer ✓
28/32 modals · already canonical
EstimateRequest wizard
→
+ Optics Stepper
4 steps · add progress [M3]
EstimateRequestStatusBadge
→
Optics Badge ✓
text + color · already accessible

Findings Summary

After completing the walkthrough, list your top findings here. Use this table to prioritize the backlog.

# Finding Section Severity Impact Effort
C1Pinch-to-zoom disabled app-wide (viewport)7 · AccessibilityCriticalHighLow
H1Icon-only buttons have weak/missing accessible names7 · AccessibilityHighHighMedium
H2Focus outline removed without :focus-visible (2 spots)7 · AccessibilityHighMediumLow
H3Two clickable <div>s have no keyboard equivalent7 · AccessibilityHighMediumLow
H4Generic "Something went wrong!" error (shared ConfirmButton)5 · FeedbackHighMediumLow
M1No skip-to-content link7 · AccessibilityMediumMediumLow
M2Active nav state is visual-only — no aria-current2 · NavigationMediumMediumLow
M3Estimate wizard has no progress indicator3 · Cognitive LoadMediumMediumMedium
M4Required fields rely on label text alone3 · Cognitive LoadMediumMediumLow
M5Flash messages lack visual hierarchy by type5 · FeedbackMediumLowLow
M6No unsaved-changes warning on most forms/modals4 · Key FlowsMediumMediumMedium
M7Breakpoints scattered and undocumented (internal)8 · MobileMediumLowMedium
M8Some mobile rules hide content rather than adapt it (internal)8 · MobileMediumMediumMedium
M9Decorative chip colors hardcoded & duplicated (internal)1 · Visual CoherenceMediumLowLow
P1Three button patterns coexist (sl-button / .btn / <button>) (internal)6 · ConsistencyPatternLowHigh
L1Mobile "More" overflow gives no preview of hidden items2 · NavigationLowLowLow
L2Long material form isn't visually sectioned3 · Cognitive LoadLowLowMedium

Claude Prompting Notes

Prompts that worked well on this audit — capture any Claude prompts that generated particularly useful output so we can build a shared library across audits.

Verify component-encapsulated a11y before trusting grep counts. On a Shoelace/Optics codebase, raw greps overstate problems — "0 aria-modal", "37 modal files" both looked alarming but reading LandOneModal.jsx and the LandOneIconFactory showed the real picture (shared wrapper handles aria-modal; icons get a title, not nothing). Prompt pattern that worked: spawn one Explore agent per audit area asking for "CONFIRMED findings with exact file:line evidence, separate what turned out FINE, do NOT fabricate," then spot-verify the headline claims by reading the source directly.

Calibrate, don't inflate. Dropped an agent's unverified "~40% of icons" estimate — the mechanism was confirmed but the count wasn't, so the finding states the mechanism and skips the number.