basic setup

This commit is contained in:
nicwands
2026-05-18 15:13:23 -04:00
parent c4113658f7
commit a54e63323f
33 changed files with 2563 additions and 43 deletions

32
components/Btn.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<component :is="tag" class="btn">
<svg-btn-outline />
<slot />
</component>
</template>
<script setup>
import SvgBtnOutline from '@/components/svg/BtnOutline.vue'
const props = defineProps({
tag: {
type: String,
default: 'button',
},
})
</script>
<style lang="scss">
.btn {
text-transform: uppercase;
padding: desktop-vw(5px) desktop-vw(16px) desktop-vw(6px);
color: var(--grey-100);
position: relative;
cursor: pointer;
&:hover {
color: var(--theme-accent);
}
}
</style>

69
components/Lenis.vue Normal file
View File

@@ -0,0 +1,69 @@
<template>
<div v-if="!root" class="lenis" ref="wrapper">
<div ref="content">
<slot />
</div>
</div>
<slot v-else />
</template>
<script setup>
import Lenis from 'lenis'
import Tempus from 'tempus'
import { onBeforeUnmount, onMounted, provide, ref, shallowRef } from 'vue'
const { root, instance, options } = defineProps({
root: {
type: Boolean,
default: () => false,
},
instance: { type: String },
options: {
type: Object,
default: () => ({
duration: 1.2,
}),
},
})
const lenis = shallowRef()
const wrapper = ref()
const content = ref()
const removeRaf = ref()
// Provide instance for useLenis composable
const instanceKey = `lenis${instance ? `-${instance}` : ''}`
provide(instanceKey, lenis)
// Initialize with Tempus
const initLenis = () => {
if (lenis.value) return
lenis.value = new Lenis({
...options,
...(!root
? {
wrapper: wrapper.value,
content: content.value,
eventsTarget: wrapper.value,
}
: {}),
})
removeRaf.value = Tempus.add((time) => {
lenis.value.raf(time)
})
}
onMounted(() => {
if (!lenis.value) {
initLenis()
}
})
// Kill lenis before unmount
onBeforeUnmount(() => {
lenis.value?.destroy()
removeRaf.value?.()
})
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="svg-btn-outline">
<svg
class="side left"
width="16"
height="28"
viewBox="0 0 16 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
stroke="currentColor"
/>
</svg>
<div class="fill">
<div class="borders" />
</div>
<svg
class="side right"
width="16"
height="28"
viewBox="0 0 16 28"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.7207 0.5H14.7207L0.720634 14.8612L14.7207 27.5H15.7207"
stroke="currentColor"
/>
</svg>
</div>
</template>
<style lang="scss">
.svg-btn-outline {
grid-template-columns: auto 1fr auto;
display: grid;
align-items: center;
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
.side {
height: 100%;
&.right {
transform: scaleX(-1);
}
}
.fill {
height: 100%;
position: relative;
.borders {
position: absolute;
inset: 0 -1px;
border: solid currentColor;
border-width: 1px 0;
}
}
}
</style>

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
export default () => {
const os = ref('Unknown')
const isMobile = ref(false)
const isAndroid = ref(false)
const isIOS = ref(false)
const isMacOS = ref(false)
const isWindows = ref(false)
const isLinux = ref(false)
if (typeof navigator !== 'undefined') {
const userAgent = navigator.userAgent.toLowerCase()
const platform = navigator.platform.toLowerCase()
const detectedAndroid = /android/.test(userAgent)
const detectedIOS = /iphone|ipad|ipod/.test(userAgent)
const detectedMacOS = /mac/.test(platform) && !detectedIOS
const detectedWindows = /win/.test(platform)
const detectedLinux = /linux/.test(platform) && !detectedAndroid
os.value = detectedAndroid
? 'Android'
: detectedIOS
? 'iOS'
: detectedMacOS
? 'macOS'
: detectedWindows
? 'Windows'
: detectedLinux
? 'Linux'
: 'Unknown'
isMobile.value = detectedAndroid || detectedIOS
isAndroid.value = detectedAndroid
isIOS.value = detectedIOS
isMacOS.value = detectedMacOS
isWindows.value = detectedWindows
isLinux.value = detectedLinux
}
return { os, isMobile, isAndroid, isIOS, isMacOS, isWindows, isLinux }
}

14
composables/useLenis.js Normal file
View File

@@ -0,0 +1,14 @@
import { inject, onBeforeUnmount } from 'vue'
export default (callback = () => {}, instanceId) => {
const instanceKey = `lenis${instanceId ? `-${instanceId}` : ''}`
const lenis = inject(instanceKey)
if (lenis.value) {
lenis.value.on('scroll', callback)
}
onBeforeUnmount(() => lenis.value?.off('scroll', callback))
return lenis
}

View File

