mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 09:44:21 +01:00
Merge branch 'dev' of https://github.com/sst/opencode into dev
This commit is contained in:
12
install
12
install
@@ -10,10 +10,14 @@ NC='\033[0m' # No Color
|
|||||||
|
|
||||||
requested_version=${VERSION:-}
|
requested_version=${VERSION:-}
|
||||||
|
|
||||||
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
raw_os=$(uname -s)
|
||||||
if [[ "$os" == "darwin" ]]; then
|
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
|
||||||
os="darwin"
|
# Normalize various Unix-like identifiers
|
||||||
fi
|
case "$raw_os" in
|
||||||
|
Darwin*) os="darwin" ;;
|
||||||
|
Linux*) os="linux" ;;
|
||||||
|
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
|
||||||
|
esac
|
||||||
arch=$(uname -m)
|
arch=$(uname -m)
|
||||||
|
|
||||||
if [[ "$arch" == "aarch64" ]]; then
|
if [[ "$arch" == "aarch64" ]]; then
|
||||||
|
|||||||
82
packages/desktop/src/components/message-progress.tsx
Normal file
82
packages/desktop/src/components/message-progress.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { For, Match, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
|
||||||
|
import { Part } from "@opencode-ai/ui"
|
||||||
|
import { useSync } from "@/context/sync"
|
||||||
|
import type { AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
export function MessageProgress(props: { assistantMessages: () => AssistantMessageType[] }) {
|
||||||
|
const sync = useSync()
|
||||||
|
const items = createMemo(() => props.assistantMessages().flatMap((m) => sync.data.part[m.id]))
|
||||||
|
|
||||||
|
const finishedItems = createMemo(() => [
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"Loading...",
|
||||||
|
...items().filter(
|
||||||
|
(p) =>
|
||||||
|
p?.type === "text" ||
|
||||||
|
(p?.type === "reasoning" && p.time?.end) ||
|
||||||
|
(p?.type === "tool" && p.state.status === "completed"),
|
||||||
|
),
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
const MINIMUM_DELAY = 400
|
||||||
|
const [visibleCount, setVisibleCount] = createSignal(1)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
const total = finishedItems().length
|
||||||
|
if (total > visibleCount()) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setVisibleCount((prev) => prev + 1)
|
||||||
|
}, MINIMUM_DELAY)
|
||||||
|
onCleanup(() => clearTimeout(timer))
|
||||||
|
} else if (total < visibleCount()) {
|
||||||
|
setVisibleCount(total)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const translateY = createMemo(() => {
|
||||||
|
const total = visibleCount()
|
||||||
|
if (total < 2) return "0px"
|
||||||
|
return `-${(total - 2) * 40 - 8}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="h-30 overflow-hidden pointer-events-none
|
||||||
|
mask-alpha mask-t-from-33% mask-t-from-background-base mask-t-to-transparent
|
||||||
|
mask-b-from-90% mask-b-from-background-base mask-b-to-transparent"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-full flex flex-col items-start self-stretch gap-2 py-8
|
||||||
|
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||||
|
style={{ transform: `translateY(${translateY()})` }}
|
||||||
|
>
|
||||||
|
<For each={finishedItems()}>
|
||||||
|
{(part) => {
|
||||||
|
if (typeof part === "string") return <div class="h-8 flex items-center w-full">{part}</div>
|
||||||
|
const message = createMemo(() => sync.data.message[part.sessionID].find((m) => m.id === part.messageID))
|
||||||
|
return (
|
||||||
|
<div class="h-8 flex items-center w-full">
|
||||||
|
<Switch>
|
||||||
|
<Match when={part.type === "text" && part}>
|
||||||
|
{(p) => (
|
||||||
|
<div
|
||||||
|
textContent={p().text}
|
||||||
|
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Match>
|
||||||
|
<Match when={part.type === "reasoning" && part}>
|
||||||
|
{(p) => <Part message={message()!} part={p()} />}
|
||||||
|
</Match>
|
||||||
|
<Match when={part.type === "tool" && part}>{(p) => <Part message={message()!} part={p()} />}</Match>
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
39
packages/desktop/src/components/spinner.tsx
Normal file
39
packages/desktop/src/components/spinner.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { ComponentProps, For } from "solid-js"
|
||||||
|
|
||||||
|
export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
|
||||||
|
const squares = Array.from({ length: 16 }, (_, i) => ({
|
||||||
|
id: i,
|
||||||
|
x: (i % 4) * 4,
|
||||||
|
y: Math.floor(i / 4) * 4,
|
||||||
|
delay: Math.random() * 3,
|
||||||
|
duration: 2 + Math.random() * 2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
classList={{
|
||||||
|
"size-4": true,
|
||||||
|
...(props.classList ?? {}),
|
||||||
|
[props.class ?? ""]: !!props.class,
|
||||||
|
}}
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<For each={squares}>
|
||||||
|
{(square) => (
|
||||||
|
<rect
|
||||||
|
x={square.x}
|
||||||
|
y={square.y}
|
||||||
|
width="3"
|
||||||
|
height="3"
|
||||||
|
rx="1"
|
||||||
|
style={{
|
||||||
|
animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
|
||||||
|
"animation-delay": `${square.delay}s`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "@opencode-ai/ui"
|
} from "@opencode-ai/ui"
|
||||||
import { FileIcon } from "@/ui"
|
import { FileIcon } from "@/ui"
|
||||||
import FileTree from "@/components/file-tree"
|
import FileTree from "@/components/file-tree"
|
||||||
|
import { MessageProgress } from "@/components/message-progress"
|
||||||
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
|
||||||
import { useLocal, type LocalFile } from "@/context/local"
|
import { useLocal, type LocalFile } from "@/context/local"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
@@ -39,6 +40,7 @@ import { useSync } from "@/context/sync"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
|
||||||
import { Markdown } from "@opencode-ai/ui"
|
import { Markdown } from "@opencode-ai/ui"
|
||||||
|
import { Spinner } from "@/components/spinner"
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
@@ -546,12 +548,21 @@ export default function Page() {
|
|||||||
<For each={local.session.userMessages()}>
|
<For each={local.session.userMessages()}>
|
||||||
{(message) => {
|
{(message) => {
|
||||||
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||||
|
const working = createMemo(() => !message.summary?.title)
|
||||||
return (
|
return (
|
||||||
<li
|
<li class="group/li flex items-center self-stretch">
|
||||||
class="group/li flex items-center gap-x-2 py-1 self-stretch cursor-default"
|
<button
|
||||||
|
class="flex items-center self-stretch w-full gap-x-2 py-1 cursor-default"
|
||||||
onClick={() => local.session.setActiveMessage(message.id)}
|
onClick={() => local.session.setActiveMessage(message.id)}
|
||||||
>
|
>
|
||||||
|
<Switch>
|
||||||
|
<Match when={working()}>
|
||||||
|
<Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
|
||||||
|
</Match>
|
||||||
|
<Match when={true}>
|
||||||
<DiffChanges diff={diffs()} variant="bars" />
|
<DiffChanges diff={diffs()} variant="bars" />
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
<div
|
<div
|
||||||
data-active={local.session.activeMessage()?.id === message.id}
|
data-active={local.session.activeMessage()?.id === message.id}
|
||||||
classList={{
|
classList={{
|
||||||
@@ -561,6 +572,7 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
{message.summary?.title ?? local.session.getMessageText(message)}
|
{message.summary?.title ?? local.session.getMessageText(message)}
|
||||||
</div>
|
</div>
|
||||||
|
</button>
|
||||||
</li>
|
</li>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -576,11 +588,17 @@ export default function Page() {
|
|||||||
const parts = createMemo(() => sync.data.part[message.id])
|
const parts = createMemo(() => sync.data.part[message.id])
|
||||||
const title = createMemo(() => message.summary?.title)
|
const title = createMemo(() => message.summary?.title)
|
||||||
const summary = createMemo(() => message.summary?.body)
|
const summary = createMemo(() => message.summary?.body)
|
||||||
|
const diffs = createMemo(() => message.summary?.diffs ?? [])
|
||||||
const assistantMessages = createMemo(() => {
|
const assistantMessages = createMemo(() => {
|
||||||
return sync.data.message[activeSession().id]?.filter(
|
return sync.data.message[activeSession().id]?.filter(
|
||||||
(m) => m.role === "assistant" && m.parentID == message.id,
|
(m) => m.role === "assistant" && m.parentID == message.id,
|
||||||
) as AssistantMessageType[]
|
) as AssistantMessageType[]
|
||||||
})
|
})
|
||||||
|
const hasToolPart = createMemo(() =>
|
||||||
|
assistantMessages()
|
||||||
|
?.flatMap((m) => sync.data.part[m.id])
|
||||||
|
.some((p) => p.type === "tool"),
|
||||||
|
)
|
||||||
const working = createMemo(() => !summary())
|
const working = createMemo(() => !summary())
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
setTimeout(() => setInitialized(!!title()), 10_000)
|
setTimeout(() => setInitialized(!!title()), 10_000)
|
||||||
@@ -600,22 +618,23 @@ export default function Page() {
|
|||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Show when={title}>
|
|
||||||
<div class="-mt-8">
|
<div class="-mt-8">
|
||||||
<Message message={message} parts={parts()} />
|
<Message message={message} parts={parts()} />
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<Show when={!working()}>
|
<Show when={!working()}>
|
||||||
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
<div class="w-full flex flex-col gap-6 items-start self-stretch">
|
||||||
<div class="flex flex-col items-start gap-1 self-stretch">
|
<div class="flex flex-col items-start gap-1 self-stretch">
|
||||||
<h2 class="text-12-medium text-text-weak">Summary</h2>
|
<h2 class="text-12-medium text-text-weak">
|
||||||
<Show when={summary()}>
|
<Switch>
|
||||||
<Markdown text={summary()!} />
|
<Match when={diffs().length}>Summary</Match>
|
||||||
</Show>
|
<Match when={true}>Response</Match>
|
||||||
|
</Switch>
|
||||||
|
</h2>
|
||||||
|
<Show when={summary()}>{(summary) => <Markdown text={summary()} />}</Show>
|
||||||
</div>
|
</div>
|
||||||
<Accordion class="w-full" multiple>
|
<Accordion class="w-full" multiple>
|
||||||
<For each={message.summary?.diffs || []}>
|
<For each={diffs()}>
|
||||||
{(diff) => (
|
{(diff) => (
|
||||||
<Accordion.Item value={diff.file}>
|
<Accordion.Item value={diff.file}>
|
||||||
<Accordion.Header>
|
<Accordion.Header>
|
||||||
@@ -666,87 +685,9 @@ export default function Page() {
|
|||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={working()}>
|
<Match when={working()}>
|
||||||
{(_) => {
|
<MessageProgress assistantMessages={assistantMessages} />
|
||||||
const items = createMemo(() =>
|
|
||||||
assistantMessages().flatMap((m) => sync.data.part[m.id]),
|
|
||||||
)
|
|
||||||
const finishedItems = createMemo(() =>
|
|
||||||
items().filter(
|
|
||||||
(p) =>
|
|
||||||
(p?.type === "text" && p.time?.end) ||
|
|
||||||
(p?.type === "reasoning" && p.time?.end) ||
|
|
||||||
(p?.type === "tool" && p.state.status === "completed"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const MINIMUM_DELAY = 800
|
|
||||||
const [visibleCount, setVisibleCount] = createSignal(1)
|
|
||||||
|
|
||||||
createEffect(() => {
|
|
||||||
const total = finishedItems().length
|
|
||||||
if (total > visibleCount()) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setVisibleCount((prev) => prev + 1)
|
|
||||||
}, MINIMUM_DELAY)
|
|
||||||
onCleanup(() => clearTimeout(timer))
|
|
||||||
} else if (total < visibleCount()) {
|
|
||||||
setVisibleCount(total)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const translateY = createMemo(() => {
|
|
||||||
const total = visibleCount()
|
|
||||||
if (total < 2) return "0px"
|
|
||||||
return `-${(total - 2) * 48 - 8}px`
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div
|
|
||||||
class="h-36 overflow-hidden pointer-events-none
|
|
||||||
mask-alpha mask-y-from-66% mask-y-from-background-base mask-y-to-transparent"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="w-full flex flex-col items-start self-stretch gap-2 py-10
|
|
||||||
transform transition-transform duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
|
||||||
style={{ transform: `translateY(${translateY()})` }}
|
|
||||||
>
|
|
||||||
<For each={finishedItems()}>
|
|
||||||
{(part) => {
|
|
||||||
const message = createMemo(() =>
|
|
||||||
sync.data.message[part.sessionID].find(
|
|
||||||
(m) => m.id === part.messageID,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div class="h-10 flex items-center w-full">
|
|
||||||
<Switch>
|
|
||||||
<Match when={part.type === "text" && part}>
|
|
||||||
{(p) => (
|
|
||||||
<div
|
|
||||||
textContent={p().text}
|
|
||||||
class="text-12-regular text-text-base whitespace-nowrap truncate w-full"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Match>
|
</Match>
|
||||||
<Match when={part.type === "reasoning" && part}>
|
<Match when={!working() && hasToolPart()}>
|
||||||
{(p) => <Part message={message()!} part={p()} />}
|
|
||||||
</Match>
|
|
||||||
<Match when={part.type === "tool" && part}>
|
|
||||||
{(p) => <Part message={message()!} part={p()} />}
|
|
||||||
</Match>
|
|
||||||
</Switch>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</Match>
|
|
||||||
<Match when={!working()}>
|
|
||||||
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
<Collapsible variant="ghost" open={expanded()} onOpenChange={setExpanded}>
|
||||||
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
<Collapsible.Trigger class="text-text-weak hover:text-text-strong">
|
||||||
<div class="flex items-center gap-1 self-stretch">
|
<div class="flex items-center gap-1 self-stretch">
|
||||||
|
|||||||
@@ -16,16 +16,6 @@ export function DiffChanges(props: { diff: FileDiff | FileDiff[]; variant?: "def
|
|||||||
)
|
)
|
||||||
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
|
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
|
||||||
|
|
||||||
const countLines = (text: string) => {
|
|
||||||
if (!text) return 0
|
|
||||||
return text.split("\n").length
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalBeforeLines = createMemo(() => {
|
|
||||||
if (!Array.isArray(props.diff)) return countLines(props.diff.before || "")
|
|
||||||
return props.diff.reduce((acc, diff) => acc + countLines(diff.before || ""), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
const blockCounts = createMemo(() => {
|
const blockCounts = createMemo(() => {
|
||||||
const TOTAL_BLOCKS = 5
|
const TOTAL_BLOCKS = 5
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
overflow: auto;
|
overflow: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
color: var(--text-base);
|
color: var(--text-base);
|
||||||
|
text-wrap: pretty;
|
||||||
|
|
||||||
/* text-14-regular */
|
/* text-14-regular */
|
||||||
font-family: var(--font-family-sans);
|
font-family: var(--font-family-sans);
|
||||||
@@ -34,4 +35,10 @@
|
|||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-color: var(--border-weaker-base);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
packages/ui/src/styles/animations.css
Normal file
13
packages/ui/src/styles/animations.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
:root {
|
||||||
|
--animate-pulse: pulse-opacity 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-opacity {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,3 +28,4 @@
|
|||||||
@import "../components/typewriter.css" layer(components);
|
@import "../components/typewriter.css" layer(components);
|
||||||
|
|
||||||
@import "./utilities.css" layer(utilities);
|
@import "./utilities.css" layer(utilities);
|
||||||
|
@import "./animations.css" layer(utilities);
|
||||||
|
|||||||
@@ -64,6 +64,8 @@
|
|||||||
--shadow-xs: var(--shadow-xs);
|
--shadow-xs: var(--shadow-xs);
|
||||||
--shadow-md: var(--shadow-md);
|
--shadow-md: var(--shadow-md);
|
||||||
--shadow-xs-border-selected: var(--shadow-xs-border-selected);
|
--shadow-xs-border-selected: var(--shadow-xs-border-selected);
|
||||||
|
|
||||||
|
--animate-pulse: var(--animate-pulse);
|
||||||
}
|
}
|
||||||
|
|
||||||
@import "./colors.css";
|
@import "./colors.css";
|
||||||
|
|||||||
@@ -48,71 +48,6 @@
|
|||||||
border-width: 0;
|
border-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroller {
|
|
||||||
/* --fade-height: 1.5rem; */
|
|
||||||
/**/
|
|
||||||
/* --mask-top: linear-gradient(to bottom, transparent, black var(--fade-height)); */
|
|
||||||
/* --mask-bottom: linear-gradient(to top, transparent, black var(--fade-height)); */
|
|
||||||
/**/
|
|
||||||
/* mask-image: var(--mask-top), var(--mask-bottom); */
|
|
||||||
/* mask-repeat: no-repeat; */
|
|
||||||
/* mask-size: 100% var(--fade-height); */
|
|
||||||
|
|
||||||
animation: scroll-fade linear;
|
|
||||||
animation-timeline: scroll(self);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Define the keyframes for the mask.
|
|
||||||
These percentages now map to scroll positions:
|
|
||||||
0% = Scrolled to the top
|
|
||||||
100% = Scrolled to the bottom
|
|
||||||
*/
|
|
||||||
@keyframes scroll-fade {
|
|
||||||
/* At the very top (0% scroll) */
|
|
||||||
0% {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
black 90%,
|
|
||||||
/* Opaque, but start fade to bottom */ transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A small amount scrolled (e.g., 5%)
|
|
||||||
This is where the top fade should be fully visible.
|
|
||||||
*/
|
|
||||||
5% {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
black 10%,
|
|
||||||
/* Fade-in top */ black 90%,
|
|
||||||
/* Fade-out bottom */ transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nearing the bottom (e.g., 95%)
|
|
||||||
The bottom fade should start disappearing.
|
|
||||||
*/
|
|
||||||
95% {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
black 10%,
|
|
||||||
/* Fade-in top */ black 90%,
|
|
||||||
/* Fade-out bottom */ transparent 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* At the very bottom (100% scroll) */
|
|
||||||
100% {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to bottom,
|
|
||||||
transparent 0%,
|
|
||||||
black 10% /* Opaque, but start fade from top */
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.truncate-start {
|
.truncate-start {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user