mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 02:16:45 +08:00
* MUL-8047: feat(onboarding): open the runtime guide issue directly instead of a welcome modal Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * MUL-8047: chore(onboarding): drop dead option cards, copy, and completion animations Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> * MUL-8047: fix(onboarding): reopen the existing guide issue on duplicate A reload, or a create whose response was lost, leaves the guide issue from an earlier Skip in place while onboarding is still incomplete; the retry then hits the server's active-duplicate guard and the step was stuck toasting forever. Recover the existing issue from the validated 409 body and continue to completion. Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Cursor <[email protected]> Co-authored-by: multica-agent <[email protected]>
402 lines
14 KiB
CSS
402 lines
14 KiB
CSS
/* =============================================================================
|
|
* Multica shared base styles — imported by all apps
|
|
* ============================================================================= */
|
|
|
|
/* The chat launcher overlays the dashboard's bottom-right corner on every
|
|
* workspace page, so anything that runs to that corner has to stop short of
|
|
* it. Named rather than spelled out at each site: the intent is legible, every
|
|
* site that yields to the launcher is findable by these classes, and the
|
|
* distance stays derived from the launcher's own geometry.
|
|
*
|
|
* Which one to reach for depends on how the site approaches the corner:
|
|
* - a full-width bar arrives from the left -> pe-chat-launcher
|
|
* - scrollable content ends there -> pb-chat-launcher
|
|
* - a centred overlay grows into it -> above-chat-launcher
|
|
*
|
|
* A centred toolbar (`left-1/2 -translate-x-1/2`) cannot use the padding
|
|
* variants: inline padding widens it symmetrically, so it reaches just as far
|
|
* right as before. It has to clear the launcher vertically instead. On a wide
|
|
* viewport a centred toolbar never gets near the corner, so its clearance is
|
|
* usually applied under a `max-md:` variant. */
|
|
@utility pe-chat-launcher {
|
|
padding-inline-end: var(--chat-launcher-clearance);
|
|
}
|
|
|
|
@utility pb-chat-launcher {
|
|
padding-bottom: var(--chat-launcher-clearance);
|
|
}
|
|
|
|
@utility above-chat-launcher {
|
|
bottom: calc(var(--chat-launcher-clearance) + var(--chat-launcher-inset));
|
|
}
|
|
|
|
/* Shiki dual themes: CSS-only light/dark switching via CSS variables */
|
|
/* @see https://shiki.style/guide/dual-themes */
|
|
.shiki,
|
|
.shiki span {
|
|
color: var(--shiki-light);
|
|
}
|
|
|
|
.dark .shiki,
|
|
.dark .shiki span {
|
|
color: var(--shiki-dark) !important;
|
|
}
|
|
|
|
/* Multica icon: entrance spin animation */
|
|
@keyframes entrance-spin {
|
|
0% { transform: rotate(0deg); opacity: 0; }
|
|
50% { opacity: 1; }
|
|
100% { transform: rotate(360deg); opacity: 1; }
|
|
}
|
|
|
|
.animate-entrance-spin {
|
|
animation: entrance-spin 0.6s ease-out forwards;
|
|
}
|
|
|
|
/* Onboarding: step / phase entry — 400ms fade.
|
|
* Applied on mount so every new step (and intra-step phase switch, via
|
|
* key=phase remount) plays once. `both` fill-mode commits the `from`
|
|
* styles pre-animation to avoid a single-frame flash at natural state
|
|
* before the animation grabs.
|
|
*
|
|
* Earlier iteration also included a 4px translateY rise. Removed because
|
|
* the transform on h-full step roots was getting counted into the
|
|
* parent's scrollable overflow (web onboarding page + desktop
|
|
* WindowOverlay both wrap with overflow-y-auto), producing a brief
|
|
* scrollbar flash on each step entry. Pure opacity has no such side
|
|
* effect. */
|
|
@keyframes onboarding-enter {
|
|
from { opacity: 0; }
|
|
}
|
|
|
|
.animate-onboarding-enter {
|
|
animation: onboarding-enter 0.4s ease both;
|
|
}
|
|
|
|
/* Chat FAB: gentle color + border tint while a chat task is running.
|
|
* Keeps the ring at the same thickness — only hue shifts towards brand
|
|
* at half-cycle, no outer glow. */
|
|
@keyframes chat-impulse {
|
|
0%, 100% {
|
|
color: var(--muted-foreground);
|
|
box-shadow: 0 0 0 1px color-mix(in oklab, var(--foreground) 10%, transparent);
|
|
}
|
|
50% {
|
|
color: var(--brand);
|
|
box-shadow: 0 0 0 1px color-mix(in oklab, var(--brand) 40%, transparent);
|
|
}
|
|
}
|
|
|
|
.animate-chat-impulse {
|
|
animation: chat-impulse 1.6s ease-in-out infinite;
|
|
}
|
|
|
|
/* ShimmerText keeps the base glyphs still and moves a masked highlight over
|
|
* them. Counter-translate the copy so only the light moves, avoiding the
|
|
* per-frame text repaint caused by animating a clipped background-position.
|
|
* The old 200%-wide gradient travelled four label widths per 2.5s cycle.
|
|
* A six-width window with a repeating two-width mask preserves that spacing
|
|
* and covers the label throughout the sweep, including the loop seam. */
|
|
@keyframes shimmer-text-window {
|
|
from { transform: translateX(-66.6666666667%); }
|
|
to { transform: translateX(0%); }
|
|
}
|
|
|
|
@keyframes shimmer-text-copy {
|
|
from { transform: translateX(400%); }
|
|
to { transform: translateX(0%); }
|
|
}
|
|
|
|
.shimmer-text {
|
|
display: inline-block;
|
|
position: relative;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
vertical-align: bottom;
|
|
white-space: nowrap;
|
|
text-overflow: ellipsis;
|
|
color: var(--muted-foreground);
|
|
}
|
|
|
|
.shimmer-text-window {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 600%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
user-select: none;
|
|
mask-image: linear-gradient(90deg, transparent 35%, black 50%, transparent 65%);
|
|
mask-size: calc(100% / 3) 100%;
|
|
mask-repeat: repeat-x;
|
|
animation: shimmer-text-window 2.5s linear infinite;
|
|
}
|
|
|
|
.shimmer-text-copy {
|
|
display: block;
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: calc(100% / 6);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
color: var(--foreground);
|
|
animation: shimmer-text-copy 2.5s linear infinite;
|
|
}
|
|
|
|
/* Navigation progress bar: 2px brand-colored indeterminate sweep with a
|
|
* right-edge glow that shows across the top of the dashboard while a
|
|
* transition-wrapped push/replace is committing. Driven by useIsNavigating();
|
|
* independent of the actual network, so it disappears the moment React commits
|
|
* the new route. */
|
|
@keyframes nav-progress-sweep {
|
|
0% { transform: translateX(-100%); }
|
|
100% { transform: translateX(100%); }
|
|
}
|
|
|
|
.animate-nav-progress-sweep {
|
|
animation: nav-progress-sweep 1.4s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
|
}
|
|
|
|
/* Border beam: a brand-tinted highlight sweeps continuously around the
|
|
* host's rounded border, drawing the eye to a CTA that would otherwise blend
|
|
* into the chrome (e.g. the "switch to agent" affordance in manual create).
|
|
* Rendered by <BorderBeam /> inside a positioned host. The ring is a static
|
|
* mask that carves a 1px band out of the host's border box and inherits its
|
|
* radius; inside it, a square conic gradient rotates via transform. Rotating
|
|
* the gradient about the host's center looks the same as sweeping its start
|
|
* angle, but the compositor can run it without repainting the ring every
|
|
* frame. Pair with a subtle background tint on the host so the highlight has
|
|
* something to ride on at low contrast. */
|
|
@keyframes border-beam-rotate {
|
|
from { transform: translate(-50%, -50%) rotate(0turn); }
|
|
to { transform: translate(-50%, -50%) rotate(1turn); }
|
|
}
|
|
|
|
.border-beam-ring {
|
|
position: absolute;
|
|
inset: 0;
|
|
border-radius: inherit;
|
|
padding: 1px;
|
|
overflow: hidden;
|
|
pointer-events: none;
|
|
container-type: size;
|
|
-webkit-mask:
|
|
linear-gradient(#000 0 0) content-box,
|
|
linear-gradient(#000 0 0);
|
|
-webkit-mask-composite: xor;
|
|
mask-composite: exclude;
|
|
}
|
|
|
|
.border-beam-light {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
/* Width + height is at least the diagonal, so the square covers the ring at
|
|
* every angle. */
|
|
width: calc(100cqw + 100cqh);
|
|
aspect-ratio: 1;
|
|
background: conic-gradient(
|
|
transparent 0deg,
|
|
transparent 220deg,
|
|
#ffbe7b 245deg,
|
|
#ff777f 270deg,
|
|
#ff8ab4 295deg,
|
|
#a07cfe 320deg,
|
|
#5b9dff 345deg,
|
|
transparent 360deg
|
|
);
|
|
animation: border-beam-rotate 3.2s linear infinite;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.border-beam-ring {
|
|
background: linear-gradient(
|
|
90deg,
|
|
#ffbe7b,
|
|
#ff777f,
|
|
#ff8ab4,
|
|
#a07cfe,
|
|
#5b9dff
|
|
);
|
|
}
|
|
|
|
.border-beam-light {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Sidebar: open triggers (dropdown/popover) get active background */
|
|
[data-sidebar="menu-button"][data-popup-open] {
|
|
background-color: var(--sidebar-accent);
|
|
color: var(--sidebar-accent-foreground);
|
|
}
|
|
|
|
/* Sidebar resizing follows the same cursor contract as
|
|
* react-resizable-panels: the resize cursor survives leaving the narrow rail
|
|
* and wins over descendants with their own cursor declarations. Live width is
|
|
* written directly to the layout shells and opt-in chrome consumers, so their
|
|
* toggle transitions must stay disabled until the gesture finishes. */
|
|
html[data-sidebar-resizing="true"],
|
|
html[data-sidebar-resizing="true"] * {
|
|
cursor: ew-resize !important;
|
|
user-select: none !important;
|
|
}
|
|
|
|
/* Row drag-and-drop shares the same cursor contract: while a dnd-kit drag
|
|
* is active the grabbing cursor must survive the pointer leaving the drag
|
|
* handle and win over descendants' own cursor declarations (including the
|
|
* preflight `cursor: pointer` on buttons the pointer crosses mid-drag). */
|
|
html[data-dnd-dragging="true"],
|
|
html[data-dnd-dragging="true"] * {
|
|
cursor: grabbing !important;
|
|
user-select: none !important;
|
|
}
|
|
|
|
[data-sidebar-resizing="true"] [data-slot="sidebar-gap"],
|
|
[data-sidebar-resizing="true"] [data-slot="sidebar-container"],
|
|
[data-sidebar-resizing="true"] [data-sidebar-resize-consumer] {
|
|
transition: none !important;
|
|
}
|
|
|
|
[data-sidebar-resizing="true"] [data-sidebar="rail"]::after {
|
|
background-color: var(--sidebar-border);
|
|
}
|
|
|
|
/* Right detail sidebars use react-resizable-panels, which sizes panels by
|
|
* updating the outer panel's flex-grow. Animate that property for button
|
|
* toggles only; mount-time layout restoration and resize sync should snap
|
|
* directly to the final state. */
|
|
[data-right-sidebar-panel][data-right-sidebar-motion="enabled"] {
|
|
transition-property: flex-grow;
|
|
transition-duration: 220ms;
|
|
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
|
}
|
|
|
|
[data-group]:has(> [data-separator="active"]) > [data-right-sidebar-panel] {
|
|
transition: none;
|
|
}
|
|
|
|
/* Each block owns only its own author/body, excluding nested replies.
|
|
* Keep space reserved and keyboard access intact when mouse actions fade out.
|
|
* Portaled menus hold their trigger visible even after focus leaves the block. */
|
|
[data-comment-actions] {
|
|
transition: opacity 120ms ease-out;
|
|
}
|
|
|
|
@media (hover: hover) and (pointer: fine) {
|
|
[data-comment-block]:not(:hover):not(:focus-within)
|
|
[data-comment-actions]:not(:has([aria-haspopup][aria-expanded="true"])) {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.animate-entrance-spin,
|
|
.animate-onboarding-enter,
|
|
.animate-chat-impulse,
|
|
.shimmer-text-window,
|
|
.shimmer-text-copy,
|
|
.animate-nav-progress-sweep {
|
|
animation: none;
|
|
}
|
|
|
|
.shimmer-text-window {
|
|
display: none;
|
|
}
|
|
|
|
[data-comment-actions],
|
|
[data-right-sidebar-panel] {
|
|
transition: none;
|
|
}
|
|
}
|
|
|
|
@media (forced-colors: active) {
|
|
.shimmer-text {
|
|
color: CanvasText;
|
|
}
|
|
|
|
.shimmer-text-window,
|
|
.shimmer-text-copy {
|
|
animation: none;
|
|
}
|
|
|
|
.shimmer-text-window {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
/* Sonner toast: align icon to first line of text, not vertically centered */
|
|
[data-sonner-toast] {
|
|
align-items: flex-start !important;
|
|
}
|
|
|
|
[data-sonner-toast] [data-icon] {
|
|
margin-top: 2.5px;
|
|
}
|
|
|
|
@layer base {
|
|
* {
|
|
@apply border-border outline-ring/50;
|
|
scrollbar-width: thin;
|
|
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
|
}
|
|
*::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
*::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
|
*::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
|
|
*::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-hover); }
|
|
body {
|
|
@apply bg-background text-foreground;
|
|
}
|
|
html {
|
|
@apply font-sans;
|
|
/* Auto-insert 1/4em space between CJK ideographs and Latin letters/numerals.
|
|
* Native CSS text-autospace (Chrome 119+, Electron recent versions).
|
|
* Progressive enhancement: browsers that don't support it simply ignore the rule. */
|
|
text-autospace: ideograph-alpha ideograph-numeric;
|
|
/* `style` keyword = allow style synthesis, forbid weight synthesis.
|
|
*
|
|
* Weight OFF: every face we load has real weights — Inter, Source Serif 4
|
|
* and Geist Mono are all variable (100-900) on both platforms, and landing's
|
|
* 400-only Instrument Serif is never set to another weight. Synthesis can
|
|
* only make those worse, and on the CJK tail of `--font-sans` it is actively
|
|
* harmful: `font-bold` against PingFang SC (which stops at Semibold) makes
|
|
* Chromium smear a fake 700, and the added stroke weight closes up the
|
|
* counters of dense Han glyphs. Forbidding it picks real Semibold instead.
|
|
*
|
|
* Style ON, deliberately: `auto` only synthesizes when no real italic face
|
|
* matches, so now that Inter Italic is loaded on both platforms the real face
|
|
* already wins for all Latin UI text and markdown <em>. What is left
|
|
* synthesizing is exactly the two stacks that ship no italic at all — the CJK
|
|
* fallbacks and Geist Mono — and there `font-style: italic` has no other
|
|
* visual carrier. Forbidding it would silently flatten every Chinese <em>,
|
|
* every editor italic mark, and every hljs comment to upright. */
|
|
font-synthesis: style;
|
|
}
|
|
|
|
@media (max-width: 767px), (pointer: coarse) {
|
|
input:not([type="button"]):not([type="checkbox"]):not([type="color"]):not([type="file"]):not([type="hidden"]):not([type="image"]):not([type="radio"]):not([type="range"]):not([type="reset"]):not([type="submit"]),
|
|
textarea,
|
|
select,
|
|
[contenteditable]:not([contenteditable="false"]) {
|
|
/* iOS Safari zooms the page when focused editable text is below 16px. */
|
|
font-size: 16px !important;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* In-page find (Cmd/Ctrl+F on the issue detail page). Matches are painted with
|
|
* the CSS Custom Highlight API — ranges only, no DOM mutation — so the tint
|
|
* layers cleanly over React-rendered markdown and the contenteditable
|
|
* title/description editors. `multica-find-active` is registered with a higher
|
|
* priority so the current match paints on top of the dimmer all-matches tint. */
|
|
::highlight(multica-find) {
|
|
background-color: var(--find-match);
|
|
color: var(--find-match-foreground);
|
|
}
|
|
::highlight(multica-find-active) {
|
|
background-color: var(--find-match-active);
|
|
color: var(--find-match-foreground);
|
|
}
|