@@ -0,0 +1,29 @@
import { useWindowSize } from '@vueuse/core'
import { viewports } from '@/libs/theme'
const { width: wWidth, height: wHeight } = useWindowSize()
export default () => {
// Desktop
const dvw = (pixels) => {
return (pixels / viewports.desktop.width) * wWidth.value
}
const dvh = (pixels) => {
return (pixels / viewports.desktop.height) * wHeight.value
}
// Mobile
const mvw = (pixels) => {
return (pixels / viewports.mobile.width) * wWidth.value
}
const mvh = (pixels) => {
return (pixels / viewports.mobile.height) * wHeight.value
}
return {
dvw,
dvh,
mvw,
mvh,
}
}

View File

@@ -0,0 +1,46 @@
import {
useElementBounding,
useIntersectionObserver,
useWindowSize,
} from '@vueuse/core'
import { ref } from 'vue'
import { mapRange, clamp } from '@/libs/math'
import useLenis from '@/composables/useLenis'
const { height: wHeight } = useWindowSize()
export const useScrollProgress = (el, callback, entry = 0.5, exit = 0.5) => {
const isActive = ref(true)
const smoothProgress = ref(0)
const { height, top } = useElementBounding(el)
const isIntersected = ref(false)
const { stop } = useIntersectionObserver(el, ([{ isIntersecting }]) => {
isIntersected.value = isIntersecting
})
useLenis(({ scroll }) => {
if (!isActive.value) return
if (!height.value || !wHeight.value) return
if (!isIntersected.value) return
const pageTop = scroll + top.value
const start = pageTop - wHeight.value * entry
const end = pageTop + height.value - wHeight.value * exit
let rawProgress = mapRange(start, end, scroll, 0, 1)
rawProgress = clamp(0, rawProgress, 1)
smoothProgress.value += (rawProgress - smoothProgress.value) * 0.1
callback?.(smoothProgress.value)
})
const destroy = () => {
isActive.value = false
stop?.()
}
return { destroy }
}

View File

@@ -0,0 +1,89 @@
import gsap from 'gsap'
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useWindowSize, useEventListener } from '@vueuse/core'
/**
* Shared global raw mouse state (only set up once)
*/
const rawMouse = {
x: ref(0),
y: ref(0),
initialized: false,
cleanup: null,
}
const useGlobalMouseListener = () => {
if (!rawMouse.initialized && !import.meta.env.SSR) {
rawMouse.initialized = true
rawMouse.cleanup = useEventListener(window, 'mousemove', (e) => {
rawMouse.x.value = e.clientX
rawMouse.y.value = e.clientY
})
}
}
/**
* Composable for smoothed mouse position with customizable smoothing and normalization.
*/
export default (options) => {
const smoothFactor = options?.smoothFactor ?? 0.1
const normalize = options?.normalize ?? false
const callback = options?.onUpdate
const { width: wWidth, height: wHeight } = useWindowSize()
const sx = ref(0)
const sy = ref(0)
useGlobalMouseListener()
const getTargetX = () =>
normalize ? rawMouse.x.value / wWidth.value : rawMouse.x.value
const getTargetY = () =>
normalize ? rawMouse.y.value / wHeight.value : rawMouse.y.value
let tween
onMounted(() => {
if (tween) tween.kill
// Start smoothing tween
tween = gsap.to(
{ x: sx.value, y: sy.value },
{
duration: 1,
ease: 'linear',
repeat: -1,
onUpdate: () => {
const newX = gsap.utils.interpolate(
sx.value,
getTargetX(),
smoothFactor,
)
const newY = gsap.utils.interpolate(
sy.value,
getTargetY(),
smoothFactor,
)
sx.value = newX
sy.value = newY
callback?.({ sx: sx.value, sy: sy.value })
},
},
)
})
onBeforeUnmount(() => {
tween?.kill()
rawMouse.cleanup()
rawMouse.initialized = false
})
return {
sx,
sy,
rawX: rawMouse.x,
rawY: rawMouse.y,
}
}

22
libs/math.js Normal file
View File

