feat(01-10): wave-1 task-2 — welcome page bundle + Vite entries + web_accessible_resources
Plan 01-10 Wave 1: welcome page bundle staged with canonical Plan 01-12
tokens.css @import + chrome.i18n for D-08 tagline (Plan 01-12 path-B
contract).
Files created:
- src/welcome/copy.ts (Russian non-tagline COPY map per D-03 Sober
voice; WELCOME_HERO_RU_FALLBACK + WELCOME_HERO_EN_FALLBACK exported
for the welcome.ts `|| <en-const>` fallback chain).
- src/welcome/welcome.html (lang='ru'; data-mokosh-key + data-mokosh-
i18n-key attribute conventions; SINGLE stylesheet link; D-02 Hero +
body + footer structure; 10 keyed attrs total; <title> populated
via populateCopy).
- src/welcome/welcome.css (FIRST LINE `@import '../shared/tokens.css';`;
ZERO hex literals in source; 65 var(--mks-*) refs; D-02 layout +
--mks-welcome-max-w=720px; --mks-rec madder for recording-related
accents per D-04 Loom palette).
- src/welcome/welcome.ts (vanilla DOM; populateCopy + populateI18n
filter-pipeline form per project rule "no `continue`"; no `as any`;
Logger from src/shared; document.readyState guard; no event
handlers per D-16-toolbar informational charter).
Files modified:
- vite.config.ts: rollupOptions.input gains `welcome:
'src/welcome/welcome.html'`; __VITE_DEV__ + __MOKOSH_UAT__ defines
untouched (Plan 01-12 Wave 5 baseline preserved verbatim).
- vite.test.config.ts: mirror entry in dist-test/; mergeConfig pattern
untouched.
- manifest.json: web_accessible_resources block added after
host_permissions, before background; storage permission preserved
in permissions array; default_locale='en' + __MSG_*__ placeholders
from Plan 01-12 Wave 3 preserved verbatim.
NO src/welcome/welcome-tokens.css file is created — Plan 01-12 must_have
#9 path-B contract: Plan 01-12 landed FIRST (b909c37 → 865d394; SUMMARY
2026-05-20); canonical src/shared/tokens.css is import-ready
(Lora @font-face + IBM Plex Sans + D-04 Loom palette + --mks-rec=
var(--mks-madder-600) = #b2543d); welcome.css @imports it directly. No
placeholder transition needed.
Verify (all GREEN):
- grep -F "@import '../shared/tokens.css'" src/welcome/welcome.css: exit 0
- grep -E '#[0-9a-fA-F]{3,8}' src/welcome/welcome.css: exit 1 (zero hex)
- grep -c 'var(--mks-' src/welcome/welcome.css: 65 (>= 5 required)
- grep -oE 'data-mokosh-(i18n-)?key=' welcome.html | wc -l: 10 (>= 7)
- npm run build: clean; dist/src/welcome/welcome.html present;
dist/assets/welcome-D9oNz95l.css carries inlined tokens.css content
(--mks-rec: var(--mks-madder-600); --mks-madder-600: #b2543d).
- npm run build:test: clean; dist-test/src/welcome/welcome.html present;
dist-test/assets/welcome-wB0e_R_n.js bundled.
- npx tsc --noEmit: clean.
- dist/manifest.json preserves "default_locale": "en" + __MSG_extName__
+ web_accessible_resources block present (Vite/crxjs propagated).
- Vitest baseline preserved: Task 1's 3-test file unchanged
(1 RED + 2 vacuous-GREEN; Task 3 flips Test A to GREEN).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
65
src/welcome/copy.ts
Normal file
65
src/welcome/copy.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// src/welcome/copy.ts — single source-of-truth for welcome-page
|
||||
// NON-TAGLINE copy.
|
||||
//
|
||||
// Plan 01-12 (landed first; SUMMARY 2026-05-20) migrated the two D-08
|
||||
// tagline strings (welcomeHeroRu + welcomeHeroEn) to
|
||||
// _locales/{en,ru}/messages.json. Those keys are read via
|
||||
// chrome.i18n.getMessage in welcome.ts and intentionally NOT included
|
||||
// in this map.
|
||||
//
|
||||
// The remaining keys are engineering-grade placeholders authored in
|
||||
// Russian per D-03 Sober voice register; a future copy-iteration plan
|
||||
// may migrate them to _locales/ for full operator-locale awareness.
|
||||
//
|
||||
// D-08 tagline reference (lives in _locales/, not here):
|
||||
// en/welcomeHeroEn → "Thirty seconds ago, always at hand."
|
||||
// ru/welcomeHeroRu → "Тридцать секунд назад, всегда под рукой."
|
||||
//
|
||||
// References:
|
||||
// - D-02 (welcome layout — Hero + Loom dial), brand-decisions-v1.md
|
||||
// - D-03 (Sober voice register), brand-decisions-v1.md
|
||||
// - D-08 (tagline), brand-decisions-v1.md
|
||||
// - D-16-toolbar (toolbar owns start path), 01-09-SUMMARY.md
|
||||
// - Plan 01-12 Wave 4 chrome.i18n fallback pattern (popup precedent)
|
||||
|
||||
/**
|
||||
* Plan 01-12 fallback-pattern constants for the welcomeHero keys.
|
||||
*
|
||||
* welcome.ts uses these as the `|| <en-const>` fallback when
|
||||
* chrome.i18n.getMessage returns empty string (Plan 01-10 RESEARCH
|
||||
* Pitfall 4 mitigation: chrome.i18n.getMessage returns '' for unknown
|
||||
* keys instead of throwing).
|
||||
*
|
||||
* Exported separately from COPY so the i18n populate path imports them
|
||||
* by name (clean import surface — COPY map stays free of tagline keys).
|
||||
*/
|
||||
export const WELCOME_HERO_RU_FALLBACK =
|
||||
'Тридцать секунд назад, всегда под рукой.';
|
||||
export const WELCOME_HERO_EN_FALLBACK =
|
||||
'Thirty seconds ago, always at hand.';
|
||||
|
||||
/**
|
||||
* Non-tagline welcome-page copy. Keys are stable identifiers used by
|
||||
* `[data-mokosh-key='<key>']` attribute selectors in welcome.html;
|
||||
* populateCopy() in welcome.ts walks every element with the attribute
|
||||
* and writes textContent (or document.title for the <title> element).
|
||||
*
|
||||
* Russian phrasing per D-03 Sober voice — restrained, declarative,
|
||||
* no marketing inflection.
|
||||
*/
|
||||
export const COPY: Readonly<Record<string, string>> = Object.freeze({
|
||||
'welcome.page.title': 'Добро пожаловать в Mokosh',
|
||||
'welcome.hero.title': 'Mokosh',
|
||||
'welcome.body.explainer.line1':
|
||||
'Mokosh непрерывно записывает последние 30 секунд экрана и 10 минут '
|
||||
+ 'логов вашего браузера.',
|
||||
'welcome.body.explainer.line2':
|
||||
'Когда возникает баг, вы одним кликом сохраняете архив для службы '
|
||||
+ 'поддержки. Данные не отправляются никуда — только локально.',
|
||||
'welcome.body.cta.toolbar':
|
||||
'Чтобы начать запись, нажмите иконку AI Call Recorder на панели '
|
||||
+ 'инструментов браузера (правый верхний угол).',
|
||||
'welcome.footer.privacy':
|
||||
'Mokosh не отправляет данные на серверы. Архив создаётся '
|
||||
+ 'локально по вашему запросу и остаётся на вашем компьютере.',
|
||||
});
|
||||
164
src/welcome/welcome.css
Normal file
164
src/welcome/welcome.css
Normal file
@@ -0,0 +1,164 @@
|
||||
/* welcome.css — Plan 01-10 D-17-onboarding welcome page styling.
|
||||
*
|
||||
* Imports the canonical Plan 01-12 token system (src/shared/tokens.css)
|
||||
* which carries the full --mks-* variable set + 8 local @font-face
|
||||
* rules (Lora display + IBM Plex Sans UI + IBM Plex Mono diagnostic
|
||||
* — all self-hosted per MV3 CSP) + the D-04 Loom palette.
|
||||
*
|
||||
* KEY INVARIANTS (Plan 01-10 A17 design-swap-readiness contract):
|
||||
* 1. FIRST LINE is `@import '../shared/tokens.css';` (Plan 01-12
|
||||
* must_have #9 path-B; verified by `grep -F` in <verify> block).
|
||||
* 2. ZERO hex literals in this source file (verified by
|
||||
* `grep -E '#[0-9a-fA-F]{3,8}'` exit 1 in <verify>). Every color
|
||||
* reads from var(--mks-*); every font reads from var(--mks-font-*).
|
||||
* The @imported tokens.css IS allowed hex literals — it IS the
|
||||
* canonical source.
|
||||
* 3. >= 5 var(--mks-*) references (verified by `grep -c`).
|
||||
* 4. NO placeholder welcome-tokens.css file is created — Plan 01-12
|
||||
* landed first and supplies the canonical tokens directly.
|
||||
*
|
||||
* Layout: max-width 720px (= --mks-welcome-max-w in tokens.css),
|
||||
* centered, hero + body + footer vertical stack per D-02 Surface Kit
|
||||
* option A. BEM-ish class names matching welcome.html. */
|
||||
|
||||
@import '../shared/tokens.css';
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--mks-font-ui);
|
||||
background: var(--mks-surface);
|
||||
color: var(--mks-fg-1);
|
||||
min-height: 100vh;
|
||||
line-height: var(--mks-lh-base);
|
||||
}
|
||||
|
||||
.welcome {
|
||||
max-width: var(--mks-welcome-max-w);
|
||||
margin: 0 auto;
|
||||
padding: var(--mks-space-12) var(--mks-space-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mks-space-10);
|
||||
}
|
||||
|
||||
/* ── Hero ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.welcome-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: var(--mks-space-4);
|
||||
padding: var(--mks-space-10) var(--mks-space-6);
|
||||
background: var(--mks-surface-raised);
|
||||
border: var(--mks-border-width) solid var(--mks-border);
|
||||
border-radius: var(--mks-radius-xl);
|
||||
box-shadow: var(--mks-shadow-2);
|
||||
}
|
||||
|
||||
.welcome-hero__mark {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--mks-space-20);
|
||||
height: var(--mks-space-20);
|
||||
border-radius: var(--mks-radius-full);
|
||||
background: var(--mks-rec);
|
||||
color: var(--mks-fg-inverse);
|
||||
}
|
||||
|
||||
.welcome-hero__mark-placeholder {
|
||||
font-family: var(--mks-font-display);
|
||||
font-size: var(--mks-text-md);
|
||||
font-weight: var(--mks-weight-semibold);
|
||||
letter-spacing: var(--mks-tracking-tight);
|
||||
}
|
||||
|
||||
.welcome-hero__title {
|
||||
font-family: var(--mks-font-display);
|
||||
font-size: var(--mks-text-3xl);
|
||||
font-weight: var(--mks-weight-regular);
|
||||
line-height: var(--mks-lh-tight);
|
||||
letter-spacing: var(--mks-tracking-display);
|
||||
color: var(--mks-fg-1);
|
||||
}
|
||||
|
||||
.welcome-hero__tagline-ru {
|
||||
font-family: var(--mks-font-display);
|
||||
font-size: var(--mks-text-lg);
|
||||
font-weight: var(--mks-weight-regular);
|
||||
font-style: italic;
|
||||
line-height: var(--mks-lh-snug);
|
||||
color: var(--mks-fg-1);
|
||||
margin-top: var(--mks-space-3);
|
||||
}
|
||||
|
||||
.welcome-hero__tagline-en {
|
||||
font-family: var(--mks-font-ui);
|
||||
font-size: var(--mks-text-base);
|
||||
font-weight: var(--mks-weight-regular);
|
||||
line-height: var(--mks-lh-snug);
|
||||
color: var(--mks-fg-2);
|
||||
}
|
||||
|
||||
/* ── Body ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.welcome-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--mks-space-5);
|
||||
}
|
||||
|
||||
.welcome-body__line {
|
||||
font-family: var(--mks-font-ui);
|
||||
font-size: var(--mks-text-md);
|
||||
line-height: var(--mks-lh-base);
|
||||
color: var(--mks-fg-1);
|
||||
}
|
||||
|
||||
.welcome-body__cta {
|
||||
font-family: var(--mks-font-ui);
|
||||
font-size: var(--mks-text-md);
|
||||
font-weight: var(--mks-weight-medium);
|
||||
line-height: var(--mks-lh-base);
|
||||
color: var(--mks-fg-1);
|
||||
padding: var(--mks-space-5) var(--mks-space-6);
|
||||
background: var(--mks-madder-100);
|
||||
border-left: 3px solid var(--mks-rec);
|
||||
border-radius: var(--mks-radius-md);
|
||||
}
|
||||
|
||||
/* ── Footer ────────────────────────────────────────────────────────── */
|
||||
|
||||
.welcome-footer {
|
||||
padding-top: var(--mks-space-6);
|
||||
border-top: var(--mks-border-width) solid var(--mks-border);
|
||||
}
|
||||
|
||||
.welcome-footer__privacy {
|
||||
font-family: var(--mks-font-ui);
|
||||
font-size: var(--mks-text-sm);
|
||||
line-height: var(--mks-lh-base);
|
||||
color: var(--mks-fg-3);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Reduced motion / small screen courtesies ─────────────────────── */
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.welcome {
|
||||
padding: var(--mks-space-6) var(--mks-space-4);
|
||||
gap: var(--mks-space-6);
|
||||
}
|
||||
.welcome-hero {
|
||||
padding: var(--mks-space-6) var(--mks-space-4);
|
||||
}
|
||||
.welcome-hero__title {
|
||||
font-size: var(--mks-text-2xl);
|
||||
}
|
||||
}
|
||||
53
src/welcome/welcome.html
Normal file
53
src/welcome/welcome.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!--
|
||||
Plan 01-10 D-17-onboarding welcome page.
|
||||
|
||||
NB on <title>: like the popup precedent (src/popup/index.html), the
|
||||
title element carries data-mokosh-key='welcome.page.title' so
|
||||
welcome.ts can populate it from the COPY map at populateCopy()
|
||||
time. The literal Russian default below stays as a graceful-
|
||||
degradation value when the script is unavailable (mirrors the popup
|
||||
pattern Plan 01-12 Wave 4 introduced).
|
||||
|
||||
The two D-08 tagline elements (welcomeHeroRu + welcomeHeroEn)
|
||||
carry data-mokosh-i18n-key='welcomeHero<Lang>' so populateI18n()
|
||||
in welcome.ts reads them via chrome.i18n.getMessage from
|
||||
_locales/{en,ru}/messages.json (Plan 01-12 Wave 3 baseline; 16
|
||||
keys total). The parallel-text layout (RU primary + EN subtitle)
|
||||
renders identically regardless of operator locale per D-08.
|
||||
|
||||
SINGLE <link rel=stylesheet> — welcome.css. The `@import
|
||||
'../shared/tokens.css'` at the top of welcome.css resolves the
|
||||
canonical Plan 01-12 token system (Lora @font-face + D-04 Loom
|
||||
palette + --mks-* full set) at Vite build time. NO placeholder
|
||||
welcome-tokens.css link (Plan 01-12 must_have #9 path-B contract).
|
||||
-->
|
||||
<title data-mokosh-key="welcome.page.title">Добро пожаловать в Mokosh</title>
|
||||
<link rel="stylesheet" href="welcome.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="welcome">
|
||||
<section class="welcome-hero" aria-labelledby="welcome-title">
|
||||
<div class="welcome-hero__mark" role="presentation" data-mokosh-slot="mark">
|
||||
<span class="welcome-hero__mark-placeholder" aria-hidden="true">Mokosh</span>
|
||||
</div>
|
||||
<h1 id="welcome-title" class="welcome-hero__title" data-mokosh-key="welcome.hero.title"></h1>
|
||||
<p class="welcome-hero__tagline-ru" data-mokosh-i18n-key="welcomeHeroRu"></p>
|
||||
<p class="welcome-hero__tagline-en" data-mokosh-i18n-key="welcomeHeroEn"></p>
|
||||
</section>
|
||||
<section class="welcome-body">
|
||||
<p class="welcome-body__line" data-mokosh-key="welcome.body.explainer.line1"></p>
|
||||
<p class="welcome-body__line" data-mokosh-key="welcome.body.explainer.line2"></p>
|
||||
<p class="welcome-body__cta" data-mokosh-key="welcome.body.cta.toolbar"></p>
|
||||
</section>
|
||||
<footer class="welcome-footer">
|
||||
<p class="welcome-footer__privacy" data-mokosh-key="welcome.footer.privacy"></p>
|
||||
</footer>
|
||||
</main>
|
||||
<script type="module" src="welcome.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
137
src/welcome/welcome.ts
Normal file
137
src/welcome/welcome.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/welcome/welcome.ts — Plan 01-10 D-17-onboarding welcome page
|
||||
// entrypoint.
|
||||
//
|
||||
// Vanilla DOM. Populates two attribute-selector flows:
|
||||
// - [data-mokosh-key='<key>'] from the COPY map (non-tagline strings;
|
||||
// engineering placeholders per D-03 Sober voice register).
|
||||
// - [data-mokosh-i18n-key='<key>'] from chrome.i18n.getMessage with
|
||||
// `|| <en-const>` fallback per the Plan 01-12 Wave 4 popup pattern.
|
||||
// The two D-08 tagline keys (welcomeHeroRu + welcomeHeroEn) live in
|
||||
// _locales/{en,ru}/messages.json (Plan 01-12 Wave 3; 16 keys total).
|
||||
//
|
||||
// NO event handlers — the welcome tab is informational + read-only per
|
||||
// D-16-toolbar charter (toolbar onClicked owns the start path; the
|
||||
// welcome page TELLS the operator where the icon is, does NOT host a
|
||||
// "Start Mokosh" actionable button).
|
||||
//
|
||||
// Style/naming:
|
||||
// - filter-pipeline form per project rule "no `continue` statements";
|
||||
// - full-word variable names;
|
||||
// - no `as any`;
|
||||
// - centralized Logger from src/shared/logger.ts.
|
||||
//
|
||||
// References:
|
||||
// - D-17-onboarding (CONTEXT.md line 537+)
|
||||
// - D-16-toolbar (01-09-SUMMARY.md)
|
||||
// - Plan 01-12 Wave 4 fallback pattern (src/popup/index.ts:41 i18n
|
||||
// helper). Plan 01-10 inlines the pattern into the filter-pipeline
|
||||
// so the per-element resolution stays branch-free.
|
||||
|
||||
import { Logger } from '../shared/logger';
|
||||
import {
|
||||
COPY,
|
||||
WELCOME_HERO_RU_FALLBACK,
|
||||
WELCOME_HERO_EN_FALLBACK,
|
||||
} from './copy';
|
||||
|
||||
const logger = new Logger('Welcome');
|
||||
|
||||
/**
|
||||
* Walk every [data-mokosh-key='<key>'] element and write its
|
||||
* textContent (or document.title for the <title> element) from the
|
||||
* COPY map. Filter-pipeline form: any element whose attribute is
|
||||
* missing or whose key is absent from COPY is dropped from the
|
||||
* applied set; we log the missing count once at the end so an unwired
|
||||
* attribute is visible in the SW devtools console without spamming.
|
||||
*/
|
||||
function populateCopy(): void {
|
||||
const els = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-mokosh-key]'),
|
||||
);
|
||||
const pairs = els
|
||||
.map((el) => ({ el, key: el.getAttribute('data-mokosh-key') }))
|
||||
.filter(
|
||||
(p): p is { el: HTMLElement; key: string } => typeof p.key === 'string',
|
||||
)
|
||||
.map((p) => ({ ...p, value: COPY[p.key] }))
|
||||
.filter(
|
||||
(p): p is { el: HTMLElement; key: string; value: string } =>
|
||||
typeof p.value === 'string',
|
||||
);
|
||||
for (const { el, value } of pairs) {
|
||||
if (el.tagName === 'TITLE') {
|
||||
document.title = value;
|
||||
} else {
|
||||
el.textContent = value;
|
||||
}
|
||||
}
|
||||
const missing = els.length - pairs.length;
|
||||
if (missing > 0) {
|
||||
logger.warn(
|
||||
'populateCopy: '
|
||||
+ String(missing)
|
||||
+ ' [data-mokosh-key] elements had missing COPY entries',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every [data-mokosh-i18n-key='<key>'] element and write its
|
||||
* textContent from chrome.i18n.getMessage(key). Plan 01-12 Wave 4
|
||||
* fallback pattern: when getMessage returns '' (unknown key OR
|
||||
* chrome.i18n undefined in non-extension contexts), fall back to the
|
||||
* en-const string exported from ./copy. Empty resolutions are dropped
|
||||
* from the applied set; missing count surfaces via logger.warn for
|
||||
* post-mortem visibility.
|
||||
*
|
||||
* The two Plan 01-10 tagline keys (welcomeHeroRu + welcomeHeroEn)
|
||||
* resolve from _locales/{en,ru}/messages.json (Plan 01-12 Wave 3).
|
||||
*/
|
||||
function populateI18n(): void {
|
||||
const fallbacks: Readonly<Record<string, string>> = Object.freeze({
|
||||
welcomeHeroRu: WELCOME_HERO_RU_FALLBACK,
|
||||
welcomeHeroEn: WELCOME_HERO_EN_FALLBACK,
|
||||
});
|
||||
const els = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-mokosh-i18n-key]'),
|
||||
);
|
||||
const pairs = els
|
||||
.map((el) => ({ el, key: el.getAttribute('data-mokosh-i18n-key') }))
|
||||
.filter(
|
||||
(p): p is { el: HTMLElement; key: string } => typeof p.key === 'string',
|
||||
)
|
||||
.map((p) => {
|
||||
const fromI18n = chrome?.i18n?.getMessage?.(p.key) ?? '';
|
||||
const fromFallback = fallbacks[p.key] ?? '';
|
||||
const value = fromI18n.length > 0 ? fromI18n : fromFallback;
|
||||
return { ...p, value };
|
||||
})
|
||||
.filter((p) => p.value.length > 0);
|
||||
for (const { el, value } of pairs) {
|
||||
el.textContent = value;
|
||||
}
|
||||
const missing = els.length - pairs.length;
|
||||
if (missing > 0) {
|
||||
logger.warn(
|
||||
'populateI18n: '
|
||||
+ String(missing)
|
||||
+ ' [data-mokosh-i18n-key] elements had missing chrome.i18n + fallback values',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the welcome page. populateCopy first (non-tagline strings),
|
||||
* then populateI18n (the two D-08 tagline strings via chrome.i18n).
|
||||
*/
|
||||
function init(): void {
|
||||
populateCopy();
|
||||
populateI18n();
|
||||
logger.log('welcome page ready');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
Reference in New Issue
Block a user