elk-zone/elk
Reviewed against Rams quality heuristics: accessibility, color, typography, spacing, components, motion, UX, and craft.
30 files reviewed·July 9, 2026
More findings
Verdict
Clean composition patterns hide fragile edge cases: crash-prone guards, dead-end error states, and half-built Explore tabs. Credit where due, the component reuse is solid, but the polish stops the moment a user strays from the happy path.
Files Rams reviewed
app/pages/[[server]]/@[account]/[status].vue
app/pages/[[server]]/@[account]/index.vue
app/pages/[[server]]/@[account]/index/index.vue
app/pages/[[server]]/explore.vue
app/pages/[[server]]/explore/index.vue
app/pages/[[server]]/explore/links.vue
app/pages/[[server]]/explore/users.vue
app/pages/[[server]]/list/[list]/index.vue
app/pages/[[server]]/list/[list]/index/accounts.vue
app/pages/[[server]]/lists/index.vue
app/pages/[[server]]/tags/[tag].vue
app/pages/notifications.vue
app/pages/settings.vue
app/pages/settings/about/index.vue
app/pages/settings/language/index.vue
app/pages/settings/preferences/index.vue
app/pages/settings/profile/appearance.vue
app/pages/settings/profile/index.vue
app/pages/settings/users/index.vue
app/components/account/AccountAvatar.vue
app/components/account/AccountBigCard.vue
app/components/account/AccountBigCardSkeleton.vue
app/components/account/AccountFollowButton.vue
app/components/account/AccountFollowRequestButton.vue
app/components/account/AccountHeader.vue
app/components/account/AccountHoverWrapper.vue
app/components/account/AccountInfo.vue
app/components/account/AccountMoreButton.vue
app/components/account/AccountPaginator.vue
app/components/account/AccountPostsFollowers.vue
UX
Paginators call account.id before confirming account exists
`fetchAccountByHandle` result is used directly as `account.id` in both `pinnedPaginator` and `postPaginator`, and the `if (account)` guard only wraps the `useHydratedHead` call below it. If the handle doesn't resolve to a real account, `account` is falsy and `account.id` throws before any not-found state can render.
Why it matters
A mistyped or deleted account handle crashes the page instead of showing a not-found state, so users land on a broken screen with no explanation and no way forward.
Fix
Guard on account existence immediately after the fetch and return an error/not-found state before constructing anything that depends on account.id.
const account = await fetchAccountByHandle(handle.value)
// we need to ensure `pinned === true` on status
// because this prop is appeared only on current account's posts
function applyPinned(statuses: mastodon.v1.Status[]) {
return statuses.map((status) => {
status.pinned = true
return status
})
}
function preprocess(items: mastodon.v1.Status[]) {
return filterAndReorderTimeline(items, 'account')
}
const pinnedPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ pinned: true })
const postPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ limit: 30, excludeReplies: true })
if (account) {
useHydratedHead({
title: () => `${t('nav.profile')} | ${getDisplayName(account)} (@${account.acct})`,
})
}const account = await fetchAccountByHandle(handle.value)
if (!account) {
throw createError({ statusCode: 404, statusMessage: 'Account not found' })
}
// we need to ensure `pinned === true` on status
// because this prop is appeared only on current account's posts
function applyPinned(statuses: mastodon.v1.Status[]) {
return statuses.map((status) => {
status.pinned = true
return status
})
}
function preprocess(items: mastodon.v1.Status[]) {
return filterAndReorderTimeline(items, 'account')
}
const pinnedPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ pinned: true })
const postPaginator = useMastoClient().v1.accounts.$select(account.id).statuses.list({ limit: 30, excludeReplies: true })
useHydratedHead({
title: () => `${t('nav.profile')} | ${getDisplayName(account)} (@${account.acct})`,
})Account-not-found state gives no way back for the user
`CommonNotFound` shows only the text "error.account_not_found" for `@{accountName}`, with no link or action to return to a known page.
Why it matters
A user who lands on a dead-end profile URL (bad link, typo, deleted account) has no recovery path except the browser back button, which is easy to miss on mobile.
Fix
Pair the not-found message with a visible recovery action, such as a link back to the home timeline.
<CommonNotFound v-else>
{{ $t('error.account_not_found', [`@${accountName}`]) }}
</CommonNotFound><CommonNotFound v-else>
{{ $t('error.account_not_found', [`@${accountName}`]) }}
<NuxtLink to="/" text-primary hover:underline>
{{ $t('nav.back_home') }}
</NuxtLink>
</CommonNotFound>Explore only surfaces trending posts, tags and links are unreachable
The template renders a single `TimelinePaginator` for `context="public"` with an explicit `<!-- TODO: Tabs for trending statuses, tags, and links -->` marking the tab UI as unbuilt.
Why it matters
A user opening "Explore" expecting to browse trending hashtags or links finds only post trends, with no tab or link to reach the other two content types the page's own TODO says are planned.
Fix
Ship the tag/link tabs before merging, or scope this route's entry copy to trending posts only until the other states exist.
<!-- TODO: Tabs for trending statuses, tags, and links --><TabsExplore v-model="activeTab" :tabs="['statuses', 'tags', 'links']" />"For you" tab disables silently with no reason shown to logged-out users
The `for_you` tab entry sets `disabled: !isHydrated.value || !currentUser.value` with no accompanying label, tooltip, or reason passed to `CommonRouteTabs`.
Why it matters
A logged-out visitor sees a dead tab with zero explanation of why it doesn't respond, which reads as a bug rather than a login requirement.
Fix
Pass a reason string (or title/tooltip prop) alongside the disabled flag so the tab communicates why it's inactive.
{
to: isHydrated.value ? `/${currentServer.value}/explore/users` : '/explore/users',
display: t('tab.for_you'),
disabled: !isHydrated.value || !currentUser.value,
},{
to: isHydrated.value ? `/${currentServer.value}/explore/users` : '/explore/users',
display: t('tab.for_you'),
disabled: !isHydrated.value || !currentUser.value,
title: !currentUser.value ? t('tab.for_you_login_required') : undefined,
},Trend links list has no empty or error state, only loading
`CommonPaginator` is given a `#default` template for loaded items and a `#loading` template with three skeleton rows, but no `#empty` or `#error` template. If the trends endpoint returns zero links or the fetch fails, the user sees whatever CommonPaginator falls back to internally, not a designed response for this specific list.
Why it matters
Users hitting a genuinely empty trends feed or a failed request get an undesigned fallback with no explanation or retry path, so they can't tell if the app broke or there's simply nothing trending right now.
Fix
Add explicit #empty and #error slots to CommonPaginator with a message and retry action.
<CommonPaginator v-bind="{ paginator }">
<template #default="{ item }">
<StatusPreviewCard :card="item" border="!b base" rounded="!none" p="!4" small-picture-only root />
</template><CommonPaginator v-bind="{ paginator }">
<template #default="{ item }">
<StatusPreviewCard :card="item" border="!b base" rounded="!none" p="!4" small-picture-only root />
</template>
<template #empty>
<CommonNotFound>{{ $t('common.not_found') }}</CommonNotFound>
</template>
<template #error="{ error, retry }">
<CommonErrorState :error="error" @retry="retry" />
</template>Accessibility
Route navigation scrolls the page but never moves keyboard/screen-reader focus
`scrollTo()` calls `statusElement.scrollIntoView(true)` after the status loads, but nothing sets focus on `main` or announces the new status. Sighted mouse users see the page jump; keyboard and screen reader users get no signal that a new status has loaded.
Why it matters
Screen reader users landing on a fresh status page via SPA navigation hear nothing change and have to manually re-explore the page to find the new content, adding real friction to every status-to-status navigation.
Fix
Move focus to the status container (with a tabindex and no visible outline change) right after scrolling, so assistive tech announces the new content.
const statusElement = unrefElement(main)
if (!statusElement)
return
statusElement.scrollIntoView(true)const statusElement = unrefElement(main)
if (!statusElement)
return
statusElement.scrollIntoView(true)
statusElement.setAttribute('tabindex', '-1')
statusElement.focus({ preventScroll: true })Typography
Color
Spacing
Components
Motion
Craft
Working well
- The page renders ancestors, the focused status, and descendants all through the same `StatusCard` component with different props (`context="account"`, `has-older`, `has-newer`). Reusing one component across three roles in the thread keeps visual treatment consistent instead of drifting into three near-identical implementations.
- Separating the pinned timeline from the regular post timeline into two distinct `TimelinePaginator` instances (with `applyPinned` and `preprocess` as separate transform functions) keeps pinned-post logic isolated from normal feed ordering, which makes each function easy to reason about independently.
- Gating the `CommonAlert` news tip behind `isHydrated && !hideNewsTips` avoids a hydration mismatch: the server and first client render agree on not showing the alert, then it appears only once localStorage is readable client-side. That's the right way to guard localStorage-dependent UI in an SSR app.
- The blocked-by state pairs `text-secondary` on the primary line ("account.profile_unavailable") with `text-secondary-light text-sm` on the explanation ("account.blocked_by"). Stepping both color and size down together for the secondary line gives a clear read order without introducing a new color.
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.