Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# Project: `froq` → https://froq.pages.dev
# Custom domain: Dashboard → Pages → froq → Custom domains → froq.me
#
# `main` deploys the production branch. Pull requests deploy a preview
# branch (not production) so the PR can be clicked before merge.
#
# Create token: https://dash.cloudflare.com/profile/api-tokens
# (Workers template + Cloudflare Pages:Edit is enough)

Expand All @@ -16,15 +19,17 @@ name: Deploy Nuxt to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read
deployments: write
pull-requests: write

concurrency:
group: pages
cancel-in-progress: false
group: pages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
deploy:
Expand Down Expand Up @@ -52,8 +57,18 @@ jobs:
NUXT_PUBLIC_FROQ_API: ${{ secrets.NUXT_PUBLIC_FROQ_API }}
run: pnpm generate

- name: Resolve Pages branch
id: pages
run: |
if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then
echo "branch=${GITHUB_HEAD_REF}" >> "$GITHUB_OUTPUT"
else
echo "branch=main" >> "$GITHUB_OUTPUT"
fi

# Use workspace wrangler (workers/froq-api) so the action does not
# `pnpm add wrangler` under pnpm 11 (ERR_PNPM_IGNORED_BUILDS).
# Preview PRs must not pass --branch=main (that is production).
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v4
Expand All @@ -63,9 +78,14 @@ jobs:
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
workingDirectory: workers/froq-api
packageManager: pnpm
command: pages deploy ../../.output/public --project-name=froq --branch=main --commit-dirty=true
command: pages deploy ../../.output/public --project-name=froq --branch=${{ steps.pages.outputs.branch }} --commit-dirty=true

- name: Deployment URL
run: |
echo "url: ${{ steps.deploy.outputs.deployment-url }}"
echo "alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}"
{
echo "### Cloudflare Pages"
echo "- url: ${{ steps.deploy.outputs.deployment-url }}"
echo "- alias: ${{ steps.deploy.outputs.pages-deployment-alias-url }}"
} >> "$GITHUB_STEP_SUMMARY"
22 changes: 22 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,25 @@ rt {
[un-prose] .math-inline {
--uno: 'text-[1.05em]';
}

html[data-sticky-cursor],
html[data-sticky-cursor] * {
cursor: none !important;
}

html[data-sticky-cursor] :is(
input,
textarea,
select,
[contenteditable]:not([contenteditable='false'])
) {
cursor: text !important;
}

html[data-sticky-cursor] [un-cursor*='grab'] {
cursor: grab !important;
}

html[data-sticky-cursor] [un-cursor*='grab']:active {
cursor: grabbing !important;
}
190 changes: 190 additions & 0 deletions app/components/SiteStickyCursor.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
<script setup lang="ts">
const RING = 0.18
const LEAVE_MS = 160

const capable = shallowRef(false)
const visible = shallowRef(false)
const lag = shallowRef(true)
const aim = shallowRef<'idle' | 'hot' | 'native'>('idle')
const dotX = shallowRef(0)
const dotY = shallowRef(0)
const ringX = shallowRef(0)
const ringY = shallowRef(0)

let raf = 0
let leaveTimer = 0
let mx = 0
let my = 0
let rx = 0
let ry = 0
let primed = false

function root() {
return import.meta.client ? document.documentElement : null
}

function stopLoop() {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
}

function loop() {
const k = lag.value ? RING : 1
rx += (mx - rx) * k
ry += (my - ry) * k
ringX.value = rx
ringY.value = ry
raf = requestAnimationFrame(loop)
}

function setCapable(next: boolean) {
capable.value = next
const el = root()
if (!el)
return
if (next) {
el.setAttribute('data-sticky-cursor', '')
paintStickyCursorCss(true)
}
else {
el.removeAttribute('data-sticky-cursor')
paintStickyCursorCss(false)
}
if (!next) {
visible.value = false
aim.value = 'idle'
stopLoop()
primed = false
}
}

function syncPrefs() {
lag.value = stickyCursorLag()
setCapable(stickyCursorCapable())
}

function onPointerMove(event: PointerEvent) {
if (!capable.value) {
if (stickyCursorCapable(event.pointerType))
setCapable(true)
else
return
}
if (leaveTimer) {
window.clearTimeout(leaveTimer)
leaveTimer = 0
}
mx = event.clientX
my = event.clientY
dotX.value = mx
dotY.value = my
if (!primed) {
rx = mx
ry = my
ringX.value = mx
ringY.value = my
primed = true
loop()
}
visible.value = true
aim.value = stickyCursorAim(event.target)
}

function onPointerOut(event: PointerEvent) {
if (event.relatedTarget)
return
if (leaveTimer)
window.clearTimeout(leaveTimer)
leaveTimer = window.setTimeout(() => {
visible.value = false
aim.value = 'idle'
}, LEAVE_MS)
}

onMounted(() => {
syncPrefs()
const fine = window.matchMedia(STICKY_CURSOR_MQ)
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)')
fine.addEventListener('change', syncPrefs)
reduce.addEventListener('change', syncPrefs)
window.addEventListener('pointermove', onPointerMove, { passive: true })
document.addEventListener('pointerout', onPointerOut)
onUnmounted(() => {
fine.removeEventListener('change', syncPrefs)
reduce.removeEventListener('change', syncPrefs)
window.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerout', onPointerOut)
if (leaveTimer)
window.clearTimeout(leaveTimer)
stopLoop()
root()?.removeAttribute('data-sticky-cursor')
paintStickyCursorCss(false)
})
})
</script>

