Compare commits

..

11 Commits

Author SHA1 Message Date
Alec Schitzkat 7bbafd0026 BLD - Bump version to 0.2.0 2026-08-20 21:35:31 +02:00
DerEchteAlec 300329825f Merge pull request #4 from sky-systems/feat/inter-font
ENH - use Inter across phone UI and improve home label readability
2026-08-20 19:21:05 +02:00
DerEchteAlec 93d2263f14 FIX - resolve PR 4 merge conflict 2026-08-20 19:18:23 +02:00
DerEchteAlec dca0868753 FIX - stabilize homescreen dragging across scaling (#18)
* FIX - stabilize homescreen dragging across scaling

Render the home drag preview in an unzoomed viewport portal and normalize FiveM CEF DOM geometry before hit testing and drop settling. Cover Chrome 103, fractional scaling, 4K, ultrawide, and non-16:9 viewport behavior.

* FIX - Implement LB guest app layout and lifecycle bridge

Ensure custom LB apps are properly sized and visible by applying the required CSS contract on initialization and upon receiving the 'componentsLoaded' signal.

* FIX - match uppercase script tags in LB bridge test
2026-08-20 19:14:00 +02:00
DerEchteAlec 074117fe33 Merge branch 'dev' into feat/inter-font 2026-08-19 20:11:48 +02:00
DerEchteAlec a1025f4520 Merge branch 'dev' into feat/inter-font 2026-08-19 19:21:42 +02:00
DerEchteAlec 7b8bab75a8 Merge branch 'dev' into feat/inter-font 2026-08-19 15:56:56 +02:00
DerEchteAlec c0492a70cb BLD - merge dev into Inter font branch 2026-08-19 15:34:49 +02:00
DerEchteAlec 05169acbfe BLD - add GitHub contribution and release automation 2026-08-19 14:20:46 +02:00
DerEchteAlec 086b77e464 ENH - enlarge home screen labels 2026-08-19 13:52:06 +02:00
DerEchteAlec de613e2502 ENH - use Inter across phone UI 2026-08-19 13:41:45 +02:00
38 changed files with 1131 additions and 103 deletions
+94
View File
@@ -26,6 +26,100 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.
## Inter 4.1
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
## Framework7 documentation placeholder media ## Framework7 documentation placeholder media
The development-only Sky UI Kitchen Sink includes placeholder images mirrored The development-only Sky UI Kitchen Sink includes placeholder images mirrored
+5
View File
@@ -1778,6 +1778,11 @@ onBeforeUnmount(() => {
" "
class="phone-resolution-wrapper phone-resolution-wrapper--primary" class="phone-resolution-wrapper phone-resolution-wrapper--primary"
> >
<div
id="phone-home-drag-portal"
class="phone-home-drag-portal"
aria-hidden="true"
></div>
<div class="phone-resolution-canvas phone-resolution-canvas--primary"> <div class="phone-resolution-canvas phone-resolution-canvas--primary">
<section <section
class="phone-device" class="phone-device"
@@ -92,6 +92,10 @@ describe('browser development preview contract', () => {
it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => { it('uses layout zoom so the fixed-resolution phone stays sharply rasterized', () => {
expect(source).toContain('phone-resolution-canvas--primary') expect(source).toContain('phone-resolution-canvas--primary')
expect(source).toMatch(
/phone-resolution-wrapper--primary[\s\S]*?id="phone-home-drag-portal"[\s\S]*?phone-resolution-canvas--primary/,
)
expect(source.match(/id="phone-home-drag-portal"/g)).toHaveLength(1)
expect(source).toContain("'--phone-rendered-height'") expect(source).toContain("'--phone-rendered-height'")
expect(source).toContain("'--phone-rendered-width'") expect(source).toContain("'--phone-rendered-width'")
expect(mainCss).toMatch( expect(mainCss).toMatch(
Binary file not shown.
Binary file not shown.
+52 -14
View File
@@ -211,9 +211,11 @@
} }
} }
:root { :root {
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', font-feature-settings:
sans-serif; 'liga' 1,
'calt' 1;
font-optical-sizing: auto;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@@ -234,7 +236,9 @@ body,
user-select: none; user-select: none;
} }
button, button,
input { input,
textarea,
select {
font: inherit; font: inherit;
} }
button { button {
@@ -295,6 +299,18 @@ button {
height: var(--phone-rendered-height, 844px); height: var(--phone-rendered-height, 844px);
} }
.phone-home-drag-portal {
position: absolute;
z-index: 70;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.phone-home-drag-portal * {
pointer-events: none !important;
}
.phone-resolution-canvas { .phone-resolution-canvas {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -1286,8 +1302,7 @@ button {
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: #f5f5f7; color: #f5f5f7;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', sans-serif;
} }
.darkchat-page button, .darkchat-page button,
.darkchat-page input, .darkchat-page input,
@@ -2622,8 +2637,30 @@ button {
.springboard--widget-dragging .springboard-widget-page-scroll { .springboard--widget-dragging .springboard-widget-page-scroll {
overflow: visible; overflow: visible;
} }
.springboard--home-dragging .springboard-page--apps { .home-drag-layer {
overflow: visible; position: absolute;
overflow: hidden;
pointer-events: none;
}
.home-drag-layer * {
pointer-events: none !important;
}
.home-drag-position {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.home-drag-ghost {
position: absolute;
top: 0;
left: 0;
margin: 0;
opacity: 1;
transition: none !important;
animation: none !important;
transform-origin: 0 0;
will-change: transform;
} }
.app-grid { .app-grid {
display: grid; display: grid;
@@ -2658,6 +2695,9 @@ button {
transition: transform var(--springboard-page-duration) transition: transform var(--springboard-page-duration)
var(--springboard-page-easing); var(--springboard-page-easing);
} }
.app-icon-item--drag-source {
opacity: 0;
}
.app-icon-remove { .app-icon-remove {
position: absolute; position: absolute;
z-index: 4; z-index: 4;
@@ -2783,7 +2823,6 @@ button {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
font-size: 11px;
} }
.app-icon--calculator { .app-icon--calculator {
background: linear-gradient(145deg, #76767b, #1b1b1d); background: linear-gradient(145deg, #76767b, #1b1b1d);
@@ -7542,18 +7581,17 @@ button {
min-height: var(--springboard-grid-row); min-height: var(--springboard-grid-row);
} }
.app-icon-button { .app-icon-button {
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.app-icon-label { .app-icon-label {
min-height: 15px; min-height: var(--sky-home-label-height);
font-size: 11.5px; font-size: var(--sky-home-label-font-size);
font-weight: 500; font-weight: 500;
letter-spacing: -0.15px; letter-spacing: -0.15px;
line-height: 15px; line-height: var(--sky-home-label-height);
text-align: center; text-align: center;
} }
.springboard-edit-add { .springboard-edit-add {
+5 -2
View File
@@ -30,11 +30,13 @@ const props = withDefaults(
app: PhoneAppDefinition app: PhoneAppDefinition
compact?: boolean compact?: boolean
editMode?: boolean editMode?: boolean
externalDragVisual?: boolean
showLabel?: boolean showLabel?: boolean
}>(), }>(),
{ {
compact: false, compact: false,
editMode: false, editMode: false,
externalDragVisual: false,
showLabel: true, showLabel: true,
}, },
) )
@@ -63,14 +65,14 @@ let dragStartPage = 0
let dragPageWidth = 0 let dragPageWidth = 0
let dragPageMetrics: SpringboardDragMetrics | null = null let dragPageMetrics: SpringboardDragMetrics | null = null
const dragStyle = computed(() => const dragStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
} }
: undefined, : undefined,
) )
const dragPointerStyle = computed(() => const dragPointerStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
} }
@@ -300,6 +302,7 @@ onBeforeUnmount(() => {
class="app-icon-item" class="app-icon-item"
:class="{ :class="{
'app-icon-item--compact': compact, 'app-icon-item--compact': compact,
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging, 'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode, 'app-icon-item--editing': editMode,
}" }"
+5 -2
View File
@@ -22,11 +22,13 @@ const props = withDefaults(
apps: PhoneAppDefinition[] apps: PhoneAppDefinition[]
defaultName: string defaultName: string
editMode?: boolean editMode?: boolean
externalDragVisual?: boolean
folder: HomeFolder folder: HomeFolder
showLabel?: boolean showLabel?: boolean
}>(), }>(),
{ {
editMode: false, editMode: false,
externalDragVisual: false,
showLabel: true, showLabel: true,
}, },
) )
@@ -55,14 +57,14 @@ let stopPointerSession: (() => void) | null = null
const folderName = computed(() => props.folder.name || props.defaultName) const folderName = computed(() => props.folder.name || props.defaultName)
const dragStyle = computed(() => const dragStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`, transform: `translate3d(${springboardPageDragCompensation(dragStartPage, phone.currentPage, dragPageWidth)}px, 0, 0)`,
} }
: undefined, : undefined,
) )
const dragPointerStyle = computed(() => const dragPointerStyle = computed(() =>
isDragging.value isDragging.value && !props.externalDragVisual
? { ? {
transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`, transform: `translate3d(${dragOffset.value.x}px, ${dragOffset.value.y}px, 0)`,
} }
@@ -232,6 +234,7 @@ onBeforeUnmount(() => {
<div <div
class="home-folder-item app-icon-item" class="home-folder-item app-icon-item"
:class="{ :class="{
'app-icon-item--drag-source': isDragging && externalDragVisual,
'app-icon-item--dragging': isDragging, 'app-icon-item--dragging': isDragging,
'app-icon-item--editing': editMode, 'app-icon-item--editing': editMode,
'home-folder-item--dragging': isDragging, 'home-folder-item--dragging': isDragging,
@@ -331,8 +331,7 @@ function finishPageSwipe(event: PointerEvent): void {
z-index: 70; z-index: 70;
inset: 0; inset: 0;
color: #fff; color: #fff;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif;
} }
.home-folder-backdrop { .home-folder-backdrop {
+1 -1
View File
@@ -402,7 +402,7 @@ onBeforeUnmount(() => {
rgb(30 38 42 / 35%), rgb(30 38 42 / 35%),
rgb(0 0 0 / 84%) 72% rgb(0 0 0 / 84%) 72%
); );
font-family: 'Segoe UI', Arial, sans-serif; font-family: var(--sky-font-family);
pointer-events: auto; pointer-events: auto;
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -210,7 +210,7 @@ onBeforeUnmount(() => {
z-index: 40; z-index: 40;
display: flex; display: flex;
pointer-events: none; pointer-events: none;
font-family: Inter, ui-sans-serif, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.radio-hud[data-horizontal='left'] { .radio-hud[data-horizontal='left'] {
@@ -677,9 +677,7 @@ onBeforeUnmount(() => {
backdrop-filter: blur(26px) saturate(125%); backdrop-filter: blur(26px) saturate(125%);
-webkit-backdrop-filter: blur(26px) saturate(125%); -webkit-backdrop-filter: blur(26px) saturate(125%);
cursor: pointer; cursor: pointer;
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text',
'Segoe UI', sans-serif;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
touch-action: none; touch-action: none;
@@ -0,0 +1,59 @@
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
const sourceDirectory = fileURLToPath(new URL('..', import.meta.url))
const tokensSource = readFileSync(new URL('./tokens.css', import.meta.url), 'utf8')
const mainCssSource = readFileSync(
new URL('../assets/main.css', import.meta.url),
'utf8',
)
function styleSources(directory: string): Array<{
file: string
source: string
}> {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) return styleSources(path)
if (!/\.(?:css|vue)$/.test(entry.name)) return []
return [{ file: path, source: readFileSync(path, 'utf8') }]
})
}
describe('Inter font contract', () => {
it('bundles normal and italic variable fonts for every supported weight', () => {
expect(
existsSync(new URL('../assets/fonts/InterVariable.woff2', import.meta.url)),
).toBe(true)
expect(
existsSync(
new URL('../assets/fonts/InterVariable-Italic.woff2', import.meta.url),
),
).toBe(true)
expect(tokensSource.match(/@font-face/g)).toHaveLength(2)
expect(tokensSource.match(/font-weight:\s*100 900;/g)).toHaveLength(2)
expect(tokensSource).toContain("--sky-font-family: 'Inter', Arial, sans-serif;")
})
it('applies the shared font to the document and native form controls', () => {
expect(mainCssSource).toMatch(
/:root\s*\{[\s\S]*?font-family:\s*var\(--sky-font-family\);/,
)
expect(mainCssSource).toMatch(
/button,\s*input,\s*textarea,\s*select\s*\{\s*font:\s*inherit;/,
)
})
it('does not bypass the shared token with a generic system UI stack', () => {
const genericSystemStack =
/font-family\s*:\s*(?:-apple-system|BlinkMacSystemFont|system-ui|ui-sans-serif|['"]Segoe UI['"])/
const violations = styleSources(sourceDirectory)
.filter(({ source }) => genericSystemStack.test(source))
.map(({ file }) => file.slice(sourceDirectory.length + 1))
expect(violations).toEqual([])
})
})
+2 -3
View File
@@ -525,9 +525,8 @@
height: var(--sky-widget-label-height); height: var(--sky-widget-label-height);
overflow: hidden; overflow: hidden;
color: var(--sky-widget-label-color, #fff); color: var(--sky-widget-label-color, #fff);
font-family: font-family: var(--sky-font-family);
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', sans-serif; font-size: var(--sky-home-label-font-size);
font-size: 11.5px;
font-weight: 500; font-weight: 500;
letter-spacing: -0.15px; letter-spacing: -0.15px;
line-height: var(--sky-widget-label-height); line-height: var(--sky-widget-label-height);
+20 -4
View File
@@ -1,3 +1,19 @@
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 100 900;
font-display: swap;
src: url('../assets/fonts/InterVariable-Italic.woff2') format('woff2');
}
:root { :root {
--sky-device-pixel-ratio: 1; --sky-device-pixel-ratio: 1;
--sky-hairline-scale: 1; --sky-hairline-scale: 1;
@@ -26,9 +42,7 @@
--sky-font-title: 17px; --sky-font-title: 17px;
--sky-font-medium-title: 24px; --sky-font-medium-title: 24px;
--sky-font-large-title: 34px; --sky-font-large-title: 34px;
--sky-font-family: --sky-font-family: 'Inter', Arial, sans-serif;
-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'SF UI Text',
'Helvetica Neue', Helvetica, Arial, sans-serif;
--sky-transition-fast: 100ms; --sky-transition-fast: 100ms;
--sky-transition-normal: 200ms; --sky-transition-normal: 200ms;
--sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1); --sky-ease-out: cubic-bezier(0.22, 1, 0.36, 1);
@@ -55,8 +69,10 @@
--sky-shadow-thumb: --sky-shadow-thumb:
0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12); 0 0.5px 4px rgba(0, 0, 0, 0.12), 0 6px 13px rgba(0, 0, 0, 0.12);
--sky-glass-highlight-color: rgba(255, 255, 255, 0.5); --sky-glass-highlight-color: rgba(255, 255, 255, 0.5);
--sky-home-label-font-size: 13px;
--sky-home-label-height: 16px;
--sky-widget-label-gap: 5px; --sky-widget-label-gap: 5px;
--sky-widget-label-height: 15px; --sky-widget-label-height: var(--sky-home-label-height);
--sky-widget-radius-small: 23px; --sky-widget-radius-small: 23px;
--sky-widget-radius-medium: 25px; --sky-widget-radius-medium: 25px;
--sky-widget-radius-large: 28px; --sky-widget-radius-large: 28px;
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { runInNewContext } from 'node:vm'
import type { ExternalPhoneAppDefinition } from '@/types/apps' import type { ExternalPhoneAppDefinition } from '@/types/apps'
import { import {
@@ -139,6 +140,79 @@ describe('LB Phone app bridge', () => {
expect(() => new Function(runtime)).not.toThrow() expect(() => new Function(runtime)).not.toThrow()
}) })
it('applies the LB iframe layout contract before revealing vendor apps', () => {
const document = createLbPhoneFrameDocument(
'<!doctype html><html><head></head><body style="visibility:hidden"></body></html>',
{
appName: 'radio-app',
localStorage: {},
resourceName: 'lb-radioapp',
settings: createLbPhoneHostSettings({
deviceName: 'Main phone',
isDarkMode: true,
language: 'en',
preferences: DEFAULT_PHONE_PREFERENCES,
securityEnabled: false,
}),
ui: 'https://cfx-nui-lb-radioapp/ui/dist/index.html',
},
)
const runtime = /<script>([\s\S]*?)<\/script>/i.exec(document)?.[1]
expect(runtime).toBeTruthy()
const messageListeners: Array<(event: { data: unknown }) => void> = []
const readyListeners: Array<() => void> = []
const documentElement = { dataset: {}, style: {} }
const body = {
dataset: {},
style: { visibility: 'hidden' },
}
const sandbox = {
addEventListener(
eventName: string,
listener: (event: { data: unknown }) => void,
) {
if (eventName === 'message') messageListeners.push(listener)
},
componentsLoaded: undefined as boolean | undefined,
console,
document: {
addEventListener(eventName: string, listener: () => void) {
if (eventName === 'DOMContentLoaded') readyListeners.push(listener)
},
body,
documentElement,
},
parent: { postMessage() {} },
}
runInNewContext(runtime ?? '', sandbox)
expect(body.style.visibility).toBe('hidden')
expect(readyListeners).toHaveLength(1)
readyListeners[0]?.()
expect(documentElement.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
width: '100%',
})
expect(body.dataset).toMatchObject({ device: 'phone', theme: 'dark' })
expect(body.style).toMatchObject({
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%',
})
body.style.visibility = 'hidden'
expect(messageListeners).toHaveLength(1)
messageListeners[0]?.({ data: 'componentsLoaded' })
expect(body.style.visibility).toBe('visible')
expect(sandbox.componentsLoaded).toBe(true)
})
it('persists isolated LB localStorage snapshots without app changes', () => { it('persists isolated LB localStorage snapshots without app changes', () => {
const values = new Map<string, string>() const values = new Map<string, string>()
const storage = { const storage = {
+28 -1
View File
@@ -130,6 +130,25 @@ function applySettings(nextSettings) {
if (document.body) document.body.dataset.theme = theme; if (document.body) document.body.dataset.theme = theme;
} }
function prepareDocument() {
Object.assign(document.documentElement.style, {
height: '100%',
margin: '0',
padding: '0',
width: '100%'
});
if (!document.body) return;
document.body.dataset.device = 'phone';
Object.assign(document.body.style, {
height: '100%',
margin: '0',
padding: '0',
visibility: 'visible',
width: '100%'
});
}
globalThis.resourceName = config.resourceName; globalThis.resourceName = config.resourceName;
globalThis.appName = config.appName; globalThis.appName = config.appName;
globalThis.components = globalThis.components ?? {}; globalThis.components = globalThis.components ?? {};
@@ -189,6 +208,11 @@ globalThis.getSettings = async () => globalThis.settings;
globalThis.addEventListener('message', (event) => { globalThis.addEventListener('message', (event) => {
const message = event.data; const message = event.data;
if (message === 'componentsLoaded') {
globalThis.componentsLoaded = true;
prepareDocument();
return;
}
if (!message || typeof message !== 'object') return; if (!message || typeof message !== 'object') return;
if (message.type === 'sky-phone:lb-settings') { if (message.type === 'sky-phone:lb-settings') {
@@ -210,7 +234,10 @@ globalThis.addEventListener('message', (event) => {
}); });
applySettings(config.settings); applySettings(config.settings);
document.addEventListener('DOMContentLoaded', () => applySettings(globalThis.settings), { once: true }); document.addEventListener('DOMContentLoaded', () => {
prepareDocument();
applySettings(globalThis.settings);
}, { once: true });
` `
function escapeAttribute(value: string): string { function escapeAttribute(value: string): string {
@@ -0,0 +1,174 @@
import { describe, expect, it } from 'vitest'
import {
normalizePhoneViewportRect,
readPhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
function measuredRect(
left: number,
top: number,
width: number,
height: number,
): RectMeasurement {
return { height, left, top, width }
}
function expectRectClose(
actual: PhoneViewportRect,
expected: PhoneViewportRect,
): void {
for (const key of [
'bottom',
'height',
'left',
'right',
'top',
'width',
] as const) {
expect(actual[key]).toBeCloseTo(expected[key], 6)
}
}
function createGeometryFixture(options: {
canvasOffsetHeight: number
canvasOffsetWidth: number
rawCanvasRect: RectMeasurement
wrapperRect: RectMeasurement
}): { anchor: Element; element: (rect: RectMeasurement) => Element } {
const wrapper = {
getBoundingClientRect: () => options.wrapperRect,
} as unknown as HTMLElement
const canvas = {
closest: (selector: string) =>
selector === '.phone-resolution-wrapper' ? wrapper : null,
getBoundingClientRect: () => options.rawCanvasRect,
offsetHeight: options.canvasOffsetHeight,
offsetWidth: options.canvasOffsetWidth,
} as unknown as HTMLElement
return {
anchor: {
closest: (selector: string) =>
selector === '.phone-resolution-canvas' ? canvas : null,
} as unknown as Element,
element: (rect) =>
({ getBoundingClientRect: () => rect }) as unknown as Element,
}
}
describe('phone viewport geometry', () => {
it('leaves modern Chrome measurements unchanged when the canvas BCR is already rendered', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const layer = measuredRect(1589.783336, 177.75, 289.533328, 603.2)
expectRectClose(normalizePhoneViewportRect(layer, wrapper, wrapper), {
bottom: layer.top + layer.height,
height: layer.height,
left: layer.left,
right: layer.left + layer.width,
top: layer.top,
width: layer.width,
})
})
it('calibrates live CEF 103 BCRs back inside the visible wrapper', () => {
const wrapper = measuredRect(1573.09, 126.25, 322.92, 698.832)
const rawCanvas = measuredRect(1899.87, 152.5, 389.98, 844)
const rawLayer = measuredRect(1920.03, 214.25, 349.66, 728.5)
const corrected = normalizePhoneViewportRect(rawLayer, rawCanvas, wrapper)
expect(corrected.left).toBeCloseTo(1589.7833360685163, 6)
expect(corrected.width).toBeCloseTo(289.5333278629674, 6)
expect(corrected.right).toBeCloseTo(1879.3166639314836, 6)
expect(corrected.left).toBeGreaterThan(wrapper.left)
expect(corrected.right).toBeLessThan(wrapper.left + wrapper.width)
})
it.each([
['80%', 0.6624],
['100%', 0.828],
['120%', 0.9936],
])(
'normalizes fractional positions, sizes, and deltas at %s scaling',
(_label, zoom) => {
const rawCanvas = measuredRect(1900.125, 212.75, 390, 844)
const wrapper = measuredRect(1530.5, 250.25, 390 * zoom, 844 * zoom)
const rawStart = measuredRect(
rawCanvas.left + 20.125,
rawCanvas.top + 50.375,
349.75,
73.125,
)
const rawEnd = measuredRect(
rawStart.left + 73.25,
rawStart.top - 41.75,
rawStart.width,
rawStart.height,
)
const start = normalizePhoneViewportRect(rawStart, rawCanvas, wrapper)
const end = normalizePhoneViewportRect(rawEnd, rawCanvas, wrapper)
expect(start.left).toBeCloseTo(wrapper.left + 20.125 * zoom, 6)
expect(start.top).toBeCloseTo(wrapper.top + 50.375 * zoom, 6)
expect(start.width).toBeCloseTo(349.75 * zoom, 6)
expect(start.height).toBeCloseTo(73.125 * zoom, 6)
expect(end.left - start.left).toBeCloseTo(73.25 * zoom, 6)
expect(end.top - start.top).toBeCloseTo(-41.75 * zoom, 6)
},
)
it('reads visual scale and normalized element rects from a canvas anchor', () => {
const fixture = createGeometryFixture({
canvasOffsetHeight: 844,
canvasOffsetWidth: 390,
rawCanvasRect: measuredRect(1899.87, 152.5, 389.98, 844),
wrapperRect: measuredRect(1573.09, 126.25, 322.92, 698.832),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
const layer = fixture.element(measuredRect(1920.03, 214.25, 349.66, 728.5))
expect(geometry).not.toBeNull()
expect(geometry?.scaleX).toBeCloseTo(0.828, 6)
expect(geometry?.scaleY).toBeCloseTo(0.828, 6)
expect(geometry?.rect(layer).left).toBeCloseTo(1589.7833360685163, 6)
})
it('uses finite identity fallbacks for zero geometry and missing anchors', () => {
const rawCanvas = measuredRect(100, 50, 0, 0)
const corrected = normalizePhoneViewportRect(
measuredRect(112.5, 58.25, 40, 20),
rawCanvas,
measuredRect(500, 300, 0, 0),
)
const fixture = createGeometryFixture({
canvasOffsetHeight: 0,
canvasOffsetWidth: 0,
rawCanvasRect: rawCanvas,
wrapperRect: measuredRect(500, 300, 0, 0),
})
const geometry = readPhoneViewportGeometry(fixture.anchor)
expect(corrected).toEqual({
bottom: 328.25,
height: 20,
left: 512.5,
right: 552.5,
top: 308.25,
width: 40,
})
expect(Object.values(corrected).every(Number.isFinite)).toBe(true)
expect(geometry?.scaleX).toBe(1)
expect(geometry?.scaleY).toBe(1)
expect(readPhoneViewportGeometry(null)).toBeNull()
expect(
readPhoneViewportGeometry({ closest: () => null } as unknown as Element),
).toBeNull()
})
})
@@ -0,0 +1,73 @@
export type PhoneViewportRect = {
bottom: number
height: number
left: number
right: number
top: number
width: number
}
type RectMeasurement = Pick<
DOMRectReadOnly,
'height' | 'left' | 'top' | 'width'
>
export type PhoneViewportGeometry = {
readonly scaleX: number
readonly scaleY: number
rect(element: Element): PhoneViewportRect
}
function positiveRatio(numerator: number, denominator: number): number {
return Number.isFinite(numerator) &&
Number.isFinite(denominator) &&
numerator > 0 &&
denominator > 0
? numerator / denominator
: 1
}
export function normalizePhoneViewportRect(
rawRect: RectMeasurement,
rawCanvasRect: RectMeasurement,
wrapperRect: RectMeasurement,
): PhoneViewportRect {
const factorX = positiveRatio(wrapperRect.width, rawCanvasRect.width)
const factorY = positiveRatio(wrapperRect.height, rawCanvasRect.height)
const left = wrapperRect.left + (rawRect.left - rawCanvasRect.left) * factorX
const top = wrapperRect.top + (rawRect.top - rawCanvasRect.top) * factorY
const width = rawRect.width * factorX
const height = rawRect.height * factorY
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
export function readPhoneViewportGeometry(
anchor: Element | null,
): PhoneViewportGeometry | null {
const canvas = anchor?.closest<HTMLElement>('.phone-resolution-canvas')
const wrapper = canvas?.closest<HTMLElement>('.phone-resolution-wrapper')
if (!canvas || !wrapper) return null
const rawCanvasRect = canvas.getBoundingClientRect()
const wrapperRect = wrapper.getBoundingClientRect()
return {
scaleX: positiveRatio(wrapperRect.width, canvas.offsetWidth),
scaleY: positiveRatio(wrapperRect.height, canvas.offsetHeight),
rect(element: Element): PhoneViewportRect {
return normalizePhoneViewportRect(
element.getBoundingClientRect(),
rawCanvasRect,
wrapperRect,
)
},
}
}
@@ -140,4 +140,56 @@ describe('springboard widget drag', () => {
expect(delta.x).toBeCloseTo(100) expect(delta.x).toBeCloseTo(100)
expect(delta.y).toBeCloseTo(200) expect(delta.y).toBeCloseTo(200)
}) })
it.each([
['80% preview', 0.6624],
['80% production clamp', 2 / 3],
['100%', 0.828],
['120%', 0.9936],
])('keeps the rendered drag delta 1:1 at %s zoom', (_label, zoom) => {
const layoutWidth = 350
const layoutHeight = 808
const viewportLeft = 128.25
const viewportTop = 64.5
const viewportWidth = layoutWidth * zoom
const viewportHeight = layoutHeight * zoom
const pointerStart = {
x: viewportLeft + 48.5 * zoom,
y: viewportTop + 132.25 * zoom,
}
const viewportDelta = { x: 73.25, y: -41.75 }
const start = springboardViewportToLocal(
pointerStart.x,
pointerStart.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const end = springboardViewportToLocal(
pointerStart.x + viewportDelta.x,
pointerStart.y + viewportDelta.y,
viewportLeft,
viewportTop,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
const localDelta = springboardViewportDeltaToLocal(
viewportDelta.x,
viewportDelta.y,
viewportWidth,
viewportHeight,
layoutWidth,
layoutHeight,
)
expect((end.x - start.x) * zoom).toBeCloseTo(viewportDelta.x, 6)
expect((end.y - start.y) * zoom).toBeCloseTo(viewportDelta.y, 6)
expect(localDelta.x * zoom).toBeCloseTo(viewportDelta.x, 6)
expect(localDelta.y * zoom).toBeCloseTo(viewportDelta.y, 6)
})
}) })
+5 -1
View File
@@ -1,3 +1,5 @@
import { readPhoneViewportGeometry } from '@/utils/phoneViewportGeometry'
export type PageTurnDirection = -1 | 0 | 1 export type PageTurnDirection = -1 | 0 | 1
export type SpringboardEdgeTurn = { export type SpringboardEdgeTurn = {
@@ -39,7 +41,9 @@ export function readSpringboardDragMetrics(
'.springboard-page, .home-folder-panel', '.springboard-page, .home-folder-panel',
) )
if (!surface) return null if (!surface) return null
const bounds = surface.getBoundingClientRect() const bounds =
readPhoneViewportGeometry(surface)?.rect(surface) ??
surface.getBoundingClientRect()
return { return {
layoutHeight: surface.offsetHeight, layoutHeight: surface.offsetHeight,
layoutWidth: surface.offsetWidth, layoutWidth: surface.offsetWidth,
@@ -19,11 +19,31 @@ const mainCss = readFileSync(
new URL('../assets/main.css', import.meta.url), new URL('../assets/main.css', import.meta.url),
'utf8', 'utf8',
) )
const tokensCss = readFileSync(
new URL('../ui/tokens.css', import.meta.url),
'utf8',
)
const foundationCss = readFileSync(
new URL('../ui/foundation.css', import.meta.url),
'utf8',
)
const builtInWallpaperCss = mainCss.slice( const builtInWallpaperCss = mainCss.slice(
mainCss.indexOf('.wallpaper--midnight'), mainCss.indexOf('.wallpaper--midnight'),
mainCss.indexOf('.wallpaper--custom'), mainCss.indexOf('.wallpaper--custom'),
) )
describe('Springboard page swipe contract', () => { describe('Springboard page swipe contract', () => {
it('keeps app and widget labels on the larger shared home typography', () => {
expect(tokensCss).toContain('--sky-home-label-font-size: 13px;')
expect(tokensCss).toContain('--sky-home-label-height: 16px;')
expect(mainCss).toMatch(
/\.app-icon-label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
expect(foundationCss).toMatch(
/\.sky-widget-frame__label\s*\{[\s\S]*?font-size:\s*var\(--sky-home-label-font-size\);/,
)
})
it('keeps the built-in wallpapers visually restrained', () => { it('keeps the built-in wallpapers visually restrained', () => {
expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/) expect(builtInWallpaperCss).not.toMatch(/(?:conic|repeating-\w+)-gradient/)
expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12) expect(builtInWallpaperCss.match(/radial-gradient/g)).toHaveLength(12)
@@ -94,36 +114,99 @@ describe('Springboard page swipe contract', () => {
expect(folderIconSource).toContain(':style="dragPointerStyle"') expect(folderIconSource).toContain(':style="dragPointerStyle"')
}) })
it('uses the same scale-aware in-phone drag path as widgets', () => { it('renders the home drag visual through the unzoomed phone portal', () => {
expect(viewSource).not.toContain('source.cloneNode(true)') expect(viewSource).toContain('source.cloneNode(true)')
expect(viewSource).not.toContain('<Teleport to="body">')
expect(viewSource).not.toContain(':external-drag-visual')
expect(viewSource).toContain( expect(viewSource).toContain(
"'springboard--home-dragging': draggingHomeApp !== null", '<Teleport defer to="#phone-home-drag-portal">',
) )
expect(viewSource).toContain('draggedElement?.getBoundingClientRect()') expect(viewSource).toContain(
expect(appIconSource).not.toContain('externalDragVisual') '<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>',
expect(folderIconSource).not.toContain('externalDragVisual') )
expect(mainCss).not.toContain('.home-drag-layer') expect(viewSource).toContain('readPhoneViewportGeometry')
expect(mainCss).not.toContain('.home-drag-ghost') expect(viewSource).toMatch(/const sourceBounds = \w+\.rect\(source\)/)
expect(mainCss).not.toContain('.app-icon-item--drag-source') expect(viewSource).toMatch(/const clipBounds = \w+\.rect\(\w+\)/)
expect(viewSource).not.toContain('homeDragLocalPoint')
expect(viewSource).not.toContain('springboardViewportToLocal')
expect(viewSource).toContain("position.className = 'home-drag-position'")
expect(viewSource).toMatch(
/homeDragGrip = \{\s*x: event\.clientX - sourceBounds\.left,\s*y: event\.clientY - sourceBounds\.top,/,
)
expect(viewSource).toMatch(
/ghost\.style\.transform = `scale\(\$\{\w+\.scaleX\}, \$\{\w+\.scaleY\}\)`/,
)
expect(
viewSource.match(/:external-drag-visual="homeDragVisualActive"/g),
).toHaveLength(4)
expect(viewSource).toContain('updateHomeDragGhost(event)')
expect(viewSource).toContain('const dropGhost = homeDragGhost')
expect(viewSource).toContain('const dropOrigin = homeDragPreviewBounds')
expect(viewSource).not.toContain('dropGhost?.getBoundingClientRect()')
expect(viewSource).toContain('clearHomeDragGhost(dropGhost)')
expect(viewSource).not.toContain('draggedElement?.getBoundingClientRect()')
expect(viewSource).not.toContain('springboard--home-dragging')
expect(appIconSource).toContain('externalDragVisual?: boolean')
expect(folderIconSource).toContain('externalDragVisual?: boolean')
expect(appIconSource).toContain('app-icon-item--drag-source')
expect(folderIconSource).toContain('app-icon-item--drag-source')
expect(mainCss).toMatch( expect(mainCss).toMatch(
/\.springboard--home-dragging \.springboard-page--apps\s*\{[^}]*overflow:\s*visible;/s, /\.phone-home-drag-portal\s*\{[^}]*pointer-events:\s*none;/s,
) )
expect(mainCss).toContain('.home-drag-position')
expect(mainCss).toContain('.home-drag-ghost')
expect(mainCss).toContain('.app-icon-item--drag-source')
expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/) expect(mainCss).toMatch(/\.springboard\s*\{[\s\S]*?overflow:\s*hidden;/)
expect(mainCss).toMatch( expect(mainCss).toMatch(
/\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s, /\.springboard-page--apps\s*\{[^}]*overflow:\s*hidden;/s,
) )
}) })
it('cleans up the external drag visual on every terminal path', () => {
expect(
viewSource.match(
/@dragstart="startHomeDrag\('(grid|dock)', [^,]+, \$event\)"/g,
),
).toHaveLength(4)
expect(viewSource.match(/@dragcancel="stopHomeDrag"/g)).toHaveLength(4)
expect(viewSource).toContain(
'if (expectedGhost && homeDragGhost !== expectedGhost) return',
)
expect(viewSource).toMatch(
/if \(!dragged\) \{\s*clearHomeDragGhost\(\)\s*return/,
)
expect(viewSource).toMatch(
/animateHomeItemDrop\(\s*draggedItem,\s*dropArea,\s*dropIndex,\s*dropOrigin,?\s*\)\.finally\(/,
)
expect(viewSource).toMatch(
/function stopHomeDrag[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/if \(folderId\) \{[\s\S]*?clearHomeDragGhost\(\)/,
)
expect(viewSource).toMatch(
/onBeforeUnmount\(\(\) => \{[\s\S]*?clearHomeDragGhost\(\)/,
)
})
it('targets the active visual page and lets grid or dock drags turn pages', () => { it('targets the active visual page and lets grid or dock drags turn pages', () => {
expect(viewSource).toContain(':data-home-page="page.page"') expect(viewSource).toContain(':data-home-page="page.page"')
expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"') expect(viewSource).toContain(':data-home-target-offset="cell.targetOffset"')
expect(viewSource).toContain('nearestGridDropTarget') expect(viewSource).toContain('nearestGridDropTarget')
expect(viewSource).toContain('moveHomeAppToGridPage') expect(viewSource).toContain('moveHomeAppToGridPage')
expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'") expect(viewSource).not.toContain("draggingHomeApp.value?.area === 'grid'")
expect(viewSource).toContain('event.clientX < pageBounds.left') expect(viewSource).toContain('event.clientX < springboardBounds.left')
expect(viewSource).toContain('event.clientY > pageBounds.bottom') expect(viewSource).toContain('event.clientY > springboardBounds.bottom')
expect(viewSource).toContain('function homeDragViewportRect(')
expect(viewSource).toContain('if (geometry) return geometry.rect(element)')
expect(viewSource).toContain('homeDragViewportRect(pageElement, geometry)')
expect(viewSource).toContain('homeDragViewportRect(springboard, geometry)')
expect(viewSource).toContain('homeDragViewportRect(slot, geometry)')
expect(viewSource).toContain('homeDragViewportRect(dock, geometry)')
expect(viewSource).toMatch(
/homeDragViewportRect\(\s*appElement,\s*readPhoneViewportGeometry\(appElement\),?\s*\)/,
)
expect(viewSource).toContain(
'springboardBounds.left + (bounds.left - pageBounds.left)',
)
expect(viewSource).toContain('nearestSpringboardRectIndex') expect(viewSource).toContain('nearestSpringboardRectIndex')
expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')") expect(viewSource).toContain("queueEdgePageTurn(lastHomePointer, 'app')")
expect(viewSource).toContain("edgePageLocked = dragType === 'widget'") expect(viewSource).toContain("edgePageLocked = dragType === 'widget'")
+273 -35
View File
@@ -41,6 +41,11 @@ import {
type HomeItem, type HomeItem,
} from '@/utils/homeLayout' } from '@/utils/homeLayout'
import type { ReorderDirection } from '@/utils/keyboard' import type { ReorderDirection } from '@/utils/keyboard'
import {
readPhoneViewportGeometry,
type PhoneViewportGeometry,
type PhoneViewportRect,
} from '@/utils/phoneViewportGeometry'
import { layoutSpringboardHomePages } from '@/utils/springboardLayout' import { layoutSpringboardHomePages } from '@/utils/springboardLayout'
import { import {
maximumRenderedWidgetPage, maximumRenderedWidgetPage,
@@ -106,6 +111,8 @@ const draggingHomeApp = ref<{
area: HomeArea area: HomeArea
index: number index: number
} | null>(null) } | null>(null)
const homeDragLayer = ref<HTMLElement | null>(null)
const homeDragVisualActive = ref(false)
const temporaryHomePage = ref<number | null>(null) const temporaryHomePage = ref<number | null>(null)
const openedFolderId = ref<string | null>(null) const openedFolderId = ref<string | null>(null)
const folderDraggingOutside = ref(false) const folderDraggingOutside = ref(false)
@@ -125,6 +132,11 @@ let edgePageDirection = 0
let edgePageLocked = false let edgePageLocked = false
let folderHoverTimer: number | undefined let folderHoverTimer: number | undefined
let lastHomePointer: { clientX: number; clientY: number } | null = null let lastHomePointer: { clientX: number; clientY: number } | null = null
let homeDragGhost: HTMLElement | null = null
let homeDragGrip: { x: number; y: number } | null = null
let homeDragGeometry: PhoneViewportGeometry | null = null
let homeDragClipBounds: PhoneViewportRect | null = null
let homeDragPreviewBounds: PhoneViewportRect | null = null
const installedApps = computed(() => const installedApps = computed(() =>
PHONE_APPS.filter((app) => appStore.isInstalled(app.id)), PHONE_APPS.filter((app) => appStore.isInstalled(app.id)),
@@ -453,7 +465,7 @@ function startWidgetDrag(id: string, event: PointerEvent): void {
).find((candidate) => candidate.dataset.widgetId === id) ).find((candidate) => candidate.dataset.widgetId === id)
if (!widget) return if (!widget) return
pageTransitioning.value = false pageTransitioning.value = false
const bounds = widget.getBoundingClientRect() const bounds = homeDragViewportRect(widget)
draggingWidgetId.value = id draggingWidgetId.value = id
widgetDragGrip.value = { widgetDragGrip.value = {
x: event.clientX - bounds.left, x: event.clientX - bounds.left,
@@ -474,7 +486,7 @@ function updateWidgetDragPreview(event: {
) )
const pageElement = grid?.closest<HTMLElement>('.springboard-page') const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return if (!grid || !pageElement) return
const renderedGridBounds = grid.getBoundingClientRect() const renderedGridBounds = homeDragViewportRect(grid)
const gridStyle = getComputedStyle(grid) const gridStyle = getComputedStyle(grid)
const scaleX = const scaleX =
grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1 grid.offsetWidth > 0 ? renderedGridBounds.width / grid.offsetWidth : 1
@@ -529,7 +541,7 @@ function clearTemporaryHomePage(keepCurrentPage = false): void {
function resolveHomeEdgeTurn(event: { clientX: number }) { function resolveHomeEdgeTurn(event: { clientX: number }) {
const springboard = document.querySelector<HTMLElement>('.springboard') const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return null if (!springboard) return null
const bounds = springboard.getBoundingClientRect() const bounds = homeDragViewportRect(springboard)
const renderedLastPage = appPages.value.length const renderedLastPage = appPages.value.length
return resolveSpringboardHomeEdgeTurn( return resolveSpringboardHomeEdgeTurn(
event.clientX, event.clientX,
@@ -548,7 +560,7 @@ function queueEdgePageTurn(
): void { ): void {
const springboard = document.querySelector<HTMLElement>('.springboard') const springboard = document.querySelector<HTMLElement>('.springboard')
if (!springboard) return if (!springboard) return
const bounds = springboard.getBoundingClientRect() const bounds = homeDragViewportRect(springboard)
const turn = const turn =
dragType === 'app' dragType === 'app'
? resolveHomeEdgeTurn(event) ? resolveHomeEdgeTurn(event)
@@ -733,10 +745,166 @@ async function saveWidgetConfig(
if (configured) changePage(configured.page) if (configured) changePage(configured.page)
} }
function startHomeDrag(area: HomeArea, index: number): void { function homeDragViewportRect(
element: Element,
geometry = homeDragGeometry ?? readPhoneViewportGeometry(element),
): PhoneViewportRect {
if (geometry) return geometry.rect(element)
const bounds = element.getBoundingClientRect()
return {
bottom: bounds.bottom,
height: bounds.height,
left: bounds.left,
right: bounds.right,
top: bounds.top,
width: bounds.width,
}
}
function viewportRectAt(
left: number,
top: number,
width: number,
height: number,
): PhoneViewportRect {
return {
bottom: top + height,
height,
left,
right: left + width,
top,
width,
}
}
function clearHomeDragGhost(expectedGhost?: HTMLElement | null): void {
if (expectedGhost && homeDragGhost !== expectedGhost) return
homeDragGhost?.remove()
homeDragGhost = null
homeDragGrip = null
homeDragGeometry = null
homeDragClipBounds = null
homeDragPreviewBounds = null
homeDragVisualActive.value = false
const layer = homeDragLayer.value
layer?.style.removeProperty('left')
layer?.style.removeProperty('top')
layer?.style.removeProperty('width')
layer?.style.removeProperty('height')
}
function updateHomeDragGhost(event: {
clientX: number
clientY: number
}): void {
const ghost = homeDragGhost
const grip = homeDragGrip
const clipBounds = homeDragClipBounds
const previewBounds = homeDragPreviewBounds
if (!ghost || !grip || !clipBounds || !previewBounds) return
const left = event.clientX - grip.x
const top = event.clientY - grip.y
ghost.style.transform = `translate3d(${left - clipBounds.left}px, ${top - clipBounds.top}px, 0)`
homeDragPreviewBounds = viewportRectAt(
left,
top,
previewBounds.width,
previewBounds.height,
)
}
function createHomeDragGhost(event: PointerEvent): void {
clearHomeDragGhost()
const eventTarget =
event.currentTarget instanceof Element
? event.currentTarget
: event.target instanceof Element
? event.target
: null
const source = eventTarget?.closest<HTMLElement>('.app-icon-item')
const layer = homeDragLayer.value
const springboard = source?.closest<HTMLElement>('.springboard')
const portal = layer?.closest<HTMLElement>('.phone-home-drag-portal')
const geometry = readPhoneViewportGeometry(source ?? null)
if (!source || !layer || !springboard || !portal || !geometry) return
const portalBounds = portal.getBoundingClientRect()
const clipBounds = geometry.rect(springboard)
const sourceBounds = geometry.rect(source)
if (
clipBounds.width <= 0 ||
clipBounds.height <= 0 ||
sourceBounds.width <= 0 ||
sourceBounds.height <= 0
) {
return
}
layer.style.left = `${clipBounds.left - portalBounds.left}px`
layer.style.top = `${clipBounds.top - portalBounds.top}px`
layer.style.width = `${clipBounds.width}px`
layer.style.height = `${clipBounds.height}px`
const ghost = source.cloneNode(true) as HTMLElement
ghost.classList.remove(
'app-icon-item--dragging',
'app-icon-item--editing',
'app-icon-item--drag-source',
'app-icon-item--drop-settling',
'home-folder-item--dragging',
'home-item--folder-hover',
)
ghost.classList.add('home-drag-ghost')
ghost.removeAttribute('style')
ghost
.querySelector<HTMLElement>('.app-icon-button')
?.style.removeProperty('transform')
ghost.setAttribute('aria-hidden', 'true')
ghost.querySelector('.app-icon-remove')?.remove()
for (const element of [ghost, ...Array.from(ghost.querySelectorAll('*'))]) {
element.removeAttribute('id')
for (const attribute of element.getAttributeNames()) {
if (attribute.startsWith('data-home-')) element.removeAttribute(attribute)
}
}
for (const control of ghost.querySelectorAll<HTMLElement>(
'a, button, input, select, textarea, [tabindex]',
)) {
control.tabIndex = -1
}
const position = document.createElement('div')
position.className = 'home-drag-position'
position.style.width = `${sourceBounds.width}px`
position.style.height = `${sourceBounds.height}px`
ghost.style.width = `${source.offsetWidth}px`
ghost.style.height = `${source.offsetHeight}px`
ghost.style.transform = `scale(${geometry.scaleX}, ${geometry.scaleY})`
position.appendChild(ghost)
homeDragGhost = position
homeDragGrip = {
x: event.clientX - sourceBounds.left,
y: event.clientY - sourceBounds.top,
}
homeDragGeometry = geometry
homeDragClipBounds = clipBounds
homeDragPreviewBounds = sourceBounds
layer.appendChild(position)
homeDragVisualActive.value = true
updateHomeDragGhost(event)
}
function startHomeDrag(
area: HomeArea,
index: number,
event: PointerEvent,
): void {
clearTemporaryHomePage() clearTemporaryHomePage()
lastHomePointer = null lastHomePointer = null
draggingHomeApp.value = { area, index } draggingHomeApp.value = { area, index }
createHomeDragGhost(event)
} }
function clearFolderHover(): void { function clearFolderHover(): void {
@@ -754,10 +922,40 @@ function queueFolderHover(event: PointerEvent): void {
return return
} }
const targetElement = document const targetElement = Array.from(
.elementsFromPoint(event.clientX, event.clientY) document.querySelectorAll<HTMLElement>('[data-home-area][data-home-index]'),
.map((element) => element.closest<HTMLElement>('[data-home-index]')) )
.find((element) => element && !element.closest('.app-icon-item--dragging')) .filter((element) => {
const area = element.dataset.homeArea as HomeArea | undefined
const index = Number(element.dataset.homeIndex)
if (
(area !== 'grid' && area !== 'dock') ||
!Number.isInteger(index) ||
(area === dragged.area && index === dragged.index)
) {
return false
}
if (
area === 'grid' &&
Number(
element.closest<HTMLElement>('[data-home-page]')?.dataset.homePage,
) !== phone.currentPage
) {
return false
}
const bounds = homeDragViewportRect(element)
return (
event.clientX >= bounds.left &&
event.clientX <= bounds.right &&
event.clientY >= bounds.top &&
event.clientY <= bounds.bottom
)
})
.sort((left, right) => {
const leftIsDock = left.dataset.homeArea === 'dock'
const rightIsDock = right.dataset.homeArea === 'dock'
return Number(rightIsDock) - Number(leftIsDock)
})[0]
const area = targetElement?.dataset.homeArea as HomeArea | undefined const area = targetElement?.dataset.homeArea as HomeArea | undefined
const targetIndex = Number(targetElement?.dataset.homeIndex) const targetIndex = Number(targetElement?.dataset.homeIndex)
if ( if (
@@ -822,6 +1020,7 @@ function queueFolderHover(event: PointerEvent): void {
if (folderId) { if (folderId) {
draggingHomeApp.value = null draggingHomeApp.value = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage() clearTemporaryHomePage()
openedFolderId.value = folderId openedFolderId.value = folderId
} }
@@ -832,6 +1031,7 @@ function queueFolderHover(event: PointerEvent): void {
function moveHomeDrag(event: PointerEvent): void { function moveHomeDrag(event: PointerEvent): void {
if (!draggingHomeApp.value) return if (!draggingHomeApp.value) return
lastHomePointer = { clientX: event.clientX, clientY: event.clientY } lastHomePointer = { clientX: event.clientX, clientY: event.clientY }
updateHomeDragGhost(event)
if (resolveHomeEdgeTurn(event)) { if (resolveHomeEdgeTurn(event)) {
clearFolderHover() clearFolderHover()
queueEdgePageTurn(event, 'app') queueEdgePageTurn(event, 'app')
@@ -845,7 +1045,7 @@ async function animateHomeItemDrop(
item: HomeItem, item: HomeItem,
area: HomeArea, area: HomeArea,
index: number, index: number,
from: DOMRect, from: PhoneViewportRect,
): Promise<void> { ): Promise<void> {
await nextTick() await nextTick()
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
@@ -861,7 +1061,10 @@ async function animateHomeItemDrop(
) )
if (!appElement) return if (!appElement) return
const target = appElement.getBoundingClientRect() const target = homeDragViewportRect(
appElement,
readPhoneViewportGeometry(appElement),
)
const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal( const { x: offsetX, y: offsetY } = springboardViewportDeltaToLocal(
from.left - target.left, from.left - target.left,
from.top - target.top, from.top - target.top,
@@ -924,13 +1127,16 @@ function nearestGridDropTarget(event: {
`.springboard-page--apps[data-home-page="${page}"] .app-grid`, `.springboard-page--apps[data-home-page="${page}"] .app-grid`,
) )
const pageElement = grid?.closest<HTMLElement>('.springboard-page') const pageElement = grid?.closest<HTMLElement>('.springboard-page')
if (!grid || !pageElement) return null const springboard = grid?.closest<HTMLElement>('.springboard')
const pageBounds = pageElement.getBoundingClientRect() if (!grid || !pageElement || !springboard) return null
const geometry = homeDragGeometry ?? readPhoneViewportGeometry(springboard)
const pageBounds = homeDragViewportRect(pageElement, geometry)
const springboardBounds = homeDragViewportRect(springboard, geometry)
if ( if (
event.clientX < pageBounds.left || event.clientX < springboardBounds.left ||
event.clientX > pageBounds.right || event.clientX > springboardBounds.right ||
event.clientY < pageBounds.top || event.clientY < springboardBounds.top ||
event.clientY > pageBounds.bottom event.clientY > springboardBounds.bottom
) { ) {
return null return null
} }
@@ -940,7 +1146,15 @@ function nearestGridDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex( const closestIndex = nearestSpringboardRectIndex(
event.clientX, event.clientX,
event.clientY, event.clientY,
slots.map((slot) => slot.getBoundingClientRect()), slots.map((slot) => {
const bounds = homeDragViewportRect(slot, geometry)
return {
height: bounds.height,
left: springboardBounds.left + (bounds.left - pageBounds.left),
top: springboardBounds.top + (bounds.top - pageBounds.top),
width: bounds.width,
}
}),
) )
const closest = closestIndex === null ? null : slots[closestIndex] const closest = closestIndex === null ? null : slots[closestIndex]
const targetOffset = Number(closest?.dataset.homeTargetOffset) const targetOffset = Number(closest?.dataset.homeTargetOffset)
@@ -953,7 +1167,8 @@ function nearestDockDropTarget(event: {
}): HTMLElement | null { }): HTMLElement | null {
const dock = document.querySelector<HTMLElement>('.app-dock') const dock = document.querySelector<HTMLElement>('.app-dock')
if (!dock) return null if (!dock) return null
const dockBounds = dock.getBoundingClientRect() const geometry = homeDragGeometry ?? readPhoneViewportGeometry(dock)
const dockBounds = homeDragViewportRect(dock, geometry)
if ( if (
event.clientX < dockBounds.left || event.clientX < dockBounds.left ||
event.clientX > dockBounds.right || event.clientX > dockBounds.right ||
@@ -970,7 +1185,7 @@ function nearestDockDropTarget(event: {
const closestIndex = nearestSpringboardRectIndex( const closestIndex = nearestSpringboardRectIndex(
event.clientX, event.clientX,
event.clientY, event.clientY,
slots.map((slot) => slot.getBoundingClientRect()), slots.map((slot) => homeDragViewportRect(slot, geometry)),
) )
return closestIndex === null ? null : slots[closestIndex] return closestIndex === null ? null : slots[closestIndex]
} }
@@ -979,12 +1194,14 @@ function finishHomeDrag(event: PointerEvent): void {
clearEdgePageTurn() clearEdgePageTurn()
clearFolderHover() clearFolderHover()
const dragged = draggingHomeApp.value const dragged = draggingHomeApp.value
if (!dragged) return if (!dragged) {
clearHomeDragGhost()
return
}
updateHomeDragGhost(event)
const draggedItem = appStore.homeLayout[dragged.area][dragged.index] const draggedItem = appStore.homeLayout[dragged.area][dragged.index]
const draggedElement = document.querySelector<HTMLElement>( const dropGhost = homeDragGhost
`[data-home-area="${dragged.area}"][data-home-index="${dragged.index}"]`, const dropOrigin = homeDragPreviewBounds ? { ...homeDragPreviewBounds } : null
)
const dropOrigin = draggedElement?.getBoundingClientRect()
const dockTarget = nearestDockDropTarget(event) const dockTarget = nearestDockDropTarget(event)
const gridTarget = dockTarget ? null : nearestGridDropTarget(event) const gridTarget = dockTarget ? null : nearestGridDropTarget(event)
let dropArea = dragged.area let dropArea = dragged.area
@@ -1022,7 +1239,14 @@ function finishHomeDrag(event: PointerEvent): void {
lastHomePointer = null lastHomePointer = null
clearTemporaryHomePage(moved && gridTarget !== null) clearTemporaryHomePage(moved && gridTarget !== null)
if (moved && draggedItem && dropOrigin) { if (moved && draggedItem && dropOrigin) {
void animateHomeItemDrop(draggedItem, dropArea, dropIndex, dropOrigin) void animateHomeItemDrop(
draggedItem,
dropArea,
dropIndex,
dropOrigin,
).finally(() => clearHomeDragGhost(dropGhost))
} else {
clearHomeDragGhost()
} }
} }
@@ -1031,6 +1255,7 @@ function stopHomeDrag(): void {
clearFolderHover() clearFolderHover()
draggingHomeApp.value = null draggingHomeApp.value = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
clearTemporaryHomePage() clearTemporaryHomePage()
} }
@@ -1043,7 +1268,9 @@ function reorderHomeApp(
const appElement = document.querySelector<HTMLElement>( const appElement = document.querySelector<HTMLElement>(
`[data-home-area="${area}"][data-home-index="${sourceIndex}"]`, `[data-home-area="${area}"][data-home-index="${sourceIndex}"]`,
) )
const moveOrigin = appElement?.getBoundingClientRect() const moveOrigin = appElement
? homeDragViewportRect(appElement, readPhoneViewportGeometry(appElement))
: null
const targetIndex = homeKeyboardTarget( const targetIndex = homeKeyboardTarget(
appStore.homeLayout, appStore.homeLayout,
area, area,
@@ -1131,7 +1358,10 @@ function extractOpenedFolderApp(
`.home-folder-panel [data-folder-app-index="${sourceIndex}"]`, `.home-folder-panel [data-folder-app-index="${sourceIndex}"]`,
) )
?.closest<HTMLElement>('.app-icon-item') ?.closest<HTMLElement>('.app-icon-item')
const sourceBounds = sourceElement?.getBoundingClientRect() const geometry = readPhoneViewportGeometry(sourceElement ?? null)
const sourceBounds = sourceElement
? homeDragViewportRect(sourceElement, geometry)
: null
const candidates = Array.from( const candidates = Array.from(
document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'), document.querySelectorAll<HTMLElement>('[data-home-index][data-home-area]'),
).filter((element) => { ).filter((element) => {
@@ -1145,8 +1375,8 @@ function extractOpenedFolderApp(
}) })
const target = candidates.reduce<HTMLElement | null>((closest, candidate) => { const target = candidates.reduce<HTMLElement | null>((closest, candidate) => {
if (!closest) return candidate if (!closest) return candidate
const bounds = candidate.getBoundingClientRect() const bounds = homeDragViewportRect(candidate, geometry)
const closestBounds = closest.getBoundingClientRect() const closestBounds = homeDragViewportRect(closest, geometry)
const distance = Math.hypot( const distance = Math.hypot(
event.clientX - (bounds.left + bounds.width / 2), event.clientX - (bounds.left + bounds.width / 2),
event.clientY - (bounds.top + bounds.height / 2), event.clientY - (bounds.top + bounds.height / 2),
@@ -1212,6 +1442,7 @@ onBeforeUnmount(() => {
pendingWidgetPointer = null pendingWidgetPointer = null
lastWidgetPointer = null lastWidgetPointer = null
lastHomePointer = null lastHomePointer = null
clearHomeDragGhost()
temporaryHomePage.value = null temporaryHomePage.value = null
draggingHomeApp.value = null draggingHomeApp.value = null
draggingWidgetId.value = null draggingWidgetId.value = null
@@ -1227,7 +1458,6 @@ onBeforeUnmount(() => {
'springboard--dragging': dragging, 'springboard--dragging': dragging,
'springboard--editing': editMode, 'springboard--editing': editMode,
'springboard--folder-open': folderOverlayVisible, 'springboard--folder-open': folderOverlayVisible,
'springboard--home-dragging': draggingHomeApp !== null,
'springboard--widget-dragging': draggingWidgetId !== null, 'springboard--widget-dragging': draggingWidgetId !== null,
}, },
]" ]"
@@ -1303,6 +1533,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex" :data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset" :data-home-target-offset="cell.targetOffset"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
folderHoverTarget === `grid:${cell.sourceIndex}`, folderHoverTarget === `grid:${cell.sourceIndex}`,
@@ -1310,7 +1541,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)" @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@remove="removeHomeApp(cell.app.id)" @remove="removeHomeApp(cell.app.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1325,6 +1556,7 @@ onBeforeUnmount(() => {
:data-home-index="cell.sourceIndex" :data-home-index="cell.sourceIndex"
:data-home-target-offset="cell.targetOffset" :data-home-target-offset="cell.targetOffset"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="cell.folder" :folder="cell.folder"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
@@ -1333,7 +1565,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('grid', cell.sourceIndex)" @dragstart="startHomeDrag('grid', cell.sourceIndex, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@open="openFolder(cell.folder.id)" @open="openFolder(cell.folder.id)"
@reorder="reorderHomeApp('grid', cell.sourceIndex, $event)" @reorder="reorderHomeApp('grid', cell.sourceIndex, $event)"
@@ -1484,6 +1716,10 @@ onBeforeUnmount(() => {
</section> </section>
</div> </div>
<Teleport defer to="#phone-home-drag-portal">
<div ref="homeDragLayer" class="home-drag-layer" aria-hidden="true"></div>
</Teleport>
<Transition name="edit-done"> <Transition name="edit-done">
<k-glass <k-glass
v-if="editMode && isEditablePage && !folderOverlayVisible" v-if="editMode && isEditablePage && !folderOverlayVisible"
@@ -1530,6 +1766,7 @@ onBeforeUnmount(() => {
:data-home-item-key="`app-${slot.app.id}`" :data-home-item-key="`app-${slot.app.id}`"
:data-home-index="slot.index" :data-home-index="slot.index"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:show-label="false" :show-label="false"
:class="{ :class="{
'home-item--folder-hover': 'home-item--folder-hover':
@@ -1538,7 +1775,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)" @dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@remove="removeHomeApp(slot.app.id)" @remove="removeHomeApp(slot.app.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)" @reorder="reorderHomeApp('dock', slot.index, $event)"
@@ -1552,6 +1789,7 @@ onBeforeUnmount(() => {
:data-home-item-key="slot.folder.id" :data-home-item-key="slot.folder.id"
:data-home-index="slot.index" :data-home-index="slot.index"
:edit-mode="editMode" :edit-mode="editMode"
:external-drag-visual="homeDragVisualActive"
:folder="slot.folder" :folder="slot.folder"
:show-label="false" :show-label="false"
:class="{ :class="{
@@ -1561,7 +1799,7 @@ onBeforeUnmount(() => {
@dragcancel="stopHomeDrag" @dragcancel="stopHomeDrag"
@dragend="finishHomeDrag" @dragend="finishHomeDrag"
@dragmove="moveHomeDrag" @dragmove="moveHomeDrag"
@dragstart="startHomeDrag('dock', slot.index)" @dragstart="startHomeDrag('dock', slot.index, $event)"
@edit="enterEditMode" @edit="enterEditMode"
@open="openFolder(slot.folder.id)" @open="openFolder(slot.folder.id)"
@reorder="reorderHomeApp('dock', slot.index, $event)" @reorder="reorderHomeApp('dock', slot.index, $event)"
+1 -1
View File
@@ -791,7 +791,7 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
color: #f6f7f9; color: #f6f7f9;
background: #07090c; background: #07090c;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.billing-app--light { .billing-app--light {
--billing-border: rgb(15 23 42 / 10%); --billing-border: rgb(15 23 42 / 10%);
+1 -7
View File
@@ -1156,13 +1156,7 @@ onMounted(async () => {
padding: 47px 0 24px; padding: 47px 0 24px;
background: var(--bg); background: var(--bg);
color: var(--label); color: var(--label);
font-family: font-family: var(--sky-font-family);
Inter,
-apple-system,
BlinkMacSystemFont,
'SF Pro Display',
system-ui,
sans-serif;
} }
.calendar--light { .calendar--light {
+1 -1
View File
@@ -2195,7 +2195,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #151613; background: #151613;
color: #f8f8f4; color: #f8f8f4;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.citymarkt--light { .citymarkt--light {
--ink: #fff; --ink: #fff;
+1 -1
View File
@@ -3586,7 +3586,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #12171b !important; background: #12171b !important;
color: #f7f8f4; color: #f7f8f4;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.feather-app--active.feather-app--light { .feather-app--active.feather-app--light {
--feather-panel: #f0f1ec; --feather-panel: #f0f1ec;
+1 -1
View File
@@ -3395,7 +3395,7 @@ onBeforeUnmount(() => {
overflow: hidden; overflow: hidden;
background: #000; background: #000;
color: #fff; color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', sans-serif; font-family: var(--sky-font-family);
} }
.state, .state,
.light-empty, .light-empty,
+1 -1
View File
@@ -1526,7 +1526,7 @@ onMounted(async () => {
overflow: hidden; overflow: hidden;
background: #12171b; background: #12171b;
color: #f7f7f2; color: #f7f7f2;
font-family: Inter, system-ui, sans-serif; font-family: var(--sky-font-family);
} }
.pages--light { .pages--light {
--yellow: #8a6500; --yellow: #8a6500;
+1 -1
View File
@@ -279,7 +279,7 @@ onBeforeUnmount(() => {
background: background:
radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%), radial-gradient(circle at 88% 8%, rgb(255 255 255 / 72%), transparent 28%),
linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%); linear-gradient(155deg, #f4efff 0%, #e7ddff 55%, #d9cdf7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -420,7 +420,7 @@ onBeforeUnmount(() => {
background: background:
radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%), radial-gradient(circle at 88% 7%, rgb(108 231 218 / 32%), transparent 30%),
linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%); linear-gradient(155deg, #effcf7 0%, #cfeee5 52%, #abdcd7 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
touch-action: manipulation; touch-action: manipulation;
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -386,7 +386,7 @@ onBeforeUnmount(() => {
padding: 52px 14px 25px; padding: 52px 14px 25px;
color: #f5fbff; color: #f5fbff;
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715); background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
touch-action: manipulation; touch-action: manipulation;
} }
+1 -1
View File
@@ -335,7 +335,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
background: background:
radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%), radial-gradient(circle at 86% 8%, rgb(255 192 94 / 28%), transparent 29%),
linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%); linear-gradient(155deg, #fff3dc 0%, #f3d8b7 55%, #e8b98d 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
touch-action: none; touch-action: none;
user-select: none; user-select: none;
} }
+1 -1
View File
@@ -167,7 +167,7 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
.flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; } .flappy-app { --sky-a:#50d8f2;--sky-b:#765ce8;--tower:#574be8;--tower-light:#9b94ff;--tower-dark:#3429a6;--tower-glow:#79e7ff; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:var(--sky-font-family);user-select:none;touch-action:manipulation; }
.flappy-app--playing { padding:0; } .flappy-app--playing { padding:0; }
.flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; } .flappy-app--neon { --sky-a:#151c58;--sky-b:#a329a2;--tower:#19dfe6;--tower-light:#82ffff;--tower-dark:#087f91;--tower-glow:#26fbff; }.flappy-app--storm { --sky-a:#6f8497;--sky-b:#2b3955;--tower:#df765f;--tower-light:#ffb18e;--tower-dark:#8d3e39;--tower-glow:#ff997d; }
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff} .flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#d9e9fa;font-size:14px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:1px 0 0;font-size:32px;line-height:1}.flappy-header button:not(.sky-button--glass),.flappy-toolbar button:not(.sky-button--glass){width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff35;border-radius:12px;color:#fff;background:#ffffff18}.flappy-header .sky-button--glass,.flappy-toolbar .sky-button--glass{color:#fff}
+1 -1
View File
@@ -306,7 +306,7 @@ onBeforeUnmount(() => {
background: background:
radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%), radial-gradient(circle at 85% 8%, rgb(100 211 91 / 22%), transparent 30%),
linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%); linear-gradient(165deg, #142b25 0%, #0c1715 62%, #08100f 100%);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-family: var(--sky-font-family);
user-select: none; user-select: none;
touch-action: none; touch-action: none;
} }
+1 -1
View File
@@ -321,7 +321,7 @@ onBeforeUnmount(() => {
</template> </template>
<style scoped> <style scoped>
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; } .tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: var(--sky-font-family); user-select: none; touch-action: manipulation; }
.tower-app--playing { padding: 0; } .tower-app--playing { padding: 0; }
.tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; } .tower-header { height: 50px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; } .tower-header span { display: block; color: #c1b8f1; font-size: 10px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
+91
View File
@@ -0,0 +1,91 @@
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
+1 -1
View File
@@ -6,7 +6,7 @@ use_experimental_fxv2_oal 'yes'
author 'Sky-Systems' author 'Sky-Systems'
description 'Sky Phone' description 'Sky Phone'
version '0.1.0' version '0.2.0'
provide 'lb-phone' provide 'lb-phone'
provide '17mov_Phone' provide '17mov_Phone'