Initial commit

This commit is contained in:
daylily
2025-03-30 13:03:07 -04:00
commit 6be8e7a185
23 changed files with 2270 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"tabWidth": 2,
"useTabs": false,
"semi": false,
"arrowParens": "avoid",
"singleQuote": true,
"printWidth": 120
}
+47
View File
@@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Write to Inkclip</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1292
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "inkclip-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tsconfig/svelte": "^5.0.4",
"@types/w3c-web-hid": "^1.0.6",
"rgbquant": "^1.1.2",
"svelte": "^5.25.2",
"svelte-check": "^4.1.4",
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg version="1.1" width="256" height="256" xmlns="http://www.w3.org/2000/svg">
<polygon points="0,0 255,0 0,255" fill="#afbabc" />
<polygon points="255,255 255,0 0,255" fill="#182435" />
</svg>

After

Width:  |  Height:  |  Size: 199 B

+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import Canvas from './lib/Canvas.svelte'
import ConnectButton from './lib/ConnectButton.svelte'
import WriteButton from './lib/WriteButton.svelte'
const hid = navigator.hid
let inProgress = $state(false)
let device: HIDDevice | null = $state(null)
let bitmapData: number[] | null = $state(null)
function isInkclip(dev: HIDDevice) {
return dev.vendorId == 0xc0de && dev.productId == 0xcafe
}
async function tryGetPairedDevice() {
const dev = (await hid.getDevices()).find(isInkclip)
if (dev !== undefined) device = dev
}
hid.addEventListener('connect', e => {
if (isInkclip(e.device) && device === null) device = e.device
})
hid.addEventListener('disconnect', e => {
if (device === e.device) device = null
})
$effect(() => {
tryGetPairedDevice()
})
</script>
<main>
<section class="section--connect">
<div class="section-text">
<h1 class="section-title">Connect to a device</h1>
{#if device !== null}
Successfully conected to device. If you want to, you can connect to another device instead.
{:else}
Not connected to any device yet. Plug in your device, and click the button to select it.
{/if}
</div>
<ConnectButton
onconnect={dev => {
device = dev
}}
{device}
/>
</section>
<section class="section--edit">
<h1 class="section-title">Choose an image</h1>
<Canvas
onchange={v => {
bitmapData = v
}}
/>
</section>
<section class="section--write">
<div class="section-text">
<h1 class="section-title">Write pattern to device</h1>
{#if device === null}
Connect your device to start writing patterns onto it.
{:else if bitmapData === null}
Select an image file in order to write it onto your device.
{:else if !inProgress}
Write the pattern onto your device if you have finished editing the image.
{:else}
Writing in progress. Do not disconnect device.
{/if}
</div>
<WriteButton
{device}
data={bitmapData}
onprogress={v => {
inProgress = v
}}
/>
</section>
</main>
<style>
main {
padding: 1em;
display: flex;
flex-direction: column;
gap: 1em;
min-height: 100vh;
}
section {
background-color: #fff1;
padding: 1em;
border-radius: 5px;
flex-grow: 0;
}
.section-text {
flex-grow: 1;
}
.section-title {
margin: 0;
line-height: 1.5em;
}
.section--connect,
.section--write {
display: flex;
}
.section--edit {
flex-grow: 1;
}
@media (prefers-color-scheme: light) {
section {
background-color: #0001;
}
}
</style>
+66
View File
@@ -0,0 +1,66 @@
* {
box-sizing: border-box;
}
body {
font-family: Fira Sans, sans-serif;
font-size: 16px;
line-height: 1.5em;
color-scheme: dark light;
background-color: #222;
color: #eee;
margin: 0;
}
button {
font-family: Fira Sans, sans-serif;
font-size: 16px;
font-weight: bold;
background-color: #fff1;
padding: 10px 15px;
border: none;
border-radius: 5px;
transition: 0.1s ease;
}
button:hover {
background-color: #fff2;
cursor: pointer;
}
button:active {
background-color: #fff3;
cursor: pointer;
}
button:disabled {
background-color: #0003;
color: #888;
cursor: default;
}
@media (prefers-color-scheme: light) {
body {
background-color: #ddd;
color: #111;
}
button {
background-color: #0001;
}
button:hover {
background-color: #0002;
}
button:active {
background-color: #0003;
}
button:disabled {
background-color: #fff3;
}
}
+272
View File
@@ -0,0 +1,272 @@
<script lang="ts">
import { Transform, withCtx, withTransform } from './image/transform'
import { Quantizer, type DitheringKernel } from './image/quantizer'
import { Scaler, type ScaleMode } from './image/scaler'
interface Props {
onchange: (bitmap: number[] | null) => void
}
const scaler = new Scaler(200, 200)
const { onchange }: Props = $props()
let scaledCanvasEl: HTMLCanvasElement
let unscaledCanvasEl: HTMLCanvasElement
let fileList: FileList | null = $state(null)
let imageBitmap: ImageBitmap | null = $state(null)
let backgroundColor: number = $state(255)
let imageState: Transform = $state(new Transform())
let scalingMethod: ScaleMode = $state('fit')
let dither = $state(true)
let ditheringKernel: DitheringKernel = $state('FloydSteinberg')
let saturation = $state(0)
let bias = $state(0)
const quantizer = $derived(
new Quantizer({
ditheringKernel: dither ? ditheringKernel : null,
saturation,
bias,
}),
)
function imageNonSquare() {
if (imageBitmap === null) return false
return imageBitmap.height !== imageBitmap.width
}
function restoreDefaultImageSettings() {
backgroundColor = 255
imageState = new Transform()
scalingMethod = 'fit'
dither = true
ditheringKernel = 'FloydSteinberg'
saturation = 0
bias = 0
}
function freshContext() {
const ctx = scaledCanvasEl.getContext('2d', {
willReadFrequently: true,
})!
ctx.clearRect(0, 0, 200, 200)
return ctx
}
function drawQuantizedData(ctx: CanvasRenderingContext2D, data: number[]) {
withCtx(
ctx,
() => {
ctx.imageSmoothingEnabled = false
},
() => {
for (let y = 0; y < 200; y++) {
for (let x = 0; x < 200; x++) {
const color = data.at(y * 200 + x)
ctx.fillStyle = color === 0 ? '#fff' : '#000'
ctx.fillRect(x, y, 1, 1)
}
}
},
)
}
async function updateImageBitmap() {
if (fileList === null || fileList.length < 1) {
imageBitmap = null
return
}
const imageFile = fileList[0]
try {
imageBitmap = await createImageBitmap(imageFile)
} catch (e) {
console.error(e)
imageBitmap = null
}
imageState = new Transform()
}
async function drawToCanvas() {
const ctx = freshContext()
if (imageBitmap === null) {
onchange(null)
return
}
withTransform(ctx, imageState, () => {
if (imageBitmap === null) return
ctx.fillStyle = `rgb(${backgroundColor} ${backgroundColor} ${backgroundColor})`
ctx.fillRect(0, 0, 200, 200)
const { dx, dy, dWidth, dHeight } = scaler.scale(imageBitmap, scalingMethod)
ctx.drawImage(imageBitmap, dx, dy, dWidth, dHeight)
})
const quantizedData = quantizer.reduce(ctx)
drawQuantizedData(ctx, quantizedData)
drawQuantizedData(unscaledCanvasEl.getContext('2d')!, quantizedData)
onchange(quantizedData)
}
$effect(() => {
updateImageBitmap()
})
$effect(() => {
drawToCanvas()
})
</script>
<div class="canvas-container">
<div>
<input type="file" id="image-file" name="image_file" accept="image/*" bind:files={fileList} />
<canvas class="scaled" bind:this={scaledCanvasEl} height={200} width={200}></canvas>
<canvas bind:this={unscaledCanvasEl} height={200} width={200}></canvas>
</div>
<div>
{#if imageNonSquare()}
<div>
<h2>Scaling</h2>
You need to choose how to scale your image since it is not square.
<br />
<select name="scaling_method" id="scaling-method-select" bind:value={scalingMethod}>
<option value="fit">Fit</option>
<option value="crop">Crop</option>
<option value="distort">Distort</option>
</select>
</div>
{/if}
<div class="transforms">
<h2>Transform</h2>
<button
onclick={() => {
imageState = imageState.cw()
}}
name="Rotate clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.ccw()
}}
name="Rotate counter-clockwise"
>
</button>
<button
onclick={() => {
imageState = imageState.h()
}}
name="Flip horizontally"
>
</button>
<button
onclick={() => {
imageState = imageState.v()
}}
name="Flip vertically"
>
</button>
</div>
<div>
<h2>Rendering</h2>
Background Color:<input
type="range"
name="background_color"
id="background-color-input"
bind:value={backgroundColor}
min={0}
max={255}
step={1}
/>
{backgroundColor}
<br />
<input type="checkbox" name="dither" id="dither-checkbox" bind:checked={dither} /> Dither
{#if dither}
<br />
Dithering Kernel:
<select name="dithering_kernel" id="dithering-kernel-select" bind:value={ditheringKernel}>
<option value="FloydSteinberg">Floyd-Steinberg</option>
<option value="FalseFloydSteinberg">False Floyd-Steinberg</option>
<option value="Stucki">Stucki</option>
<option value="Atkinson">Atkinson</option>
<option value="Jarvis">Jarvis</option>
<option value="Burkes">Burkes</option>
<option value="Sierra">Sierra</option>
<option value="TwoSierra">2-Sierra</option>
<option value="SierraLite">Sierra Lite</option>
</select>
<br />
Saturation:
<input
type="range"
name="saturation"
id="saturation-input"
bind:value={saturation}
min={0}
max={1}
step={0.01}
/>
{Math.floor(saturation * 100)}%
{/if}
{#if !dither || saturation !== 0}
<br />
Bias:
<input type="range" name="bias" id="bias-input" bind:value={bias} min={-1} max={1} step={0.01} />
{Math.floor(bias * 100)}%
{/if}
<br />
(white = {Math.floor(quantizer.white)}, black = {Math.floor(quantizer.black)})
</div>
<div>
<h2>Reset</h2>
<button onclick={restoreDefaultImageSettings}>Reset All</button>
</div>
</div>
</div>
<style>
canvas {
display: block;
image-rendering: pixelated;
image-rendering: crisp-edges;
}
.scaled {
width: 400px;
height: 400px;
}
.canvas-container {
display: flex;
gap: 20px;
}
.transforms button {
font-family: sans-serif;
font-size: 32px;
}
h2 {
font-size: 1.3em;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
const hid = navigator.hid
interface Props {
onconnect: (device: HIDDevice) => void
device: HIDDevice | null
}
const { onconnect, device }: Props = $props()
async function requestDevice() {
const devs = await hid.requestDevice({
filters: [
{
vendorId: 0xc0de,
productId: 0xcafe,
},
],
})
if (devs == undefined || devs[0] == undefined) {
return
}
onconnect(devs[0])
}
</script>
<button onclick={requestDevice}>
{#if device == null}
Choose device
{:else}
Connect to another device
{/if}
</button>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
interface Props {
device: HIDDevice | null
data: number[] | null
onprogress: (inPropgress: boolean) => void
}
const { device, data, onprogress }: Props = $props()
let inProgress = $state(false)
let disabled = $derived(device === null || data === null || inProgress)
async function connectAndWrite() {
if (device === null || data === null) return
if (!device.opened) {
try {
await device.open()
} catch (e) {
if (!(e instanceof Error)) throw e
alert('Unable to open device: ' + e.toString())
}
}
const buffer = new Uint8Array(5000)
for (let y = 0; y < 200; y++) {
for (let xStride = 0; xStride < 25; xStride++) {
let cell = 0x0
for (let xStroll = 0; xStroll < 8; xStroll++) {
const index = y * 200 + xStride * 8 + xStroll
cell |= data[index] << xStroll
}
buffer[y * 25 + xStride] = cell
}
}
inProgress = true
setTimeout(() => {
inProgress = false
}, 2000)
await device.sendReport(0, buffer)
}
$effect(() => {
onprogress(inProgress)
})
</script>
<button onclick={connectAndWrite} {disabled}>Write pattern to device</button>
+37
View File
@@ -0,0 +1,37 @@
import RgbQuant from 'rgbquant'
import type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export type { DitheringKernel, RgbQuantImage } from 'rgbquant'
export type QuantizerOptions = {
ditheringKernel: DitheringKernel | null
saturation: number
bias: number
}
export class Quantizer {
public readonly black: number
public readonly white: number
private readonly rgbquant: RgbQuant
constructor({ ditheringKernel, saturation, bias }: QuantizerOptions) {
const saturationAdjustment = ditheringKernel === null ? 127 : 127 * saturation
this.white = 255 + (bias - 1) * saturationAdjustment
this.black = (1 + bias) * saturationAdjustment
this.rgbquant = new RgbQuant({
colors: 2,
dithKern: ditheringKernel,
palette: [
[this.white, this.white, this.white],
[this.black, this.black, this.black],
],
useCache: false,
})
}
reduce(image: RgbQuantImage): number[] {
return this.rgbquant.reduce(image, 2)
}
}
+51
View File
@@ -0,0 +1,51 @@
declare module 'rgbquant' {
type DitheringKernel =
| 'FloydSteinberg'
| 'FalseFloydSteinberg'
| 'Stucki'
| 'Atkinson'
| 'Jarvis'
| 'Burkes'
| 'Sierra'
| 'TwoSierra'
| 'SierraLite'
type Rgb = [number, number, number]
type RgbQuantOptions = {
colors?: number
method?: 1 | 2
boxSize?: [number, number]
boxPxls?: number
initColors?: number
minHueCols?: number
dithKern?: DitheringKernel | null
dithDelta?: number
dithSerp?: boolean
palette?: Rgb[]
reIndex?: boolean
useCache?: boolean
cacheFreq?: number
colorDist?: 'euclidean' | 'manhattan'
}
type RgbQuantImage =
| HTMLImageElement
| HTMLCanvasElement
| CanvasRenderingContext2D
| ImageData
| Uint8Array
| Uint8ClampedArray
| Uint32Array
| number[]
export default class RgbQuant {
constructor(opts?: RgbQuantOptions)
sample(image: RgbQuantImage, width?: number): void
palette(tuples: true, noSort?: boolean = false): Rgb[]
palette(tuples: false, noSort?: boolean = false): Uint8Array
palette(): Uint8Array
reduce(image: RgbQuantImage, retType: 1, dithKern?: DitheringKernel | null, dithSerp?: boolean): Uint8Array
reduce(image: RgbQuantImage, retType: 2, dithKern?: DitheringKernel | null, dithSerp?: boolean): number[]
}
}
+86
View File
@@ -0,0 +1,86 @@
export type DrawParameters = {
dx: number
dy: number
dWidth: number
dHeight: number
}
export type ScaleMode = 'fit' | 'crop' | 'distort'
export class Scaler {
private readonly canvasAspectRatio: number
constructor(private readonly canvasWidth: number, private readonly canvasHeight: number) {
this.canvasAspectRatio = canvasWidth / canvasHeight
}
fitScale(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
cropScale(image: ImageBitmap): DrawParameters {
const { width, height } = image
const aspectRatio = width / height
let dx = 0
let dy = 0
let dWidth = this.canvasWidth
let dHeight = this.canvasHeight
if (aspectRatio > this.canvasAspectRatio) {
dWidth = dHeight * aspectRatio
dx = (this.canvasWidth - dWidth) / 2
} else if (aspectRatio < this.canvasAspectRatio) {
dHeight = dWidth / aspectRatio
dy = (this.canvasHeight - dHeight) / 2
}
return {
dx,
dy,
dWidth,
dHeight,
}
}
distortScale(): DrawParameters {
return {
dx: 0,
dy: 0,
dWidth: this.canvasWidth,
dHeight: this.canvasHeight,
}
}
scale(image: ImageBitmap, mode: ScaleMode) {
switch (mode) {
case 'crop':
return this.cropScale(image)
case 'fit':
return this.fitScale(image)
case 'distort':
return this.distortScale()
}
}
}
+60
View File
@@ -0,0 +1,60 @@
export type Rotation = 0 | 90 | 180 | 270
export type Side = 'obverse' | 'reverse'
/**
* A transform of a square image is represented by first rotating it by a multiple of 90 degrees, and then optionally
* flipping it horizontally.
*
* Algebraically, this is the dihedral group of order 8 (D₄).
*/
export class Transform {
constructor(public readonly side: Side = 'obverse', public readonly rotation: Rotation = 0) {}
cw(): Transform {
const rotationAngle = this.side === 'obverse' ? 90 : 270
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
ccw(): Transform {
const rotationAngle = this.side === 'obverse' ? 270 : 90
const newAngle = (this.rotation + rotationAngle) % 360
return new Transform(this.side, newAngle as Rotation)
}
h(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
return new Transform(newSide, this.rotation)
}
v(): Transform {
const newSide = this.side == 'obverse' ? 'reverse' : 'obverse'
const newAngle = (this.rotation + 180) % 360
return new Transform(newSide, newAngle as Rotation)
}
}
export function withCtx<T>(ctx: CanvasRenderingContext2D, pre: () => void, action: () => T): T {
ctx.save()
try {
pre()
return action()
} finally {
ctx.restore()
}
}
export function withTransform<T>(ctx: CanvasRenderingContext2D, transform: Transform, action: () => T): T {
return withCtx(
ctx,
() => {
ctx.translate(100, 100)
if (transform.side === 'reverse') {
ctx.scale(-1, 1)
}
ctx.rotate((transform.rotation / 180) * Math.PI)
ctx.translate(-100, -100)
},
action
)
}
+9
View File
@@ -0,0 +1,9 @@
import { mount } from "svelte";
import "./app.css";
import App from "./App.svelte";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+7
View File
@@ -0,0 +1,7 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors
preprocess: vitePreprocess(),
}
+20
View File
@@ -0,0 +1,20 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true,
"moduleDetection": "force",
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
},
"include": ["vite.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte'
// https://vite.dev/config/
export default defineConfig({
plugins: [svelte()],
})