Ваш профиль

  • Аккаунт и публикация
  • компонентов: 8
  • пакетов: 1

Место, где у модели сохранения появляется исключение: правки ждут, действия — нет.

Ревизия 2026-09-06

На этой странице
Во всю ширину

Что он доказывает

Экран настроек настаивал на одной модели сохранения на страницу. Этот — уточнение, и уточнение важнее правила:

Правки ждут «Сохранить». Действия происходят в момент нажатия.

Переименовать себя — правка: передумать можно, поэтому она ждёт. Закрыть сеанс на устройстве — действие: сеанса больше нет, и ставить его в очередь к кнопке значило бы соврать о том, что уже случилось.

Граница проходит не по «срочности». Она проходит по тому, осталось ли что-то, что можно пересмотреть потом.

Смена почты происходит не здесь

Введите новый адрес, нажмите кнопку — и ничего не изменится. Подтверждение уйдёт на адрес, которым вы пользуетесь сейчас, и смена будет ждать его.

Этот крюк — единственное, что стоит между учётной записью и тем, кто нашёл незапертый ноутбук. Профиль, меняющий адрес по «Сохранить», отдаёт учётную запись вместе со стулом.

Кнопка называется «Отправить подтверждение», а не «Сохранить», потому что делает именно это. Кнопка, называющая не тот исход, — дефект, выехавший в продакшен текстом.

Пустой аватар — не нейтральность

Без изображения экран показывает инициалы, и это тоже изображение, просто предсказуемое. Пустой кружок на месте человека убирает единственное, чем один участник отличается от другого в списке из девяти.

Это устройство отсюда не закрывается

У каждого чужого сеанса есть кнопка, у этого — бейдж. Элемент, выбрасывающий человека из экрана, на котором он стоит, — не действие, а ловушка. И «выйти везде» названо «на всех остальных», с числом сеансов, которые закончатся.

Использованные компоненты

Список строится из frontmatter и сверяется с исходником: компонент, добавленный в код и забытый в шапке, роняет гейт.

Пакеты:

  • @feugene/granularity

Точка входа

Тот же файл, который здесь исполняется, — examples/profile/App.vue. Отдельной копии кода не существует.

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>

Собрать такое же