toeverything/affine
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Radix primitives sit unused right beside hand-rolled divs that quietly drop keyboard access and screen reader support. The underlying architecture is sound, but every interactive surface skips the accessibility basics its own dependencies would have handled for free.
Files Rams reviewed
packages/frontend/core/src/desktop/pages/theme-editor/components/color-cell.tsx
packages/frontend/core/src/desktop/pages/theme-editor/components/tree-node.tsx
packages/frontend/core/src/desktop/pages/theme-editor/components/variable-list.tsx
packages/frontend/core/src/desktop/pages/workspace/all-page/all-page.tsx
packages/frontend/core/src/desktop/pages/workspace/layouts/workspace-layout.tsx
packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx
packages/frontend/core/src/desktop/pages/workspace/trash-page.tsx
packages/frontend/core/src/mobile/pages/workspace/layout.tsx
packages/frontend/component/src/components/affine-other-page-layout/layout.tsx
packages/frontend/component/src/components/auth-components/change-email-page.tsx
packages/frontend/component/src/components/auth-components/change-password-page.tsx
packages/frontend/component/src/components/auth-components/onboarding-page.tsx
packages/frontend/component/src/components/auth-components/set-password-page.tsx
packages/frontend/component/src/components/auth-components/sign-up-page.tsx
packages/frontend/component/src/components/member-components/accept-invite-page.tsx
packages/frontend/component/src/components/member-components/failed-to-send-page.tsx
packages/frontend/component/src/components/member-components/join-failed-page.tsx
packages/frontend/component/src/components/member-components/request-to-join-page.tsx
packages/frontend/component/src/components/member-components/sent-request-page.tsx
packages/frontend/component/src/components/not-found-page/not-found-page.tsx
packages/frontend/core/src/components/page-list/docs/select-page.tsx
packages/frontend/core/src/components/page-list/selector/selector-layout.tsx
packages/frontend/core/src/desktop/pages/theme-editor/components/empty.tsx
packages/frontend/core/src/desktop/pages/theme-editor/components/simple-color-picker.tsx
packages/frontend/core/src/desktop/pages/theme-editor/components/string-cell.tsx
packages/frontend/core/src/desktop/pages/404/index.tsx
packages/frontend/core/src/desktop/pages/ai-upgrade-success/index.tsx
packages/frontend/core/src/desktop/pages/import-clipper/index.tsx
packages/frontend/core/src/desktop/pages/import-template/index.tsx
packages/frontend/core/src/desktop/pages/index/index.tsx
Accessibility
Tree row is a clickable div, so keyboard users can't navigate or select themes
The row wrapping the icon and `{node.label}` is a `<div onClick={onClick}>` with no `role`, `tabIndex`, or key handler. This is the only way to select a theme node or expand a branch in this tree.
Why it matters
Keyboard-only and screen-reader users cannot Tab to a theme node, expand a branch, or select a variable set at all: the entire theme editor tree becomes unusable without a mouse.
Fix
Use a native `<button>` for the clickable row so it gets focus, Enter/Space activation, and a role for free.
<div
data-checked={node === checked}
data-active={isActive?.(node)}
data-customized={isCustomized?.(node)}
className={styles.treeNode}
onClick={onClick}
><button
type="button"
data-checked={node === checked}
data-active={isActive?.(node)}
data-customized={isCustomized?.(node)}
aria-expanded={node.children ? open : undefined}
className={styles.treeNode}
onClick={onClick}
>Icon-only menu trigger has no accessible name for screen readers
The `<IconButton size="14" icon={<MoreHorizontalIcon />} />` that opens the color menu has no `aria-label`, `title`, or visible text.
Why it matters
Screen reader users hear only "button" with no indication this opens color editing options, so they can't discover or operate the color-override menu at all.
Fix
Give every icon-only control an aria-label that names its action.
<IconButton size="14" icon={<MoreHorizontalIcon />} /><IconButton size="14" icon={<MoreHorizontalIcon />} aria-label="Edit color" />Decorative logo icon next to 'built with' text has no aria-hidden
The footer link renders `<span className={styles.linkText}>{t['...built-with']()}</span>` immediately followed by `<Logo1Icon fontSize={20} />` with no `aria-hidden`. The icon adds no information beyond the adjacent label.
Why it matters
Screen readers announce the icon as an unlabeled image or generic name right after reading the link text, adding noise to an already simple footer link.
Fix
Mark decorative icons that sit beside their own text label as aria-hidden.
<span className={styles.linkText}>
{t['com.affine.share-page.footer.built-with']()}
</span>
<Logo1Icon fontSize={20} /><span className={styles.linkText}>
{t['com.affine.share-page.footer.built-with']()}
</span>
<Logo1Icon fontSize={20} aria-hidden="true" />UX
The "Save" button silently switches between two different actions
The filter panel's `<Button onClick={handleSaveFilters}>{t['save']()}</Button>` always reads "Save," but `handleSaveFilters` branches on `selectedCollectionId`: with a collection selected it silently updates that collection's rules, with none selected it opens a prompt modal asking the user to name and create a brand-new collection.
Why it matters
Users can't predict from the label whether clicking commits instantly or launches an extra naming step, so the same button feels inconsistent depending on hidden state they can't see.
Fix
Label the commit button by the outcome it actually performs in each state, not a generic verb.
<Button onClick={handleSaveFilters}>{t['save']()}</Button><Button onClick={handleSaveFilters}>
{selectedCollectionId
? t['save']()
: t['com.affine.editCollection.saveCollection']()}
</Button>Node with no variables renders a bare header and no explanation
`const variables = node.variables ?? [];` falls back to an empty array with no branch for that case, so when a `node` has no variables the `Scrollable.Viewport` renders zero rows below the "Name / Light / Dark" header.
Why it matters
Users who select an empty node see what looks like a broken or loading panel, with no confirmation that the node genuinely has no theme variables and no next step.
Fix
Render a designed empty state (message, no action needed here since variables are data-driven) when the variables array is empty.
const variables = node.variables ?? [];const variables = node.variables ?? [];
const isEmpty = variables.length === 0;Want this on your repo?
Rams reviews your next PR automatically and posts inline fix suggestions.
Review my public repo for freeComponents
Inline flex styles on the menu list bypass the existing style system
The menu's `<ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>` and the `Menu`'s `contentOptions={{ style: { background: cssVar('white') } }}` set layout and background inline instead of through the `theme-editor.css` classes already used elsewhere in this file (`styles.colorCellRow`, `styles.colorCellValue`).
Why it matters
Every inline style is a rule the design system can't see or theme, so dark mode, spacing-scale updates, and future refactors have to special-case this one component instead of inheriting from the shared stylesheet.
Fix
Move inline layout and color declarations into a named class in theme-editor.css.
contentOptions={{ style: { background: cssVar('white') } }}
items={
<ul style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>contentOptions={{ className: styles.colorCellMenuContent }}
items={
<ul className={styles.colorCellMenuList}>Typography
Color
Spacing
Motion
Craft
Working well
- The `useAllDocsOptions` hook reads `viewMode`, `displayPreference`, and `selectedCollectionId` only once via lazy `useState` initializers instead of subscribing to shared state, which is exactly right for the split-view case the file's own comment describes: each pane gets its own independent copy instead of fighting over one shared source.
- `<Filters key={selectedCollectionId ?? 'all'} ... />` forces a full remount whenever the selected collection changes. This is the correct fix for stale-state bugs: instead of manually resetting every internal field of `Filters` when the collection switches, the key change lets React discard and recreate the component cleanly.
- The footer 'built with' link hides itself when `isPresent || loginStatus === 'authenticated'`. That's a sharp contextual call: authenticated users already know the product and presenters don't need a promo link competing with their content, so the footer only shows up for the audience it's actually meant to persuade.
- `EditorOutlineViewer` is gated on `currentPublishMode === 'page'`, keeping the outline affordance out of edgeless mode where a linear document outline doesn't make sense. Conditioning UI on the actual content mode instead of always rendering it keeps the interface honest about what's usable in each state.
Score your own repo with Rams.
Free on public repos. Rams reviews every pull request for accessibility, color, type, spacing, components, motion, UX, and craft, and posts inline fixes.