mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
feat(onboarding): card redesign + add wish step routing to feedback hub
Onboarding wird zur 4-Step-Card im Workbench-Look und schließt mit einer
Freitext-Frage, die als @mana/feedback-Record landet.
UI-Redesign:
- Wraps die Screens in einer zentrierten Card mit ModuleShell-Chrome
(paper texture, soft border, 1.25rem radius, dual shadow). Liest sich
wie eine Workbench-Page statt eines flat Takeover-Screens.
- Header weg. Globaler Skip-Button sitzt unten links, Step-Dots zentriert
unten — drei-Spalten-Grid hält Dots perfekt zentriert egal wie breit
der Skip-Button ist.
- Per-Screen-Skip-Buttons aus name/ und templates/ entfernt — eine
einzige Skip-Affordance reicht.
Wish-Step (neu, Step 4):
- /onboarding/wish: Freitext-Textarea (max 2000), Aktivierungstext
("Eine letzte Sache — was wünschst du dir von Mana?"). Submit postet
fail-soft an feedbackService.createFeedback({ category:
'onboarding-wish', isPublic: false }) — Server-Down blockiert das
Onboarding nicht.
- onboarding-flow Store um pendingWish erweitert (Back-Nav-Preserve).
- Layout: 3 → 4 Step-Dots, Path-Mapping erweitert.
- markComplete + reset wandert von templates' Fertig-Handler in den
wish-Screen; templates' Button heißt jetzt "Weiter" und routet zu
/onboarding/wish.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ba6274edbe
commit
e5cd98936f
5 changed files with 412 additions and 146 deletions
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* Ephemeral state for the three-screen onboarding flow — holds values
|
||||
* Ephemeral state for the four-screen onboarding flow — holds values
|
||||
* a later screen needs from an earlier screen (the freshly-typed name
|
||||
* for Screen 2's greeting, the multi-selected template ids for the
|
||||
* Screen 3 finish handler).
|
||||
* Screen 3 finish handler, the wish text for Screen 4's submit).
|
||||
*
|
||||
* Deliberately module-local and non-persistent:
|
||||
* - The canonical source of truth is `authStore.user` (name) and the
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
|
||||
let pendingName = $state<string | null>(null);
|
||||
let selectedTemplateIds = $state<string[]>([]);
|
||||
let pendingWish = $state<string>('');
|
||||
|
||||
export const onboardingFlow = {
|
||||
get pendingName() {
|
||||
|
|
@ -26,14 +27,21 @@ export const onboardingFlow = {
|
|||
get selectedTemplateIds() {
|
||||
return selectedTemplateIds;
|
||||
},
|
||||
get pendingWish() {
|
||||
return pendingWish;
|
||||
},
|
||||
setPendingName(value: string) {
|
||||
pendingName = value.trim() || null;
|
||||
},
|
||||
setSelectedTemplateIds(ids: string[]) {
|
||||
selectedTemplateIds = ids;
|
||||
},
|
||||
setPendingWish(value: string) {
|
||||
pendingWish = value;
|
||||
},
|
||||
reset() {
|
||||
pendingName = null;
|
||||
selectedTemplateIds = [];
|
||||
pendingWish = '';
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,16 +1,22 @@
|
|||
<!--
|
||||
Onboarding shell — wraps the three onboarding screens with a
|
||||
centered layout, progress dots, and a skip-all affordance.
|
||||
Onboarding shell — wraps the four onboarding screens (name, look,
|
||||
templates, wish) in a centered card that mirrors the workbench
|
||||
ModuleShell chrome (paper texture, soft border, rounded corners,
|
||||
shadow). The card has no header bar; the global skip-all sits in the
|
||||
footer next to centered progress dots so the body owns the full top
|
||||
of the card.
|
||||
|
||||
Lives under (app)/ so the AuthGate in the parent layout keeps
|
||||
unauthenticated users out. The parent layout hides its own chrome
|
||||
(PillNav, bottom-stack) when the pathname starts with /onboarding,
|
||||
so this shell renders into a clean full-viewport container.
|
||||
(PillNav, bottom-stack, wallpaper) when the pathname starts with
|
||||
/onboarding, so this shell renders into a clean full-viewport
|
||||
container and supplies its own page background + centered card.
|
||||
|
||||
The skip-all button writes `onboardingCompletedAt = now()` via the
|
||||
shared store and navigates home. Individual screens can also call
|
||||
`onboardingStatus.markComplete()` themselves (templates/+page.svelte
|
||||
does this as part of its finish handler).
|
||||
shared store and navigates home. The wish screen (final step) also
|
||||
calls `onboardingStatus.markComplete()` as part of its finish
|
||||
handler — templates used to do this too but now just advances to the
|
||||
wish screen.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -24,6 +30,7 @@
|
|||
// Any unknown /onboarding/* path falls back to step 0.
|
||||
let currentStep = $derived.by(() => {
|
||||
const path = $page.url.pathname;
|
||||
if (path.startsWith('/onboarding/wish')) return 3;
|
||||
if (path.startsWith('/onboarding/templates')) return 2;
|
||||
if (path.startsWith('/onboarding/look')) return 1;
|
||||
return 0; // /onboarding, /onboarding/name, or anything else
|
||||
|
|
@ -40,33 +47,36 @@
|
|||
</script>
|
||||
|
||||
<div class="onboarding-shell">
|
||||
<header class="onboarding-header">
|
||||
<div
|
||||
class="progress-dots"
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={3}
|
||||
aria-valuenow={currentStep + 1}
|
||||
>
|
||||
{#each [0, 1, 2] as step (step)}
|
||||
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="skip-all"
|
||||
onclick={handleSkipAll}
|
||||
aria-label="Onboarding überspringen"
|
||||
>
|
||||
<X size={14} />
|
||||
<span>Überspringen</span>
|
||||
</button>
|
||||
</header>
|
||||
<div class="onboarding-card">
|
||||
<main class="onboarding-body">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<main class="onboarding-body">
|
||||
{@render children()}
|
||||
</main>
|
||||
<footer class="onboarding-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="skip-all"
|
||||
onclick={handleSkipAll}
|
||||
aria-label="Onboarding überspringen"
|
||||
>
|
||||
<X size={14} weight="bold" />
|
||||
<span>Überspringen</span>
|
||||
</button>
|
||||
<div
|
||||
class="progress-dots"
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={4}
|
||||
aria-valuenow={currentStep + 1}
|
||||
>
|
||||
{#each [0, 1, 2, 3] as step (step)}
|
||||
<span class="dot" class:active={step === currentStep} class:done={step < currentStep}
|
||||
></span>
|
||||
{/each}
|
||||
</div>
|
||||
<span class="footer-spacer" aria-hidden="true"></span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
|
@ -75,16 +85,107 @@
|
|||
inset: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Paper card — visual chrome mirrors ModuleShell.svelte so onboarding
|
||||
reads as a workbench page. CSS vars come from applyThemeToDocument()
|
||||
in @mana/shared-theme. */
|
||||
.onboarding-card {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
max-height: calc(100dvh - 2rem);
|
||||
background-color: hsl(var(--color-card));
|
||||
background-image: var(--paper-texture, none);
|
||||
background-size: var(--paper-size, 240px 240px);
|
||||
background-repeat: repeat;
|
||||
background-blend-mode: var(--paper-blend-mode, multiply);
|
||||
border: 2px solid hsl(0 0% 0% / 0.12);
|
||||
border-radius: 1.25rem;
|
||||
box-shadow:
|
||||
0 8px 24px hsl(0 0% 0% / 0.12),
|
||||
0 3px 8px hsl(0 0% 0% / 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: fadeInUp 0.25s ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .onboarding-card {
|
||||
border-color: hsl(0 0% 0% / 0.28);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
.onboarding-card {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.skip-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.skip-all:hover {
|
||||
background: hsl(var(--color-surface-hover, var(--color-muted)));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.onboarding-header {
|
||||
.onboarding-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.onboarding-footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem 1.5rem;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 0.875rem 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.onboarding-footer .skip-all {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.onboarding-footer .progress-dots {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.footer-spacer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
|
|
@ -108,33 +209,19 @@
|
|||
background: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.skip-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.skip-all:hover {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.onboarding-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem 1.5rem 4rem;
|
||||
overflow-y: auto;
|
||||
@media (max-width: 540px) {
|
||||
.onboarding-shell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.onboarding-card {
|
||||
max-height: calc(100dvh - 1rem);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
.onboarding-body {
|
||||
padding: 1.5rem 1rem 1.25rem;
|
||||
}
|
||||
.onboarding-footer {
|
||||
padding: 0.75rem 0.75rem 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -60,23 +60,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
async function handleSkip() {
|
||||
const fallback = (authStore.user?.email ?? '').split('@')[0] || 'du';
|
||||
saving = true;
|
||||
error = null;
|
||||
try {
|
||||
// Persist the fallback too so the user shows up as something
|
||||
// other than "User 1234" in admin UIs — cheap, idempotent.
|
||||
await saveName(fallback);
|
||||
} catch (err) {
|
||||
console.warn('[onboarding/name] skip-save failed:', err);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
onboardingFlow.setPendingName(fallback);
|
||||
await goto('/onboarding/look');
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && canSubmit) {
|
||||
e.preventDefault();
|
||||
|
|
@ -109,9 +92,6 @@
|
|||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={handleSkip} disabled={saving}>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
|
|
@ -173,28 +153,10 @@
|
|||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
<!--
|
||||
Onboarding — Screen 3: Templates.
|
||||
Multi-select of use-case templates. On Finish, the dedup'd union of
|
||||
Multi-select of use-case templates. On Weiter, the dedup'd union of
|
||||
picked templates' modules (capped at 8) is written to a fresh Home
|
||||
scene via `workbenchScenesStore.createScene`, then the flow is
|
||||
marked complete and we navigate home.
|
||||
scene via `workbenchScenesStore.createScene`, then we advance to the
|
||||
wish screen (Screen 4), which owns markComplete + reset + goto('/').
|
||||
|
||||
Skip path: no scene written (the hardcoded DEFAULT_HOME_APPS
|
||||
fallback in workbench-scenes kicks in on first liveQuery), onboarding
|
||||
still marked complete so the guard doesn't re-route.
|
||||
Skip path (no selections): no scene written (the hardcoded
|
||||
DEFAULT_HOME_APPS fallback in workbench-scenes kicks in on first
|
||||
liveQuery), still advance to /onboarding/wish.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
|
|
@ -29,7 +29,6 @@
|
|||
type OnboardingTemplateId,
|
||||
} from '@mana/shared-branding';
|
||||
import { workbenchScenesStore } from '$lib/stores/workbench-scenes.svelte';
|
||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
|
||||
|
||||
// Icon lookup — templates ship a phosphor name (string), the page
|
||||
|
|
@ -54,8 +53,6 @@
|
|||
let saving = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let selectedCount = $derived(selected.size);
|
||||
|
||||
function toggle(id: OnboardingTemplateId) {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
|
|
@ -64,13 +61,13 @@
|
|||
onboardingFlow.setSelectedTemplateIds(Array.from(next));
|
||||
}
|
||||
|
||||
async function finish({ skip = false }: { skip?: boolean } = {}) {
|
||||
async function handleNext() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (!skip && selected.size > 0) {
|
||||
if (selected.size > 0) {
|
||||
// Preserve selection order. `selected` is a Set, but we inserted
|
||||
// in order toggled — that matches the user's mental priority.
|
||||
const modules = resolveModulesForTemplates(Array.from(selected));
|
||||
|
|
@ -82,12 +79,10 @@
|
|||
});
|
||||
}
|
||||
}
|
||||
await onboardingStatus.markComplete();
|
||||
onboardingFlow.reset();
|
||||
await goto('/');
|
||||
await goto('/onboarding/wish');
|
||||
} catch (err) {
|
||||
console.error('[onboarding/templates] finish failed:', err);
|
||||
error = 'Konnte den Flow nicht abschließen. Versuch es noch mal.';
|
||||
console.error('[onboarding/templates] save failed:', err);
|
||||
error = 'Konnte die Auswahl nicht speichern. Versuch es noch mal.';
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -144,25 +139,15 @@
|
|||
<ArrowLeft size={16} weight="bold" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div class="actions-right">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost"
|
||||
onclick={() => finish({ skip: true })}
|
||||
disabled={saving}
|
||||
>
|
||||
Überspringen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={() => finish()}
|
||||
disabled={saving || selectedCount === 0}
|
||||
aria-label="Onboarding abschließen"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Fertig'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={handleNext}
|
||||
disabled={saving}
|
||||
aria-label="Weiter zur Wunsch-Frage"
|
||||
>
|
||||
{saving ? 'Speichere…' : 'Weiter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -293,11 +278,6 @@
|
|||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.actions-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
229
apps/mana/apps/web/src/routes/(app)/onboarding/wish/+page.svelte
Normal file
229
apps/mana/apps/web/src/routes/(app)/onboarding/wish/+page.svelte
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
<!--
|
||||
Onboarding — Screen 4: Wish.
|
||||
Free-text "what do you want from Mana?" capture. Posts to the central
|
||||
@mana/feedback hub as category='onboarding-wish', isPublic=false.
|
||||
Submit is fail-soft: a network/server failure logs a warning and
|
||||
still completes the flow — onboarding must never block on backend
|
||||
latency.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ArrowLeft, Check } from '@mana/shared-icons';
|
||||
import { onboardingFlow } from '$lib/stores/onboarding-flow.svelte';
|
||||
import { onboardingStatus } from '$lib/stores/onboarding-status.svelte';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
|
||||
const MAX_LEN = 2000;
|
||||
|
||||
let wish = $state(onboardingFlow.pendingWish ?? '');
|
||||
let saving = $state(false);
|
||||
|
||||
let trimmed = $derived(wish.trim());
|
||||
let charsLeft = $derived(MAX_LEN - wish.length);
|
||||
|
||||
async function handleFinish() {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
|
||||
// Fail-soft: a failed POST must not block onboarding completion.
|
||||
if (trimmed.length > 0) {
|
||||
onboardingFlow.setPendingWish(trimmed);
|
||||
try {
|
||||
await feedbackService.createFeedback({
|
||||
feedbackText: trimmed,
|
||||
category: 'onboarding-wish',
|
||||
isPublic: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('[onboarding/wish] feedback submit failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await onboardingStatus.markComplete();
|
||||
} catch (err) {
|
||||
console.warn('[onboarding/wish] markComplete failed:', err);
|
||||
}
|
||||
onboardingFlow.reset();
|
||||
await goto('/');
|
||||
}
|
||||
|
||||
async function handleBack() {
|
||||
onboardingFlow.setPendingWish(wish);
|
||||
await goto('/onboarding/templates');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<div class="hero">
|
||||
<h1>Eine letzte Sache</h1>
|
||||
<p class="subtitle">
|
||||
Was wünschst du dir von Mana? Wofür willst du's nutzen, was erhoffst du dir?
|
||||
</p>
|
||||
<p class="subtitle subtle">
|
||||
Schreib einfach, wie's dir kommt — wir lesen jede Antwort und sie hilft uns, Mana für dich
|
||||
besser zu machen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<textarea
|
||||
bind:value={wish}
|
||||
maxlength={MAX_LEN}
|
||||
placeholder="Ich möchte Mana nutzen, um …"
|
||||
rows="6"
|
||||
autofocus
|
||||
aria-label="Was du dir von Mana wünschst"
|
||||
></textarea>
|
||||
<div class="counter" class:warn={charsLeft < 100}>
|
||||
{charsLeft} Zeichen übrig
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn-ghost" onclick={handleBack} disabled={saving}>
|
||||
<ArrowLeft size={16} weight="bold" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-primary"
|
||||
onclick={handleFinish}
|
||||
disabled={saving}
|
||||
aria-label="Onboarding abschließen"
|
||||
>
|
||||
<span>{saving ? 'Speichere…' : 'Fertig'}</span>
|
||||
<Check size={16} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.screen {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 0.5rem 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.subtitle.subtle {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.field textarea {
|
||||
width: 100%;
|
||||
min-height: 8rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-surface, var(--color-background)));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.field textarea:focus {
|
||||
outline: none;
|
||||
border-color: hsl(var(--color-primary));
|
||||
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.15);
|
||||
}
|
||||
|
||||
.counter {
|
||||
align-self: flex-end;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.counter.warn {
|
||||
color: hsl(var(--color-error, 0 84% 60%));
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.625rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.9375rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: hsl(var(--color-muted) / 0.4);
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground, 0 0% 100%));
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.15s ease,
|
||||
box-shadow 0.15s ease,
|
||||
opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue