/* Depth and motion for Mission Control. * * WHAT WAS WRONG, MEASURED * * Of 84 elements painted in the first screen, THREE had any box-shadow. The * whole interface sat on one plane, so nothing was above anything else and * every separation had to be drawn with a hairline -- 410 `1px solid` rules * across the stylesheets. That flatness, not the palette, is what reads as * old. * * Motion had the same shape of problem. 138 of the transitions in the first * screen animate `background`, `color` or `border-color` -- point at a thing * and it changes colour -- against 4 that touch `opacity` and 4 that touch a * dimension. Nothing arrives, nothing settles, nothing answers a press. The * vocabulary was hover-recolour, which is the interaction language of 2004. * * WHAT THIS IS NOT * * Not a repaint: this file sets no colour. The palette was not the complaint * and is not touched. * * SCOPE * * Mission Control only, on purpose. The elevation and motion scales below are * general, but they are applied to .mc-* and nothing else until they have been * looked at on a real screen. Four other views keep exactly the styling they * have today. */ /* ── the theme bridge ──────────────────────────────────────────────────── Open Props derives all six shadow levels from two inputs and switches them on `prefers-color-scheme`. The engine's theme is a USER choice carried on [data-theme], so somebody running the dark theme on a light OS would get light-mode shadows: a pale grey smudge under a dark card instead of depth. Re-declare the two inputs against our switch -- with Open Props' own values, so the two systems cannot drift -- and its scale follows. The OS-default case needs nothing; Open Props already handles it. */ /* STRENGTH, not level. Open Props' light default is 1%, tuned for a card on a PURE WHITE page where almost nothing is needed to separate the two. This app's ground is off-white and the cards are white, so at 1% every level of the ramp under-read: measured live, a 680x180 card carried a shadow nobody could see, and raising the LEVEL only lengthened a blur that was still too faint. Raising the strength lifts the whole ramp at once and keeps the relationships between its six steps intact, which is the point of having a ramp. 4% on the light ground; dark keeps Open Props' own 25%. Three declarations, not two: the first covers the DEFAULT case, where the reader has expressed no preference and no [data-theme] attribute exists — without it, anyone who never touched the theme toggle keeps the invisible 1%. The media query then restores dark for that same default case, because a bare :root here would otherwise beat Open Props' own dark block. */ :root { --shadow-strength: 4%; } @media (prefers-color-scheme: dark) { :root { --shadow-strength: 25%; } } :root[data-theme="light"] { --shadow-color: 220 3% 15%; --shadow-strength: 4%; } :root[data-theme="dark"] { --shadow-color: 220 40% 2%; --shadow-strength: 25%; } /* ── elevation by role ─────────────────────────────────────────────────── Height has to MEAN something or it is just decoration on every box. Three roles, and nothing else in Mission Control lifts: rest (--shadow-2) a card is an object sitting on the page raised (--shadow-4) the pointer is on it and it can be acted on press (--shadow-1) it has been pushed back down into the page The 2px rise is small on purpose. A card that jumps is a card that was designed to be noticed rather than used. */ .mc-cx { box-shadow: var(--shadow-2); /* Explicit properties, never `transition: all`: `all` animates things we never chose -- including layout properties, which is where jank comes from -- and it is set on 177 elements in the first screen today. */ transition: box-shadow var(--t-slow) var(--ease-3), transform var(--t-slow) var(--ease-3), border-color var(--t-fast) var(--ease-3); } @media (prefers-reduced-motion: no-preference) { .mc-cx:hover { transform: translateY(-2px); } /* The press answers in --t-fast: lift can be leisurely, but feedback to a finger cannot be, or the control feels like it missed the click. */ .mc-cx:active { transform: translateY(0) scale(0.995); transition-duration: var(--t-fast); } } /* Depth still changes without motion -- somebody on reduced-motion should lose the movement, not the affordance. */ .mc-cx:hover { box-shadow: var(--shadow-4); } .mc-cx:active { box-shadow: var(--shadow-1); } /* A focus ring and an elevation live in the same property, so the ring has to carry the shadow with it or keyboard focus would flatten the card. */ .mc-cx:focus-visible { box-shadow: var(--ring) var(--brand-ring), var(--shadow-4); outline: none; } /* ── the press on the primary action ───────────────────────────────────── The one control on this screen that starts work. It had a colour change and nothing else. */ @media (prefers-reduced-motion: no-preference) { .mc-enter-cta { transition: transform var(--t-fast) var(--ease-3), box-shadow var(--t-fast) var(--ease-3), background var(--t-fast) var(--ease), color var(--t-fast) var(--ease); } .mc-enter-cta:hover { transform: translateY(-1px); } .mc-enter-cta:active { transform: translateY(0) scale(0.97); } } /* The filled CTA takes LESS than a card, not more. Its dark fill already separates it from the page; at the raised strength, a card-weight shadow under it reads as a sticker. */ .mc-enter-cta { box-shadow: var(--shadow-1); } .mc-enter-cta:active { box-shadow: var(--shadow-1); } /* ── entrance ──────────────────────────────────────────────────────────── THE RULE THIS FILE MUST NOT BREAK: content is never parked invisible. Every animation here lives inside `prefers-reduced-motion: no-preference` and is applied by a class that app.js adds and then removes. If the CSS never loads, if the JS never runs, if the browser refuses the animation -- the resting state is the finished state and the cards are simply there. An entrance that can strand a paying customer on a blank grid is worse than no entrance at all. It also runs ONCE, when the view is first populated. Mission Control repaints whenever work lands, and a grid that replays its arrival every time somebody saves is a grid that punishes you for working. */ @media (prefers-reduced-motion: no-preference) { .mc-grid.is-entering > * { /* `backwards` so the delay holds the start frame rather than showing the element, hiding it, then animating it in. */ animation: mc-rise var(--t-slow) var(--ease-3) backwards; } /* Stagger, capped. Open Props' own slide-in-up travels 100%, which is right for a panel and wrong for a card in a grid, so the distance is ours; the easing is theirs. */ .mc-grid.is-entering > *:nth-child(1) { animation-delay: 0ms; } .mc-grid.is-entering > *:nth-child(2) { animation-delay: 40ms; } .mc-grid.is-entering > *:nth-child(3) { animation-delay: 80ms; } .mc-grid.is-entering > *:nth-child(4) { animation-delay: 120ms; } .mc-grid.is-entering > *:nth-child(5) { animation-delay: 160ms; } /* Everything past the fifth arrives together. A stagger long enough to watch is a stagger that has become a loading screen. */ .mc-grid.is-entering > *:nth-child(n+6) { animation-delay: 200ms; } } @keyframes mc-rise { from { opacity: 0; transform: translateY(8px); } /* No `to`: the resting state IS the destination, so this keyframe cannot disagree with the stylesheet about where the card ends up. */ } /* ── the label leans toward its action ─────────────────────────────────── "Review research →" is the thing the card is FOR, and it sat perfectly still while the card lit up around it. A 2px lean on hover is the oldest refined micro-interaction there is and it still works: the eye reads it as the card pointing at where the click goes. The nudge is on the whole label rather than the arrow alone because the arrow is a bare → inside the text node -- wrapping it would mean touching two markup sites in app.js for two pixels. */ @media (prefers-reduced-motion: no-preference) { .mc-cx-act-go { transition: transform var(--t-slow) var(--ease-3); } .mc-cx:hover .mc-cx-act-go { transform: translateX(2px); } } /* ── the secondary controls answer the same way ────────────────────────── The primary CTA got a press in the last pass and the two controls beside it did not, which reads worse than if none of them had: one button in a row behaving differently is a bug the eye notices before the mind does. */ @media (prefers-reduced-motion: no-preference) { .mc-enter-find, .ws-entry { transition: transform var(--t-fast) var(--ease-3), box-shadow var(--t-fast) var(--ease-3), border-color var(--t-fast) var(--ease-3), color var(--t-fast) var(--ease); } .mc-enter-find:active, .ws-entry:active { transform: scale(0.97); } /* Nav items take the press but never the lift: a sidebar that rises under the pointer turns a list into a row of buttons competing with the work. The transition lives on .nav-item in app.css, alongside the hover fade it already had — declaring a second one here would have replaced that fade rather than extended it. */ .nav-item:active { transform: scale(0.985); } } /* ── focus, everywhere the press went ──────────────────────────────────── Keyboard users get the same geometry as the mouse: one ring, carried on var(--ring) so test_shape_scale stays true, and never `outline: none` without a replacement. */ .mc-enter-cta:focus-visible, .mc-enter-find:focus-visible, .ws-entry:focus-visible { box-shadow: var(--ring) var(--brand-ring); outline: none; } /* ══ ENGINE-WIDE INTERACTION ═════════════════════════════════════════════ Mission Control was the pilot. This extends the same vocabulary to the rest of the engine — and the operative word is VOCABULARY, not coverage. There are 357 rules in these stylesheets that draw a bordered, filled, rounded box. Elevating all of them would raise everything, and a page where everything is raised reads exactly like a page where nothing is: depth only means something while most things stay on the ground. So: two roles, assigned by what the thing DOES, and nothing else gets touched. A CARD is content you open. It lifts under the pointer and presses in. A CONTROL is a thing you press. It presses, and never lifts — a toolbar that rises under the cursor turns into a row of hovering chiclets. Floating surfaces (.modal-panel, .acct-menu, .toast) already carry their own elevation and are deliberately absent: they are never pressed, and restating their shadow here would fight the sheet that owns them. */ /* ── cards: lift, then press ───────────────────────────────────────────── Gated on being ACTIVATABLE, not on carrying a card-ish class name. Measured on the live engine: `.card` is 23
elements and NONE of them is clickable — it is a layout container. Lifting it would be a false affordance, content that rises under the pointer and then does nothing when you press it, which is worse than no motion at all. `:is(button, a, [role=button])` makes that rule structural instead of a list I maintain by hand: a class only gets the lift on the routes where it is actually a control, and a future
never does. */ :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item):hover { box-shadow: var(--shadow-4); } :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item) { transition: box-shadow var(--t-slow) var(--ease-3), transform var(--t-slow) var(--ease-3), border-color var(--t-fast) var(--ease-3); } :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item):active { box-shadow: var(--shadow-1); } @media (prefers-reduced-motion: no-preference) { :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item):hover { transform: translateY(-2px); } :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item):active { transform: translateY(0) scale(0.995); transition-duration: var(--t-fast); } } :is(button, a, [role="button"]):is(.mc-recent, .card, .model-card, .primer-card, .cp-eg, .lib-row, .hist-item, .cds-item):focus-visible { box-shadow: var(--ring) var(--brand-ring), var(--shadow-4); outline: none; } /* ── controls: press only ──────────────────────────────────────────────── Every one of these already transitions its own colours; the property lists in app.css were widened to include `transform` in the same change that retired `transition: all`, so this adds the state and not a second, competing shorthand. */ @media (prefers-reduced-motion: no-preference) { .primary:not(:disabled):active, .primary-ghost:not(:disabled):active, .ghost:not(:disabled):active, .identify-btn:not(:disabled):active, .copy-btn:active, .expand-toggle:active, .modal-close:active, .nav-toggle:active, .theme-toggle:active, .cmd-trigger:active, .acct-trigger:active, .filter-clear:active, .crispr-vendor-btn:not(:disabled):active, .crispr-struct-mini:active, .tab:active { transform: scale(0.97); } }