<template>
<Teleport to="body">
<span
v-show="capable && visible && aim !== 'native'"
class="sticky-cursor-dot"
aria-hidden="true"
:style="{ transform: `translate3d(${dotX}px, ${dotY}px, 0)` }"
/>
<span
v-show="capable && visible && aim !== 'native'"
class="sticky-cursor-ring"
aria-hidden="true"
:data-hot="aim === 'hot' ? '' : undefined"
:style="{ transform: `translate3d(${ringX}px, ${ringY}px, 0)` }"
/>
</Teleport>
</template>

<style scoped>
.sticky-cursor-dot,
.sticky-cursor-ring {
position: fixed;
top: 0;
left: 0;
z-index: 400;
pointer-events: none;
border-radius: 50%;
}

.sticky-cursor-dot {
width: 7px;
height: 7px;
margin: -3.5px 0 0 -3.5px;
background: var(--ink);
}

.sticky-cursor-ring {
width: 28px;
height: 28px;
margin: -14px 0 0 -14px;
border: 1.5px solid var(--ink);
background: color-mix(in srgb, var(--ink) 10%, transparent);
transition:
width 0.28s var(--ease-out),
height 0.28s var(--ease-out),
margin 0.28s var(--ease-out),
background 0.28s var(--ease-out);
}

.sticky-cursor-ring[data-hot] {
width: 52px;
height: 52px;
margin: -26px 0 0 -26px;
background: color-mix(in srgb, var(--colored-ink) 16%, transparent);
border-color: var(--colored-ink);
}

@media (prefers-reduced-motion: reduce) {
.sticky-cursor-ring {
transition: none;
}
}
</style>
1 change: 1 addition & 0 deletions app/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,6 @@ const hideChromeMast = computed(() => isHome.value || isHub.value)
<AppFooter v-if="!isHome" />
<StatsSession />
<SelectionToolbar />
<SiteStickyCursor />
</div>
</template>
82 changes: 82 additions & 0 deletions app/utils/stickyCursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export const STICKY_CURSOR_FIELD_SEL
= 'input, textarea, select, [contenteditable]:not([contenteditable="false"])'

export const STICKY_CURSOR_HOT_SEL = [
'a[href]',
'button:not([disabled])',
'[role="button"]',
'summary',
'label[for]',
'[un-cursor-pointer]',
].join(', ')

export const STICKY_CURSOR_NATIVE_SEL
= `${STICKY_CURSOR_FIELD_SEL}, [un-cursor*="grab"], iframe`

export const STICKY_CURSOR_MQ
= '(pointer: fine) and (hover: hover)'

export const STICKY_CURSOR_STYLE_ID = 'sticky-cursor-css'

const STICKY_CURSOR_CSS = `
html[data-sticky-cursor],
html[data-sticky-cursor] * {
cursor: none !important;
}
html[data-sticky-cursor] :is(
input,
textarea,
select,
[contenteditable]:not([contenteditable='false'])
) {
cursor: text !important;
}
html[data-sticky-cursor] [un-cursor*='grab'] {
cursor: grab !important;
}
html[data-sticky-cursor] [un-cursor*='grab']:active {
cursor: grabbing !important;
}
`

export function paintStickyCursorCss(on: boolean) {
if (typeof document === 'undefined')
return
const existing = document.getElementById(STICKY_CURSOR_STYLE_ID)
if (!on) {
existing?.remove()
return
}
if (existing)
return
const style = document.createElement('style')
style.id = STICKY_CURSOR_STYLE_ID
style.textContent = STICKY_CURSOR_CSS
document.head.appendChild(style)
}

export type StickyCursorAim = 'idle' | 'hot' | 'native'

export function stickyCursorAim(target: EventTarget | null): StickyCursorAim {
if (!(target instanceof Element))
return 'idle'
if (target.closest(STICKY_CURSOR_NATIVE_SEL))
return 'native'
if (target.closest(STICKY_CURSOR_HOT_SEL))
return 'hot'
return 'idle'
}

export function stickyCursorLag(): boolean {
if (typeof window === 'undefined')
return false
return !window.matchMedia('(prefers-reduced-motion: reduce)').matches
}

export function stickyCursorCapable(pointerType?: string): boolean {
if (typeof window === 'undefined')
return false
if (pointerType === 'mouse' || pointerType === 'pen')
return true
return window.matchMedia(STICKY_CURSOR_MQ).matches
}
Loading
Loading