diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 2c6709f..fcdf2f7 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -4,7 +4,7 @@ name: Docker image # staging, which runs `main`. A release, the `v1.2.3` tag Release Please starts this workflow for, # publishes `latest`, `1.2.3` and `1.2` and deploys production, which runs `latest`. Every image also # gets a `sha-` tag to roll back to. With the Sentry secret and variables set, the build -# uploads source maps. See DEPLOY.md. +# uploads source maps and each deploy is recorded on its release in Sentry. See DEPLOY.md. on: push: branches: [main] @@ -25,6 +25,8 @@ jobs: image: name: build, scan and push runs-on: ubuntu-latest + outputs: + version: ${{ steps.version.outputs.value }} permissions: contents: read packages: write @@ -146,8 +148,13 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: staging + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} steps: - name: Call the staging deploy webhook + id: deploy env: WEBHOOK_URL: ${{ secrets.DOKPLOY_STAGING_WEBHOOK_URL }} run: | @@ -158,6 +165,21 @@ jobs: curl -fsS --retry 3 --retry-all-errors -X POST "$WEBHOOK_URL" echo echo "Dokploy is deploying the main image to staging." + echo "called=true" >> "$GITHUB_OUTPUT" + + # Records the deploy on the release the image reports, `sha-` or the version, so + # Sentry's Releases page shows when each one reached staging. Skipped without the token. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.deploy.outputs.called == 'true' && env.SENTRY_AUTH_TOKEN != '' + - name: Record the deploy in Sentry + if: steps.deploy.outputs.called == 'true' && env.SENTRY_AUTH_TOKEN != '' + uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0 + with: + release: ${{ needs.image.outputs.version }} + environment: staging + # Linking commits needs Sentry's GitHub integration. Switch to `auto` once it is installed. + set_commits: skip + disable_telemetry: true deploy-production: name: deploy production @@ -165,8 +187,13 @@ jobs: if: startsWith(github.ref, 'refs/tags/v') runs-on: ubuntu-latest environment: production + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ vars.SENTRY_ORG }} + SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }} steps: - name: Call the production deploy webhook + id: deploy env: WEBHOOK_URL: ${{ secrets.DOKPLOY_WEBHOOK_URL }} run: | @@ -177,3 +204,18 @@ jobs: curl -fsS --retry 3 --retry-all-errors -X POST "$WEBHOOK_URL" echo echo "Dokploy is deploying the release to production." + echo "called=true" >> "$GITHUB_OUTPUT" + + # Records the deploy on the release the image reports, `sha-` or the version, so + # Sentry's Releases page shows when each one reached production. Skipped without the token. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.deploy.outputs.called == 'true' && env.SENTRY_AUTH_TOKEN != '' + - name: Record the deploy in Sentry + if: steps.deploy.outputs.called == 'true' && env.SENTRY_AUTH_TOKEN != '' + uses: getsentry/action-release@ff07929a6537bac57790c3451cf4d364aca38528 # v3.7.0 + with: + release: ${{ needs.image.outputs.version }} + environment: production + # Linking commits needs Sentry's GitHub integration. Switch to `auto` once it is installed. + set_commits: skip + disable_telemetry: true diff --git a/DEPLOY.md b/DEPLOY.md index 7bb5ceb..7cffdd8 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -159,13 +159,27 @@ Without them the image builds the same and Sentry shows minified stack traces. With `VITE_SENTRY_DSN` set, Sentry receives: - **Errors** from the browser, server requests and server functions, with file names removed. -- **Logs**: warnings and errors the app writes to the console, with file names removed. +- **Logs**: everything the server writes to the console, the same lines Dokploy shows, and the + warnings and errors the browser writes, with file names removed. - **Traces** of a fifth of page loads, navigations and server requests, under **Explore → Traces** and **Insights**. Browser reports go to a same-origin route the build generates, which forwards them to Sentry, so content blockers do not drop them. Session replay stays off. +What it leaves out: + +- **PostHog's own console messages**, such as its toolbar failing to load for a signed-in admin. +- **Visitor IP addresses.** Server spans have the address the proxy forwards removed, and header + values that name an address are filtered. Also turn on **Settings → Projects → hexlode → + Security & Privacy → Prevent Storing of IP Addresses**, so Sentry keeps none either. +- **Aborts from visitors leaving.** A browser that closes a tab while it sends a report makes the + server's read fail with `AbortError`; the server drops those instead of reporting a 500. + +With the Sentry token and variables set, every deploy is recorded on its release: `sha-` +for staging and the version for production. **Releases** then shows when each one went out and +where. Profiling is left off because Sentry's free plan does not include it. + Set these up in Sentry itself: - **Uptime monitor** (**Insights → Uptime**): check `https://your-domain/api/health` so Sentry diff --git a/biome.json b/biome.json index 16493e0..158895e 100644 --- a/biome.json +++ b/biome.json @@ -12,6 +12,7 @@ "!src/routeTree.gen.ts", "!src/styles.css", "!pnpm-lock.yaml", + "!.release-please-manifest.json", "!src/features/theme/hexlode.js", "!src/features/theme/hexlode.d.ts", "!src/features/theme/hexlode.variants.d.ts", diff --git a/instrument.server.mjs b/instrument.server.mjs index d149be0..5c05ed6 100644 --- a/instrument.server.mjs +++ b/instrument.server.mjs @@ -2,6 +2,30 @@ import * as Sentry from '@sentry/tanstackstart-react' const sentryDsn = import.meta.env?.VITE_SENTRY_DSN ?? process.env.VITE_SENTRY_DSN +/** + * The server starts no aborts of its own, so an AbortError means the browser went away before its + * request was read, such as a tab closing while it sent an error report through the tunnel. + */ +function isClientDisconnect(error) { + // A few causes deep at most, in case a chain of causes loops. + for (let current = error, depth = 0; current && depth < 5; current = current.cause, depth++) { + if (current.name === 'AbortError') return true + } + return false +} + +/** + * Span attributes that hold the visitor's IP address. Sentry copies the first X-Forwarded-For + * address, which the proxy in front of the server sets, into every request span even without + * personal data, and analytics must not keep IP addresses (ADR 0005). + */ +const IP_ATTRIBUTES = ['http.client_ip', 'client.address'] + +function withoutIp(span) { + for (const key of IP_ATTRIBUTES) delete span.data?.[key] + return span +} + if (sentryDsn) { Sentry.init({ dsn: sentryDsn, @@ -18,7 +42,11 @@ if (sentryDsn) { httpBodies: [], }, enableLogs: true, + // Everything the server writes to the console, which is what Dokploy's log view shows. + integrations: [Sentry.consoleLoggingIntegration({ levels: ['log', 'info', 'warn', 'error'] })], // Matches TRACES_SAMPLE_RATE in src/features/usage/constants.ts. tracesSampleRate: 0.2, + beforeSendSpan: withoutIp, + beforeSend: (event, hint) => (isClientDisconnect(hint.originalException) ? null : event), }) } diff --git a/src/features/home/device-section.tsx b/src/features/home/device-section.tsx index d05771f..5ac759c 100644 --- a/src/features/home/device-section.tsx +++ b/src/features/home/device-section.tsx @@ -10,13 +10,14 @@ import { MonitorSmartphone, ShieldCheck, } from 'lucide-react' -import { motion, useInView } from 'motion/react' -import { type ComponentType, type SVGProps, useRef } from 'react' +import { type ComponentType, type SVGProps, useRef, useState } from 'react' import { REPOSITORY_URL } from '#/features/app-shell/constants' import { GitHubMark } from '#/features/app-shell/github-mark' import { HexlodeMark } from '#/features/app-shell/hexlode-mark' -import { Drop, FitDrawing, Reveal } from '#/features/home/motion-kit' +import { BEAT, EASE } from '#/features/home/constants' +import { Drop, FitDrawing, Reveal, Rolling } from '#/features/home/motion-kit' +import { useScene } from '#/features/home/scene' import { Section, SectionHeader } from '#/features/home/section' const WIDTH = 900 @@ -31,8 +32,8 @@ const INPUTS = [ ] as const const OUTPUTS = [ - { icon: Archive, name: 'photos.zip', detail: 'Download', y: 63 }, - { icon: FolderOpen, name: 'photos/', detail: 'Or save to a folder', y: 133 }, + { icon: Archive, name: 'photos.zip', detail: 'Download', y: 28 }, + { icon: FolderOpen, name: 'photos/', detail: 'Or save to a folder', y: 168 }, ] function curve(sx: number, sy: number, tx: number, ty: number) { @@ -43,16 +44,14 @@ function curve(sx: number, sy: number, tx: number, ty: number) { const PATHS = [ ...INPUTS.map((input, index) => ({ d: curve(CHIP.width, input.y + CHIP.height / 2, TAB.x, TAB.y + 70 + index * 30), - delay: index * 0.25, })), ...OUTPUTS.map((output, index) => ({ d: curve( TAB.x + TAB.width, - TAB.y + 85 + index * 30, + TAB.y + 70 + index * 60, WIDTH - CHIP.width, output.y + CHIP.height / 2, ), - delay: 1.3 + index * 0.25, })), ] @@ -84,36 +83,57 @@ function InputChip({ function OutputChip({ output, + detail = output.detail, + isDelivered = false, className = '', style, }: { output: Output + detail?: string + isDelivered?: boolean className?: string style?: React.CSSProperties }) { return ( -
+
{output.name} - {output.detail} + + +
) } +const STAGES = ['Decode', 'Edit', 'Encode'] as const + function BrowserTab({ + status = 'Web Workers · WebAssembly', + isBusy = false, className = '', style, }: { + status?: string + isBusy?: boolean className?: string style?: React.CSSProperties }) { return (
@@ -121,84 +141,199 @@ function BrowserTab({ This browser tab - {['Decode', 'Edit', 'Encode'].map((stage) => ( + {STAGES.map((stage) => ( - {stage} - + + {stage} + + + ))} - Web Workers · WebAssembly + + +
) } -/** Images flowing into this browser tab and out as a ZIP or a folder, left to right. */ -function DeviceGraph({ isLit }: { isLit: boolean }) { +/** What the output chips say once a pass has delivered its three files to them. */ +const DELIVERED = ['3 files · 2.1 MB', '3 files saved'] + +/** + * Images flowing into this browser tab and out as a ZIP or a folder, left to right, as a directed + * scene. Each file leaves its chip, runs through decode, edit and encode, and the finished batch + * goes to the ZIP on the first pass and to the folder on the second. Pointing at a chip lights its + * path, and pressing an input sends that file through next. + */ +function DeviceGraph() { + const [status, setStatus] = useState('Waiting for images') + const [isBusy, setBusy] = useState(false) + const [delivered, setDelivered] = useState<(string | null)[]>([null, null]) + const [hovered, setHovered] = useState(null) + const timeline = useRef(null) + const ref = useScene( + (scene, q) => { + const chips = q('[data-chip]') + const trails = q('[data-trail]') + const fills = q('[data-fill]') + // A signal runs along a link: the line draws in from its source, then drains into the + // chip or tab it points at, so it never fades out halfway. + const signal = (index: number, position?: gsap.Position) => + scene + .fromTo( + trails[index], + { attr: { 'stroke-dashoffset': 1 }, autoAlpha: 1 }, + { attr: { 'stroke-dashoffset': 0 }, duration: BEAT.move, ease: EASE.move }, + position, + ) + .to(trails[index], { + attr: { 'stroke-dashoffset': -1 }, + duration: BEAT.base, + ease: EASE.exit, + }) + OUTPUTS.forEach((output, pass) => { + scene + .call(() => { + setStatus('Waiting for images') + setDelivered([null, null]) + }) + .to({}, { duration: BEAT.base }) + INPUTS.forEach((input, index) => { + const leave = `${pass}-${index}` + // Each file sets off once the tab is free, so pressing a file starts from a clean frame. + scene.addLabel(leave) + if (pass === 0) scene.addLabel(`file${index}`, leave) + scene + .to(chips[index], { scale: 0.96, duration: 0.1, yoyo: true, repeat: 1 }, leave) + .call(setStatus, [`Reading ${input.name}`], leave) + signal(index, leave).call(setBusy, [true], '<') + STAGES.forEach((stage, step) => { + scene + .call(setStatus, [`${stage} · ${input.name}`]) + .fromTo( + fills[step], + { scaleX: 0, autoAlpha: 1 }, + { scaleX: 1, duration: 0.24, ease: EASE.enter }, + ) + }) + scene.to(fills, { autoAlpha: 0, duration: BEAT.quick }) + }) + const outputPath = INPUTS.length + pass + scene.call(setStatus, [`3 images · ${output.name}`]).call(setBusy, [false]) + signal(outputPath) + .call(() => + setDelivered((current) => + current.map((entry, index) => (index === pass ? DELIVERED[pass] : entry)), + ), + ) + .to( + chips[outputPath], + { scale: 1.04, duration: BEAT.quick, yoyo: true, repeat: 1, ease: EASE.enter }, + '<', + ) + if (pass === 0) scene.addLabel('poster') + scene.to({}, { duration: BEAT.rest }) + }) + }, + { repeat: -1, repeatDelay: 0, timelineRef: timeline }, + ) + + function sendNext(index: number) { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return + timeline.current?.play(`file${index}`) + } + + const chipBox = (y: number, left: number) => ({ + left, + top: y, + width: CHIP.width, + height: CHIP.height, + }) return ( - ) } @@ -225,12 +360,10 @@ function DeviceStack() { } function DeviceDiagram() { - const ref = useRef(null) - const inView = useInView(ref, { margin: '-10% 0px' }) return ( -
+
- + diff --git a/src/features/home/motion-kit.tsx b/src/features/home/motion-kit.tsx index 281b5d3..391830b 100644 --- a/src/features/home/motion-kit.tsx +++ b/src/features/home/motion-kit.tsx @@ -148,6 +148,9 @@ export function Crosshair({ side }: { side: 'start' | 'end' }) { ) } +/** The pointer's outline, in a 16 × 20 box with its tip at the top left. */ +export const POINTER_PATH = 'M1 1v15.5l4.2-4 2.9 6.5 2.6-1.2-2.9-6.3H13z' + /** * The pointer that acts out a scene: `clickOn` in scene.ts moves it and presses. It starts hidden * at the top left of its positioned parent, with its tip on that corner. @@ -161,7 +164,7 @@ export function SceneCursor() { className="pointer-events-none invisible absolute top-0 left-0 z-20 w-4 origin-top-left text-primary opacity-0 drop-shadow-md" > ( build: SceneBuilder, - { delay = 0, repeat = 0, repeatDelay = BEAT.rest }: SceneOptions = {}, + { delay = 0, repeat = 0, repeatDelay = BEAT.rest, timelineRef }: SceneOptions = {}, ) { const ref = useRef(null) useGSAP( @@ -34,6 +34,7 @@ export function useScene( if (!element) return const timeline = gsap.timeline({ paused: true, repeat, repeatDelay }) build(timeline, gsap.utils.selector(element), element) + if (timelineRef) timelineRef.current = timeline if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) { timeline.seek(timeline.labels.poster ?? timeline.duration(), false) @@ -70,6 +71,8 @@ type SceneOptions = { repeat?: number /** Seconds between repeats. */ repeatDelay?: number + /** Receives the timeline, for a scene that the user can steer, such as jumping to a label. */ + timelineRef?: RefObject } /** The centre of `target`, in the coordinates of its positioned ancestor `container`. */ diff --git a/src/features/home/studio-bento.tsx b/src/features/home/studio-bento.tsx index c8d2936..c5553d1 100644 --- a/src/features/home/studio-bento.tsx +++ b/src/features/home/studio-bento.tsx @@ -419,31 +419,42 @@ const PHOTOS = ['dusk', 'dawn', 'desert'] as const type Lane = { job: number; isDone: boolean } | null +const NO_LANES: Lane[] = Array.from({ length: WORKERS }, () => null) +/** A queued image's slot in the up-next row, in pixels: a 14px thumbnail and its gap. */ +const QUEUE_SLOT = 17 + /** - * The last twelve images of the batch shared over four workers. Each worker shows the image it is - * on and its progress; the next image goes to whichever worker frees up first, and the count goes - * up by one each time an image is done, until the batch is finished. + * The last twelve images of the batch shared over four workers. They wait in the up-next row; each + * worker takes one, shows its progress, and takes the next as soon as it is free, so the row + * empties as the count climbs. When the batch is done the row gives way to the result, then the + * workers clear and the row fills again for the next pass. */ function WorkersDemo() { - const [lanes, setLanes] = useState(() => Array.from({ length: WORKERS }, () => null)) + const [lanes, setLanes] = useState(NO_LANES) const [done, setDone] = useState(FIRST_DONE) const ref = useScene( (timeline, q) => { const bars = q('[data-bar]') + const queue = q('[data-queued]') const setLane = (worker: number, lane: Lane) => setLanes((current) => current.map((entry, index) => (index === worker ? lane : entry))) timeline .call(() => { setDone(FIRST_DONE) - setLanes(Array.from({ length: WORKERS }, () => null)) + setLanes(NO_LANES) }) - .set(bars, { scaleX: 0 }) - .set(q('[data-total]'), { scaleX: FIRST_DONE / BATCH_SIZE }) + .set(bars, { scaleX: 0, autoAlpha: 1 }) const start = BEAT.base const finishes = [...JOBS].sort((a, b) => a.end - b.end) for (const job of JOBS) { - const at = start + job.start + // The first four leave the row one after another rather than as one block. + const at = start + job.start + (job.start === 0 ? job.worker * STAGGER : 0) timeline + .to( + queue[job.index], + { width: 0, autoAlpha: 0, duration: BEAT.quick, ease: EASE.exit }, + at - BEAT.quick, + ) .call(setLane, [job.worker, { job: job.index, isDone: false }], at) .fromTo( bars[job.worker], @@ -457,17 +468,28 @@ function WorkersDemo() { timeline .call(setLane, [job.worker, { job: job.index, isDone: true }], at) .call(setDone, [FIRST_DONE + order + 1], at) - .to( - q('[data-total]'), - { scaleX: (FIRST_DONE + order + 1) / BATCH_SIZE, duration: 0.3, ease: EASE.enter }, - at, - ) }) - timeline.addLabel('poster').to({}, { duration: BEAT.rest }) + // Rest on the finished batch, then clear the workers and refill the row, which is the frame + // the scene starts from, so the repeat picks up without a jump. + timeline + .addLabel('poster') + .to({}, { duration: BEAT.rest }) + .call(setLanes, [NO_LANES]) + .to(bars, { autoAlpha: 0, duration: BEAT.base, ease: EASE.exit }) + .call(setDone, [FIRST_DONE]) + .to(queue, { + width: QUEUE_SLOT, + autoAlpha: 1, + duration: BEAT.base, + ease: EASE.enter, + stagger: STAGGER / 2, + }) }, { repeat: -1, repeatDelay: 0.4 }, ) const isFinished = done === BATCH_SIZE + const working = lanes.filter((lane) => lane && !lane.isDone).length + const queued = JOBS.length - (done - FIRST_DONE) - working return (
@@ -492,10 +514,10 @@ function WorkersDemo() { src={`/home/photo-${PHOTOS[lane.job % PHOTOS.length]}.webp`} alt="" className="absolute inset-0 size-full object-cover" - initial={{ opacity: 0, scale: 1.2 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0 }} - transition={{ duration: BEAT.base }} + initial={{ opacity: 0, scale: 0.4, x: 12, y: 24 }} + animate={{ opacity: 1, scale: 1, x: 0, y: 0 }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ duration: BEAT.base, ease: [0.22, 1, 0.36, 1] }} /> ) : null} @@ -511,13 +533,14 @@ function WorkersDemo() { ) : null} - - +
))} - - - +
+ + + {JOBS.map((job) => ( + + + + ))} + + + + + + + + + + All {BATCH_SIZE} saved as WebP + {WORKERS} workers + +
) } diff --git a/src/features/home/tool-demos.tsx b/src/features/home/tool-demos.tsx index 69b3ca5..26b214b 100644 --- a/src/features/home/tool-demos.tsx +++ b/src/features/home/tool-demos.tsx @@ -1,9 +1,9 @@ import { Icon } from '@astryxdesign/core/Icon' import { Aperture, CalendarClock, Camera, Check, MapPin, RotateCw } from 'lucide-react' import { AnimatePresence, motion, useSpring, useTransform } from 'motion/react' -import { useEffect } from 'react' +import { Fragment, useEffect, useState } from 'react' -import { Rolling, useLoop } from '#/features/home/motion-kit' +import { POINTER_PATH, Rolling, useLoop } from '#/features/home/motion-kit' import type { QuickTool } from '#/features/quick-tools/tools' /* @@ -41,8 +41,22 @@ function FileTile({ : 'border-border' }`} > -
+
+ {isResult ? ( + // A band that passes over the picture each time it is encoded to a new format. + + + ) : null}
@@ -63,27 +77,54 @@ const CONVERT_TARGETS = [ { format: 'PNG', size: '3.1 MB' }, ] +/** Seconds a pulse takes to cross the link from the source file to the result. */ +const PULSE = 0.6 + +/** + * The source file on the left, the result on the right, and a link between them with a port at + * each end. Each format change sends a pulse across the link; it lands in the result's port, and + * only then does the result re-encode to the new format. + */ function ConvertDemo() { - const { ref, step } = useLoop(CONVERT_TARGETS.length, 1800) - const target = CONVERT_TARGETS[step] + const { ref, tick, step } = useLoop(CONVERT_TARGETS.length, 1800) + const [shown, setShown] = useState(step) + useEffect(() => { + const timer = setTimeout(() => setShown(step), PULSE * 1000) + return () => clearTimeout(timer) + }, [step]) + const target = CONVERT_TARGETS[shown] return ( -
+
-
@@ -94,12 +135,16 @@ function kilobytes(value: number) { return value >= 1024 ? `${(value / 1024).toFixed(1)} MB` : `${Math.round(value)} KB` } +/** Quality moves from 100 to 75 first, then the file shrinks to match. */ function CompressDemo() { const { ref, step } = useLoop(2, 2400) const after = step === 1 const size = useSpring(2458, { stiffness: 60, damping: 18 }) const label = useTransform(size, kilobytes) - useEffect(() => size.set(after ? 338 : 2458), [after, size]) + useEffect(() => { + const timer = setTimeout(() => size.set(after ? 338 : 2458), after ? 350 : 0) + return () => clearTimeout(timer) + }, [after, size]) return (
-
- Quality 75 +
+ + Quality + + + + + + −86% @@ -152,9 +214,31 @@ function ResizeDemo() { transition={SPRING} > - + - + {/* The pointer holds the handle, so it follows the corner as the picture changes size. */} + +
@@ -189,10 +273,26 @@ function CropDemo() { - - - + + {CROPS.map((entry) => ( + + {entry.label === crop.label ? ( + + ) : null} + {entry.label} + + ))} +
) } @@ -211,10 +311,23 @@ function RotateDemo() { > - - + + + + - +
) } @@ -255,9 +368,19 @@ function StripDemo() { layout initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} - exit={{ opacity: 0, x: 24, transition: { duration: 0.3 } }} - className="flex items-center gap-2 rounded bg-muted px-2 py-1.5 text-xs" + exit="removed" + variants={{ + removed: { opacity: 0, x: 24, transition: { delay: 0.3, duration: 0.3 } }, + }} + className="relative flex items-center gap-2 rounded bg-muted px-2 py-1.5 text-xs" > + {/* Struck through first, so each field is seen going before it slides away. */} +