Half the editor was on the phone and none of it could be touched
Reported as "quite a few options don't function on mobile", and measured in a real touch emulation rather than a narrow window — the distinction matters, because two of the three causes need `pointer: coarse` to appear at all. Three causes, one theme: the chrome was built for a pointer that hovers. The toolbar reveals itself on :hover — flex-wrap: wrap, overflow: visible. A touchscreen never hovers, so that rule never fired and the row stayed clipped at overflow: hidden permanently. Fourteen of its twenty-three controls could not be reached by any gesture: every heading, both lists, all three alignments, link, image, table, outline, and both AI passes. The faded right edge promised there was more that way, and there was no way. It now scrolls sideways on a coarse pointer, keeping every control, exactly as .petal-chrome-strip does one line above it — the same trade that comment already argued for, applied to the row it was comparing itself to. That move was only safe once the panels could get out. Every dropdown in the editor chrome — tone, export, colour, highlight, size — was already broken on a phone for a subtler reason: `position: fixed` is relative to the viewport only while no ancestor establishes a containing block, and mask-image does, exactly like transform. Both scrollers fade their edges with a mask. So on any screen narrow enough for the fade to appear, the menu was pulled back inside the very box it was escaping, painted under the toolbar and untappable. The comment in anchoredMenu.ts said "nothing clips a fixed box unless an ancestor has a transform, and none of the editor's chrome does"; it was true when it was written and had quietly stopped being true. The panels now portal to <body>, where nothing above them can clip, stack over or contain them whatever the chrome does with masks later, and the outside-tap tests ask about both halves. The kitten took the last two. She covered "Español" and "I am learning Português" in the drawer, and "Hide falling petals" in the status bar outright — three sample points across it, all three landing on the cat. She already knows how to yield: useCardOverlap fades her for cards and for anything with the modal role, and the drawer is the one overlay that has neither, being navigation rather than something opened on purpose. It is named there now. The status bar is a different problem — it is always present, so yielding to it would mean fading forever — and the honest fix is to sit above it on a narrow screen instead of negotiating with it every frame. Measuring, not eyeballing: a sweep of every visible, enabled control reports zero off-screen and zero blocked, against fourteen and three before. Desktop is deliberately untouched — at rest the slim clipped line with its mask, on hover the wrapped row with all twenty-three buttons on screen — and the edge measurement ChromeStrip already did is now shared with the toolbar rather than written twice. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
@@ -17,7 +17,14 @@ const POLL_MS = 500
|
||||
const HOLD_PX = 24
|
||||
|
||||
const CARD = '.petal-rail-card'
|
||||
const MODAL = '[role="dialog"][aria-modal="true"]'
|
||||
// The mobile sidebar drawer is named outright because it is the one overlay that
|
||||
// isn't a dialog. It slides over the page behind a scrim exactly as History and
|
||||
// Garden do, but it is the app's own navigation rather than something opened on
|
||||
// purpose, so it carries no modal role for the selector above to catch — and the
|
||||
// kitten sat in its bottom corner, over the last two rows of the language
|
||||
// picker. On a 390px phone that put "Español" and "I am learning Português"
|
||||
// under the halo: visibly there, and only partly tappable.
|
||||
const MODAL = '[role="dialog"][aria-modal="true"], .petal-sidebar.petal-drawer-open'
|
||||
|
||||
export interface CardOverlap {
|
||||
// A suggestion card reaches the mascot. It should get out of the way, but may
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useScrollEdge } from './useScrollEdge'
|
||||
|
||||
// ChromeStrip is the row of document pills (tone, history, export) on a screen
|
||||
// too narrow to hold them. It scrolls within itself rather than letting the
|
||||
@@ -15,7 +15,9 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
// The fade is a mask rather than a gradient overlay so it works on whatever is
|
||||
// behind it (the cream page, the night theme, a falling petal) without knowing
|
||||
// the background colour.
|
||||
type Edge = 'none' | 'left' | 'right' | 'both'
|
||||
//
|
||||
// The measuring itself lives in useScrollEdge, shared with the formatting
|
||||
// toolbar — which has to say the same thing for the same reason.
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
@@ -23,36 +25,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ChromeStrip({ className = '', children }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [edge, setEdge] = useState<Edge>('none')
|
||||
|
||||
// A pixel of slack: scrollLeft is fractional under browser zoom and on
|
||||
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
|
||||
// something like 0.5px remaining and fade an edge that has nothing behind it.
|
||||
const measure = useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
|
||||
const less = el.scrollLeft > 1
|
||||
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
measure()
|
||||
el.addEventListener('scroll', measure, { passive: true })
|
||||
// Both halves of "does it fit" can change without a scroll: the window
|
||||
// resizes, or the labels themselves change when she switches her pair
|
||||
// language and every pill in the row grows or shrinks at once.
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
for (const child of Array.from(el.children)) ro.observe(child)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', measure)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [measure])
|
||||
const { ref, edge } = useScrollEdge<HTMLDivElement>()
|
||||
|
||||
return (
|
||||
<div ref={ref} data-edge={edge} className={`petal-chrome-strip ${className}`}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { usePack } from '../../i18n'
|
||||
import { useAnchoredMenu } from './anchoredMenu'
|
||||
@@ -36,18 +37,20 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
const pk = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
||||
const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 200)
|
||||
const current = TONES.find((t) => t.value === value) ?? TONES[0]
|
||||
|
||||
// Click outside closes the menu.
|
||||
// Click outside closes the menu. The list is portalled to <body>, so a tap on
|
||||
// an option is not inside `ref` and has to be asked about separately.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||
const target = e.target as Node
|
||||
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
}, [open, panelRef])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="shrink-0">
|
||||
@@ -75,8 +78,10 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="listbox"
|
||||
className="petal-word-card p-1.5"
|
||||
style={{
|
||||
@@ -117,7 +122,8 @@ export function ToneSelect({ value, onChange }: Props) {
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,15 +6,40 @@ import { useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
// button's wrapper, which was fine until that wrapper became ChromeStrip — a
|
||||
// horizontal scroller, and so a box that clips what overflows it. An absolute
|
||||
// menu inside it is 36px tall and scrolls away with the pills. Positioning the
|
||||
// menu against the viewport instead takes it out of the strip's hands entirely:
|
||||
// nothing clips a fixed box unless an ancestor has a transform, and none of the
|
||||
// editor's chrome does.
|
||||
// menu against the viewport instead takes it out of the strip's hands entirely.
|
||||
//
|
||||
// Or rather: it does once the menu is also *portalled out* of it, which is the
|
||||
// part this originally got wrong. `position: fixed` is only relative to the
|
||||
// viewport while no ancestor establishes a containing block for it — and a
|
||||
// `mask-image` does, exactly like a transform. Both scrollers fade their edges
|
||||
// with a mask (that is how each says "there is more this way"), so on any screen
|
||||
// narrow enough for the fade to appear — i.e. every phone — the menu was pulled
|
||||
// back inside the very box it was trying to escape: painted underneath the
|
||||
// toolbar, and untappable. It looked open and did nothing.
|
||||
//
|
||||
// So the panel is rendered through a portal into <body>. Nothing above it can
|
||||
// clip it, stack over it, or contain it, whatever the chrome does with masks
|
||||
// later. `panelRef` is returned for the outside-tap test, which can no longer
|
||||
// rely on the panel being a DOM descendant of the trigger's wrapper.
|
||||
//
|
||||
// The trade is that a fixed box doesn't follow its anchor, so anything that
|
||||
// moves the button — the page scrolling under it, the strip scrolling, the
|
||||
// window resizing — has to re-place the menu.
|
||||
export function useAnchoredMenu(open: boolean, width: number) {
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
//
|
||||
// The element type is a parameter because the two kinds of caller anchor
|
||||
// against different things: the tone and export pills hand it their own
|
||||
// <button>, while the toolbar's popovers anchor against the wrapper that holds
|
||||
// trigger and panel together (it is that wrapper an outside-tap test already
|
||||
// asks about, so measuring anything else would be a second source of truth).
|
||||
export function useAnchoredMenu<T extends HTMLElement = HTMLButtonElement>(
|
||||
open: boolean,
|
||||
width: number,
|
||||
) {
|
||||
const triggerRef = useRef<T>(null)
|
||||
// The portalled panel. Attach it to the element the style is spread onto, so
|
||||
// an outside-tap test can ask "was this inside the menu?" of a node that is no
|
||||
// longer beneath the trigger in the tree.
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
// Nothing to place before the first measurement; keeping it off-screen rather
|
||||
// than at 0,0 means no flash in the top-left corner on open.
|
||||
const [style, setStyle] = useState<CSSProperties>({ position: 'fixed', top: -9999, left: -9999 })
|
||||
@@ -41,5 +66,5 @@ export function useAnchoredMenu(open: boolean, width: number) {
|
||||
}
|
||||
}, [open, width])
|
||||
|
||||
return { triggerRef, style }
|
||||
return { triggerRef, panelRef, style }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Which end of a horizontal scroller has more behind it.
|
||||
//
|
||||
// Extracted from ChromeStrip when the formatting toolbar needed the same
|
||||
// answer. Both rows are in the same situation and it is a harsher one than most
|
||||
// scrollers face: the row is the *only* way to reach what is in it, so a control
|
||||
// that has scrolled out of sight is indistinguishable from a control that does
|
||||
// not exist. Fading the edge that has more behind it is the one cue that tells
|
||||
// those two apart.
|
||||
//
|
||||
// The caller owns the element and the styling; this hook only measures. Apply
|
||||
// the returned `edge` as a `data-edge` attribute and let CSS decide what a
|
||||
// faded edge looks like — the two rows sit on different backgrounds and mask
|
||||
// themselves at slightly different insets.
|
||||
export type Edge = 'none' | 'left' | 'right' | 'both'
|
||||
|
||||
export function useScrollEdge<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null)
|
||||
const [edge, setEdge] = useState<Edge>('none')
|
||||
|
||||
// A pixel of slack: scrollLeft is fractional under browser zoom and on
|
||||
// high-DPI screens, so an exactly-scrolled-to-the-end strip can report
|
||||
// something like 0.5px remaining and fade an edge that has nothing behind it.
|
||||
const measure = useCallback(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
const more = el.scrollWidth - el.clientWidth - el.scrollLeft > 1
|
||||
const less = el.scrollLeft > 1
|
||||
setEdge(less && more ? 'both' : less ? 'left' : more ? 'right' : 'none')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
measure()
|
||||
el.addEventListener('scroll', measure, { passive: true })
|
||||
// Both halves of "does it fit" can change without a scroll: the window
|
||||
// resizes, or the labels themselves change when she switches her pair
|
||||
// language and every control in the row grows or shrinks at once.
|
||||
const ro = new ResizeObserver(measure)
|
||||
ro.observe(el)
|
||||
for (const child of Array.from(el.children)) ro.observe(child)
|
||||
return () => {
|
||||
el.removeEventListener('scroll', measure)
|
||||
ro.disconnect()
|
||||
}
|
||||
}, [measure])
|
||||
|
||||
return { ref, edge }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { api, type ExportFormat } from '../../api/client'
|
||||
import { usePack } from '../../i18n'
|
||||
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||
@@ -29,16 +30,20 @@ export function ExportMenu({ docId }: Props) {
|
||||
const t = usePack()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
||||
const { triggerRef, panelRef, style: menuStyle } = useAnchoredMenu(open, 220)
|
||||
|
||||
// The menu is portalled to <body>, so a tap on a format is outside `ref` and
|
||||
// has to be asked about separately or it would close the menu instead of
|
||||
// exporting.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false)
|
||||
const target = e.target as Node
|
||||
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open])
|
||||
}, [open, panelRef])
|
||||
|
||||
return (
|
||||
<div ref={ref} className="shrink-0">
|
||||
@@ -63,8 +68,10 @@ export function ExportMenu({ docId }: Props) {
|
||||
<span style={{ color: 'var(--color-muted)' }}>· Export</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="menu"
|
||||
className="petal-word-card p-1.5"
|
||||
style={{
|
||||
@@ -116,7 +123,8 @@ export function ExportMenu({ docId }: Props) {
|
||||
Print / PDF
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { useEditorState } from '@tiptap/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { uploadImageInto } from '../Editor/EditorCore'
|
||||
import { useAnchoredMenu } from '../Editor/anchoredMenu'
|
||||
import { useScrollEdge } from '../Editor/useScrollEdge'
|
||||
import { usePack } from '../../i18n'
|
||||
|
||||
interface Props {
|
||||
@@ -56,9 +59,26 @@ const Divider = () => (
|
||||
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
|
||||
)
|
||||
|
||||
// A popover anchored under its trigger. The trigger + panel share a relative
|
||||
// wrapper; `open`/`onClose` are owned by the toolbar so only one is open at once.
|
||||
// A pointer-down outside the wrapper closes it.
|
||||
// A popover anchored under its trigger. The trigger + panel share a wrapper;
|
||||
// `open`/`onClose` are owned by the toolbar so only one is open at once. A
|
||||
// pointer-down outside the wrapper closes it.
|
||||
//
|
||||
// The panel is placed in viewport coordinates rather than absolutely inside
|
||||
// that wrapper, for the same two reasons ChromeStrip's menus were (see
|
||||
// useAnchoredMenu) — and on a phone both of them bite at once:
|
||||
//
|
||||
// * The toolbar clips what overflows it. On a desktop that clip is lifted on
|
||||
// hover, which is where an absolutely-positioned panel got away with it for
|
||||
// as long as it did; a touchscreen never hovers, so tapping A or H opened a
|
||||
// panel that was simply not on the screen. The button lit up and nothing
|
||||
// else happened, which is the worst shape a bug can take — it reads as the
|
||||
// feature not existing.
|
||||
// * `left-0` hangs a 200px panel off the right edge of a 390px phone when its
|
||||
// trigger sits near the end of the row. useAnchoredMenu clamps it back
|
||||
// inside the window instead.
|
||||
//
|
||||
// The panel stays a DOM child of the wrapper (fixed, not portalled) so the
|
||||
// outside-tap test below keeps working on containment alone.
|
||||
function Popover({
|
||||
open,
|
||||
onClose,
|
||||
@@ -72,23 +92,27 @@ function Popover({
|
||||
children: React.ReactNode
|
||||
width?: number
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const { triggerRef: ref, panelRef, style } = useAnchoredMenu<HTMLDivElement>(open, width)
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose()
|
||||
const target = e.target as Node
|
||||
// The panel is portalled to <body>, so "inside" is either half.
|
||||
if (!ref.current?.contains(target) && !panelRef.current?.contains(target)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', onDown)
|
||||
return () => document.removeEventListener('mousedown', onDown)
|
||||
}, [open, onClose])
|
||||
}, [open, onClose, ref, panelRef])
|
||||
return (
|
||||
<div ref={ref} className="relative flex items-center">
|
||||
<div ref={ref} className="flex items-center">
|
||||
{trigger}
|
||||
{open && (
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
className="absolute left-0 top-full z-40 mt-1.5 p-2"
|
||||
ref={panelRef}
|
||||
className="p-2"
|
||||
style={{
|
||||
width,
|
||||
...style,
|
||||
borderRadius: 'var(--radius-card)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
@@ -96,7 +120,8 @@ function Popover({
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
@@ -175,6 +200,10 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
||||
const [menu, setMenu] = useState<'color' | 'highlight' | 'size' | 'link' | 'table' | 'outline' | null>(null)
|
||||
const [linkUrl, setLinkUrl] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
// Which end of the row still has controls behind it. Only ever visible on a
|
||||
// coarse pointer, where the row scrolls instead of expanding on hover — see
|
||||
// the .petal-toolbar rules in index.css.
|
||||
const { ref: toolbarRef, edge } = useScrollEdge<HTMLDivElement>()
|
||||
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
@@ -255,6 +284,8 @@ export function Toolbar({ editor, onVoiceCheck, voicing, onCollocationCheck, col
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-edge={edge}
|
||||
className="petal-toolbar mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
|
||||
style={{
|
||||
borderRadius: 'var(--radius-card)',
|
||||
|
||||
@@ -564,6 +564,64 @@ button, a, input {
|
||||
padding-top: 0.25rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* The formatting toolbar reaches everything it holds by expanding on hover
|
||||
(see .petal-toolbar above). A touchscreen never hovers, so that rule never
|
||||
fired here and the row stayed clipped at `overflow: hidden` for good: on a
|
||||
390px phone roughly 750px of it — every heading, both lists, all three
|
||||
alignments, link, image, table, outline, and both AI passes — could not be
|
||||
reached at all. The faded edge said "there is more this way" and there was
|
||||
no way.
|
||||
|
||||
So on a coarse pointer the row does what the pill strip does one line
|
||||
above it: keeps every control and scrolls sideways, but only itself.
|
||||
overscroll-behavior stops a swipe that runs out of buttons from dragging
|
||||
the page of writing along with it, and the scrollbar is hidden because a
|
||||
half-visible button at the edge is the affordance. The panels that hang off
|
||||
these buttons are placed in viewport coordinates (see Popover in
|
||||
Toolbar.tsx), so nothing here clips them. */
|
||||
.petal-toolbar {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
}
|
||||
.petal-toolbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* :hover can still be reported on a touchscreen — a tap leaves a lingering
|
||||
hover state on the last thing touched — and the desktop rule would answer
|
||||
it by unwrapping the row mid-scroll. Hold the scrolling shape instead. */
|
||||
.petal-toolbar:hover,
|
||||
.petal-toolbar:focus-within {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
/* Which edge has more behind it, from the same measurement the pill strip
|
||||
uses (useScrollEdge → data-edge). A row whose buttons all fit is left
|
||||
unmasked, so the fade only ever appears when it means something. */
|
||||
.petal-toolbar[data-edge='right'] {
|
||||
-webkit-mask-image: linear-gradient(to right, #000 92%, transparent 100%);
|
||||
mask-image: linear-gradient(to right, #000 92%, transparent 100%);
|
||||
}
|
||||
.petal-toolbar[data-edge='left'] {
|
||||
-webkit-mask-image: linear-gradient(to left, #000 92%, transparent 100%);
|
||||
mask-image: linear-gradient(to left, #000 92%, transparent 100%);
|
||||
}
|
||||
.petal-toolbar[data-edge='both'] {
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent 0%,
|
||||
#000 8%,
|
||||
#000 92%,
|
||||
transparent 100%
|
||||
);
|
||||
mask-image: linear-gradient(to right, transparent 0%, #000 8%, #000 92%, transparent 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive sidebar (narrow screens) ------------------------------------
|
||||
@@ -604,6 +662,17 @@ button, a, input {
|
||||
z-index: 20;
|
||||
background: rgba(61, 46, 57, 0.18);
|
||||
}
|
||||
|
||||
/* Sit the mascot above the status bar rather than on top of it.
|
||||
--petal-companion-size bottoms out at 9rem, which is most of a phone's
|
||||
width, and at `bottom-4` the bottom of that circle lands inside the 2.75rem
|
||||
status bar — directly over "Hide falling petals", which could not be tapped
|
||||
at all. The kitten yields to cards and panels (useCardOverlap) but the
|
||||
status bar is neither: it is always there, so yielding to it would mean
|
||||
fading forever. Moving up once is the honest fix. */
|
||||
.petal-corner {
|
||||
bottom: calc(2.75rem + 0.5rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* Small phones only: see the header in App.tsx for why the wordmark yields.
|
||||
|
||||
Reference in New Issue
Block a user