Profile Header — Shared Components

An interactive rebuild of the engagement tray — the right-docked drawer, its notification, community and profile screens, and the in-place Edit Profile form. Controls on the left drive every state; the Components tab above inventories what this proposal reuses, restyles, reworks and needs built.

Figma-sourced tokens
Tray
Drawer

On mobile the rail is replaced by a back arrow and a full-page Menu, per request. That is not a responsive squeeze of the same control — it is a different pattern for a different width, and the reason is measurable: the rail cost 56px of a 373px drawer permanently, to show three icons, which is what held the rows at the 317px this sheet has documented at length as the narrowest measure the row design is ever asked to work at. Removing it hands those 56px straight back — rows go 317 → 373 — and the largest layout gain available on this screen turns out to come from deleting something. The rail's expand-to-188px behaviour could never have worked here either: on desktop it covers 188 of a 480px row and leaves 292 readable, but at 375 it would cover 188 of 373, which is not an overlay but a modal that has not admitted it.

No hamburger and no scrim any more — Menu is a screen, not a panel. It used to be a slide-in panel over whichever tab was showing, opened by a hamburger and dismissed by tapping its scrim or pressing Escape. Per request that is gone: Menu is now a fourth screen sitting above Notifications/Community/Profile in the same one-directional stack Edit Profile and the community member screen already use, reached and left the same way — a back arrow in the header's leading slot, animated with the drawer's own forward/back transition rather than a separate slide-and-dim. The leading slot's back arrow means "up one level" on every mobile screen now, never "open a menu" on some and "go up" on others. Menu rows still reuse the .tray-rail-item recipe, because the rail and this screen are the same choice made at two widths and should not look like two controls.

Resizes the stage to an iPhone 13 mini: 375×812 CSS px, the device's logical viewport. Correcting myself: this note previously called that “the 1080×2340 panel at its 3× ratio”, which does not multiply — 375×3 is 1125, not 1080. The 13 mini renders at 1125×2436 and then downsamples to its physically smaller 2340×1080 panel, a factor of 0.96, while devicePixelRatio still reports 3. It is one of the few iPhones that does this. So the size was right for the wrong reason: 375×812 is the logical viewport, not the panel divided by three. Renamed from the 12 mini on request, and the dimensions genuinely did not move — the two share the same 5.4″ panel and the same logical size. The drawer goes full width there rather than staying 536 — at 375 it would not fit, and a right-docked panel narrower than the screen is not a pattern any mobile tray uses. That leaves the rows 317px against the 480 they get on desktop — measured, not derived: 375 minus the frame is a 373px drawer, minus the rail's 56 is 317. (375 − 56 = 319 is the tempting arithmetic and it is wrong by the 2px of frame, which is exactly the kind of error this sheet exists to catch.) That is the number worth looking at: it is the narrowest the row design is ever asked to work at, and nothing here could show it before.

The X on the drawer closes it; this control reopens it to the same tab you left. The glyph is the usual open-sidebar mark — a panel with the edge it slides in from picked out, mirrored to the right because that is the edge this drawer is docked to. Note there is no sidebar icon in any of the five in-house folders — icons_v2, the v1 icons folder, illustrations, the app-level set, or design-system/primitives/icons (45 glyphs, the newest set) — so this one is drawn.

Auth view

Does email really have to be verified to complete registration? No — on three counts. The notice used to render unconditionally on the Sign up tab; it is now behind this setting, which is what hasUnverifiedEmail() actually checks: Boolean(settings.enable_email_verification) && isViafouraAccount(user) && !isModerationStatusApproved(...). So (1) a publisher can switch verification off entirely, (2) it applies to Viafoura accounts only — six of the seven providers on that same screen are exempt, so a line sitting under all of them was wrong for most of them, and (3) the copy misdescribes the behaviour: registration does complete. What verification gates is participation, and only after the first post — hasCommentLimit() is isViafouraAccount && needsEmailVerification && user.comments_made > 0, and canCreateLiveContent() returns false for an unapproved Viafoura account. You register, you comment once, then you are limited until you verify.

The string feed.signup.nav.verification_required has no consumer anywhere in src/ — translated into every locale, rendered by nothing. Third one found this pass, after sdk.empty_notifications and profile.login. Nobody has had to reconcile it with the permitters because nothing has ever shown it. Kept verbatim rather than rewritten, since a correction is new copy and needs POEditor; the conditional is the half that can be fixed without one.

