Your profile

  • Account and publishing
  • components: 8
  • packages: 1

Where the save model meets its exception: edits wait, actions do not.

Reviewed 2026-09-06

On this page
Full width

What this proves

The settings screen argued for one save model per page. This one is the refinement, and the refinement matters more than the rule:

Edits wait for Save. Actions happen the moment you press them.

Renaming yourself is an edit — you can change your mind, so it waits. Signing a device out is an action: the session is gone, and queueing it behind a Save button would be a lie about something that already happened.

The line is not “some things are urgent”. It is whether there is anything left to reconsider afterwards.

Changing your email does not happen here

Type a new address and press the button, and nothing changes. A confirmation goes to the address you use now, and the change waits for it.

That single detour is the only thing standing between an account and whoever found an unlocked laptop. A profile screen that changes the address on Save hands over the account along with the chair.

The button says Send confirmation rather than Save, because that is what it does. A button that names the wrong outcome is a bug that ships as copy.

An empty avatar is not neutral

Without a picture the screen shows initials, and that is a picture too — just a predictable one. A blank circle where a person should be removes the only thing that distinguishes one participant from another in a list of nine.

You cannot sign this device out from here

Every other session has a button; this one has a badge instead. A control that throws you out of the screen you are standing on is not an action, it is a trap — and “sign out everywhere” is stated as everywhere else, with the count of sessions it will end.

Components used

The list is built from frontmatter and checked against the source: a component added to the code and forgotten in the header fails the gate.

Packages:

  • @feugene/granularity

Entry point

The same file that runs here — examples/profile/App.vue. No second copy of the code exists.

examples/profile/App.vue
<script setup lang="ts">
/**
 * `/examples/profile` — профиль, работа C2.
 *
 * Экран настроек уже показал одну модель сохранения. Здесь она уточняется, и
 * уточнение важнее самой модели:
 *
 * > **Правки ждут «Сохранить». Действия происходят сразу.**
 *
 * Выход с чужого устройства и запрос на смену почты — не настройки, а
 * действия: после них передумывать нечего, и ставить их в очередь к кнопке
 * значит врать о том, что уже случилось. Разница написана на экране, а не
 * оставлена читателю на догадку.
 *
 * Смена почты вообще не происходит здесь. Она уходит письмом на **нынешний**
 * адрес, и это единственная защита от того, кто дорвался до чужой открытой
 * вкладки.
 */
import { computed, reactive, ref } from 'vue'
import { GrCard } from '@feugene/granularity/components/GrCard'
import { GrAvatar } from '@feugene/granularity/components/GrAvatar'
import { GrFormField } from '@feugene/granularity/components/GrFormField'
import { GrInput } from '@feugene/granularity/components/GrInput'
import { GrButton } from '@feugene/granularity/components/GrButton'
import { GrAlert } from '@feugene/granularity/components/GrAlert'
import { GrBadge } from '@feugene/granularity/components/GrBadge'
import { GrConfirmDialog } from '@feugene/granularity/components/GrConfirmDialog'
import type { ProfileData, Session } from './data'

const props = defineProps<{ data: ProfileData }>()

const saved = reactive({ name: 'Ada Lovelace' })
const draft = reactive({ ...saved })

const currentEmail = '[email protected]'
const newEmail = ref('')
const pendingEmail = ref('')

const sessions = reactive<Session[]>(props.data.sessions.list.map(item => ({ ...item })))
const revoking = ref(false)
const notice = ref('')

const dirty = computed(() => draft.name !== saved.name)
const others = computed(() => sessions.filter(session => !session.current))

function save() {
  Object.assign(saved, draft)
  notice.value = props.data.saved
}

function discard() {
  Object.assign(draft, saved)
}

/**
 * Смена почты — действие, а не правка.
 *
 * Поэтому она не ждёт «Сохранить» и не меняет адрес: она ставит смену в
 * ожидание и отправляет письмо туда, где человек уже подтверждён.
 */
function requestEmailChange() {
  if (!newEmail.value.trim())
    return

  pendingEmail.value = newEmail.value.trim()
  newEmail.value = ''
}

/** Выход с устройства — тоже действие: он происходит сразу. */
function revoke(id: number) {
  const index = sessions.findIndex(session => session.id === id)
  if (index >= 0)
    sessions.splice(index, 1)

  notice.value = props.data.sessions.revoked
}

function revokeAll() {
  for (let index = sessions.length - 1; index >= 0; index--) {
    if (!sessions[index].current)
      sessions.splice(index, 1)
  }

  revoking.value = false
  notice.value = props.data.sessions.revoked
}

/** Инициалы — запасное изображение, а не пустое место. */
const initials = computed(() => draft.name
  .split(/\s+/)
  .slice(0, 2)
  .map(part => part[0] ?? '')
  .join('')
  .toUpperCase())
</script>