@@ -0,0 +1,22 @@
export function clamp(min, input, max) {
return Math.max(min, Math.min(input, max))
}
export function mapRange(in_min, in_max, input, out_min, out_max) {
return (
((input - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min
)
}
export function lerp(start, end, amt) {
return (1 - amt) * start + amt * end
}
export function truncate(value, decimals) {
return parseFloat(value.toFixed(decimals))
}
export function wrapValue(value, lowBound, highBound) {
const range = highBound - lowBound
return ((((value - lowBound) % range) + range) % range) + lowBound
}

220
libs/sass-utils/index.js Normal file
View File

@@ -0,0 +1,220 @@
// https://github.com/dawaltconley/sass-cast/blob/main/index.js
import * as sass from 'sass-embedded'
import { isQuoted, unquoteString, parseString, getAttr } from './utils'
import { List, OrderedMap } from 'immutable'
/**
* Converts any Javascript object to an equivalent Sass value.
*
* This method is recursive and will convert the values of any array or object,
* as well as the array or object itself.
*
* @example
* const { toSass } = require('sass-cast');
*
* const string = toSass('a simple string');
* // quoted SassString => '"a simple string"'
*
* const map = toSass({
* key: 'value',
* nested: {
* 'complex//:key': [ null, 4 ],
* }
* });
* // SassMap => '("key": "value", "nested": ("complex//:key": (null, 4)))'
*
* @param {*} value - the value to be converted
* @param {Object} options
* @param {boolean} [options.parseUnquotedStrings=false] - whether to parse unquoted strings for colors or numbers with units
* @param {boolean|*[]} [options.resolveFunctions=false] - if true, resolve functions and attempt to cast their return values. if an array, pass as arguments when resolving
* @param {boolean} [options.quotes=true] - controls whether returned SassStrings are quoted. input strings that contain quotes will always return a quoted SassString even if this flag is false.
* @return {Value} - a {@link https://sass-lang.com/documentation/js-api/classes/Value Sass value}
*/
export const toSass = (value, options = {}) => {
let {
parseUnquotedStrings = false,
resolveFunctions = false,
quotes = true,
} = options
if (value instanceof sass.Value) {
return value
} else if (value === null || value === undefined) {
return sass.sassNull
} else if (typeof value === 'boolean') {
return value ? sass.sassTrue : sass.sassFalse
} else if (typeof value === 'number') {
return new sass.SassNumber(value)
} else if (typeof value === 'string') {
const valueIsQuoted = isQuoted(value)
if (parseUnquotedStrings && !valueIsQuoted) {
let parsed = parseString(value)
if (
parsed instanceof sass.SassColor ||
parsed instanceof sass.SassNumber
)
return parsed
}
return new sass.SassString(value, {
quotes: valueIsQuoted || quotes,
})
} else if (typeof value === 'object') {
if (Array.isArray(value)) {
let sassList = value.map((value) => toSass(value, options))
return new sass.SassList(sassList)
} else {
let sassMap = OrderedMap(value).mapEntries(([key, value]) => [
new sass.SassString(key, { quotes: true }),
toSass(value, options),
])
return new sass.SassMap(sassMap)
}
} else if (resolveFunctions && typeof value === 'function') {
const args = Array.isArray(resolveFunctions) ? resolveFunctions : []
return toSass(value(...args), options)
}
return sass.sassNull
}
const colorProperties = [
'red',
'green',
'blue',
'hue',
'lightness',
'saturation',
'whiteness',
'blackness',
'alpha',
]
/**
* Converts Sass values to their Javascript equivalents.
*
* @example
* const { fromSass, toSass } = require('sass-cast');
*
* const sassString = toSass('a sass string object');
* const string = fromSass(sassString);
* // 'a sass string object'
*
* @param {Value} object - a {@link https://sass-lang.com/documentation/js-api/classes/Value Sass value}
* @param {Object} options
* @param {boolean} [options.preserveUnits=false] - By default, only the values of numbers are returned, not their units. If true, `fromSass` will return numbers as a two-item Array, i.e. [ value, unit ]
* @param {boolean} [options.rgbColors=false] - By default, colors are returned as strings. If true, `fromSass` will return colors as an object with `r`, `g`, `b`, and `a`, properties.
* @param {boolean} [options.preserveQuotes=false] - By default, quoted Sass strings return their inner text as a string. If true, `fromSass` will preserve the quotes in the returned string value.
* @return {*} - a Javascript value corresponding to the Sass input
*/
export const fromSass = (object, options = {}) => {
let {
preserveUnits = false,
rgbColors = false,
preserveQuotes = false,
} = options
if (object instanceof sass.SassBoolean) {
return object.value
} else if (object instanceof sass.SassNumber) {
if (preserveUnits) {
return [
object.value,
object.numeratorUnits.toArray(),
object.denominatorUnits.toArray(),
]
} else if (object.numeratorUnits.size || object.denominatorUnits.size) {
return object.toString()
}
return object.value
} else if (object instanceof sass.SassColor) {
if (rgbColors) {
return colorProperties.reduce((colorObj, p) => {
colorObj[p] = object[p]
return colorObj
}, {})
}
return object.toString()
} else if (object instanceof sass.SassString) {
return preserveQuotes ? object.text : unquoteString(object.text)
} else if (object instanceof sass.SassList || List.isList(object)) {
let list = []
for (
let i = 0, value = object.get(i);
value !== undefined;
i++, value = object.get(i)
) {
list.push(fromSass(value, options))
}
return list
} else if (object instanceof sass.SassMap) {
return object.contents
.mapEntries(([k, v]) => [k.text, fromSass(v, options)])
.toObject()
} else {
return object.realNull
}
}
/**
* An object defining Sass utility functions.
*
* @example <caption>Pass to sass using the JS API</caption>
* const { sassFunctions } = require('sass-cast');
* const sass = require('sass');
*
* sass.compile('main.scss', { functions: sassFunctions });
*/
export const sassFunctions = {
/**
* Sass function for importing data from Javascript or JSON files.
* Calls the CommonJS `require` function under the hood.
*
* #### Examples
*
* ```scss
* // import config info from tailwindcss
* $tw: require('./tailwind.config.js', $parseUnquotedStrings: true);
* $tw-colors: map.get($tw, theme, extend, colors);
* ```
* @name require
* @memberof sassFunctions
* @param {SassString} $module - Path to the file or module. Relative paths are relative to the Node process running Sass compilation.
* @param {SassList} [$properties=()] - List of properties, if you only want to parse part of the module data.
* @param {SassBoolean} [$parseUnquotedStrings=false] - Passed as an option to {@link #tosass toSass}.
* @param {SassBoolean} [$resolveFunctions=false] - Passed as an option to {@link #tosass toSass}.
* @param {SassBoolean} [$quotes=true] - Passed as an option to {@link #tosass toSass}.
* @return {Value} - a {@link https://sass-lang.com/documentation/js-api/classes/Value Sass value}
*/
'require($module, $properties: (), $parseUnquotedStrings: false, $resolveFunctions: false, $quotes: true)':
(args) => {
const moduleName = args[0].assertString('module').text
const properties = args[1].realNull && fromSass(args[1].asList)
const parseUnquotedStrings = args[2].isTruthy
const resolveFunctions = args[3].isTruthy
const quotes = args[4].isTruthy
const options = {
parseUnquotedStrings,
resolveFunctions,
quotes,
}
const convert = (data) =>
toSass(properties ? getAttr(data, properties) : data, options)
let mod,
paths = [moduleName, `${process.cwd()}/${moduleName}`]
for (let path of paths) {
try {
mod = require(path)
break
} catch (e) {
if (e.code !== 'MODULE_NOT_FOUND') throw e
continue
}
}
if (!mod) throw new Error(`Couldn't find module: ${moduleName}`)
if (resolveFunctions && typeof mod === 'function') mod = mod()
if (mod instanceof Promise) return mod.then(convert)
return convert(mod)
},
}

108
libs/sass-utils/utils.js Normal file
View File

@@ -0,0 +1,108 @@
// https://github.com/dawaltconley/sass-cast/blob/main/utils.js
import * as sass from 'sass-embedded'
/**
* Check if string is quoted
* @private
* @param {string} str
* @return {boolean}
*/
export const isQuoted = (str) => /^['"].*['"]$/.test(str)
/**
* Surrounds a string with quotes
* @private
* @param {string} str
* @param {string} q - quotes, double or single
* @return {string}
*/
export const quoteString = (str, q) => {
if (!q) return str
if (isQuoted(str)) {
q = str[0]
str = str.slice(1, -1)
}
let r = new RegExp(q, 'g')
return q + str.replace(r, '\\' + q) + q
}
/**
* Unquotes a string
* @private
* @param {string} str
* @return {string}
*/
export const unquoteString = (str) => (isQuoted(str) ? str.slice(1, -1) : str)
/**
* Parse a string as a Sass object
* cribbed from davidkpiano/sassport
*
* @private
* @param {string} str
* @return {Value}
*/
export const parseString = (str) => {
let result
try {
sass.compileString(`$_: ___(${str});`, {
functions: {
'___($value)': (args) => {
result = args[0]
return result
},
},
})
} catch (e) {
return str
}
return result
}
/**
* Parse a string as a legacy Sass object
* cribbed from davidkpiano/sassport
*
* @private
* @param {string} str
* @return {LegacyObject}
*/
export const parseStringLegacy = (str) => {
let result
try {
sass.renderSync({
data: `$_: ___((${str}));`,
functions: {
'___($value)': (value) => {
result = value
return value
},
},
})
} catch (e) {
return str
}
return result
}
/**
* Function to handle 'toString()' methods with legacy API.
*
* @private
* @param {LegacyObject} obj
* @return {string}
*/
export const legacyToString = (obj) => (obj.dartValue || obj).toString()
/**
* Return a value from an object and a list of keys.
* @private
* @param {Object|Array} obj
* @param {*[]} attrs
*/
export const getAttr = (obj, attrs) => attrs.reduce((o, attr) => o[attr], obj)

48
libs/theme.js Normal file
View File

@@ -0,0 +1,48 @@
const colors = {
black: '#181818',
white: '#D5D5D5',
'grey-100': '#747474',
green: '#87FF5B',
blue: '#5B92FF',
purple: '#94079E',
red: '#D40202',
}
const themes = {
dark: {
bg: colors.black,
fg: colors.white,
accent: colors.green,
link: colors.blue,
},
light: {
bg: colors.white,
fg: colors.black,
accent: colors.purple,
link: colors.blue,
},
}
const breakpoints = {
mobile: 800,
}
const viewports = {
mobile: {
width: 440,
height: 956,
},
desktop: {
width: 1728,
height: 1117,
},
}
export { colors, themes, breakpoints, viewports }
export default {
colors,
themes,
breakpoints,
viewports,
}

915
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,13 @@
"preview": "vike build && vike preview"
},
"dependencies": {
"@fuzzco/font-loader": "^1.0.2",
"@vueuse/core": "^14.3.0",
"lenis": "^1.3.23",
"lodash": "^4.18.1",
"sass": "^1.99.0",
"sass-embedded": "^1.99.0",
"tempus": "^1.0.0-dev.17",
"vike": "^0.4.255",
"vike-vue": "^0.9.11",
"vue": "^3.5.30"

View File

@@ -1,34 +1,61 @@
<!-- https://vike.dev/Layout -->
<template>
<div class="layout">
<slot />
</div>
<lenis root>
<div :class="classes" :style="styles">
<slot />
</div>
</lenis>
</template>
<script lang="ts" setup></script>
<script setup>
import '@/styles/main.scss'
import { ref, computed, onMounted } from 'vue'
import loadFonts from '@fuzzco/font-loader'
import { useWindowSize } from '@vueuse/core'
import Lenis from '@/components/Lenis.vue'
<style>
body {
margin: 0;
font-family: sans-serif;
}
* {
box-sizing: border-box;
}
a {
text-decoration: none;
}
</style>
const { height } = useWindowSize()
<style scoped>
.layout {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: #131313;
color: #ebebeb;
const fontsLoading = ref(true)
const classes = computed(() => [
'container',
{ 'fonts-ready': !fontsLoading.value },
'theme-dark',
])
onMounted(async () => {
// Load fonts
loadFonts([
{
name: 'Office Times',
weights: [400],
},
])
.then(() => {
fontsLoading.value = false
})
.catch(() => {
fontsLoading.value = false
})
})
const styles = computed(() => ({
'--vh': height.value ? height.value / 100 + 'px' : '100vh',
}))
</script>
<style lang="scss">
.container {
min-height: calc(100 * var(--vh));
max-width: 100vw;
overflow-x: clip;
background: var(--theme-bg);
color: var(--theme-fg);
transition: opacity 1000ms;
&:not(.fonts-ready) {
opacity: 0;
}
}
/* Page Transition Animation */

View File

@@ -1,19 +1,68 @@
<template>
<main class="splash">
<svg-wordmark />
<a :href="downloadUrl" download>
<btn :disabled="loading">
{{ loading ? 'Checking for updates…' : `Download for ${os}` }}
</btn>
</a>
</main>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import SvgWordmark from '@/components/svg/Wordmark.vue'
import useDetectOS from '@/composables/useDetectOS'
import Btn from '@/components/Btn.vue'
const BASE_URL = 'https://s3.takerofnotes.com'
const { os } = useDetectOS()
const version = ref(null)
const downloadPath = ref(null)
const loading = ref(true)
const downloadUrl = computed(() => {
if (!downloadPath.value || os.value === 'Unknown') return null
return `${BASE_URL}/dist/${os.value.toLowerCase()}/${version.value}/${downloadPath.value}`
})
onMounted(async () => {
try {
const response = await fetch(
`${BASE_URL}/dist/${os.value.toLowerCase()}/latest.yml`,
)
if (!response.ok) throw new Error(response.statusText)
const yaml = await response.text()
const versionMatch = yaml.match(/^version:\s*(.+)/m)
const pathMatch = yaml.match(/^path:\s*(.+)/m)
if (versionMatch) version.value = versionMatch[1].trim()
if (pathMatch) downloadPath.value = pathMatch[1].trim()
} catch {
// fallback to placeholder
downloadPath.value = `takerofnotes-app-0.2.0.${os.value === 'Windows' ? 'exe' : os.value === 'macOS' ? 'dmg' : 'AppImage'}`
} finally {
loading.value = false
}
})
</script>
<style>
<style lang="scss">
main.splash {
height: calc(100 * var(--vh));
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: desktop-vw(40px);
.svg-wordmark {
width: 70%;
height: auto;
margin: auto;
display: block;
}
}

Binary file not shown.

Binary file not shown.

7
styles/_colors.scss Normal file
View File

@@ -0,0 +1,7 @@
@use 'sass:color';
:root {
@each $name, $color in getColors() {
--#{$name}: #{$color};
}
}

22
styles/_easings.scss Normal file
View File

@@ -0,0 +1,22 @@
:root {
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
--ease-in-cubic: cubic-bezier(0.55, 0.055, 0.675, 0.19);
--ease-in-quart: cubic-bezier(0.895, 0.03, 0.685, 0.22);
--ease-in-quint: cubic-bezier(0.755, 0.05, 0.855, 0.06);
--ease-in-expo: cubic-bezier(0.95, 0.05, 0.795, 0.035);
--ease-in-circ: cubic-bezier(0.6, 0.04, 0.98, 0.335);
--ease-out-quad: cubic-bezier(0.25, 0.46, 0.45, 0.94);
--ease-out-cubic: cubic-bezier(0.215, 0.61, 0.355, 1);
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
--ease-out-quint: cubic-bezier(0.23, 1, 0.32, 1);
--ease-out-expo: cubic-bezier(0.19, 1, 0.22, 1);
--ease-out-circ: cubic-bezier(0.075, 0.82, 0.165, 1);
--ease-in-out-quad: cubic-bezier(0.455, 0.03, 0.515, 0.955);
--ease-in-out-cubic: cubic-bezier(0.645, 0.045, 0.355, 1);
--ease-in-out-quart: cubic-bezier(0.77, 0, 0.175, 1);
--ease-in-out-quint: cubic-bezier(0.86, 0, 0.07, 1);
--ease-in-out-expo: cubic-bezier(1, 0, 0, 1);
--ease-in-out-circ: cubic-bezier(0.785, 0.135, 0.15, 0.86);
--ease-custom: cubic-bezier(0.315, 0.365, 0.23, 0.985);
}

31
styles/_font-style.scss Normal file
View File

@@ -0,0 +1,31 @@
@use 'functions' as *;
@mixin size-font($ds, $ms) {
font-size: mobile-vw($ms);
&.vh {
font-size: mobile-vh($ms);
}
@include desktop {
font-size: desktop-vw($ds);
&.vh {
font-size: desktop-vh($ds);
}
}
}
@mixin h1 {
font-family: var(--font-times);
font-weight: 400;
letter-spacing: 0.1em;
@include size-font(42px, 27px);
}
@mixin p {
font-family: var(--font-times);
font-weight: 400;
letter-spacing: 0.03em;
@include size-font(25px, 18px);
}

26
styles/_fonts.scss Normal file
View File

@@ -0,0 +1,26 @@
/*
Font Weights:
100 - Thin
200 - Extra Light (Ultra Light)
300 - Light
400 - Normal
500 - Medium
600 - Semi Bold (Demi Bold)
700 - Bold
800 - Extra Bold (Ultra Bold)
900 - Black (Heavy)
*/
/* OFFICE TIMES */
@font-face {
font-family: 'Office Times';
font-style: normal;
font-weight: 400;
src:
url('/fonts/OfficeTimesRound-Regular.woff2') format('woff2'),
url('/fonts/OfficeTimesRound-Regular.woff') format('woff');
}
:root {
--font-times: 'Office Times', monospace;
}

187
styles/_functions.scss Normal file
View File

@@ -0,0 +1,187 @@
@use 'sass:math';
/* Breakpoints */
$mobile-breakpoint: get('breakpoints.mobile');
// Viewport Sizes
$desktop-width: get('viewports.desktop.width');
$desktop-height: get('viewports.desktop.height');
$mobile-width: get('viewports.mobile.width');
$mobile-height: get('viewports.mobile.height');
// Breakpoint
@mixin mobile {
@media (max-width: #{$mobile-breakpoint * 1px - 1px}) {
@content;
}
}
@mixin desktop {
@media (min-width: #{$mobile-breakpoint * 1px}) {
@content;
}
}
@function mobile-vw($pixels, $base-vw: $mobile-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function mobile-vh($pixels, $base-vh: $mobile-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function desktop-vw($pixels, $base-vw: $desktop-width) {
$px: math.div($pixels, $base-vw);
$perc: math.div($px, 1px);
@return calc($perc * 100vw);
}
@function desktop-vh($pixels, $base-vh: $desktop-height) {
$px: math.div($pixels, $base-vh);
$perc: math.div($px, 1px);
@return calc($perc * 100vh);
}
@function columns($columns) {
@return calc(
(#{$columns} * var(--layout-column-width)) +
((#{$columns} - 1) * var(--layout-column-gap))
);
}
@mixin child-grid($columns) {
grid-template-columns: repeat($columns, minmax(0, 1fr));
column-gap: var(--layout-columns-gap);
display: grid;
}
@mixin reduced-motion {
@media (prefers-reduced-motion: reduce) {
@content;
}
}
@mixin fill($position: absolute) {
position: #{$position};
bottom: 0;
right: 0;
left: 0;
top: 0;
}
@mixin fade-on-ready($class: 'ready', $duration: 400ms) {
opacity: 0;
transition: opacity $duration ease;
&.#{$class} {
opacity: 1;
}
}
// Clamp text block to number of lines
@mixin line-clamp($lines: 3, $mobile-lines: $lines) {
display: -webkit-box;
overflow: hidden;
text-overflow: ellipsis;
-webkit-box-orient: vertical;
-webkit-line-clamp: $lines;
@include mobile {
-webkit-line-clamp: $mobile-lines;
}
}
// Flip animations
@keyframes flip-r {
50% {
transform: translateX(100%);
opacity: 0;
}
51% {
transform: translateX(-100%);
opacity: 0;
}
}
@keyframes flip-l {
50% {
transform: translateX(-100%);
opacity: 0;
}
51% {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes flip-d {
50% {
transform: translateY(100%);
opacity: 0;
}
51% {
transform: translateY(-100%);
opacity: 0;
}
}
@keyframes flip-u {
50% {
transform: translateY(-100%);
opacity: 0;
}
51% {
transform: translateY(100%);
opacity: 0;
}
}
@mixin flip-animation(
$direction: 'r',
$duration: 600ms,
$easing: var(--ease-out-expo),
$iteration-count: 1
) {
overflow: hidden;
animation: flip-#{$direction} $duration $easing $iteration-count forwards;
}
@mixin link-hover {
position: relative;
&::before {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 1px;
background: var(--theme-fg);
transform-origin: left;
transform: scaleX(0);
transition: transform 300ms var(--ease-out-quad);
}
@include desktop {
&:hover::before {
transform: scaleX(1);
}
}
}
@mixin stagger-animate($stagger: 100, $num-children: 10, $base-delay: 0) {
@for $i from 0 through $num-children {
&:nth-child(#{$i + 1}) {
transition-delay: #{$stagger * $i + $base-delay}ms;
}
}
}
@mixin hover {
@include desktop {
&:hover {
@content;
}
}
}

7
styles/_layers.scss Normal file
View File

@@ -0,0 +1,7 @@
// z-index
.lily-cursor {
z-index: 20;
}
.site-header {
z-index: 10;
}

91
styles/_layout.scss Normal file
View File

@@ -0,0 +1,91 @@
@use 'sass:list';
@use 'functions' as *;
// css variables exposed globally:
// --layout-column-count: columns count in the layout
// --layout-column-gap: gap size between columns
// --layout-margin: layout margin size (left or right)
// --layout-width: 100vw minus 2 * --layout-margin
// --layout-column-width: size of a single column
// css classes exposed globally:
// .layout-block: element takes the whole layout width
// .layout-block-inner: same as .layout-block but using padding instead of margin
// .layout-grid: extends .layout-block with grid behaviour using layout settings
// .layout-grid-inner: same as .layout-grid but using padding instead of margin
@use 'sass:map';
// config to fill
// 'variable': (mobile, desktop)
$layout: (
'columns-count': (
5,
18,
),
'columns-gap': (
20px,
20px,
),
'margin': (
30px,
60px,
),
);
//internal process, do not touch
:root {
--layout-column-count: #{list.nth(map.get($layout, 'columns-count'), 1)};
--layout-column-gap: #{mobile-vw(
list.nth(map.get($layout, 'columns-gap'), 1)
)};
--layout-margin: #{mobile-vw(list.nth(map.get($layout, 'margin'), 1))};
--layout-width: calc(100vw - (2 * var(--layout-margin)));
--layout-column-width: calc(
(
var(--layout-width) -
(
(var(--layout-column-count) - 1) *
var(--layout-column-gap)
)
) /
var(--layout-column-count)
);
@include desktop {
--layout-column-count: #{list.nth(map.get($layout, 'columns-count'), 2)};
--layout-column-gap: #{desktop-vw(
list.nth(map.get($layout, 'columns-gap'), 2)
)};
--layout-margin: #{desktop-vw(list.nth(map.get($layout, 'margin'), 2))};
}
}
.layout-block {
max-width: var(--layout-width);
margin-left: auto;
margin-right: auto;
width: 100%;
}
.layout-block-inner {
padding-left: var(--layout-margin);
padding-right: var(--layout-margin);
width: 100%;
}
.layout-grid {
@extend .layout-block;
display: grid;
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap);
}
.layout-grid-inner {
@extend .layout-block-inner;
display: grid;
grid-template-columns: repeat(var(--layout-column-count), minmax(0, 1fr));
grid-gap: var(--layout-column-gap);
}

99
styles/_reset.scss Normal file
View File

@@ -0,0 +1,99 @@
/***
The new CSS reset - version 1.9 (last updated 19.6.2023)
GitHub page: https://github.com/elad2412/the-new-css-reset
***/
/*
Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property
- The "symbol *" part is to solve Firefox SVG sprite bug
- The "html" element is excluded, otherwise a bug in Chrome breaks the CSS hyphens property (https://github.com/elad2412/the-new-css-reset/issues/36)
*/
*:where(
:not(html, iframe, canvas, img, svg, video, audio):not(svg *, symbol *)
) {
all: unset;
display: revert;
}
/* Preferred box-sizing value */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Reapply the pointer cursor for anchor tags */
a,
button {
cursor: revert;
}
/* For images to not be able to exceed their container */
img {
max-inline-size: 100%;
max-block-size: 100%;
}
/* removes spacing between cells in tables */
table {
border-collapse: collapse;
}
/* Safari - solving issue when using user-select:none on the <body> text input doesn't working */
input,
textarea {
-webkit-user-select: auto;
}
/* revert the 'white-space' property for textarea elements on Safari */
textarea {
white-space: revert;
}
/* minimum style to allow to style meter element */
meter {
-webkit-appearance: revert;
appearance: revert;
}
/* preformatted text - use only for this feature */
:where(pre) {
all: revert;
}
/* reset default text opacity of input placeholder */
::placeholder {
color: unset;
}
/* remove default dot (•) sign */
::marker {
content: initial;
}
/* fix the feature of 'hidden' attribute.
display:revert; revert to element instead of attribute */
:where([hidden]) {
display: none;
}
/* revert for bug in Chromium browsers
- fix for the content editable attribute will work properly.
- webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/
:where([contenteditable]:not([contenteditable='false'])) {
-moz-user-modify: read-write;
-webkit-user-modify: read-write;
overflow-wrap: break-word;
-webkit-line-break: after-white-space;
-webkit-user-select: auto;
}
/* apply back the draggable feature - exist only in Chromium and Safari */
:where([draggable='true']) {
-webkit-user-drag: element;
}
/* Revert Modal native behavior */
:where(dialog:modal) {
all: revert;
}

34
styles/_scroll.scss Normal file
View File

@@ -0,0 +1,34 @@
html {
&:not(.dev) {
&,
* {
scrollbar-width: none !important;
-ms-overflow-style: none !important;
&::-webkit-scrollbar {
width: 0 !important;
height: 0 !important;
}
}
}
}
html.lenis {
height: auto;
}
.lenis.lenis-smooth {
scroll-behavior: auto;
}
.lenis.lenis-smooth [data-lenis-prevent] {
overscroll-behavior: contain;
}
.lenis.lenis-stopped {
overflow: hidden;
}
.lenis.lenis-scrolling iframe {
pointer-events: none;
}

11
styles/_themes.scss Normal file
View File

@@ -0,0 +1,11 @@
@use 'sass:color';
:root {
@each $name, $theme in getThemes() {
.theme-#{$name} {
@each $name, $color in $theme {
--theme-#{$name}: #{$color};
}
}
}
}

43
styles/_transitions.scss Normal file
View File

@@ -0,0 +1,43 @@
// Fades
.fade-enter-active,
.fade-leave-active {
transition: opacity 400ms;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.quick-fade-enter-active,
.quick-fade-leave-active {
transition: opacity 100ms;
}
.quick-fade-enter-from,
.quick-fade-leave-to {
opacity: 0;
}
.slow-fade-enter-active,
.slow-fade-leave-active {
transition: opacity 600ms;
}
.slow-fade-enter-from,
.slow-fade-leave-to {
opacity: 0;
}
// Slides
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 400ms var(--ease-out-quad);
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(-100%);
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: transform 400ms var(--ease-out-quad);
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(-100%);
}

42
styles/_utils.scss Normal file
View File

@@ -0,0 +1,42 @@
@use 'functions' as *;
.full-width {
width: 100vw;
position: relative;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
}
.overflow-hidden {
overflow: hidden;
}
.relative {
position: relative;
}
.mobile-only {
@include desktop {
display: none !important;
}
}
.desktop-only {
@include mobile {
display: none !important;
}
}
html:not(.has-scroll-smooth) {
.hide-on-native-scroll {
display: none;
}
}
html.has-scroll-smooth {
.hide-on-smooth-scroll {
display: none;
}
}

111
styles/main.scss Normal file
View File

@@ -0,0 +1,111 @@
@use 'colors' as *;
@use 'themes' as *;
@use 'easings' as *;
@use 'reset' as *;
@use 'layers' as *;
@use 'functions' as *;
@use 'utils' as *;
@use 'fonts' as *;
@use 'font-style' as *;
@use 'layout' as *;
@use 'scroll' as *;
@use 'transitions' as *;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
background: var(--white);
color: var(--black);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
min-height: 100vh;
}
// Type
.h1,
h1,
h2,
h3,
h4,
h5,
h6 {
@include h1;
}
.p,
p,
a,
button,
input,
pre {
@include p;
}
.entry {
img {
max-width: 100%;
height: auto;
}
p {
min-height: 1px;
}
a {
text-decoration: underline;
}
& > * {
margin-bottom: 1em;
margin-top: 1em;
}
& > *:first-child {
margin-top: 0;
}
& > *:last-child {
margin-bottom: 0;
}
ul,
ol {
li {
margin-bottom: 1em;
width: desktop-vw(577px);
}
}
ul {
list-style: disc;
padding-left: 1em;
}
}
#app {
min-height: 100vh;
}
@keyframes blink {
5% {
opacity: 0;
}
40% {
opacity: 0;
}
60% {
opacity: 1;
}
95% {
opacity: 1;
}
}
@keyframes bonk {
0% {
transform: rotate(calc(var(--bonk-angle) * -1));
}
50% {
transform: rotate(var(--bonk-angle));
}
}

42
vite.config.js Normal file
View File

@@ -0,0 +1,42 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vike from 'vike/plugin'
import { defineConfig } from 'vite'
import { toSass } from './libs/sass-utils'
import theme from './libs/theme'
export default defineConfig({
plugins: [vike(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./', import.meta.url)),
},
},
css: {
preprocessorOptions: {
scss: {
api: 'modern-compiler',
additionalData:
'@use "/styles/_functions.scss" as *; @use "/styles/_font-style.scss" as *;',
functions: {
'get($keys)': function (keys) {
keys = keys.toString().replace(/['"]+/g, '').split('.')
let result = theme
for (let i = 0; i < keys.length; i++) {
result = result[keys[i]]
}
return toSass(result)
},
'getColors()': function () {
return toSass(theme.colors)
},
'getThemes()': function () {
return toSass(theme.themes)
},
},
},
},
},
})

View File

@@ -1,13 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import vue from '@vitejs/plugin-vue'
import vike from 'vike/plugin'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [vike(), vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./', import.meta.url)),
},
},
})