One radio, not three checkboxes — these were already mutually exclusive in the code and the old controls let you tick all three. reset/success writes to account.vue's message array and reset/failure to forgot-password.vue's own; those two components are never mounted together, so the states cannot coexist. syncAuthState() resolved the conflict with an else if, which meant the workbench silently ignored a control you had just pressed. A radio makes the exclusivity visible instead of hiding it.

The two views of account.vueauth and fp — using its own value names. The three message states are the real subscriptions: reset/success pushes [ 'success', 'An email was sent to {email} with password reset instructions.' ] and switches the view back to auth, so the confirmation appears on the login screen rather than on the one you submitted from; reset/failure pushes [ 'error', 'Your password could not be reset. Please try again later.' ] and stays on fp. Two separate message arrays in two places, which is why they cannot both be shown at once. Note the level naming: level="error" paints vf-warning-bg-colour — the warning tokens (#b95000), not error-color. That is the shipped mapping, copied rather than corrected, and logged as a gap.

Empty states

One radio, not two checkboxes. Ticking both asked for “the lists are empty and the feature is switched off”, which is not a state: renderCommunityTab() returns the disabled message before it ever reaches the lists, so disabled silently won and one of the two controls did nothing. Three exclusive values say what the surface can actually be.

Six empty states are now wired, all six on verbatim shipped strings. Notifications empty is reachable by clearing every section in the preview (Reset feed brings it back) and comment history via Comment history → Empty; the other four are posed with these checkboxes. The three Community lists have to be posed, and that is a finding rather than a shortcut: unfollowing someone in the preview flips the relationship and leaves the row in place, which is faithful — the list is server-side and the real Following tab does not pull a row out from under your cursor — so no amount of clicking can ever empty it. The state is one the data model can be in, not one this interaction leads to. community.empty is a different condition from the other three and that distinction was missed on a first read of the language file: the list empties mean “you have not followed anyone”, this one means the publisher has community features switched off site-wide, so there is nothing to be empty of — no tabs, no segmented control, no follow buttons. It returns above the segmented control for that reason. Two shipped empty-state treatments are used, split by scope: whole-surface empties get announce-layout with the no_data illustration in a 15px-padded ring (empty-liveblog.vue, trending-articles-announce.vue); empties inside a surface that still has chrome above them get the 25px inline circled glyph (empty-comments.vue). The bespoke grey paragraphs this drawer used to carry are gone — they were a third and fourth empty-state design in a codebase that already has two. sdk.empty_notifications is worth a note: it is translated into every locale and referenced by nothing in src/. The copy for that state exists, is maintained, and has never been rendered.

Feed data

Feed content is bear-research sample copy, structured exactly like the production demo tray it was modelled on. Removing a notification or clearing a section here is destructive (matches the real "Permanently remove" action) — use this to restore it.

Loading replaces the sections with bones in the real row shell, Vuetify's v-skeleton-loader look rather than either loading treatment already shipped (see the CSS note by .vf-skeleton-bone). It is a placeholder for "notifications have not arrived yet", not a third empty state — Reset feed and the empty-feed condition above are both about data that HAS arrived.

Theme

Moved here from the masthead, on request, along with what it governs. System/Light/Dark used to sit next to Expand all notes because it governed the whole sheet — the notes, the rail and the token list all followed it. It now governs only .stage (the drawer, its hint and the computed-state readout), the same box the one live override below governs, so the mode switch and the override that layers on top of it belong in the same group.

Background and Sunken colour swatches removed, on request — they did not play well with the mode switch above them. --surface and --surface-sunken are coupled to --text and --border in each mode's own block (light text needs a light surface under it, dark text needs a dark one), and a flat inline override cannot participate in that coupling — it wins over EITHER mode's pairing, so switching System/Light/Dark after picking a background left text in the new mode's colour sitting on a surface stuck in the old one. Font family has no such pairing — a typeface is not tied to a mode — which is what keeps it live.

Accent colour swatch removed too, on request. It had the opposite problem from Background/Sunken: not a bad interaction with the mode switch, just not worth what it cost everywhere else. A flat override on --accent-color only ever touched that one token, so every derived step (-light, -semi-dark, -dark, the four alpha steps, plus the dark-mode --accent-ink) had to become a color-mix() keyed off it instead of the literal tokens/_colors.scss ships, just so a picked hue would reach them too. That is a lot of indirection — including a second copy of the whole ramp restated on .stage so it would not freeze against :root's own value — to buy one workbench control. Gone, and every one of those tokens is a plain literal again.

Viewer & site
Current viewer's role

Session folded into role, on request, and it was a soundness bug rather than a tidy-up. A separate boolean session checkbox sitting beside a role radio made unauthenticated + Moderator / Admin a reachable combination — a state no session can be in. These are three mutually exclusive answers to one question, “who is looking”, so they are one radio.

Site settings
Profile owner
Display name

Avatar photo

Avatar moderation badge

Account state

Interaction states
Follow button

Mute button

Failed action alert
Edge case

When on, every other control is moot — header.vue has no defensive guard for this today. Shown here as a skeleton.

Profile activity
Comment history

Verb phrasing (asked a question / posted / answered) is the real string set from base.json, keyed by content type. Profile is only ever your own, so this list always takes the self phrasing — "You posted on …". The other phrasing is still live: it renders another member's history on the Community member screen, which is where someone else's profile now lives. The article titles and comment bodies are bear-research sample content.

Profile — your own
Avatar upload state

Click the avatar circle in the preview to cycle through these. Hover the "Uploaded" state to see the real delete overlay. "Error" leaves the Profile Picture fieldset dirty but invalid, so its submit stays disabled.

Four fieldsets, four transactions

Each fieldset owns its own submit, enabled only once that fieldset is dirty and valid — dirty measured against the values the form mounted with, so clearing a field back to its original is not a change. Notification Preferences has no submit because its switches have always saved themselves.

This is a departure from the code, not a rendering of it. settings.html is one form with one submit: tryUpdateProfile() collects name, email, new_password and old_password into a single updates object and fires one UPDATE_USER_BY_UUID. Splitting it needs that method broken into per-fieldset dispatches. The store action does not have to change — each fieldset hits the same endpoint with a narrower payload — but the component does. Avatar was already independent (UPDATE_AVATAR_BY_UUID, fired from avatar_upload.vue), so it is really Name & Email and Change Password being split.

Open question this exposes: the API requires old_password for an email change and for a password change. Today one shared "Enter current password" field authorises both, because both live in one submit. With the fieldsets independent there is nowhere for that single field to live — so either each of those two fieldsets grows its own current-password field (asking for it twice if you change both), or the endpoint stops requiring it for one of them. Built to the spec as given, which has neither; flagging it because it is a 400 from the API, not a design preference.

Account type

This is the axis the form is really built around. settings.js derives isSocial and isThirdParty from the account and gates every section on them, and fields that fail their test are removed from baseSchema, not disabled. Watch the form empty out: a Viafoura Defined Cookie account keeps its avatar and its one remaining toggle but loses all four fields and the Update profile button; Janrain / LoginRadius hits the one carve-out in canUpdateAvatar and keeps only the avatar; a plain social account is left with nothing at all, heading included, which is what triggers the "every fieldset is gated off" placeholder below.

Site settings

disable_avatars is not a straight off switch — it narrows the avatar upload to moderators and admins, so it interacts with "Current viewer's role" above. disable_email_notifications is the "Follow disabled site-wide" checkbox in Viewer & site.

enable_push_notifications and the Browser push toggle it gated are gone, on request — the feature has been deprecated. That takes the whole "Browser push state" workbench group with it: determineErrorState()'s five message states, the indeterminate slider, all of it existed only to demonstrate that one toggle. Notification Preferences now carries only Email, and a plain social account — the one login type push alone used to keep this fieldset alive for — loses it entirely, which is the correct outcome now rather than a regression: nothing on that account was ever really editable here, push just used to paper over it.

Email notifications

email_notice_frequency: the toggle writes immediately or never, and the real one flips optimistically then sits indeterminate for 300ms after the call returns.

Save status — all three fieldsets

Sets all three at once, for looking at them together. Each fieldset really has its own status though — click a fieldset's own submit and only that one cycles, which is the point of splitting them and is what the readout tracks separately. loader-button.vue fades the label out and centres a spinner, checkmark or exclamation over it without resizing the button.

This control poses all three regardless of whether their fieldset is actually dirty. The real path is a click: a submit stays disabled until its fieldset is dirty and valid, so reaching Pending/Success/Error by pressing the button yourself means dirtying something first — change the display name, or type two matching passwords. Type a bad email or two different passwords to see the real strings — Invalid email address, New passwords do not match — with the offending field going red.

Click a rail item, the avatar, Follow, or Mute directly to cycle their states — the workbench controls stay in sync.

Computed state

What this shell is, and where it departs from the code

Tray shell (tray.vue / tray-navigation.vue) — now simulated as the real right-docked drawer it is, full viewport height, not a floating card. No scrim: the drawer slides over an article the reader is still meant to see, so dimming the page states the wrong relationship — a scrim suspends what is behind it, which is true of a modal and false of a panel you open to check one notification. The drawer's box-shadow carries the separation on its own. The trade is that there is no click-out target, so dismissal is the X or Escape. The top tab row is a vertical rail, 56px and icon-only, with an expand/collapse button at its head — per request, replacing a hover reveal. Hovering fired on the way past (crossing the rail to reach the close X opened it), reported no state to assistive tech, and had no touch equivalent; a button carries aria-expanded and stays where you put it. Expanding overlays the content rather than pushing it, per request: the rail's own box stays 56px and the 188px panel floats over the rows on a drop shadow. Nothing downstream reflows, so rows keep their full 480px measure — revealing some labels never re-wraps row text or resizes a thumbnail. The cost is that the left 188px of the content column is covered while it is open, and the same button uncovers it. On mobile that is the only workable choice: at 375px the rows are already down to 317px, and pushing would have left them 185. Notifications carries an unread badge, counted the way notification-list-item.vue defines unread (new or seen), off the live list rather than the seed data. The drawer is 528px — widened by exactly the rail's collapsed width, so rows keep the full 480px measure the real tray gives them. Opening and closing slides the panel its own width; the first paint is deliberately un-animated so a drawer that starts open does not slide in on load. The Notifications and Profile tabs render the real notification and activity components, shared with the Feed & Activity state matrix — same CSS, same row primitives, injected from the same two partials. Every design change that sheet lets you toggle is switched on here, permanently; this is the proposal in situ, not a comparison. Per request, the separate cog-icon Settings tab is gone. Your own profile has no sub-tab row: it shows Comment History under an ordinary section-header.vue heading, and Edit Profile is a header button, left of Sign Out — a toggle (aria-pressed), since with no Comment History control there is nothing else to navigate to and pressing it again is the way back. Edit Profile is four independent <fieldset>s — Profile Picture, Name & Email, Change Password, Notification Preferences — each named by aria-labelledby pointing at its own section header. The first three own a submit, always present, labelled Update and disabled until that fieldset is dirty and valid. Dirty is measured against the values the form mounted with, so clearing a field back to its original is not a change. Pressing it holds a spinner centred on the still-disabled button; on success the spinner morphs into a checkmark and the button turns green at the same moment, with no label under either — that colour and glyph ARE the confirmation now, replacing a separate "Profile updated" message, and after a beat the button reverts to disabled Update. Notification Preferences has no submit, because its switches always saved themselves. Text fields are design-system/primitives/input/InputText.vue — an outlined box with the label above it — replacing settings.html's floating-label field with the expanding blue underline. Contents are otherwise still the whole of settings.html — the shipped 35×18 toggles, all five push-permission messages, loader-button's pending spinner and status.vue's banner — and the real strings (Invalid email address, New passwords do not match) with the offending field going red — which in the primitive means its border going transparent and a 1px error outline taking over, so an invalid field is exactly the same size as a valid one. This is a departure from the code, not a rendering of it: settings.html is one form with one submit, so four submits means splitting tryUpdateProfile() into per-fieldset dispatches — and the API's old_password requirement for email and password changes no longer has one shared field to live in. Flagged in the workbench rather than papered over. Sign Out stays at the tiny Button variant, rightmost, being a destructive action rather than a destination. Includes a real Avatar Picker component (.Avatar Picker, node 23841:9054) the code doesn't visually implement yet. Feed and Community tabs are honest placeholders — not built this pass.

Live tokens in this render

Code reference: src/components/tray/tray-navigation.vue (nav chrome, real i18n copy), src/components/tray_content/profile/header.vue (permutation logic). Change Profile Information is the whole of settings.html: settings.js, avatar_upload.vue, input-list.vueform/input/{text,input,floating_input_wrapper}.vue, update_password.vue, verify_password.vue, toggle_settings/{toggle_settings.js,constants.js,util.js}form/toggle/toggle.c.js, status_message.vue, settings_controls.vueloader-button.vue + status.vue. Figma reference: Shared Components file hyDu1jEHR3HQY6ZZgAfURL — Button (node 61:347, exact tiny/base tokens via get_variable_defs), Avatar (node 61:341), Avatar Picker (node 23841:9054: Default/Hover/Uploading/Uploaded/Delete/Error). "Removed", "Reported", "Unauthorised", and the Avatar Picker's upload-progress/delete-overlay states are documented in the shared library but not yet wired into the current code — included here to show what's available. Feed and Community tabs are placeholders, not built this pass. Note: merging Settings into an in-place "Edit Profile" view on the Profile tab, and the Sign Out placement/size, are this session's proposed reorganization — tray-navigation.vue still ships a separate Settings tab today.