<template>
  <div class="screen">
    <header>
      <p class="screen__brand">{{ data.brand }}</p>
      <h1 class="screen__title">{{ data.title }}</h1>
      <p class="screen__subtitle">{{ data.subtitle }}</p>
      <!-- Правило, отличающее правку от действия, стоит на экране. -->
      <p class="screen__rule">{{ data.rule }}</p>
    </header>

    <GrAlert v-if="notice" tone="success" live="polite" :title="notice" />

    <GrCard class="screen__panel">
      <h2 class="screen__section">{{ data.identity.heading }}</h2>

      <div class="screen__identity">
        <!--
          У изображения есть текстовая замена, а у отсутствующего — инициалы.
          Пустой кружок на месте человека — не нейтральность, а потеря
          единственного, чем один участник отличается от другого в списке.
        -->
        <GrAvatar :size="64" :alt="data.identity.avatarAlt">{{ initials }}</GrAvatar>

        <div class="screen__identityText">
          <p class="screen__label">{{ data.identity.avatar }}</p>
          <p class="screen__hint">{{ data.identity.avatarHint }}</p>
          <div class="screen__row">
            <GrButton size="sm" variant="outline">{{ data.identity.replace }}</GrButton>
            <GrButton size="sm" variant="ghost">{{ data.identity.remove }}</GrButton>
          </div>
        </div>
      </div>

      <GrFormField :label="data.identity.name" :hint="data.identity.nameHint">
        <GrInput v-model="draft.name" />
      </GrFormField>
    </GrCard>

    <GrCard class="screen__panel">
      <h2 class="screen__section">{{ data.email.heading }}</h2>

      <p class="screen__label">{{ data.email.current }}: <code>{{ currentEmail }}</code></p>

      <GrAlert v-if="pendingEmail" tone="info" :title="data.email.pending" :description="pendingEmail">
        <GrButton size="sm" variant="ghost" @click="pendingEmail = ''">{{ data.email.cancel }}</GrButton>
      </GrAlert>

      <GrFormField :label="data.email.label" :hint="data.email.hint">
        <GrInput v-model="newEmail" type="email" autocomplete="email" />
      </GrFormField>

      <!--
        Кнопка называет то, что произойдёт: уйдёт подтверждение. «Сохранить»
        здесь было бы обещанием, которого экран не исполняет.
      -->
      <GrButton class="screen__self" variant="outline" @click="requestEmailChange">
        {{ data.email.action }}
      </GrButton>
    </GrCard>

    <GrCard class="screen__panel">
      <h2 class="screen__section">{{ data.sessions.heading }}</h2>
      <p class="screen__hint">{{ data.sessions.lead }}</p>

      <ul class="screen__sessions">
        <li v-for="session in sessions" :key="session.id">
          <div>
            <p class="screen__label">
              {{ session.device }}
              <GrBadge v-if="session.current" size="sm" tone="success">{{ data.sessions.thisDevice }}</GrBadge>
            </p>
            <p class="screen__hint">{{ session.place }} · {{ session.lastSeen }}</p>
          </div>

          <!-- Свой сеанс отсюда не закрывается: кнопка, выбрасывающая человека
               из экрана, на котором он стоит, — не действие, а ловушка. -->
          <GrButton v-if="!session.current" size="sm" variant="ghost" tone="danger" @click="revoke(session.id)">
            {{ data.sessions.revoke }}
          </GrButton>
        </li>
      </ul>

      <GrButton
        v-if="others.length"
        class="screen__self"
        variant="outline"
        tone="danger"
        @click="revoking = true"
      >
        {{ data.sessions.revokeAll }}
      </GrButton>
    </GrCard>

    <div v-if="dirty" class="screen__bar" role="status">
      <strong>{{ data.changed }}</strong>
      <div class="screen__row">
        <GrButton variant="ghost" @click="discard">{{ data.discard }}</GrButton>
        <GrButton @click="save">{{ data.save }}</GrButton>
      </div>
    </div>

    <GrConfirmDialog
      v-model="revoking"
      :title="data.sessions.revokeTitle"
      :description="data.sessions.revokeDescription"
      :confirm-text="data.sessions.revokeConfirm"
      :cancel-text="data.sessions.keep"
      confirm-tone="danger"
      @confirm="revokeAll"
    />
  </div>
</template>

<style scoped>
.screen {
  display: grid;
  align-content: start;
  gap: var(--gr-space-4);
  min-height: 100dvh;
  padding: var(--gr-space-4);
  background: var(--gr-bg);
  color: var(--gr-fg);
}

.screen__brand {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__title {
  margin: var(--gr-space-1) 0 0;
  font-size: var(--gr-text-xl);
  font-weight: 600;
}

.screen__subtitle {
  margin: var(--gr-space-1) 0 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-sm);
}

.screen__rule {
  margin: var(--gr-space-2) 0 0;
  max-width: 60ch;
  font-size: var(--gr-text-sm);
}

.screen__panel {
  display: grid;
  align-content: start;
  gap: var(--gr-space-3);
  padding: var(--gr-space-4);
}

.screen__section {
  margin: 0;
  font-size: var(--gr-text-sm);
  font-weight: 600;
}

.screen__identity {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: var(--gr-space-4);
}

.screen__identityText {
  display: grid;
  gap: var(--gr-space-2);
}

.screen__label {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gr-space-2);
  margin: 0;
  font-size: var(--gr-text-sm);
  font-weight: 600;
}

.screen__hint {
  margin: 0;
  color: var(--gr-muted-fg);
  font-size: var(--gr-text-xs);
}

.screen__row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gr-space-2);
}

.screen__self {
  justify-self: start;
}

.screen__sessions {
  display: grid;
  gap: var(--gr-space-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

.screen__sessions li {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gr-space-3);
  padding-block: var(--gr-space-2);
  border-block-end: 1px solid var(--gr-brd);
}

.screen__bar {
  position: sticky;
  bottom: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--gr-space-3);
  padding: var(--gr-space-3);
  border: 1px solid var(--gr-brd);
  border-radius: var(--gr-radius-md, 8px);
  background: var(--gr-card, var(--gr-bg));
  box-shadow: var(--gr-shadow-1);
}

@media (min-width: 768px) {
  .screen {
    padding: var(--gr-space-5);
  }
}
</style>

Build this with Granularity