├── .npmrc
├── src
├── canvas-dither.d.ts
├── lib
│ └── index.ts
├── utils
│ ├── number.ts
│ ├── vibration.ts
│ ├── webcam.ts
│ ├── frames.ts
│ ├── canvas.ts
│ ├── cursor.ts
│ └── filmstrip.ts
├── routes
│ ├── (app)
│ │ ├── rooms
│ │ │ └── [roomId]
│ │ │ │ ├── +page.ts
│ │ │ │ └── +page.svelte
│ │ ├── +layout.svelte
│ │ ├── +page.svelte
│ │ └── camera
│ │ │ └── +page.svelte
│ ├── (social)
│ │ └── og
│ │ │ ├── +layout.svelte
│ │ │ └── +page.svelte
│ └── +error.svelte
├── icons
│ ├── arrow-right.svelte
│ ├── select-arrows.svelte
│ ├── caret-left.svelte
│ ├── x.svelte
│ ├── send-arrow.svelte
│ ├── aperture.svelte
│ └── loading.svelte
├── app.d.ts
├── firebase.ts
├── components
│ ├── webcam-permission-button.svelte
│ ├── render-if-visible.svelte
│ ├── room-list
│ │ ├── room-list.svelte
│ │ └── room-list-item.svelte
│ ├── nav.svelte
│ ├── resolution-selector.svelte
│ ├── progress.svelte
│ ├── color-palette-selector.svelte
│ ├── message.svelte
│ ├── select.svelte
│ ├── button.svelte
│ ├── filmstrip.svelte
│ ├── dithered-bg.svelte
│ ├── new-message.svelte
│ └── stylized-webcam-feed.svelte
├── constants.ts
├── app.html
└── store.svelte.ts
├── cypress.json
├── firestore.indexes.json
├── static
├── arrow.png
├── text.png
├── favicon.png
├── og-image.jpg
├── pointer.png
├── home-promo-filmstrip.png
├── crt.css
└── global.css
├── .prettierignore
├── .firebaserc
├── design_assets
└── cursors.afdesign
├── cors_config.json
├── vite.config.ts
├── cypress
├── fixtures
│ └── example.json
├── integration
│ └── spec.js
├── plugins
│ └── index.js
└── support
│ ├── index.js
│ └── commands.js
├── tests
└── test.ts
├── .prettierrc.json
├── playwright.config.ts
├── .gitignore
├── storage.rules
├── README.md
├── firestore.rules
├── tsconfig.json
├── firebase.json
├── eslint.config.js
├── svelte.config.js
└── package.json
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 |
--------------------------------------------------------------------------------
/src/canvas-dither.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'canvas-dither';
2 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:3000",
3 | "video": false
4 | }
--------------------------------------------------------------------------------
/firestore.indexes.json:
--------------------------------------------------------------------------------
1 | {
2 | "indexes": [],
3 | "fieldOverrides": []
4 | }
5 |
--------------------------------------------------------------------------------
/static/arrow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/arrow.png
--------------------------------------------------------------------------------
/static/text.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/text.png
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Package Managers
2 | package-lock.json
3 | pnpm-lock.yaml
4 | yarn.lock
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/favicon.png
--------------------------------------------------------------------------------
/static/og-image.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/og-image.jpg
--------------------------------------------------------------------------------
/static/pointer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/pointer.png
--------------------------------------------------------------------------------
/.firebaserc:
--------------------------------------------------------------------------------
1 | {
2 | "projects": {
3 | "default": "not-firebase-58b83"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/index.ts:
--------------------------------------------------------------------------------
1 | // place files you want to import through the `$lib` alias in this folder.
2 |
--------------------------------------------------------------------------------
/design_assets/cursors.afdesign:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/design_assets/cursors.afdesign
--------------------------------------------------------------------------------
/static/home-promo-filmstrip.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/takempf/dither/HEAD/static/home-promo-filmstrip.png
--------------------------------------------------------------------------------
/src/utils/number.ts:
--------------------------------------------------------------------------------
1 | export function clamp(min: number, input: number, max: number): number {
2 | return Math.max(min, Math.min(max, input));
3 | }
4 |
--------------------------------------------------------------------------------
/cors_config.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "origin": ["https://dither.kempf.dev"],
4 | "method": ["GET"],
5 | "responseHeader": ["image/png"],
6 | "maxAgeSeconds": 3600
7 | }
8 | ]
9 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite';
2 | import { defineConfig } from 'vite';
3 |
4 | export default defineConfig({
5 | plugins: [sveltekit()]
6 | });
7 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
--------------------------------------------------------------------------------
/tests/test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test('home page has expected h1', async ({ page }) => {
4 | await page.goto('/');
5 | await expect(page.locator('h1')).toBeVisible();
6 | });
7 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": true,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 100,
6 | "plugins": ["prettier-plugin-svelte"],
7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
8 | }
9 |
--------------------------------------------------------------------------------
/src/utils/vibration.ts:
--------------------------------------------------------------------------------
1 | export function hapticBuzz() {
2 | if ('vibrate' in navigator) {
3 | try {
4 | // A short, sharp vibration pattern, 50ms
5 | navigator.vibrate([50]);
6 | } catch (error) {
7 | console.error('Error triggering vibration:', error);
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/routes/(app)/rooms/[roomId]/+page.ts:
--------------------------------------------------------------------------------
1 | import type { LoadEvent } from '@sveltejs/kit';
2 |
3 | import { error } from '@sveltejs/kit';
4 |
5 | export function load({ params }: LoadEvent) {
6 | if (!params.roomId) {
7 | throw error(404, 'Room not found');
8 | }
9 |
10 | return { roomId: params.roomId };
11 | }
12 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import type { PlaywrightTestConfig } from '@playwright/test';
2 |
3 | const config: PlaywrightTestConfig = {
4 | webServer: {
5 | command: 'npm run build && npm run preview',
6 | port: 4173
7 | },
8 | testDir: 'tests',
9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/
10 | };
11 |
12 | export default config;
13 |
--------------------------------------------------------------------------------
/src/icons/arrow-right.svelte:
--------------------------------------------------------------------------------
1 |
16 |
--------------------------------------------------------------------------------
/src/routes/(social)/og/+layout.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
19 |
--------------------------------------------------------------------------------
/src/icons/select-arrows.svelte:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/routes/(app)/+layout.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
19 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 | declare namespace App {
4 | interface ChatMessageT {
5 | id: string;
6 | createdAt: Date;
7 | imageUrl: string;
8 | text?: string;
9 | }
10 |
11 | interface RoomT {
12 | id: string;
13 | createdAt: Date;
14 | name: string;
15 | messages: ChatMessageT[];
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | # Output
4 | .output
5 | .vercel
6 | /.svelte-kit
7 | /build
8 | public/
9 |
10 | # OS
11 | .DS_Store
12 | Thumbs.db
13 |
14 | # Env
15 | .env
16 | .env.*
17 | !.env.example
18 | !.env.test
19 |
20 | # Vite
21 | vite.config.js.timestamp-*
22 | vite.config.ts.timestamp-*
23 |
24 | # Firebase
25 | .firebase/
26 | firebase-debug.log
27 | firestore-debug.log
28 | ui-debug.log
--------------------------------------------------------------------------------
/src/icons/caret-left.svelte:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/cypress/integration/spec.js:
--------------------------------------------------------------------------------
1 | describe('Sapper template app', () => {
2 | beforeEach(() => {
3 | cy.visit('/')
4 | });
5 |
6 | it('has the correct
', () => {
7 | cy.contains('h1', 'Great success!')
8 | });
9 |
10 | it('navigates to /about', () => {
11 | cy.get('nav a').contains('about').click();
12 | cy.url().should('include', '/about');
13 | });
14 |
15 | it('navigates to /blog', () => {
16 | cy.get('nav a').contains('blog').click();
17 | cy.url().should('include', '/blog');
18 | });
19 | });
--------------------------------------------------------------------------------
/storage.rules:
--------------------------------------------------------------------------------
1 | rules_version = '2';
2 | service firebase.storage {
3 | match /b/{bucket}/o {
4 | match /messages/{messageId}/filmstrip.png {
5 | allow write: if request.resource.contentType == 'image/png'
6 | && request.resource.size < 1 * 1024 * 1024; // 1MB limit
7 | }
8 |
9 | // New rule to allow reading any image within /messages/{messageId}
10 | match /messages/{messageId}/{imageName} {
11 | allow read: if true && resource.contentType.matches('image/png');
12 | }
13 | }
14 | }
--------------------------------------------------------------------------------
/src/utils/webcam.ts:
--------------------------------------------------------------------------------
1 | export function getCrop(videoElementToCrop: HTMLVideoElement, aspectRatio: number) {
2 | const { videoWidth, videoHeight } = videoElementToCrop;
3 | const webcamVideoAspect = videoWidth / videoHeight;
4 | let width, height;
5 |
6 | if (webcamVideoAspect > aspectRatio) {
7 | height = videoHeight;
8 | width = height * aspectRatio;
9 | } else {
10 | width = videoWidth;
11 | height = width / aspectRatio;
12 | }
13 |
14 | const x = videoWidth / 2 - width / 2;
15 | const y = videoHeight / 2 - height / 2;
16 |
17 | return { x, y, width, height };
18 | }
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dither - A lo-fi GIF chat app
2 |
3 | 
4 |
5 | https://dither.kempf.dev/
6 |
7 | Taking inspiration from [Return of the Obra Dinn](https://obradinn.com/) and [Meatspac.es](https://chat.meatspac.es/), I got to work making my own mix of a micro-vlog and chat app. I used several new (to me) technologies to make it happen:
8 |
9 | - [Svelte 5](https://svelte-5-preview.vercel.app/)
10 | - TypeScript
11 | - Firebase [Firestore](https://firebase.google.com/docs/firestore/)
12 | - Lots of `