diff --git a/apps/skillnet-web/src/components/settings/AccountSection.test.tsx b/apps/skillnet-web/src/components/settings/AccountSection.test.tsx index 46c57a8..8141121 100644 --- a/apps/skillnet-web/src/components/settings/AccountSection.test.tsx +++ b/apps/skillnet-web/src/components/settings/AccountSection.test.tsx @@ -105,10 +105,12 @@ function renderSection() { ) } -/** The card is the `

` title's grandparent (Card > div > h3). */ -async function findCard(title: string) { - const heading = await screen.findByRole('heading', { name: title }) - return heading.parentElement as HTMLElement +/** Each row opens its form in a window: press the row's button and work inside the dialog. */ +async function openWindow(title: string) { + const name = await screen.findByText(title) + const row = name.parentElement!.parentElement as HTMLElement + await userEvent.click(within(row).getByRole('button')) + return screen.findByRole('dialog') } beforeEach(() => { @@ -124,7 +126,7 @@ describe('AccountSection — change password', () => { it('submits current and new password to the change-password endpoint', async () => { installFetch() renderSection() - const card = await findCard('Contraseña') + const card = await openWindow('Contraseña') await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'old-password') await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'new-password-123') @@ -143,7 +145,7 @@ describe('AccountSection — change password', () => { it('shows the field-level validation hint for a too-short new password', async () => { installFetch() renderSection() - const card = await findCard('Contraseña') + const card = await openWindow('Contraseña') await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'short') await userEvent.tab() @@ -154,7 +156,7 @@ describe('AccountSection — change password', () => { it('surfaces the server error when the current password is wrong', async () => { installFetch({ changePasswordStatus: 422 }) renderSection() - const card = await findCard('Contraseña') + const card = await openWindow('Contraseña') await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'wrong') await userEvent.type(within(card).getByLabelText('Contraseña nueva'), 'new-password-123') @@ -168,7 +170,7 @@ describe('AccountSection — change email', () => { it('submits the new email and current password', async () => { installFetch() renderSection() - const card = await findCard('Correo electrónico') + const card = await openWindow('Correo electrónico') const emailInput = within(card).getByLabelText('Correo nuevo') await userEvent.clear(emailInput) @@ -189,7 +191,7 @@ describe('AccountSection — change email', () => { it('surfaces the conflict when the email is already taken', async () => { installFetch({ changeEmailStatus: 409 }) renderSection() - const card = await findCard('Correo electrónico') + const card = await openWindow('Correo electrónico') const emailInput = within(card).getByLabelText('Correo nuevo') await userEvent.clear(emailInput) @@ -202,20 +204,20 @@ describe('AccountSection — change email', () => { }) describe('AccountSection — delete account (workspace gating)', () => { - it('hides the delete-account card in an organization workspace', async () => { + it('hides the delete-account row in an organization workspace', async () => { installFetch({ workspaceMode: 'organization' }) renderSection() // Wait for /auth/me to resolve before asserting absence. - await screen.findByRole('heading', { name: 'Contraseña' }) - expect(screen.queryByRole('heading', { name: 'Borrar cuenta' })).toBeNull() + await screen.findByText('Contraseña') + expect(screen.queryByText('Borrar cuenta')).toBeNull() }) - it('shows the delete-account card in an individual workspace and submits it', async () => { + it('shows the delete-account row in an individual workspace and submits it', async () => { installFetch({ workspaceMode: 'individual' }) vi.spyOn(window, 'confirm').mockReturnValue(true) renderSection() - const card = await findCard('Borrar cuenta') + const card = await openWindow('Borrar cuenta') await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'my-password') await userEvent.click(within(card).getByRole('button', { name: 'Borrar mi cuenta' })) @@ -234,7 +236,7 @@ describe('AccountSection — delete account (workspace gating)', () => { installFetch({ workspaceMode: 'individual' }) vi.spyOn(window, 'confirm').mockReturnValue(false) renderSection() - const card = await findCard('Borrar cuenta') + const card = await openWindow('Borrar cuenta') await userEvent.type(within(card).getByLabelText('Contraseña actual'), 'my-password') await userEvent.click(within(card).getByRole('button', { name: 'Borrar mi cuenta' })) diff --git a/apps/skillnet-web/src/components/settings/AccountSection.tsx b/apps/skillnet-web/src/components/settings/AccountSection.tsx index f1ed20c..76b2aaa 100644 --- a/apps/skillnet-web/src/components/settings/AccountSection.tsx +++ b/apps/skillnet-web/src/components/settings/AccountSection.tsx @@ -1,6 +1,7 @@ import { useState, type FormEvent } from 'react' import { useIntl } from 'react-intl' -import { Button, Card, CardTitle, Input } from '../ui' +import { Button, Input } from '../ui' +import { SettingsWindowRow } from './SettingsWindowRow' import { useChangeEmail, useChangePassword, useDeleteAccount } from '../../api/users' import { useLogout } from '../../api/auth' import { useAuth, useWorkspaceMode } from '../../hooks/useAuth' @@ -9,20 +10,46 @@ import { apiErrorMessage } from '../../lib/apiErrors' /** * Self-service account management: change password, change email, and (in an * individual workspace only) delete the account. Shared between admin Settings - * and the employee Learning Preferences page — same forms either way, there is + * and the employee Learning Preferences page — same rows either way, there is * nothing role-specific about "what is my password". + * + * Each one is a quiet row; the form lives in a window that opens from its button, so the page + * is not six open fields that almost nobody needs. */ export function AccountSection() { + const intl = useIntl() + const workspaceMode = useWorkspaceMode() return ( -
- - - +
+ + + + + + + {workspaceMode === 'individual' && ( + + + + )}
) } -function ChangePasswordCard() { +function ChangePasswordForm() { const intl = useIntl() const mutation = useChangePassword() const [currentPassword, setCurrentPassword] = useState('') @@ -45,12 +72,8 @@ function ChangePasswordCard() { } return ( - - {intl.formatMessage({ id: 'account.password.title' })} -

- {intl.formatMessage({ id: 'account.password.description' })} -

-
+ <> +
-
+ ) } -function ChangeEmailCard() { +function ChangeEmailForm() { const intl = useIntl() const { user } = useAuth() const mutation = useChangeEmail() @@ -116,12 +139,8 @@ function ChangeEmailCard() { } return ( - - {intl.formatMessage({ id: 'account.email.title' })} -

- {intl.formatMessage({ id: 'account.email.description' })} -

-
+ <> +
-
+ ) } @@ -160,15 +179,12 @@ function ChangeEmailCard() { * them would orphan the org, which needs an explicit ownership-transfer flow that * does not exist yet. Hidden here is UX; the 404 is the real guard. */ -function DeleteAccountCard() { +function DeleteAccountForm() { const intl = useIntl() - const workspaceMode = useWorkspaceMode() const mutation = useDeleteAccount() const logout = useLogout() const [password, setPassword] = useState('') - if (workspaceMode !== 'individual') return null - async function handleSubmit(e: FormEvent) { e.preventDefault() if (!password) return @@ -182,12 +198,11 @@ function DeleteAccountCard() { } return ( - - {intl.formatMessage({ id: 'account.delete.title' })} -

+ <> +

{intl.formatMessage({ id: 'account.delete.description' })}

-
+
-
+ ) } diff --git a/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx b/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx index 3d8939b..3dbd083 100644 --- a/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx +++ b/apps/skillnet-web/src/components/settings/BugReportEntry.test.tsx @@ -38,7 +38,7 @@ describe('BugReportEntry', () => { it('shows only a row until the button is pressed, then the form', async () => { renderEntry() expect(screen.queryByLabelText('Título')).not.toBeInTheDocument() - await userEvent.click(screen.getByRole('button', { name: 'Reportar un problema' })) + await userEvent.click(screen.getByRole('button', { name: 'Reportar' })) expect(await screen.findByLabelText('Título')).toBeInTheDocument() }) }) diff --git a/apps/skillnet-web/src/components/settings/BugReportEntry.tsx b/apps/skillnet-web/src/components/settings/BugReportEntry.tsx index 74790bd..30ed133 100644 --- a/apps/skillnet-web/src/components/settings/BugReportEntry.tsx +++ b/apps/skillnet-web/src/components/settings/BugReportEntry.tsx @@ -1,67 +1,20 @@ -import { useRef, useState } from 'react' import { useIntl } from 'react-intl' -import { Button, Modal } from '../ui' import { BugReportSection } from './BugReportSection' import { BugReportsAdmin } from './BugReportsAdmin' -import { SettingRow } from './SettingRow' - -/** A settings row whose button opens a window that grows from the button. */ -function WindowRow({ - title, - description, - action, - windowTitle, - size, - children, -}: { - title: string - description: string - action: string - windowTitle: string - size: 'md' | 'lg' - children: React.ReactNode -}) { - const [open, setOpen] = useState(false) - const [origin, setOrigin] = useState(null) - const button = useRef(null) - - return ( - <> - - - - setOpen(false)} origin={origin} size={size}> -

{windowTitle}

- {children} -
- - ) -} +import { SettingsWindowRow } from './SettingsWindowRow' /** Report a problem. Rare and technical, so it is one quiet row in Settings. */ export function BugReportEntry() { const intl = useIntl() return ( - - + ) } @@ -69,14 +22,13 @@ export function BugReportEntry() { export function BugReportsAdminEntry() { const intl = useIntl() return ( - - + ) } diff --git a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx index 0c5c741..4bdab96 100644 --- a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx +++ b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { IntlProvider } from 'react-intl' import type { ReactNode } from 'react' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { CapabilityHealthBanner } from './CapabilityHealthBanner' import { es } from '../../i18n/es' @@ -27,6 +27,8 @@ function renderWith(capabilities?: Partial) { } describe('', () => { + afterEach(() => window.localStorage.clear()) + it('shows nothing when every capability is present', () => { const { container } = renderWith(caps()) expect(container).toBeEmptyDOMElement() @@ -77,4 +79,24 @@ describe('', () => { await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] })) expect(screen.queryByText(es['capabilityBanner.ai'])).not.toBeInTheDocument() }) + + it('stays dismissed on the next visit', async () => { + const user = userEvent.setup() + const first = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() })) + await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] })) + first.unmount() + + const second = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() })) + expect(second.container).toBeEmptyDOMElement() + }) + + it('comes back when a different capability is missing', async () => { + const user = userEvent.setup() + const first = renderWith(caps({ ai: blocked(), generation: blocked(), tutor: blocked() })) + await user.click(screen.getByRole('button', { name: es['capabilityBanner.dismiss'] })) + first.unmount() + + renderWith(caps({ tts: degraded('provider_quota') })) + expect(screen.getByRole('status')).toBeInTheDocument() + }) }) diff --git a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx index 7f7d216..ce67683 100644 --- a/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx +++ b/apps/skillnet-web/src/components/settings/CapabilityHealthBanner.tsx @@ -3,11 +3,26 @@ import { useIntl } from 'react-intl' import { isReady, useCapabilities, type CapabilityName } from '../../api/setup' import { capabilityTag } from '../../lib/capabilityCopy' +/** + * Where "I saw it, stop telling me" is remembered, in this browser. It stores *which* + * capabilities were missing when the notice was dismissed, not just that it was: if a different + * set is degraded later, that is news and the notice comes back. + */ +const DISMISSED_KEY = 'skillnet:capability-banner-dismissed' + +function readDismissed(): string | null { + try { + return window.localStorage.getItem(DISMISSED_KEY) + } catch { + return null + } +} + /** Watched in severity order: `ai` first (nothing AI works without it), then voice, then images. */ const WATCHED: CapabilityName[] = ['ai', 'tts', 'images'] /** - * Consolidated, dismissible "what is degraded" notice for the admin surface + * Consolidated, dismissible "what is degraded" notice, and it stays dismissed for the admin surface * (docs/design/degraded-mode-ux.md §1). SkillNet keeps working when external keys * are missing — it just degrades in concrete, invisible ways (robotic offline voice, * infographics without a poster, no AI at all). This banner makes those states @@ -28,7 +43,7 @@ const WATCHED: CapabilityName[] = ['ai', 'tts', 'images'] export function CapabilityHealthBanner() { const intl = useIntl() const capabilities = useCapabilities() - const [dismissed, setDismissed] = useState(false) + const [dismissedFor, setDismissedFor] = useState(readDismissed) const lines = WATCHED.filter((name) => !isReady(capabilities[name])).map((name) => ({ name, @@ -37,7 +52,8 @@ export function CapabilityHealthBanner() { hint: capabilities[name].hint ?? null, })) - if (lines.length === 0 || dismissed) return null + const signature = lines.map((line) => line.name).join(',') + if (lines.length === 0 || dismissedFor === signature) return null return (
+ + setOpen(false)} origin={origin} size={size}> +

{windowTitle}

+ {children} +
+ + ) +} diff --git a/apps/skillnet-web/src/components/ui/Modal.test.tsx b/apps/skillnet-web/src/components/ui/Modal.test.tsx new file mode 100644 index 0000000..f44c253 --- /dev/null +++ b/apps/skillnet-web/src/components/ui/Modal.test.tsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { IntlProvider } from '../../i18n/IntlProvider' +import { Modal } from './Modal' + +/** + * The motion itself is not asserted here (jsdom has no layout). These pin what must hold + * whatever the animation does: it is a labelled dialog, it closes on Escape and on the close + * button, and it leaves the page once closed. Reduced motion is forced so mounting and + * unmounting are immediate and the test does not wait on a timer. + */ +vi.mock('../../hooks/useReducedMotion', () => ({ useReducedMotion: () => true })) + +function renderModal(props: Partial> = {}) { + const onClose = vi.fn() + const view = render( + + +

Contenido de la ventana

+
+
, + ) + return { onClose, ...view } +} + +describe('Modal', () => { + it('shows its content inside a modal dialog', () => { + renderModal() + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true') + expect(screen.getByText('Contenido de la ventana')).toBeInTheDocument() + }) + + it('closes on Escape', async () => { + const { onClose } = renderModal() + await userEvent.keyboard('{Escape}') + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('closes from the close button, and the button can be hidden', async () => { + const { onClose, rerender } = renderModal() + await userEvent.click(screen.getByRole('button')) + expect(onClose).toHaveBeenCalledTimes(1) + + rerender( + + +

Contenido de la ventana

+
+
, + ) + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('leaves the page when it closes and locks scrolling while it is open', () => { + const { rerender } = renderModal() + expect(document.body.style.overflow).toBe('hidden') + + rerender( + + {}}> +

Contenido de la ventana

+
+
, + ) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(document.body.style.overflow).not.toBe('hidden') + }) + + it('opens with an origin rect without failing', () => { + renderModal({ origin: new DOMRect(10, 10, 120, 36), size: 'lg' }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('keeps its box on the panel once settled, so content that arrives late fits', () => { + renderModal() + const box = screen.getByRole('dialog').previousElementSibling as HTMLElement + expect(box).toHaveClass('inset-0') + expect(box.getAttribute('style')).toBeNull() + }) +}) diff --git a/apps/skillnet-web/src/components/ui/Modal.tsx b/apps/skillnet-web/src/components/ui/Modal.tsx index 525858c..f7cddf9 100644 --- a/apps/skillnet-web/src/components/ui/Modal.tsx +++ b/apps/skillnet-web/src/components/ui/Modal.tsx @@ -1,8 +1,9 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useIntl } from 'react-intl' -import { motion, useAnimationControls } from 'framer-motion' +import { animate, motion, useAnimationControls, useMotionValue, useTransform } from 'framer-motion' import { duration, ease } from '../../lib/motion' +import { useReducedMotion } from '../../hooks/useReducedMotion' type ModalSize = 'sm' | 'md' | 'lg' @@ -17,7 +18,7 @@ export interface ModalProps { onClose: () => void children: React.ReactNode size?: ModalSize - /** Rect of the control that opened the modal — the panel grows from / shrinks to it (FLIP). */ + /** Rect of the control that opened the modal. The box grows from it and folds back into it. */ origin?: DOMRect | null /** Hide the top-right close button (e.g. when the content provides its own). */ hideClose?: boolean @@ -32,31 +33,98 @@ function CloseIcon() { ) } -/** Delta transform that maps the panel's current box onto the origin control's box. */ -function flipToOrigin(panel: DOMRect, origin: DOMRect | null | undefined) { - const o = origin && origin.width ? origin : new DOMRect(panel.left + panel.width / 2 - 24, panel.top + panel.height / 2 - 24, 48, 48) - const scale = Math.min(Math.max(o.width / panel.width, 0.08), 1) +interface Box { + left: number + top: number + width: number + height: number +} + +const boxOf = (r: DOMRect): Box => ({ left: r.left, top: r.top, width: r.width, height: r.height }) + +/** Content comes in once the box is mostly there, and leaves before it folds back (seconds). */ +const CONTENT_IN_DELAY = 0.3 +const CONTENT_IN = 0.22 +const CONTENT_OUT = 0.16 +/** How far out of focus the content starts. */ +const BLUR_PX = 6 + +/** Corner radius of a control, and of the panel it grows into. */ +const ORIGIN_RADIUS = 8 +const PANEL_RADIUS = 16 + +/** Where the box starts: the control that opened it, or a small box in the middle of the panel. */ +function startBox(origin: DOMRect | null | undefined, final: Box): Box { + if (origin && origin.width) return boxOf(origin) return { - x: o.left + o.width / 2 - (panel.left + panel.width / 2), - y: o.top + o.height / 2 - (panel.top + panel.height / 2), - scale, + left: final.left + final.width / 2 - 24, + top: final.top + final.height / 2 - 24, + width: 48, + height: 48, } } +const lerp = (from: number, to: number, t: number) => from + (to - from) * t + /** - * Clean, native-feeling modal — modeled on stepbro.site's ``: - * a borderless surface with a large radius and deep soft shadow that **grows - * from the trigger control** (FLIP zoom-from-origin) over the signature ease, - * while the content **fades in** once the box has settled. Portaled to - * `document.body` so it escapes the page-transition transform. + * Calm modal: a box that grows out of the control that opened it. + * + * Modeled on elastic-ui's rules (DECISIONS.md, "Motion rules"): + * + * - **The text never scales.** The panel is laid out at its final size from the first frame and + * a clip uncovers it, so nothing is stretched. The box behind it changes its real size. + * - **It starts exactly on the control** that was pressed, and folds back into it. + * - **Closing is faster than opening**, and the content leaves first, before the box folds. + * - **The content fades in** once the box has nearly arrived, coming into focus rather than + * switching on, and leaves in a blink before the box folds. + * - **Depth from tones, not shadows.** The box is a raised surface with a hairline round it and + * only a soft shadow under it. The page behind is dimmed, never blurred. + * - With reduced motion (system or declared) it just appears and disappears. + * + * Portaled to `document.body` so it escapes the page-transition transform. */ export function Modal({ open, onClose, children, size = 'md', origin, hideClose = false }: ModalProps) { const intl = useIntl() + const reduceMotion = useReducedMotion() const [mounted, setMounted] = useState(open) - const panelRef = useRef(null) - const panel = useAnimationControls() - const scrim = useAnimationControls() + // Once the box has landed it follows the panel's own size (the content may grow). + const [settled, setSettled] = useState(false) + const wrapRef = useRef(null) const closingRef = useRef(false) + const geo = useRef<{ final: Box; start: Box } | null>(null) + const scrim = useAnimationControls() + + // 0 = the box is the control, 1 = the box is the panel. + const t = useMotionValue(0) + const content = useMotionValue(0) + // `none` once it is in focus: any filter, even blur(0px), would become the containing block of + // what is positioned inside it. + const contentBlur = useTransform(content, (v) => (v >= 1 ? 'none' : `blur(${(1 - v) * BLUR_PX}px)`)) + + // Positions are relative to the wrapper, which is where the panel and its box live. + const rel = (v: number) => { + const g = geo.current + if (!g) return null + const left = lerp(g.start.left - g.final.left, 0, v) + const top = lerp(g.start.top - g.final.top, 0, v) + const width = lerp(g.start.width, g.final.width, v) + const height = lerp(g.start.height, g.final.height, v) + return { left, top, width, height, right: g.final.width - left - width, bottom: g.final.height - top - height } + } + const radius = (v: number) => lerp(ORIGIN_RADIUS, PANEL_RADIUS, v) + + const boxLeft = useTransform(t, (v) => rel(v)?.left ?? 0) + const boxTop = useTransform(t, (v) => rel(v)?.top ?? 0) + const boxWidth = useTransform(t, (v) => rel(v)?.width ?? 0) + const boxHeight = useTransform(t, (v) => rel(v)?.height ?? 0) + const boxRadius = useTransform(t, radius) + const clipPath = useTransform(t, (v) => { + const r = rel(v) + // Until the box has been measured nothing shows, so there is no flash at the wrong size. + if (!r) return 'inset(50% 50% 50% 50%)' + if (v >= 1) return 'none' + return `inset(${r.top}px ${r.right}px ${r.bottom}px ${r.left}px round ${radius(v)}px)` + }) // Mount as soon as it opens; unmount only after the close animation finishes. useEffect(() => { @@ -66,33 +134,76 @@ export function Modal({ open, onClose, children, size = 'md', origin, hideClose } }, [open]) - // Enter — grow from the origin control. + // Enter: measure the panel at its final size, then grow the box from the control. useLayoutEffect(() => { if (!open || !mounted) return - const el = panelRef.current - if (!el) return - const from = flipToOrigin(el.getBoundingClientRect(), origin) - panel.set({ x: from.x, y: from.y, scale: from.scale, opacity: 0 }) - panel.start({ x: 0, y: 0, scale: 1, opacity: 1, transition: { duration: duration.slow, ease: ease.base } }) + const wrap = wrapRef.current + if (!wrap) return + const final = boxOf(wrap.getBoundingClientRect()) + geo.current = { final, start: startBox(origin, final) } + + if (reduceMotion) { + t.set(1) + content.set(1) + scrim.set({ opacity: 1 }) + setSettled(true) + return + } + + setSettled(false) + t.set(0.0001) // wakes the transforms now that the box is measured + content.set(0) scrim.set({ opacity: 0 }) - scrim.start({ opacity: 1, transition: { duration: duration.fast } }) + scrim.start({ opacity: 1, transition: { duration: duration.normal, ease: 'linear' } }) + const grow = animate(t, 1, { + duration: duration.medium, + ease: [...ease.base], + onComplete: () => setSettled(true), + }) + const fade = animate(content, 1, { + delay: CONTENT_IN_DELAY, + duration: CONTENT_IN, + ease: 'linear', + }) + // Content that arrives while the box is still growing (a list that loads) changes the panel's + // size: keep the target current so the box lands on the real size, not the first measure. + const watcher = + typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(() => { + if (!geo.current || !wrapRef.current) return + geo.current = { ...geo.current, final: boxOf(wrapRef.current.getBoundingClientRect()) } + }) + watcher?.observe(wrap) + return () => { + grow.stop() + fade.stop() + watcher?.disconnect() + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, mounted]) - // Exit — shrink back toward the origin control, then unmount. + // Exit: the content leaves at once, then the box folds back into the control, faster than it + // opened. useEffect(() => { if (open || !mounted || closingRef.current) return closingRef.current = true - const el = panelRef.current - if (!el) { + const wrap = wrapRef.current + if (reduceMotion || !wrap) { setMounted(false) return } - const to = flipToOrigin(el.getBoundingClientRect(), origin) - scrim.start({ opacity: 0, transition: { duration: duration.normal } }) - panel - .start({ x: to.x, y: to.y, scale: to.scale, opacity: 0, transition: { duration: duration.normal, ease: ease.base } }) - .then(() => setMounted(false)) + const final = boxOf(wrap.getBoundingClientRect()) + geo.current = { final, start: startBox(origin, final) } + setSettled(false) + scrim.start({ opacity: 0, transition: { duration: duration.normal, ease: 'linear' } }) + animate(content, 0, { duration: CONTENT_OUT, ease: 'linear' }) + animate(t, 0, { + delay: CONTENT_OUT, + duration: duration.normal, + ease: [...ease.base], + onComplete: () => setMounted(false), + }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, mounted]) @@ -116,45 +227,52 @@ export function Modal({ open, onClose, children, size = 'md', origin, hideClose return createPortal(
- - {!hideClose && ( - +
+ {/* The box. While it grows it changes its real size, from the control to the panel. Once it + has landed it is a fresh element that simply fills the panel: the animation leaves + inline sizes behind, and reusing the element would pin the box to the size the panel + had when it opened (a list that loads late would overflow it). */} + {settled ? ( +
, document.body, ) diff --git a/apps/skillnet-web/src/i18n/en.ts b/apps/skillnet-web/src/i18n/en.ts index 5ace00f..5223de5 100644 --- a/apps/skillnet-web/src/i18n/en.ts +++ b/apps/skillnet-web/src/i18n/en.ts @@ -304,14 +304,14 @@ export const en: Record = { 'settings.subtitle': 'How SkillNet behaves for your organization.', 'settings.loadError': 'Could not load settings.', 'settings.chatGenUi': 'Format tutor responses', - 'settings.chatGenUiDesc': 'The tutor replies with steps, tables or alerts when they fit better than a paragraph. If the model misses, the response falls back to text.', 'settings.saveError': 'Could not save the setting', 'settings.shareBugs': 'Share bugs with the SkillNet team', - 'settings.shareBugsDesc': "Your people's reports also reach the SkillNet team, who review them and publish them as issues in a public repository. Only what the person writes, the page and the browser are sent; never their name or email. If you turn it off, reports stay here.", + 'settings.shareBugsDesc': 'Sends reports to the SkillNet team as public issues. Never your name or email.', + 'account.delete.action': 'Delete', + 'account.change': 'Change', + 'bugReport.action': 'Report', 'bugReport.title': 'Report a problem', - 'bugReport.hint': 'Something is broken or you have an idea. An assistant reviews it and, if it applies, it reaches the team.', 'bugReport.privacy': 'What you write, the page you are on and your browser are sent. Never your name or email.', - 'bugReport.admin.hint': 'What your people have reported and what happened to each report.', 'bugReport.admin.open': 'View reports', 'bugReport.titleLabel': 'Title', 'bugReport.bodyLabel': 'What happened, and what did you expect?', @@ -404,7 +404,7 @@ export const en: Record = { 'appearance.mascotaTitle': 'Mascot', 'appearance.mascotaShow': 'Show the mascot', 'appearance.mascotaSpeaks': 'Let it speak', - 'appearance.mascotaSpeaksHint': 'Shows the speech bubble and reads aloud. Turn it off to keep the mascot silent.', + 'appearance.mascotaSpeaksHint': 'Shows the speech bubble and reads aloud.', 'appearance.accentColorDesc': 'Personalize controls and navigation without changing success, warning, or error states.', 'appearance.accent.neutral': 'Neutral', 'appearance.accent.blue': 'SkillNet blue', diff --git a/apps/skillnet-web/src/i18n/es.ts b/apps/skillnet-web/src/i18n/es.ts index f0a52ed..68aa060 100644 --- a/apps/skillnet-web/src/i18n/es.ts +++ b/apps/skillnet-web/src/i18n/es.ts @@ -304,14 +304,14 @@ export const es: Record = { 'settings.subtitle': 'Cómo se comporta SkillNet para tu organización.', 'settings.loadError': 'No se pudieron cargar los ajustes.', 'settings.chatGenUi': 'Maquetar las respuestas del tutor', - 'settings.chatGenUiDesc': 'El tutor contesta con pasos, tablas o avisos cuando encajan mejor que un párrafo. Si el modelo no acierta, la respuesta sale en texto.', 'settings.saveError': 'No se pudo guardar el ajuste', 'settings.shareBugs': 'Compartir errores con el equipo de SkillNet', - 'settings.shareBugsDesc': 'Los reportes de tu gente también llegan al equipo de SkillNet, que los revisa y los publica como issues en un repositorio público. Solo viaja lo que la persona escribe, la página y el navegador; nunca su nombre ni su correo. Si lo apagas, los reportes se quedan aquí.', + 'settings.shareBugsDesc': 'Envía los reportes al equipo de SkillNet como issues públicas. Nunca tu nombre ni tu correo.', + 'account.delete.action': 'Borrar', + 'account.change': 'Cambiar', + 'bugReport.action': 'Reportar', 'bugReport.title': 'Reportar un problema', - 'bugReport.hint': 'Algo no funciona o tienes una idea. Lo revisa un asistente y, si procede, llega al equipo.', 'bugReport.privacy': 'Se envía lo que escribes, la página en la que estás y tu navegador. Nunca tu nombre ni tu correo.', - 'bugReport.admin.hint': 'Lo que ha reportado tu gente y qué ha pasado con cada reporte.', 'bugReport.admin.open': 'Ver reportes', 'bugReport.titleLabel': 'Título', 'bugReport.bodyLabel': '¿Qué pasó y qué esperabas?', @@ -404,7 +404,7 @@ export const es: Record = { 'appearance.mascotaTitle': 'Mascota', 'appearance.mascotaShow': 'Mostrar la mascota', 'appearance.mascotaSpeaks': 'Que hable', - 'appearance.mascotaSpeaksHint': 'Muestra el bocadillo y lee en voz alta. Si lo apagas, la mascota se queda en silencio.', + 'appearance.mascotaSpeaksHint': 'Muestra el bocadillo y lee en voz alta.', 'appearance.accentColorDesc': 'Personaliza los controles y la navegación sin cambiar los estados de éxito, aviso o error.', 'appearance.accent.neutral': 'Neutro', 'appearance.accent.blue': 'Azul SkillNet', diff --git a/apps/skillnet-web/src/pages/admin/Settings.tsx b/apps/skillnet-web/src/pages/admin/Settings.tsx index 4a3d174..6d93647 100644 --- a/apps/skillnet-web/src/pages/admin/Settings.tsx +++ b/apps/skillnet-web/src/pages/admin/Settings.tsx @@ -34,7 +34,7 @@ export function Settings() { const features = useUpdateFeatures() return ( -
+
{isLoading ? ( @@ -69,13 +69,13 @@ function SettingsBody({ the `!ai` line covers the "no model configured" case and adds voice / images. */} - +
+ -
+
{/* Language selector */}