@spunto/design-system 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,305 @@
1
+ "use client"
2
+
3
+ import type { ReactNode } from "react"
4
+ import {
5
+ AlertTriangleIcon,
6
+ ChevronRightIcon,
7
+ ClockIcon,
8
+ ServerIcon,
9
+ TimerIcon,
10
+ WifiOffIcon,
11
+ XIcon,
12
+ } from "lucide-react"
13
+
14
+ import { cn, formatDuration, formatRelativeTime } from "../../utils"
15
+ import { buttonVariants } from "../button-variants"
16
+ import { CreatorAvatar } from "./creator-avatar"
17
+ import { GitBranchChips, type GitRepoStatus } from "./git-branch-chips"
18
+ import { ResourceBars, type WorkerStats } from "./resource-bars"
19
+ import { SetupProgress, setupTotalMs } from "./setup-progress"
20
+ import { TagChips } from "./tag-chips"
21
+ import type { WorkerCardWorker, WorkerCreator } from "./types"
22
+ import { WorkerStatusDot, WorkerStatusPill, resolveWorkerStatus } from "./worker-status"
23
+
24
+ /** Renders the "View" affordance. A slot, so the package never imports `next/link`. */
25
+ export type LinkRender = (props: { href: string; className?: string; children: ReactNode }) => ReactNode
26
+
27
+ export interface WorkerCardProps {
28
+ worker: WorkerCardWorker
29
+
30
+ /** Target of the title + footer button. Omitted → the card isn't a link. */
31
+ href?: string
32
+ /**
33
+ * Escape hatches for app-specific rendering. `link` receives `{ href, className,
34
+ * children }` and must return an anchor-ish node (`next/link`, a router link…);
35
+ * without it the card falls back to a plain `<a>`.
36
+ */
37
+ render?: { link?: LinkRender }
38
+
39
+ creator?: WorkerCreator
40
+ nodeName?: string
41
+ /** `false` draws the "Node offline" banner. `undefined` = unknown, no banner. */
42
+ nodeOnline?: boolean
43
+ /** Live container stats. Absent → no resource bars (the usual case for a stopped worker). */
44
+ stats?: WorkerStats | null
45
+ /** Checked-out repositories. Absent/empty → no chips. */
46
+ gitStatus?: GitRepoStatus[] | null
47
+ /** Chip → URL (e.g. code-server at that folder). Returning undefined renders a static chip. */
48
+ repoHref?: (repo: GitRepoStatus) => string | undefined
49
+
50
+ /** Tags are read-only unless at least one of these is given. */
51
+ onAddTag?: (tag: string) => void
52
+ onRemoveTag?: (tag: string) => void
53
+
54
+ /** Project's current config version — a worker below it gets the "Rebuild" banner. */
55
+ currentProjectVersion?: number
56
+ onRebuild?: () => void
57
+ rebuilding?: boolean
58
+
59
+ /** Error banner inside the card (a failed mutation, typically). */
60
+ error?: string | null
61
+ onDismissError?: () => void
62
+
63
+ /** The `⋯` menu, top-right. A slot: every entry in it calls the API. */
64
+ actions?: ReactNode
65
+ /** Replaces the default "View" footer entirely (extra buttons, a VS Code link…). */
66
+ footer?: ReactNode
67
+ /** Label of the default footer button. */
68
+ viewLabel?: string
69
+
70
+ className?: string
71
+ }
72
+
73
+ /**
74
+ * One worker, as a card: state, who spawned it, where it runs, what it's working
75
+ * on, and what you can do with it.
76
+ *
77
+ * **Purely presentational.** It receives a snapshot and draws it — no query, no
78
+ * mutation, no router. Everything that would talk to an API is a slot
79
+ * (`actions`, `footer`) or a callback (`onAddTag`, `onRebuild`…). That rule is
80
+ * what makes the same card renderable by the dashboard, by Spunto Lite, and by
81
+ * the showcase with hand-written fixtures. The card is the assembly; each brick
82
+ * (`WorkerStatusDot`, `SetupProgress`, `ResourceBars`, `GitBranchChips`,
83
+ * `TagChips`) is exported on its own because the table view needs them without
84
+ * the card.
85
+ *
86
+ * The layout is a fixed sequence of optional bands — header, setup bar, git
87
+ * chips, resource bars, banners, footer — and each band disappears when its data
88
+ * is absent, so a stopped worker collapses to a two-line card without any
89
+ * caller-side conditional.
90
+ */
91
+ export function WorkerCard({
92
+ worker,
93
+ href,
94
+ render,
95
+ creator,
96
+ nodeName,
97
+ nodeOnline,
98
+ stats,
99
+ gitStatus,
100
+ repoHref,
101
+ onAddTag,
102
+ onRemoveTag,
103
+ currentProjectVersion,
104
+ onRebuild,
105
+ rebuilding = false,
106
+ error,
107
+ onDismissError,
108
+ actions,
109
+ footer,
110
+ viewLabel = "View",
111
+ className,
112
+ }: WorkerCardProps) {
113
+ const status = resolveWorkerStatus(worker)
114
+ const setup = worker.setupStatus ?? null
115
+ const startupMs = setupTotalMs(setup)
116
+ const title = worker.name || (worker.index != null ? `Workspace #${worker.index}` : worker.id)
117
+
118
+ // Live detail is only meaningful once the container is up *and* done setting
119
+ // up: mid-setup a stale "running" from docker would show stats for a worker
120
+ // that isn't usable yet.
121
+ const settled = status.running && !status.settingUp
122
+ const repos = settled ? (gitStatus ?? []) : []
123
+ const outdated =
124
+ !status.settingUp &&
125
+ worker.projectVersion != null &&
126
+ currentProjectVersion != null &&
127
+ worker.projectVersion < currentProjectVersion
128
+
129
+ const Link: LinkRender =
130
+ render?.link ?? (({ href: to, className: cls, children }) => <a href={to} className={cls}>{children}</a>)
131
+
132
+ return (
133
+ <div
134
+ data-slot="worker-card"
135
+ className={cn("overflow-hidden rounded-xl border border-border bg-card transition-colors hover:shadow-sm", className)}
136
+ >
137
+ {/* ── Header : état, nom, pills, tags, méta ── */}
138
+ <div className="flex items-start justify-between gap-3 px-4 pt-3.5 pb-3">
139
+ <div className="flex min-w-0 items-start gap-3">
140
+ <WorkerStatusDot status={status} className="mt-1" />
141
+
142
+ <div className="min-w-0 flex-1">
143
+ <div className="flex flex-wrap items-center gap-2">
144
+ <span className="text-sm leading-none font-semibold">{title}</span>
145
+ <WorkerStatusPill status={status} />
146
+ {worker.projectVersion != null && (
147
+ <span className="inline-flex items-center rounded-full border border-border bg-muted px-2 py-0.5 font-mono text-[10px] leading-none text-muted-foreground">
148
+ v{worker.projectVersion}
149
+ </span>
150
+ )}
151
+ <TagChips tags={worker.tags ?? []} onAdd={onAddTag} onRemove={onRemoveTag} />
152
+ </div>
153
+
154
+ <div className="mt-1.5 flex flex-wrap items-center gap-2">
155
+ {creator && (
156
+ <span className="flex items-center gap-1.5">
157
+ <CreatorAvatar creator={creator} />
158
+ <span className="text-[11px] text-muted-foreground">{creator.name}</span>
159
+ </span>
160
+ )}
161
+ {nodeName && (
162
+ <>
163
+ <Dot />
164
+ <span className="flex items-center gap-1 text-[11px] text-muted-foreground/60">
165
+ <ServerIcon className="h-3 w-3" />
166
+ {nodeName}
167
+ </span>
168
+ </>
169
+ )}
170
+ {worker.createdAt && (
171
+ <>
172
+ {creator || nodeName ? <Dot /> : null}
173
+ <span
174
+ className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
175
+ title={new Date(worker.createdAt).toLocaleString()}
176
+ >
177
+ <ClockIcon className="h-3 w-3" />
178
+ {formatRelativeTime(worker.createdAt)}
179
+ </span>
180
+ </>
181
+ )}
182
+ {/* Only worth showing once it's over — during setup the live bar says it better. */}
183
+ {!status.settingUp && startupMs != null && (
184
+ <>
185
+ <Dot />
186
+ <span
187
+ className="flex items-center gap-1 text-[11px] text-muted-foreground/60"
188
+ title="Worker startup time (clone, features, postCreate…)"
189
+ >
190
+ <TimerIcon className="h-3 w-3" />
191
+ started in {formatDuration(startupMs)}
192
+ </span>
193
+ </>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ {actions}
200
+ </div>
201
+
202
+ {status.settingUp && (
203
+ <div className="px-4 pb-3">
204
+ <SetupProgress status={setup} />
205
+ </div>
206
+ )}
207
+
208
+ {repos.length > 0 && (
209
+ <div className="px-4 pb-3">
210
+ <GitBranchChips repos={repos} repoHref={repoHref} />
211
+ </div>
212
+ )}
213
+
214
+ {settled && stats && (
215
+ <div className="px-4 pb-3">
216
+ <ResourceBars stats={stats} />
217
+ </div>
218
+ )}
219
+
220
+ {nodeOnline === false && (
221
+ <Banner tone="offline">
222
+ <WifiOffIcon className="h-3.5 w-3.5 shrink-0" />
223
+ Node offline
224
+ </Banner>
225
+ )}
226
+
227
+ {outdated && (
228
+ <Banner tone="warn">
229
+ <span className="flex min-w-0 items-center gap-1.5">
230
+ <AlertTriangleIcon className="h-3.5 w-3.5 shrink-0" />
231
+ Config <span className="font-mono font-semibold">v{worker.projectVersion}</span> → project at{" "}
232
+ <span className="font-mono font-semibold">v{currentProjectVersion}</span>
233
+ </span>
234
+ {onRebuild && (
235
+ <button
236
+ type="button"
237
+ disabled={rebuilding}
238
+ onClick={onRebuild}
239
+ className="shrink-0 cursor-pointer text-[11px] font-medium hover:underline disabled:opacity-50"
240
+ >
241
+ {rebuilding ? "Rebuilding…" : "Rebuild"}
242
+ </button>
243
+ )}
244
+ </Banner>
245
+ )}
246
+
247
+ {error && (
248
+ <Banner tone="error">
249
+ <span className="truncate font-mono">{error}</span>
250
+ {onDismissError && (
251
+ <button
252
+ type="button"
253
+ onClick={onDismissError}
254
+ aria-label="Dismiss error"
255
+ className="shrink-0 cursor-pointer opacity-60 transition-opacity hover:opacity-100"
256
+ >
257
+ <XIcon className="h-3.5 w-3.5" />
258
+ </button>
259
+ )}
260
+ </Banner>
261
+ )}
262
+
263
+ {(footer ?? href) && (
264
+ <div className="border-t border-border/50 bg-muted/20 px-3 py-2">
265
+ {footer ?? (
266
+ <Link href={href!} className={cn(buttonVariants({ variant: "default", size: "sm" }), "h-7 w-full gap-1.5 text-xs")}>
267
+ {viewLabel}
268
+ <ChevronRightIcon className="h-3.5 w-3.5" />
269
+ </Link>
270
+ )}
271
+ </div>
272
+ )}
273
+ </div>
274
+ )
275
+ }
276
+
277
+ /** The faint separator between two meta items. */
278
+ function Dot() {
279
+ return <span className="text-[11px] text-muted-foreground/30">·</span>
280
+ }
281
+
282
+ /**
283
+ * The card's inline bands. Warm tones = something to know (an unreachable node,
284
+ * a stale config), red = something went wrong. They keep the card's horizontal
285
+ * margin so they read as *inside* the card, not as a strip glued across it.
286
+ */
287
+ const BANNER_TONES = {
288
+ /** Infrastructure problem — orange, one notch louder than a stale config. */
289
+ offline: "border-orange-500/25 bg-orange-500/5 text-orange-600 dark:text-orange-400",
290
+ warn: "border-amber-500/25 bg-amber-500/5 text-amber-600 dark:text-amber-400",
291
+ error: "border-destructive/25 bg-destructive/5 text-destructive",
292
+ } as const
293
+
294
+ function Banner({ tone, children }: { tone: keyof typeof BANNER_TONES; children: ReactNode }) {
295
+ return (
296
+ <div
297
+ className={cn(
298
+ "mx-4 mb-3 flex items-center justify-between gap-2 rounded-lg border px-3 py-2 text-xs",
299
+ BANNER_TONES[tone]
300
+ )}
301
+ >
302
+ {children}
303
+ </div>
304
+ )
305
+ }
@@ -0,0 +1,181 @@
1
+ // The single state → colours/label table both worker views read from.
2
+ //
3
+ // It used to live twice — `STATUS_CONFIG` in the dashboard and its hand-copied
4
+ // twin in Spunto Lite — and the two had already drifted (different labels for
5
+ // the same docker state, transient states handled in one and not the other).
6
+ // This module is the union of both, plus the transient states (`pulling`,
7
+ // `stopping`, `deleting`) that only ever existed as ad-hoc `if`s above the table.
8
+ //
9
+ // No "use client": pure functions and hookless components, so a React Server
10
+ // Component can call them.
11
+
12
+ import { cn } from "../../utils"
13
+ import type { WorkerCardWorker } from "./types"
14
+
15
+ export type WorkerStatusKey =
16
+ | "running"
17
+ | "stopped"
18
+ | "exited"
19
+ | "created"
20
+ | "not_found"
21
+ | "error"
22
+ | "pending"
23
+ | "setup"
24
+ | "pulling"
25
+ | "stopping"
26
+ | "deleting"
27
+
28
+ export interface WorkerStatusConfig {
29
+ label: string
30
+ /** Class for the 8 px dot. */
31
+ dotClass: string
32
+ /** Classes for the rounded state pill (background + text + border). */
33
+ pillClass: string
34
+ /** Left border accent, for surfaces that stripe the card by state. */
35
+ accent: string
36
+ }
37
+
38
+ // Status semantics follow docs/design-system.md: green = running/healthy,
39
+ // amber/yellow = in progress or warning, red = failure, neutral = idle. Errors
40
+ // go through the `destructive` token rather than a raw red so they track the
41
+ // theme; green/yellow have no token, so the Tailwind palette stays.
42
+ const RUNNING: WorkerStatusConfig = {
43
+ label: "Running",
44
+ dotClass: "bg-green-500",
45
+ pillClass: "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20",
46
+ accent: "border-l-green-500",
47
+ }
48
+
49
+ const IDLE: WorkerStatusConfig = {
50
+ label: "Stopped",
51
+ dotClass: "bg-muted-foreground/50",
52
+ pillClass: "bg-muted text-muted-foreground border-border",
53
+ accent: "border-l-border",
54
+ }
55
+
56
+ const BUSY: WorkerStatusConfig = {
57
+ label: "Starting…",
58
+ dotClass: "bg-yellow-400",
59
+ pillClass: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20",
60
+ accent: "border-l-yellow-400",
61
+ }
62
+
63
+ export const workerStatusConfig: Record<WorkerStatusKey, WorkerStatusConfig> = {
64
+ running: RUNNING,
65
+ stopped: IDLE,
66
+ exited: { ...IDLE, label: "Exited" },
67
+ created: { ...BUSY, label: "Created" },
68
+ not_found: {
69
+ label: "Not found",
70
+ dotClass: "bg-orange-400",
71
+ pillClass: "bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20",
72
+ accent: "border-l-orange-400",
73
+ },
74
+ error: {
75
+ label: "Error",
76
+ dotClass: "bg-destructive",
77
+ pillClass: "bg-destructive/10 text-destructive border-destructive/20",
78
+ accent: "border-l-destructive",
79
+ },
80
+ pending: BUSY,
81
+ setup: { ...BUSY, label: "Setting up…" },
82
+ pulling: { ...BUSY, label: "Pulling image…" },
83
+ stopping: { ...BUSY, label: "Stopping…" },
84
+ deleting: { ...BUSY, label: "Deleting…" },
85
+ }
86
+
87
+ export interface WorkerStatus extends WorkerStatusConfig {
88
+ key: WorkerStatusKey
89
+ /** The worker is still being provisioned — the setup bar is the interesting part. */
90
+ settingUp: boolean
91
+ /** Container up and past its setup — stats, git chips and the pulsing dot apply. */
92
+ running: boolean
93
+ }
94
+
95
+ /** Lifecycle states that mean "not usable yet, a setup is in flight". */
96
+ const SETUP_STATES = new Set(["provisioning", "starting", "setup"])
97
+
98
+ /**
99
+ * Worker snapshot → one status, tolerant of shapes it has never seen.
100
+ *
101
+ * Order matters: the *lifecycle* state wins whenever it describes an action in
102
+ * flight (deleting, stopping, pulling, setting up), because docker still reports
103
+ * the container as `running` throughout — showing "Running" while a worker is
104
+ * being deleted is the bug this ordering exists to prevent. Past that, the
105
+ * docker state is the truth. Apps with a single `state` field (Spunto Lite) fall
106
+ * through to the last block, and anything unknown lands on `pending` rather than
107
+ * throwing.
108
+ */
109
+ export function resolveWorkerStatus(worker: WorkerCardWorker): WorkerStatus {
110
+ const state = worker.state ?? null
111
+ const docker = worker.dockerState ?? null
112
+
113
+ const of = (key: WorkerStatusKey, flags?: Partial<Pick<WorkerStatus, "settingUp" | "running">>): WorkerStatus => ({
114
+ key,
115
+ ...workerStatusConfig[key],
116
+ settingUp: false,
117
+ running: key === "running",
118
+ ...flags,
119
+ })
120
+
121
+ if (state === "deleting") return of("deleting")
122
+ if (state === "stopping") return of("stopping")
123
+ if (state === "pulling") return of("pulling", { settingUp: true })
124
+ if (state && SETUP_STATES.has(state)) return of("setup", { settingUp: true })
125
+
126
+ if (docker && docker in workerStatusConfig) return of(docker as WorkerStatusKey)
127
+ if (docker) return of("pending")
128
+
129
+ // Single-`state` apps (Lite): no docker level to consult.
130
+ if (state === "ready") return of("running")
131
+ if (state === "error") return of("error")
132
+ if (state === "stopped") return of("stopped")
133
+ return of("pending")
134
+ }
135
+
136
+ /**
137
+ * The 8 px state dot. It pings while the worker runs — the one animation on the
138
+ * card, reserved for the one state where "it is alive right now" is the message.
139
+ */
140
+ export function WorkerStatusDot({
141
+ status,
142
+ className,
143
+ }: {
144
+ status: WorkerStatus | WorkerStatusKey
145
+ className?: string
146
+ }) {
147
+ const cfg = typeof status === "string" ? { ...workerStatusConfig[status], running: status === "running" } : status
148
+ return (
149
+ <span className={cn("relative flex h-2 w-2 shrink-0", className)}>
150
+ <span className={cn("h-2 w-2 rounded-full", cfg.dotClass)} />
151
+ {cfg.running && (
152
+ <span className="absolute inset-0 h-2 w-2 animate-ping rounded-full bg-green-500 opacity-50 motion-reduce:animate-none" />
153
+ )}
154
+ </span>
155
+ )
156
+ }
157
+
158
+ /** The state chip that sits next to the worker name. */
159
+ export function WorkerStatusPill({
160
+ status,
161
+ label,
162
+ className,
163
+ }: {
164
+ status: WorkerStatus | WorkerStatusKey
165
+ /** Overrides the table's label — e.g. a live setup phase instead of "Setting up…". */
166
+ label?: string
167
+ className?: string
168
+ }) {
169
+ const cfg = typeof status === "string" ? workerStatusConfig[status] : status
170
+ return (
171
+ <span
172
+ className={cn(
173
+ "inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium leading-none",
174
+ cfg.pillClass,
175
+ className
176
+ )}
177
+ >
178
+ {label ?? cfg.label}
179
+ </span>
180
+ )
181
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  // @spunto/design-system — shared UI primitives, helpers, and color constants.
2
2
  // Tokens ship as a CSS file: `import "@spunto/design-system/styles.css"`.
3
3
 
4
- export { cn } from "./utils"
4
+ export { cn, formatDuration, formatRelativeTime } from "./utils"
5
5
  export * from "./colors"
6
6
 
7
+ // Domain (Spunto-specific) components live behind their own entry point —
8
+ // `@spunto/design-system/workers` — so this root stays domain-free primitives.
9
+ // See README § "Domain components".
10
+
7
11
  export { Button } from "./components/button"
8
12
  // `buttonVariants` is exported from its own directive-free module (not through the
9
13
  // "use client" button.tsx) so it stays callable from React Server Components.
@@ -26,6 +30,11 @@ export { Avatar, AvatarImage, AvatarFallback } from "./components/avatar"
26
30
  export { Terminal } from "./components/terminal"
27
31
  export type { TerminalHandle, TerminalProps, TerminalOptions } from "./components/terminal"
28
32
 
33
+ // Docker image pull as a real UI (one segmented bar, one block per layer) —
34
+ // presentational only: give it a snapshot, it draws it.
35
+ export { ImagePull } from "./components/image-pull"
36
+ export type { ImagePullProps, ImagePullState, PullLayer, PullLayerStatus } from "./components/image-pull"
37
+
29
38
  // Inline, declarative status banner (persistent) — the counterpart to `toast()`.
30
39
  // `alertVariants` ships from this directive-free module so it stays RSC-callable.
31
40
  export { Alert, AlertTitle, AlertDescription, alertVariants } from "./components/alert"
package/src/utils.ts CHANGED
@@ -5,3 +5,26 @@ import { twMerge } from "tailwind-merge"
5
5
  export function cn(...inputs: ClassValue[]) {
6
6
  return twMerge(clsx(inputs))
7
7
  }
8
+
9
+ /**
10
+ * Coarse "3h ago" stamp — minutes, then hours, then days. Deliberately not a
11
+ * live-ticking relative time: every surface using it (worker cards, tables)
12
+ * already re-renders on its own polling cadence, and a second timer per row
13
+ * would cost more than the precision is worth.
14
+ */
15
+ export function formatRelativeTime(date: string | number | Date): string {
16
+ const ms = date instanceof Date ? date.getTime() : new Date(date).getTime()
17
+ if (!Number.isFinite(ms)) return ""
18
+ const minutes = Math.floor((Date.now() - ms) / 60_000)
19
+ if (minutes < 1) return "just now"
20
+ if (minutes < 60) return `${minutes}m ago`
21
+ const hours = Math.floor(minutes / 60)
22
+ if (hours < 24) return `${hours}h ago`
23
+ return `${Math.floor(hours / 24)}d ago`
24
+ }
25
+
26
+ /** Short duration, one significant decimal under 10 s ("840ms", "4.2s", "38s"). */
27
+ export function formatDuration(ms: number): string {
28
+ if (ms >= 1000) return `${(ms / 1000).toFixed(ms >= 10_000 ? 0 : 1)}s`
29
+ return `${Math.max(0, Math.round(ms))}ms`
30
+ }
package/styles.css CHANGED
@@ -493,3 +493,23 @@
493
493
  @media (prefers-reduced-motion: reduce) {
494
494
  [data-slot="toast"] { transition-duration: 0.01ms; }
495
495
  }
496
+
497
+ /* ---------------------------------------------------------------------------
498
+ ImagePull — indeterminate sweep.
499
+ Used by the `starting` state (registry contacted, no layer announced yet) and
500
+ by any layer still moving without a known size: there's no honest fraction to
501
+ fill, so the surface sweeps instead of inventing one.
502
+ --------------------------------------------------------------------------- */
503
+ @keyframes spunto-pull-sweep {
504
+ from { transform: translateX(-100%); }
505
+ to { transform: translateX(400%); }
506
+ }
507
+ [data-slot="image-pull-sweep"] {
508
+ animation: spunto-pull-sweep 1.6s cubic-bezier(0.4, 0, 0.6, 1) infinite;
509
+ }
510
+
511
+ @media (prefers-reduced-motion: reduce) {
512
+ /* No sweep, no gliding segment widths — the bar still reads, it just stops moving. */
513
+ [data-slot="image-pull-sweep"] { animation: none; opacity: 0.45; }
514
+ [data-slot="image-pull"] * { transition-duration: 0.01ms !important; }
515
+ }