feat: modernize web stack and harden chat error handling

- Upgrade Tailwind, Skeleton, SvelteKit, Vite, and supporting dependencies.
- Replace removed Skeleton components with compatible local implementations.
- Report pre-stream chat failures without duplicating streamed errors.
- Return empty model arrays and skip malformed vendor lists.
- Prevent stream completion crashes and normalize displayed error messages.
- Stack toasts correctly and add warning notification support.
- Restore linting, formatting, testing, and pnpm override configuration.
- Preserve custom themes through Tailwind CSS-based configuration migration.
This commit is contained in:
Kayvan Sylvan 2026-07-30 15:06:58 -07:00
parent 737c3ac372
commit 1b1feea2b5
51 changed files with 6952 additions and 3893 deletions

View file

@ -0,0 +1,31 @@
### PR [#2116](https://github.com/danielmiessler/Fabric/pull/2116) by [dependabot](https://github.com/apps/dependabot) and [ksylvan](https://github.com/ksylvan): chore(deps): bump the npm_and_yarn group across 1 directory with 2 updates
- Updated `postcss` from 8.5.8 to 8.5.14 in the `/web` directory to incorporate the latest fixes and improvements.
- Updated `picomatch` from 2.3.1 to 4.0.4 (indirect dependency) to resolve compatibility issues and apply upstream patches.
- Refactored regional locale parsing to use `strings.Cut`, simplifying boundary detection while preserving base-language and default-variant candidate fallback behavior.
- Moved the pnpm `overrides` and build-script allowlist from the `pnpm` field in `web/package.json` to a new `web/pnpm-workspace.yaml`, because pnpm 11 no longer reads that field. Without this move, pnpm silently discarded all dependency overrides.
- Renamed the build-script allowlist from `onlyBuiltDependencies` to `allowBuilds`, and gave the git-hosted `pdf-to-markdown-core` package its full dependency path, which pnpm 11 requires for a package that has no semantic version.
- Removed the stale `@sveltejs/vite-plugin-svelte` pin to version 4, which held back a plugin for Vite 5 while the project uses Vite 8. The plugin is now at version 7.2.0, which agrees with `package-lock.json`.
- Set the Vite CSS minifier to esbuild, because Vite 8 uses Lightning CSS by default and Lightning CSS stops the build on the `::file-selector-button:disabled` selector that Tailwind and the Skeleton plugin generate.
- Added the missing `prettier`, `prettier-plugin-svelte`, `eslint`, `eslint-config-prettier`, `globals`, `typescript-eslint`, and `vitest` packages. The `lint` and `test` scripts referred to these packages, but no package declared them, so neither script could start.
- Turned off `@typescript-eslint/no-unused-vars` for Svelte components and turned on the core `no-unused-vars` rule instead. The Svelte parser gives a `$:` statement a definition of type `ComputedVariable`, and the typescript-eslint rule stops ESLint with a TypeError on that type.
- Removed the deprecated `optimizeDeps.esbuildOptions` block. Vite 8 optimizes dependencies with Rolldown and reads neither the `target` nor the `supported` key, so the block had no effect.
- Gave the `cookie` override an upper bound of `<2.0.0` in both lock files. Cookie 2.0.0 removed the `parse` and `serialize` exports that SvelteKit needs, and the open-ended override let an update select that version and stop the build.
- Added `powershell` to the Shiki language list. The code example in `about/README.md` showed plain text and the build gave an error for each occurrence.
- Set Prettier to 2-space indentation, which agrees with 100 of the 126 source files. The configuration asked for tabs.
- Moved from Tailwind 3 to Tailwind 4 and from Skeleton 2 to Skeleton 5, which must happen together: Skeleton 5 needs Tailwind 4, and the Skeleton 2 Tailwind plugin needs Tailwind 3.
- Replaced `tailwind.config.ts` and `my-custom-theme.ts` with CSS. Tailwind 4 reads its settings from the stylesheet. The custom theme is now `src/themes/my-custom-theme.css` and keeps each color of the v2 theme without change.
- Replaced the PostCSS setup with the Tailwind Vite plugin, and removed two PostCSS configuration files. One of the two, `postcss.config.cjs`, had sat unused next to `postcss.config.js` since 2024.
- Renamed `src/app.postcss` to `src/app.css`, because the Tailwind Vite plugin reads files with a `.css` name. Also removed the earlier `src/app.css`, which no file imported and which held only comments.
- Replaced the Skeleton components that version 5 no longer supplies: the layout in place of `AppShell`, a backdrop and a panel in place of `Drawer`, a label with a file input in place of `FileButton`, and images in place of `Avatar`. `InputChip` became a `TagsInputField` component that wraps the Skeleton `TagsInput` parts and keeps the earlier API for its three call sites.
- Replaced the Skeleton toast and drawer stores, which version 5 removed. The toast calls now use the toast store that this project already had, which needed a new `warning` type. A new `drawer-store.ts` keeps the `open`, `close`, and `$drawerStore.open` API of the store it replaces.
- Renamed the Skeleton utility classes: `variant-filled-*` to `preset-filled-*-500`, `variant-ghost` to `preset-tonal`, `variant-gradient-success-warning` to Tailwind color stops, and the design tokens lost their `-token` suffix. The theme names `skeleton` and `gold-nouveau` became `legacy` and `nouveau`.
- Removed `vite-plugin-tailwind-purgecss`, which Tailwind 4 makes unnecessary because it writes only the utilities that the code uses.
- Removed the esbuild CSS minifier plugin and the `esbuild` dependency that it needed. The Skeleton 2 Tailwind plugin produced the selector that Lightning CSS refuses, and Skeleton 5 does not produce it.
- The CSS output grew from 68 KB to 252 KB. The ten themes that the theme button can select account for 121 KB of that total.
- Updated dependencies: `@sveltejs/kit` to 2.70.1, `svelte` to 5.56.8, `vite` to 8.2.0, `shiki` to 4.3.1, `marked` to 18.0.7, `pdfjs-dist` to 6.2.108, `svelte-check` to 4.7.4, `mdsvex` to 0.12.8, `svelte-youtube-lite` to 1.3.0, `svelte-youtube-embed` to 0.4.6, `@skeletonlabs/tw-plugin` to 0.4.1, `@types/node` to 26.1.2, and other patch and minor versions.
- Corrected the report of a failure that happens before the answer starts to stream, such as a pattern that needs a variable the request does not give. The server wrote the error to its log and sent the client nothing, so the chat page showed no error at all. The server now sends such an error to the client, and the chat page shows it both in the conversation and as a toast. A failure that happens during the stream still gives one report, not two.
- Corrected the position of each toast, which was fixed to the same corner as every other toast. Two or more toasts covered one another, and the spacing that the container sets between them had no effect. Each toast now stays in the flow of the container.
- Corrected the error text, which read `Error: Error: could not get pattern ...`, because the client put its own `Error:` in front of the text that already came with one from the server.
- Corrected a crash while reading the message that ends a stream. That message carries no content field, and the code called `trim` on the absent value, which wrote `Error parsing stream message` to the console and dropped the message.
- Corrected the vendor and model dropdown menus in the chat page, which listed no models. The `/models/names` response held `"Ollama": null`, because a vendor that is in the configuration but serves no models produces an empty Go slice, and an empty slice becomes null in JSON. The web client called `map` on that null, which stopped the read of every other vendor and left both menus with only their first entry. The client now steps over a vendor whose model list is not an array, and the server sends an empty array in place of null.

View file

@ -100,6 +100,11 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
i+1, prompt.Model, prompt.PatternName, prompt.ContextName)
streamChan := make(chan domain.StreamUpdate)
// Send returns an error for a failure that happens before it starts
// to stream, such as a pattern that needs a variable the request
// does not give. Such an error never reaches streamChan, so keep it
// here and report it after the stream ends.
sendErrChan := make(chan error, 1)
go func(p PromptRequest) {
defer close(streamChan)
@ -135,11 +140,12 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
_, err = chatter.Send(c.Request.Context(), chatReq, opts)
if err != nil {
log.Printf("Error from chatter.Send: %v", err)
// Error already sent to streamChan via domain.StreamTypeError if occurred in Send loop
sendErrChan <- err
return
}
}(prompt)
sawError := false
for update := range streamChan {
select {
case <-clientGone:
@ -159,6 +165,7 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
Usage: update.Usage,
}
case domain.StreamTypeError:
sawError = true
response = StreamResponse{
Type: "error",
Format: "plain",
@ -173,6 +180,15 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
}
}
// The goroutine writes sendErrChan before it closes streamChan, so
// the value is here by the time the loop above ends.
if response, ok := unreportedSendError(sendErrChan, sawError); ok {
if err := writeSSEResponse(c.Writer, response); err != nil {
log.Printf("Error writing error response: %v", err)
return
}
}
completeResponse := StreamResponse{
Type: "complete",
Format: "plain",
@ -186,6 +202,32 @@ func (h *ChatHandler) HandleChat(c *gin.Context) {
}
}
// unreportedSendError reads the error that Send returned and makes the response
// that tells the client about it. The second result is false when there is
// nothing to send.
//
// Send both gives a stream error to the update channel and returns it, so the
// client already has that error and must not get it twice. The sawError
// argument says whether an error went to the client during the stream. An error
// that Send makes before or after the stream, such as a pattern that needs a
// variable the request does not give, never goes to the update channel, and
// this function is the only way the client learns about it.
func unreportedSendError(sendErrChan <-chan error, sawError bool) (StreamResponse, bool) {
select {
case sendErr := <-sendErrChan:
if sendErr == nil || sawError {
return StreamResponse{}, false
}
return StreamResponse{
Type: "error",
Format: "plain",
Content: fmt.Sprintf(i18n.T("server_chat_error"), sendErr),
}, true
default:
return StreamResponse{}, false
}
}
func buildPromptChatRequest(p PromptRequest, language string) *domain.ChatRequest {
return &domain.ChatRequest{
Message: &chat.ChatCompletionMessage{

View file

@ -1,6 +1,9 @@
package restapi
import "testing"
import (
"errors"
"testing"
)
func TestBuildPromptChatRequest_PreservesStrategyAndUserInput(t *testing.T) {
prompt := PromptRequest{
@ -43,3 +46,72 @@ func TestBuildPromptChatRequest_PreservesStrategyAndUserInput(t *testing.T) {
t.Fatalf("expected variables to be preserved, got %q", got)
}
}
func TestUnreportedSendError(t *testing.T) {
patternErr := errors.New("could not get pattern write_essay: missing required variable: author_name")
tests := []struct {
name string
sendErr error
sawError bool
wantReport bool
wantContent string
}{
{
// A pattern that needs a variable the request does not give fails
// before Send starts to stream, so no error reached the client yet.
// This is the only chance to tell the client about it.
name: "error before the stream reaches the client",
sendErr: patternErr,
sawError: false,
wantReport: true,
wantContent: "Error: " + patternErr.Error(),
},
{
// Send gives a stream error to the update channel and also returns
// it. The client has it already, so a second report would show the
// same failure twice.
name: "error during the stream is not sent again",
sendErr: errors.New("vendor stream failed"),
sawError: true,
wantReport: false,
},
{
name: "no error and no report",
sendErr: nil,
sawError: false,
wantReport: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sendErrChan := make(chan error, 1)
if test.sendErr != nil {
sendErrChan <- test.sendErr
}
response, ok := unreportedSendError(sendErrChan, test.sawError)
if ok != test.wantReport {
t.Fatalf("want report=%v, got %v", test.wantReport, ok)
}
if !test.wantReport {
return
}
if response.Type != "error" {
t.Errorf("want type %q, got %q", "error", response.Type)
}
if response.Content != test.wantContent {
t.Errorf("want content %q, got %q", test.wantContent, response.Content)
}
})
}
}
// An empty channel must not block the response, which the client waits for.
func TestUnreportedSendErrorWithEmptyChannel(t *testing.T) {
if _, ok := unreportedSendError(make(chan error, 1), false); ok {
t.Error("want no report from an empty channel")
}
}

View file

@ -34,15 +34,26 @@ func (h *ModelsHandler) GetModelNames(c *gin.Context) {
}
response := make(map[string]any)
vendors := make(map[string][]string)
for _, groupItems := range vendorsModels.GroupsItems {
vendors[groupItems.Group] = groupItems.Items
response["models"] = h.getAllModelNames(vendorsModels)
response["vendors"] = buildVendorsMap(vendorsModels)
c.JSON(200, response)
}
response["models"] = h.getAllModelNames(vendorsModels)
response["vendors"] = vendors
c.JSON(200, response)
// buildVendorsMap groups the model names by vendor name for the response.
// A vendor with no models gets an empty slice, not a nil slice, because a nil
// slice becomes null in JSON and a client that reads the list of a vendor then
// gets null in place of an array. Ollama does this when it is in the
// configuration but serves no models.
func buildVendorsMap(vendorsModels *ai.VendorsModels) map[string][]string {
vendors := make(map[string][]string)
for _, groupItems := range vendorsModels.GroupsItems {
if groupItems.Items == nil {
vendors[groupItems.Group] = []string{}
continue
}
vendors[groupItems.Group] = groupItems.Items
}
return vendors
}
func (h *ModelsHandler) getAllModelNames(vendorsModels *ai.VendorsModels) []string {

View file

@ -0,0 +1,48 @@
package restapi
import (
"encoding/json"
"testing"
"github.com/danielmiessler/fabric/internal/plugins/ai"
)
func TestBuildVendorsMapGivesEmptyArrayForVendorWithoutModels(t *testing.T) {
vendorsModels := ai.NewVendorsModels()
vendorsModels.AddGroupItems("Anthropic", "claude-opus-5", "claude-sonnet-5")
// A variadic call with no items makes a nil slice, which is what a vendor in
// the configuration that serves no models produces.
vendorsModels.AddGroupItems("Ollama")
vendors := buildVendorsMap(vendorsModels)
if got := vendors["Ollama"]; got == nil {
t.Error("want an empty slice for a vendor with no models, got nil")
}
if got := len(vendors["Ollama"]); got != 0 {
t.Errorf("want 0 models for Ollama, got %d", got)
}
if got := len(vendors["Anthropic"]); got != 2 {
t.Errorf("want 2 models for Anthropic, got %d", got)
}
// The client reads vendors[name] as an array. Confirm the JSON holds an
// array and not null, which is what stopped the web UI from listing models.
encoded, err := json.Marshal(vendors)
if err != nil {
t.Fatalf("marshal failed: %v", err)
}
var decoded map[string][]string
if err = json.Unmarshal(encoded, &decoded); err != nil {
t.Fatalf("unmarshal failed: %v", err)
}
if decoded["Ollama"] == nil {
t.Errorf("want an array for Ollama in JSON, got null: %s", encoded)
}
}
func TestBuildVendorsMapWithNoVendors(t *testing.T) {
if got := len(buildVendorsMap(ai.NewVendorsModels())); got != 0 {
t.Errorf("want an empty map, got %d entries", got)
}
}

View file

@ -1,5 +1,6 @@
{
"useTabs": true,
"useTabs": false,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,

View file

@ -24,6 +24,14 @@ export default tseslint.config(
parserOptions: {
parser: tseslint.parser
}
},
rules: {
// svelte-eslint-parser gives a `$:` reactive statement a variable
// definition of type "ComputedVariable". The typescript-eslint rule
// does not know that type and stops ESLint with a TypeError. Use the
// core ESLint rule for Svelte components instead.
'@typescript-eslint/no-unused-vars': 'off',
'no-unused-vars': 'error'
}
},
{

View file

@ -1,102 +0,0 @@
import type { CustomThemeConfig } from '@skeletonlabs/tw-plugin';
export const myCustomTheme: CustomThemeConfig = {
name: 'my-custom-theme',
properties: {
// =~= Theme Properties =~=
"--theme-font-family-base": `system-ui`,
"--theme-font-family-heading": `system-ui`,
"--theme-font-color-base": "var(--color-primary-800)",
"--theme-font-color-dark": "var(--color-primary-300)",
"--theme-rounded-base": "9999px",
"--theme-rounded-container": "8px",
"--theme-border-base": "1px",
// =~= Theme On-X Colors =~=
"--on-primary": "0 0 0",
"--on-secondary": "0 0 0",
"--on-tertiary": "0 0 0",
"--on-success": "0 0 0",
"--on-warning": "0 0 0",
"--on-error": "0 0 0",
"--on-surface": "0 0 0",
// =~= Theme Colors =~=
// primary | #613bf7
"--color-primary-50": "231 226 254", // #e7e2fe
"--color-primary-100": "223 216 253", // #dfd8fd
"--color-primary-200": "216 206 253", // #d8cefd
"--color-primary-300": "192 177 252", // #c0b1fc
"--color-primary-400": "144 118 249", // #9076f9
"--color-primary-500": "97 59 247", // #613bf7
"--color-primary-600": "87 53 222", // #5735de
"--color-primary-700": "73 44 185", // #492cb9
"--color-primary-800": "58 35 148", // #3a2394
"--color-primary-900": "48 29 121", // #301d79
// secondary | #9de1ae
"--color-secondary-50": "240 251 243", // #f0fbf3
"--color-secondary-100": "235 249 239", // #ebf9ef
"--color-secondary-200": "231 248 235", // #e7f8eb
"--color-secondary-300": "216 243 223", // #d8f3df
"--color-secondary-400": "186 234 198", // #baeac6
"--color-secondary-500": "157 225 174", // #9de1ae
"--color-secondary-600": "141 203 157", // #8dcb9d
"--color-secondary-700": "118 169 131", // #76a983
"--color-secondary-800": "94 135 104", // #5e8768
"--color-secondary-900": "77 110 85", // #4d6e55
// tertiary | #3fa0a6
"--color-tertiary-50": "226 241 242", // #e2f1f2
"--color-tertiary-100": "217 236 237", // #d9eced
"--color-tertiary-200": "207 231 233", // #cfe7e9
"--color-tertiary-300": "178 217 219", // #b2d9db
"--color-tertiary-400": "121 189 193", // #79bdc1
"--color-tertiary-500": "63 160 166", // #3fa0a6
"--color-tertiary-600": "57 144 149", // #399095
"--color-tertiary-700": "47 120 125", // #2f787d
"--color-tertiary-800": "38 96 100", // #266064
"--color-tertiary-900": "31 78 81", // #1f4e51
// success | #37b3fc
"--color-success-50": "225 244 255", // #e1f4ff
"--color-success-100": "215 240 254", // #d7f0fe
"--color-success-200": "205 236 254", // #cdecfe
"--color-success-300": "175 225 254", // #afe1fe
"--color-success-400": "115 202 253", // #73cafd
"--color-success-500": "55 179 252", // #37b3fc
"--color-success-600": "50 161 227", // #32a1e3
"--color-success-700": "41 134 189", // #2986bd
"--color-success-800": "33 107 151", // #216b97
"--color-success-900": "27 88 123", // #1b587b
// warning | #d209f8
"--color-warning-50": "248 218 254", // #f8dafe
"--color-warning-100": "246 206 254", // #f6cefe
"--color-warning-200": "244 194 253", // #f4c2fd
"--color-warning-300": "237 157 252", // #ed9dfc
"--color-warning-400": "224 83 250", // #e053fa
"--color-warning-500": "210 9 248", // #d209f8
"--color-warning-600": "189 8 223", // #bd08df
"--color-warning-700": "158 7 186", // #9e07ba
"--color-warning-800": "126 5 149", // #7e0595
"--color-warning-900": "103 4 122", // #67047a
// error | #90df16
"--color-error-50": "238 250 220", // #eefadc
"--color-error-100": "233 249 208", // #e9f9d0
"--color-error-200": "227 247 197", // #e3f7c5
"--color-error-300": "211 242 162", // #d3f2a2
"--color-error-400": "177 233 92", // #b1e95c
"--color-error-500": "144 223 22", // #90df16
"--color-error-600": "130 201 20", // #82c914
"--color-error-700": "108 167 17", // #6ca711
"--color-error-800": "86 134 13", // #56860d
"--color-error-900": "71 109 11", // #476d0b
// surface | #46a1ed
"--color-surface-50": "227 241 252", // #e3f1fc
"--color-surface-100": "218 236 251", // #daecfb
"--color-surface-200": "209 232 251", // #d1e8fb
"--color-surface-300": "181 217 248", // #b5d9f8
"--color-surface-400": "126 189 242", // #7ebdf2
"--color-surface-500": "70 161 237", // #46a1ed
"--color-surface-600": "63 145 213", // #3f91d5
"--color-surface-700": "53 121 178", // #3579b2
"--color-surface-800": "42 97 142", // #2a618e
"--color-surface-900": "34 79 116", // #224f74
}
}

5487
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -16,37 +16,42 @@
"format": "prettier --write ."
},
"devDependencies": {
"@eslint/js": "^9.39.3",
"@skeletonlabs/skeleton": "^2.11.0",
"@skeletonlabs/tw-plugin": "^0.3.1",
"@eslint/js": "^9.39.5",
"@skeletonlabs/skeleton": "^5.0.0",
"@skeletonlabs/skeleton-svelte": "^5.0.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/kit": "^2.53.4",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@sveltejs/kit": "^2.70.1",
"@sveltejs/vite-plugin-svelte": "^7.2.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^20.19.37",
"autoprefixer": "^10.4.27",
"eslint-plugin-svelte": "^2.46.1",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^26.1.2",
"eslint": "^9.39.5",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.22.0",
"globals": "^17.8.0",
"lucide-svelte": "^0.575.0",
"mdsvex": "^0.11.2",
"mdsvex": "^0.12.8",
"patch-package": "^8.0.1",
"pdf-to-markdown-core": "github:jzillmann/pdf-to-markdown#modularize",
"pdfjs-dist": "^5.5.207",
"postcss": "^8.5.8",
"postcss-load-config": "^6.0.1",
"pdfjs-dist": "^6.2.108",
"postcss": "^8.5.25",
"prettier": "^3.9.6",
"prettier-plugin-svelte": "^4.1.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"shiki": "^1.29.2",
"svelte": "^5.53.7",
"svelte-check": "^3.8.6",
"shiki": "^4.3.1",
"svelte": "^5.56.8",
"svelte-check": "^4.7.4",
"svelte-inview": "^4.0.4",
"svelte-reveal": "^1.1.0",
"svelte-youtube-embed": "^0.3.3",
"svelte-youtube-lite": "^0.6.2",
"tailwindcss": "^3.4.19",
"svelte-reveal": "^1.2.0",
"svelte-youtube-embed": "^0.4.6",
"svelte-youtube-lite": "^1.3.0",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.3",
"vite": "^8.0.8",
"vite-plugin-tailwind-purgecss": "^0.3.5"
"typescript-eslint": "^8.65.0",
"vite": "^8.1.5",
"vitest": "^4.1.10"
},
"type": "module",
"overrides": {
@ -58,52 +63,24 @@
"http-signature": ">=0.10.0",
"mime": ">=1.4.1",
"hoek": ">=4.2.1",
"cookie": ">=0.7.0",
"cookie": ">=0.7.0 <2.0.0",
"tough-cookie": ">=4.1.3",
"esbuild": ">=0.25.0",
"@eslint/plugin-kit": ">=0.3.4"
},
"dependencies": {
"@floating-ui/dom": "^1.7.6",
"@floating-ui/dom": "^1.8.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns": "^4.4.0",
"highlight.js": "^11.11.1",
"marked": "^15.0.12",
"marked": "^18.0.7",
"nanoid": "5.0.9",
"rehype": "^13.0.2",
"rehype-external-links": "^3.0.0",
"rehype-unwrap-images": "^1.0.0",
"tailwind-merge": "^2.6.1",
"vfile-message": "^4.0.3",
"yaml": "^2.8.3",
"youtube-transcript": "^1.2.1"
},
"pnpm": {
"overrides": {
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"tunnel-agent@<0.6.0": ">=0.6.0",
"qs@<6.0.4": ">=6.0.4",
"qs@<1.0.0": ">=1.0.0",
"qs@<6.14.1": ">=6.14.1",
"hawk@<3.1.3": ">=3.1.3",
"http-signature@<0.10.0": ">=0.10.0",
"request@>=2.2.6 <2.47.0": ">=2.68.0",
"mime@<1.4.1": ">=1.4.1",
"hoek@<4.2.1": ">=4.2.1",
"hawk@<9.0.1": ">=9.0.1",
"qs@<6.2.4": ">=6.2.4",
"cookie@<0.7.0": ">=0.7.0",
"tough-cookie@<4.1.3": ">=4.1.3",
"nanoid@<3.3.8": ">=3.3.8",
"form-data@<2.5.4": ">=2.5.4",
"glob@>=10.2.0 <10.5.0": ">=10.5.0",
"esbuild@<=0.24.2": ">=0.25.0",
"@eslint/plugin-kit@<0.3.4": ">=0.3.4"
},
"onlyBuiltDependencies": [
"esbuild",
"pdf-to-markdown-core",
"svelte-preprocess"
]
"yaml": "^2.9.0",
"youtube-transcript": "^1.3.1"
}
}

File diff suppressed because it is too large Load diff

53
web/pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,53 @@
# pnpm 11 and later versions do not read the "pnpm" field in package.json.
# Keep pnpm settings in this file. See https://pnpm.io/settings
#
# The "overrides" field in package.json stays there for npm, which reads it
# from package.json and writes package-lock.json.
# Each entry below corrects a vulnerable transitive dependency. Do not add
# version pins for direct dependencies here. Set those in package.json, or the
# two files can disagree.
overrides:
'tunnel-agent@<0.6.0': '>=0.6.0'
'qs@<6.0.4': '>=6.0.4'
'qs@<1.0.0': '>=1.0.0'
'qs@<6.14.1': '>=6.14.1'
'hawk@<3.1.3': '>=3.1.3'
'http-signature@<0.10.0': '>=0.10.0'
'request@>=2.2.6 <2.47.0': '>=2.68.0'
'mime@<1.4.1': '>=1.4.1'
'hoek@<4.2.1': '>=4.2.1'
'hawk@<9.0.1': '>=9.0.1'
'qs@<6.2.4': '>=6.2.4'
# Keep the upper bound. Cookie 2.0.0 removed the `parse` and `serialize`
# exports, and SvelteKit needs them. Cookie 1.x corrects the vulnerability and
# keeps both names.
'cookie@<0.7.0': '>=0.7.0 <2.0.0'
'tough-cookie@<4.1.3': '>=4.1.3'
'nanoid@<3.3.8': '>=3.3.8'
'form-data@<2.5.4': '>=2.5.4'
'glob@>=10.2.0 <10.5.0': '>=10.5.0'
'esbuild@<=0.24.2': '>=0.25.0'
'@eslint/plugin-kit@<0.3.4': '>=0.3.4'
peerDependencyRules:
allowedVersions:
# vite-plugin-tailwind-purgecss 0.3.5 is the most recent release, and its
# last change was December 2024. It declares support only through Vite 6,
# but it does its work correctly with Vite 8: the plugin decreases the CSS
# output from 208 KB to 72 KB. Accept the newer Vite to hide a warning that
# gives no information. Remove this rule if the plugin gets a new release,
# or when a move to Tailwind 4 makes the plugin unnecessary.
'vite-plugin-tailwind-purgecss>vite': '8'
# "allowBuilds" replaces the "onlyBuiltDependencies" list. Each package that
# runs install scripts needs an entry with the value true. pnpm blocks the
# scripts of all other packages.
allowBuilds:
esbuild: true
svelte-preprocess: true
# A git-hosted package needs the full dependency path as the key, because
# pnpm does not trust the identity of a package that has no semver version.
# The commit hash must agree with the version in pnpm-lock.yaml. If you move
# the "modularize" branch, update this key and the lockfile together.
'pdf-to-markdown-core@https://codeload.github.com/jzillmann/pdf-to-markdown/tar.gz/5e930fafd305644cff00a3824030f0d6741acde8': true

View file

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View file

@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};

View file

@ -1,134 +1,109 @@
/* @tailwind base;
@tailwind components;
@tailwind utilities;
/* Tailwind 4 reads its configuration from CSS. The settings in this file come
* from the tailwind.config.ts file that it replaces.
*
* Every @import must come before the other rules. Tailwind needs to read its
* own theme before it reads the Skeleton CSS, because Skeleton uses the
* Tailwind breakpoint variants. */
@import 'tailwindcss';
:root {
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Go Mono', 'Fira Sans', 'Helvetica Neue', sans-serif;
/* Skeleton 5 supplies CSS only. The Svelte components are in a separate
* package. */
@import '@skeletonlabs/skeleton';
/* Each theme that theme-store.ts can select needs an import. The v2 names
* "skeleton" and "gold-nouveau" are now "legacy" and "nouveau". */
@import '@skeletonlabs/skeleton/themes/legacy';
@import '@skeletonlabs/skeleton/themes/modern';
@import '@skeletonlabs/skeleton/themes/crimson';
@import '@skeletonlabs/skeleton/themes/nouveau';
@import '@skeletonlabs/skeleton/themes/hamlindigo';
@import '@skeletonlabs/skeleton/themes/vintage';
@import '@skeletonlabs/skeleton/themes/seafoam';
@import '@skeletonlabs/skeleton/themes/sahara';
@import '@skeletonlabs/skeleton/themes/rocket';
@import './themes/my-custom-theme.css';
@plugin '@tailwindcss/forms';
@plugin '@tailwindcss/typography';
/* Tailwind must scan the Skeleton component package to keep the classes that
* those components use. */
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist';
/* The v3 config set darkMode to "class". Tailwind 4 needs this variant to read
* the "dark" class that app.html puts on the html element. */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-mono: 'Fira Code', monospace;
--column-width: 42rem;
--column-margin-top: 4rem;
} */
/* Light theme variables */
/* :root {
--background: hsl(249, 81%, 85%);
--foreground: hsl(229, 65%, 29%);
--card: 0 0% 100%;
--card-foreground: 224 71.4% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 224 71.4% 4.1%;
--primary: hsl(262.1 83.3% 57.8%);
--primary-foreground: hsl(274, 100%, 90%);
--secondary: hsl(173, 74%, 68%);
--secondary-foreground: hsl(195, 100%, 90%);
--muted: 220 14.3% 95.9%;
--muted-foreground: 220 8.9% 46.1%;
--accent: hsl(220, 37%, 49%);
--accent-foreground: 220.9 39.3% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 20% 98%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 262.1 83.3% 57.8%;
--radius: 0.5rem;
} */
/* These colors read CSS variables that no file defines, because the block
* that declared them in app.css is commented out. Tailwind 3 behaved the
* same way, so the declarations stay as they are to hold the appearance
* steady. Define the variables in this file to make the colors work. */
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
/* Dark theme variables */
/* .dark {
--background: 224 71.4% 4.1%;
--foreground: 210 20% 98%;
--card: 224 71.4% 4.1%;
--card-foreground: 210 20% 98%;
--popover: 224 71.4% 4.1%;
--popover-foreground: 210 20% 98%;
--primary: 263.4 70% 50.4%;
--primary-foreground: 210 20% 98%;
--secondary: 215 27.9% 16.9%;
--secondary-foreground: 210 20% 98%;
--muted: 215 27.9% 16.9%;
--muted-foreground: 217.9 10.6% 64.9%;
--accent: 215 27.9% 16.9%;
--accent-foreground: 210 20% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 20% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--ring: 263.4 70% 50.4%;
--animate-blink: blink 1s step-end infinite;
}
@layer base {
* {
@apply border-border;
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
/* Tailwind 4 no longer takes container settings from a configuration file. */
@utility container {
margin-inline: auto;
padding-inline: 2rem;
max-width: 1400px;
}
html,
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
} */
/* Enhanced Typography */
/* h1, h2, h3, h4, h5, h6 {
@apply font-semibold tracking-tight;
@apply h-full overflow-hidden;
}
h1 {
@apply text-4xl lg:text-5xl;
}
h2 {
@apply text-3xl lg:text-4xl;
}
h3 {
@apply text-2xl lg:text-3xl;
}
p {
@apply leading-7;
}
*/
/* Links */
/* a {
@apply text-primary hover:text-primary/80 transition-colors;
} */
/* Code blocks */
/* pre {
@apply p-4 rounded-lg bg-muted/50 font-mono text-sm;
}
code {
.terminal-output {
@apply font-mono text-sm;
}
*/
/* Terminal specific styles */
/* .terminal-window {
@apply rounded-lg border bg-card shadow-lg overflow-hidden;
}
.terminal-text {
.terminal-input {
@apply font-mono text-sm;
}
*/
/* Form elements */
/* input, textarea, select {
@apply rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
/* The typography plugin puts quotation marks around inline code. Remove them. */
.prose :where(code):not(:where([class~='not-prose'] *))::before,
.prose :where(code):not(:where([class~='not-prose'] *))::after {
content: '';
}
button {
@apply inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2;
/* Theme overrides. The v2 "skeleton" theme is now "legacy". */
:root [data-theme='legacy'] {
--typo-base--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--typo-heading--font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
} */
/* Custom scrollbar */
/* ::-webkit-scrollbar {
@apply w-2;
}
::-webkit-scrollbar-track {
@apply bg-muted;
}
::-webkit-scrollbar-thumb {
@apply bg-muted-foreground/30 rounded-full hover:bg-muted-foreground/50 transition-colors;
} */

View file

@ -1,25 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
html,
body {
@apply h-full overflow-hidden;
}
.terminal-output {
@apply font-mono text-sm;
}
.terminal-input {
@apply font-mono text-sm;
}
/* Skeleton theme overrides */
:root [data-theme='skeleton'] {
--theme-font-family-base: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--theme-font-family-heading: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}

View file

@ -0,0 +1,56 @@
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { api } from './base';
import { modelsApi } from './models';
describe('modelsApi.getAvailable', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('returns one entry for each model of each vendor', async () => {
vi.spyOn(api, 'fetch').mockResolvedValue({
data: { models: ['a', 'b'], vendors: { Anthropic: ['a'], OpenAI: ['b'] } }
});
const models = await modelsApi.getAvailable();
expect(models).toEqual([
{ name: 'a', vendor: 'Anthropic' },
{ name: 'b', vendor: 'OpenAI' }
]);
});
// The server sends null for the model list of a vendor that it can reach no
// models for, because an empty slice in Go becomes null in JSON. Ollama does
// this when it is in the configuration but has no models. One such vendor
// must not stop the models of the other vendors.
it('skips a vendor whose model list is null', async () => {
vi.spyOn(api, 'fetch').mockResolvedValue({
data: {
models: ['a'],
vendors: { Anthropic: ['a'], Ollama: null } as unknown as Record<string, string[]>
}
});
const models = await modelsApi.getAvailable();
expect(models).toEqual([{ name: 'a', vendor: 'Anthropic' }]);
});
it('skips a vendor whose model list is not an array', async () => {
vi.spyOn(api, 'fetch').mockResolvedValue({
data: {
models: [],
vendors: { Broken: 'not-a-list' } as unknown as Record<string, string[]>
}
});
await expect(modelsApi.getAvailable()).resolves.toEqual([]);
});
it('reports an error when the response holds no vendors', async () => {
vi.spyOn(api, 'fetch').mockResolvedValue({ data: { models: [] } as never });
await expect(modelsApi.getAvailable()).rejects.toThrow('missing vendors data');
});
});

View file

@ -10,11 +10,17 @@ export const modelsApi = {
throw new Error('Invalid response format: missing vendors data');
}
// The server sends null for the model list of a vendor that it can find
// no models for, because an empty slice in Go becomes null in JSON.
// Ollama does this when it is in the configuration but serves no models.
// Skip such a vendor: one of them must not hide the models of the others.
return Object.entries(response.data.vendors).flatMap(([vendor, models]) =>
models.map(model => ({
Array.isArray(models)
? models.map(model => ({
name: model,
vendor
}))
: []
);
} catch (error) {
console.error("Failed to fetch models:", error);

View file

@ -12,11 +12,10 @@
import { Textarea } from "$lib/components/ui/textarea";
import { obsidianSettings } from "$lib/store/obsidian-store";
import { featureFlags } from "$lib/config/features";
import { getDrawerStore } from '@skeletonlabs/skeleton';
import { drawerStore } from '$lib/store/drawer-store';
import { systemPrompt, selectedPatternName } from "$lib/store/pattern-store";
import { onMount } from "svelte";
const drawerStore = getDrawerStore();
function openDrawer() {
drawerStore.open({});
}

View file

@ -3,8 +3,7 @@
import { Textarea } from "$lib/components/ui/textarea";
import { sendMessage, messageStore } from '$lib/store/chat-store';
import { systemPrompt, selectedPatternName } from '$lib/store/pattern-store';
import { getToastStore } from '@skeletonlabs/skeleton';
import { FileButton } from '@skeletonlabs/skeleton';
import { toastStore } from '$lib/store/toast-store';
import { Paperclip, Send, FileCheck } from 'lucide-svelte';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
@ -14,6 +13,7 @@
import { languageStore } from '$lib/store/language-store';
import { obsidianSettings, updateObsidianSettings } from '$lib/store/obsidian-store';
import { PdfConversionService } from '$lib/services/PdfConversionService';
import { formatErrorMessage } from '$lib/utils/error-message';
const pdfService = new PdfConversionService();
@ -23,7 +23,6 @@
const chatService = new ChatService();
let userInput = "";
let isYouTubeURL = false;
const toastStore = getToastStore();
let files: FileList | undefined = undefined;
let uploadedFiles: string[] = [];
let fileContents: string[] = [];
@ -87,10 +86,7 @@
if (!files || files.length === 0) return;
if (uploadedFiles.length >= 5 || (uploadedFiles.length + files.length) > 5) {
toastStore.trigger({
message: 'Maximum 5 files allowed',
background: 'variant-filled-error'
});
toastStore.error('Maximum 5 files allowed');
return;
}
@ -126,10 +122,7 @@
);
} catch (error) {
toastStore.trigger({
message: 'Error processing files: ' + (error as Error).message,
background: 'variant-filled-error'
});
toastStore.error('Error processing files: ' + (error as Error).message);
// Clean up processing message on error
messageStore.update(messages =>
@ -246,26 +239,17 @@ async function readFileContent(file: File): Promise<string> {
}
if (!$obsidianSettings.noteName) {
toastStore.trigger({
message: 'Please enter a note name in Obsidian settings',
background: 'variant-filled-error'
});
toastStore.error('Please enter a note name in Obsidian settings');
return;
}
if (!$selectedPatternName) {
toastStore.trigger({
message: 'No pattern selected',
background: 'variant-filled-error'
});
toastStore.error('No pattern selected');
return;
}
if (!content) {
toastStore.trigger({
message: 'No content to save',
background: 'variant-filled-error'
});
toastStore.error('No content to save');
return;
}
@ -292,16 +276,10 @@ async function readFileContent(file: File): Promise<string> {
saveToObsidian: false, // Reset the save flag
noteName: '' // Clear the note name
});
toastStore.trigger({
message: responseData.message || `Saved to Obsidian: ${responseData.fileName}`,
background: 'variant-filled-success'
});
toastStore.success(responseData.message || `Saved to Obsidian: ${responseData.fileName}`);
} catch (error) {
console.error('Failed to save to Obsidian:', error);
toastStore.trigger({
message: error instanceof Error ? error.message : 'Failed to save to Obsidian',
background: 'variant-filled-error'
});
toastStore.error(error instanceof Error ? error.message : 'Failed to save to Obsidian');
}
}
@ -417,12 +395,17 @@ async function readFileContent(file: File): Promise<string> {
);
console.error('Stream processing error:', error);
// Show error message using a valid format type
const message = formatErrorMessage(error);
// Show the error in the chat, where it stays for the person to read.
messageStore.update(messages => [...messages, {
role: 'system',
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
content: message,
format: 'plain'
}]);
// And as a toast, so that a failure is visible even when the chat is
// scrolled away from the end.
toastStore.error(message);
}
);
} catch (error) {
@ -440,12 +423,15 @@ async function readFileContent(file: File): Promise<string> {
messages.filter(m => m.format !== 'loading')
);
// Show error message using a valid format type
const message = formatErrorMessage(error);
// Show the error in the chat and as a toast, as the stream handler does.
messageStore.update(messages => [...messages, {
role: 'system',
content: `Error: ${error instanceof Error ? error.message : String(error)}`,
content: message,
format: 'plain'
}]);
toastStore.error(message);
} finally {
// As a final safety measure, ensure loading message is removed
messageStore.update(messages =>
@ -531,17 +517,25 @@ async function readFileContent(file: File): Promise<string> {
</span>
{/if}
{#key fileButtonKey}
<FileButton
<!-- Skeleton 5 replaced FileButton with FileUpload, which draws a drop
zone and reports files through a callback. A label with a hidden file
input keeps both the appearance and the change handler of the button
that was here before. -->
<label
class="btn-icon preset-tonal inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full bg-primary-800/30 transition-colors hover:bg-primary-800/50"
class:pointer-events-none={isProcessingFiles || uploadedFiles.length >= 5}
class:opacity-50={isProcessingFiles || uploadedFiles.length >= 5}
>
<input
type="file"
name="file-upload"
button="btn-icon variant-ghost"
class="hidden"
bind:files
on:change={handleFileUpload}
disabled={isProcessingFiles || uploadedFiles.length >= 5}
class="h-10 w-10 bg-primary-800/30 hover:bg-primary-800/50 rounded-full transition-colors"
>
/>
<Paperclip class="w-5 h-5" />
</FileButton>
</label>
{/key}
<Button
type="button"

View file

@ -139,7 +139,7 @@ function renderContent(message: Message): string {
<div class="message-header flex items-center gap-2 mb-1 {message.role === 'assistant' || message.role === 'system' ? '' : 'justify-end'}">
<span class="text-xs text-muted-foreground rounded-lg p-1 variant-glass-secondary font-bold uppercase">
<span class="text-xs text-muted-foreground rounded-lg p-1 bg-secondary-500/20 backdrop-blur-lg font-bold uppercase">
{#if message.role === 'system'}
SYSTEM
{:else if message.role === 'assistant'}

View file

@ -1,8 +1,7 @@
<script lang='ts'>
import { getToastStore } from '@skeletonlabs/skeleton';
import { toastStore } from '$lib/store/toast-store';
import { Button } from "$lib/components/ui/button";
import Input from '$lib/components/ui/input/Input.svelte';
import { Toast } from '@skeletonlabs/skeleton';
let url = '';
let transcript = '';
@ -10,7 +9,6 @@
let error = '';
let title = '';
const toastStore = getToastStore();
async function fetchTranscript() {
function isValidYouTubeUrl(url: string) {
@ -20,10 +18,7 @@
if (!isValidYouTubeUrl(url)) {
error = 'Please enter a valid YouTube URL';
toastStore.trigger({
message: error,
background: 'variant-filled-error'
});
toastStore.error(error);
return;
}
@ -59,15 +54,9 @@
async function copyToClipboard() {
try {
await navigator.clipboard.writeText(transcript);
toastStore.trigger({
message: 'Transcript copied to clipboard!',
background: 'variant-filled-success'
});
toastStore.success('Transcript copied to clipboard!');
} catch (err) {
toastStore.trigger({
message: 'Failed to copy transcript',
background: 'variant-filled-error'
});
toastStore.error('Failed to copy transcript');
}
}
</script>
@ -98,7 +87,6 @@
{/if}
{#if transcript}
<Toast position="l" />
<div class="space-y-4 border rounded-lg p-4 bg-muted/50 h-96">
<div class="flex justify-between items-center">
<h3 class="text-xs font-semibold">{title || 'Transcript'}</h3>

View file

@ -2,7 +2,7 @@
import { Contact } from 'lucide-svelte';
</script>
<div class="form-control w-full m-auto p-4 rounded-lg bg-gradient-to-br variant-gradient-success-warning shadow-lg text-current" title="contact form">
<div class="form-control w-full m-auto p-4 rounded-lg bg-gradient-to-br from-success-500 to-warning-500 shadow-lg text-current" title="contact form">
<h2 class="font-bold pl-2">We'd love to hear from you</h2>
<p class="font-bold pl-2">Email</p>
<div class="input-group input-group-divider grid-cols-[1fr_auto]">
@ -21,6 +21,6 @@
<span class="font-bold pl-2">Message</span>
<textarea class="textarea" rows="4" placeholder="Enter your message ..."></textarea>
</label>
<a href="/" title=""><button class="button variant-filled-secondary rounded-lg p-2"><Contact /></button></a>
<a href="/" title=""><button class="button preset-filled-secondary-500 rounded-lg p-2"><Contact /></button></a>
</div>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { Sun, Moon, Menu, X, Github, FileText } from 'lucide-svelte';
import { Avatar } from '@skeletonlabs/skeleton';
import { fade } from 'svelte/transition';
import { theme, cycleTheme, initTheme } from '$lib/store/theme-store';
import { onMount } from 'svelte';
@ -45,11 +44,12 @@
<header class="fixed top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="container flex h-16 items-center justify-between px-4">
<div class="flex items-center gap-4">
<Avatar
<!-- The Skeleton 5 Avatar is a compound component for a picture with a
fallback. A plain image gives the same result for a fixed logo. -->
<img
src="/fabric-logo.png"
width="w-10"
rounded="rounded-full"
class="border-2 border-primary/20"
alt="Fabric logo"
class="aspect-square w-10 rounded-full border-2 border-primary/20 object-cover"
/>
<a href="/" class="flex items-center">
<span class="text-lg font-semibold">fabric</span>

View file

@ -17,6 +17,9 @@
</article>
<style lang="postcss">
/* Tailwind 4 processes the styles of each component on their own. The reference
* below gives @apply the theme without a second copy of the CSS. */
@reference '../../../app.css';
:global(h1) {
@apply h1;

View file

@ -135,7 +135,11 @@
</div>
</div>
<style>
<style lang="postcss">
/* Tailwind 4 processes the styles of each component on their own. The
* reference below gives @apply the theme without a second copy of the CSS. */
@reference '../../../app.css';
.terminal-window {
@apply rounded-lg border border-gray-700/50 bg-gray-900/95 shadow-2xl;
box-shadow: 0 0 60px -15px rgba(0, 0, 0, 0.3);

View file

@ -7,7 +7,7 @@ const buttonVariants = {
default: "bg-primary text-primary-foreground hover:bg-primary/90 rounded-full border shadow-sm",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
outline: "border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full border variant-glass-secondary shadow-sm",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 rounded-full border bg-secondary-500/20 backdrop-blur-lg shadow-sm",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline"
},

View file

@ -7,7 +7,7 @@
href={url}
target="_blank"
rel="noopener noreferrer"
class="text-sm px-3 py-1.5 btn variant-filled-tertiary hover:variant-filled-secondary transition-all duration-200 flex items-center gap-2"
class="text-sm px-3 py-1.5 btn preset-filled-tertiary-500 hover:preset-filled-secondary-500 transition-all duration-200 flex items-center gap-2"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View file

@ -1,5 +1,4 @@
<script>
import { Avatar } from '@skeletonlabs/skeleton';
export let header = '';
export let imageUrl = '';
@ -11,7 +10,7 @@
export let link = '';
</script>
<div class="w-full text-token grid grid-cols-1 md:grid-cols-1 justify-end gap-4">
<div class="w-full grid grid-cols-1 md:grid-cols-1 justify-end gap-4">
<a class="card card-hover overflow-hidden" href={link}>
<header>
<img src={imageUrl} class="bg-black/50 w-full" alt={imageAlt} />
@ -27,7 +26,11 @@
</div>
<hr class="opacity-50" />
<footer class="p-4 flex justify-start items-center space-x-4">
<Avatar src={authorAvatarUrl} width="w-8" />
<img
src={authorAvatarUrl}
alt="Picture of {authorName}"
class="aspect-square w-8 rounded-full object-cover"
/>
<div class="flex-auto flex justify-between items-center">
<h6 class="font-bold" data-toc-ignore>By {authorName}</h6>
<small>On {new Date().toLocaleDateString()}</small>

View file

@ -1,13 +1,11 @@
<script lang="ts">
import { Drawer, getDrawerStore, getToastStore } from '@skeletonlabs/skeleton';
import type { DrawerStore } from '@skeletonlabs/skeleton';
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { noteStore } from '$lib/store/note-store';
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { clickOutside } from '$lib/actions/clickOutside';
const drawerStore = getDrawerStore();
const toastStore = getToastStore();
import { drawerStore } from '$lib/store/drawer-store';
import { toastStore } from '$lib/store/toast-store';
let textareaEl: HTMLTextAreaElement;
let saving = false;
@ -24,10 +22,7 @@
async function saveContent() {
if (!$noteStore.content.trim()) {
toastStore.trigger({
message: 'Cannot save empty note',
background: 'variant-filled-warning'
});
toastStore.warning('Cannot save empty note');
return;
}
@ -35,16 +30,10 @@
saving = true;
await noteStore.save();
toastStore.trigger({
message: `Note saved successfully!`,
background: 'variant-filled-success'
});
toastStore.success('Note saved successfully!');
} catch (error) {
console.error('Failed to save note:', error);
toastStore.trigger({
message: error instanceof Error ? error.message : 'Failed to save notes',
background: 'variant-filled-error'
});
toastStore.error(error instanceof Error ? error.message : 'Failed to save notes');
} finally {
saving = false;
}
@ -81,8 +70,16 @@
});
</script>
<Drawer width="w-[40%]" class="flex flex-col h-[calc(100vh-theme(spacing.32))] p-4 mt-16">
<!-- Skeleton 5 removed the Drawer utility. The backdrop and the panel below take
its place. The panel keeps the width, height, padding and margin that the
Drawer received through its props. The height was `calc(100vh-theme(spacing.32))`,
and spacing.32 is 8rem. -->
{#if $drawerStore.open}
<div class="fixed inset-0 z-40 bg-surface-950/50" aria-hidden="true"></div>
<aside
class="fixed left-0 top-0 z-50 mt-16 flex h-[calc(100vh-8rem)] w-[40%] flex-col bg-surface-100-900 p-4 shadow-xl"
transition:fly={{ x: -320, duration: 200 }}
>
<div
class="flex flex-col h-full"
use:clickOutside={() => {
@ -130,13 +127,13 @@
</span>
<div class="flex gap-2">
<button
class="btn btn-sm variant-filled-surface"
class="btn btn-sm preset-filled-surface-500"
on:click={noteStore.reset}
>
Reset
</button>
<button
class="btn btn-sm variant-filled-primary"
class="btn btn-sm preset-filled-primary-500"
on:click={saveContent}
>
{#if saving}
@ -148,5 +145,5 @@
</div>
</footer>
</div>
</aside>
{/if}
</Drawer>

View file

@ -5,6 +5,6 @@
export { className as class };
</script>
<SelectPrimitive.Label class={cn("px-2 py-1.5 text-sm variant-filled-secondary font-semibold", className)} {...$$restProps}>
<SelectPrimitive.Label class={cn("px-2 py-1.5 text-sm preset-filled-secondary-500 font-semibold", className)} {...$$restProps}>
<slot />
</SelectPrimitive.Label>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { InputChip } from '@skeletonlabs/skeleton';
import TagsInputField from '$lib/components/ui/tagsInput/TagsInputField.svelte';
import type { PostMetadata } from '$lib/types';
import type { Post } from '$lib/interfaces/post-interface'
import PostCard from '$lib/components/posts/PostCard.svelte';
@ -47,7 +47,7 @@
<div class="container py-12">
<div class="my-4">
<InputChip
<TagsInputField
name="tags"
placeholder="Filter by tags..."
validation={validateTag}

View file

@ -0,0 +1,54 @@
<script lang="ts">
// Skeleton 5 renamed InputChip to TagsInput and made it a set of parts that
// each caller assembles. This component holds that assembly in one place and
// keeps the small API that the InputChip call sites used.
import { TagsInput } from '@skeletonlabs/skeleton-svelte';
interface Props {
value?: string[];
name?: string;
placeholder?: string;
/** Return false to refuse the tag that the person typed. */
// eslint.config.js uses the core no-unused-vars rule for Svelte components,
// and that rule reads the parameter name of a function type as a variable.
// eslint-disable-next-line no-unused-vars
validation?: (value: string) => boolean;
class?: string;
}
let {
value = $bindable([]),
name = 'tags',
placeholder = '',
validation,
class: classes = ''
}: Props = $props();
</script>
<TagsInput
{name}
{value}
onValueChange={(details) => (value = details.value)}
validate={validation ? (details) => validation(details.inputValue) : undefined}
class={classes}
>
<TagsInput.Context>
{#snippet children(api)}
<TagsInput.Control class="input flex flex-wrap items-center gap-2">
{#each api().value as tag, index (tag)}
<TagsInput.Item {index} value={tag} class="chip preset-filled-primary-500">
<TagsInput.ItemPreview class="flex items-center gap-1">
<TagsInput.ItemText />
<TagsInput.ItemDeleteTrigger class="cursor-pointer opacity-70 hover:opacity-100">
&times;
</TagsInput.ItemDeleteTrigger>
</TagsInput.ItemPreview>
<TagsInput.ItemInput />
</TagsInput.Item>
{/each}
<TagsInput.Input {placeholder} class="flex-auto border-none bg-transparent outline-none" />
</TagsInput.Control>
<TagsInput.HiddenInput />
{/snippet}
</TagsInput.Context>
</TagsInput>

View file

@ -16,16 +16,22 @@
});
</script>
<!-- ToastContainer holds the position for the group. This element must stay in
the flow of that container, because a fixed position here would put every
toast in the same corner, one on top of another, and would also stop the
spacing that the container sets between them. -->
<div
class="fixed bottom-4 right-4 p-4 rounded-lg shadow-lg"
class="p-4 rounded-lg shadow-lg"
class:bg-green-100={toast.type === 'success'}
class:bg-red-100={toast.type === 'error'}
class:bg-amber-100={toast.type === 'warning'}
class:bg-blue-100={toast.type === 'info'}
transition:fly={{ y: 200, duration: 300 }}
>
<p
class:text-green-800={toast.type === 'success'}
class:text-red-800={toast.type === 'error'}
class:text-amber-800={toast.type === 'warning'}
class:text-blue-800={toast.type === 'info'}
>
{toast.message}

View file

@ -13,11 +13,10 @@ aliases:
<script>
import { Button } from '$lib/components/ui/button';
import NoteDrawer from '$lib/components/ui/noteDrawer/NoteDrawer.svelte';
import { getDrawerStore } from '@skeletonlabs/skeleton';
import { drawerStore } from '$lib/store/drawer-store';
import { page } from '$app/stores';
import { beforeNavigate } from '$app/navigation';
const drawerStore = getDrawerStore();
function openDrawer() {
drawerStore.open({});
}
@ -35,7 +34,7 @@ This is the first post of your new blog, powered by [SvelteKit](/posts/getting-s
<div class="flex text-inherit justify-start mt-2">
<Button
variant="primary"
class="btn border variant-filled-primary text-align-center"
class="btn border preset-filled-primary-500 text-align-center"
on:click={openDrawer}
>Open Drawer
</Button>

View file

@ -122,7 +122,11 @@ export class ChatService {
// and .trim() strips leading spaces that serve as word separators.
// Cleaning should be done on the final accumulated content at display time.
// Simplified format determination - always markdown unless mermaid
// Simplified format determination - always markdown unless mermaid.
// The server leaves the content field out of a message that carries
// no text, such as the one that ends the stream, so read an absent
// value as empty text.
const content = response.content ?? "";
const isMermaid = [
"graph TD",
"gantt",
@ -130,7 +134,7 @@ export class ChatService {
"sequenceDiagram",
"classDiagram",
"stateDiagram",
].some((starter) => response.content.trim().startsWith(starter));
].some((starter) => content.trim().startsWith(starter));
response.format = isMermaid ? "mermaid" : "markdown";
}

View file

@ -0,0 +1,27 @@
import { writable } from 'svelte/store';
// Skeleton 5 removed the drawer utility and its store. This store keeps the
// small part of the Skeleton v2 API that this project used, so that the call
// sites stay the same: drawerStore.open(), drawerStore.close(), and a read of
// $drawerStore.open.
export interface DrawerSettings {
id?: string;
meta?: unknown;
}
export interface DrawerState extends DrawerSettings {
open: boolean;
}
function createDrawerStore() {
const { subscribe, set, update } = writable<DrawerState>({ open: false });
return {
subscribe,
open: (settings: DrawerSettings = {}) => set({ ...settings, open: true }),
close: () => update((state) => ({ ...state, open: false }))
};
}
export const drawerStore = createDrawerStore();

View file

@ -1,11 +1,13 @@
import { writable } from 'svelte/store';
// Skeleton 5 renamed two of these themes: "skeleton" is now "legacy" and
// "gold-nouveau" is now "nouveau". Each name needs an @import in app.css.
const themes = [
'my-custom-theme',
'skeleton',
'legacy',
'modern',
'crimson',
'gold-nouveau',
'nouveau',
'hamlindigo',
'vintage',
'seafoam',
@ -16,7 +18,7 @@ const themes = [
type ThemeType = typeof themes[number];
function createThemeStore() {
const { subscribe, set, update } = writable<ThemeType>('skeleton');
const { subscribe, set, update } = writable<ThemeType>('legacy');
return {
subscribe,

View file

@ -2,7 +2,7 @@ import { writable } from 'svelte/store';
export interface ToastMessage {
message: string;
type: 'success' | 'error' | 'info';
type: 'success' | 'error' | 'warning' | 'info';
id: number;
}
@ -18,6 +18,9 @@ function createToastStore() {
error: (message: string) => {
update(toasts => [...toasts, { message, type: 'error', id: nextId++ }]);
},
warning: (message: string) => {
update(toasts => [...toasts, { message, type: 'warning', id: nextId++ }]);
},
info: (message: string) => {
update(toasts => [...toasts, { message, type: 'info', id: nextId++ }]);
},

View file

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { formatErrorMessage } from './error-message';
describe('formatErrorMessage', () => {
// The server sends 'Error: could not get pattern write_essay: missing
// required variable: author_name'. The reader must not see the word twice.
it('keeps one Error word when the text already starts with it', () => {
const serverText = 'Error: could not get pattern write_essay: missing required variable: author_name';
expect(formatErrorMessage(new Error(serverText))).toBe(serverText);
});
it('adds the Error word when the text does not start with it', () => {
expect(formatErrorMessage(new Error('the vendor refused the request'))).toBe(
'Error: the vendor refused the request'
);
});
it('reads a plain string', () => {
expect(formatErrorMessage('something broke')).toBe('Error: something broke');
});
it('gives a message for an empty error', () => {
expect(formatErrorMessage(new Error(''))).toBe('Error: unknown error');
expect(formatErrorMessage(' ')).toBe('Error: unknown error');
});
it('reads a value that is not an error and not a string', () => {
expect(formatErrorMessage({ code: 12 })).toBe('Error: [object Object]');
expect(formatErrorMessage(null)).toBe('Error: null');
});
});

View file

@ -0,0 +1,21 @@
/**
* Makes the text that tells the person what went wrong.
*
* The server writes its stream errors as "Error: <what happened>", and the
* client showed that text after a second "Error: " of its own, which gave
* "Error: Error: could not get pattern ...". Add the word only when the text
* does not start with it.
*/
export function formatErrorMessage(error: unknown): string {
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : String(error);
const trimmed = message.trim();
if (trimmed === '') {
return 'Error: unknown error';
}
if (/^error\b/i.test(trimmed)) {
return trimmed;
}
return `Error: ${trimmed}`;
}

View file

@ -1,19 +1,13 @@
<script>
import '../app.postcss';
import { AppShell } from '@skeletonlabs/skeleton';
import '../app.css';
import ToastContainer from '$lib/components/ui/toast/ToastContainer.svelte';
import Footer from '$lib/components/home/Footer.svelte';
import Header from '$lib/components/home/Header.svelte';
import { initializeStores, getDrawerStore } from '@skeletonlabs/skeleton';
import { page } from '$app/stores';
import { fly } from 'svelte/transition';
import { onMount } from 'svelte';
import { toastStore } from '$lib/store/toast-store';
// Initialize stores
initializeStores();
const drawerStore = getDrawerStore();
onMount(() => {
toastStore.info("👋 Welcome to the site! Tell people about yourself and what you do.");
});
@ -21,15 +15,20 @@
<ToastContainer />
<!-- Skeleton 5 removed the AppShell component and asks each project to write
its own layout. The structure below does what AppShell did for this page: a
column that fills the window, with a header and a footer that keep their
height and a middle section that scrolls. -->
{#key $page.url.pathname}
<AppShell class="relative">
<div class="relative flex h-full w-full flex-col overflow-hidden">
<div class="fixed inset-0 bg-gradient-to-br from-primary-500/20 via-tertiary-500/20 to-secondary-500/20 -z-10"></div>
<svelte:fragment slot="header">
<header class="flex-none">
<Header />
<div class="h-2 py-4"></div>
</svelte:fragment>
</header>
<div
class="min-h-0 flex-auto overflow-y-auto"
in:fly={{ duration: 500, delay: 100, y: 100 }}
>
<main class="main m-auto">
@ -37,10 +36,10 @@
</main>
</div>
<svelte:fragment slot="footer">
<footer class="flex-none">
<Footer />
</svelte:fragment>
</AppShell>
</footer>
</div>
{/key}
<style>

View file

@ -1,3 +1,3 @@
import '../app.postcss';
import '../app.css';
export const prerender = false;

View file

@ -26,7 +26,7 @@
<h2 class="h2 text-2xl text-center font-extrabold bg-gradient-to-br to-blue-500 from-cyan-300 bg-clip-text text-transparent pb-2">Human Flourishing via AI Augmentation</h2>
<div class="text-2xl">
<p class="mt-2 font-bold">Fill in the blanks... </p>
<i class="mt-2 font-medium justify-end">I believe one of the biggest problems in my world is <code class="code variant-filled-secondary">___________,</code> which is why I'm building/creating/doing <code class="code variant-filled-secondary">___________.</code></i>
<i class="mt-2 font-medium justify-end">I believe one of the biggest problems in my world is <code class="code preset-filled-secondary-500">___________,</code> which is why I'm building/creating/doing <code class="code preset-filled-secondary-500">___________.</code></i>
</div>
</div>
{/if}

View file

@ -1,8 +1,5 @@
<script lang="ts">
import Chat from '$lib/components/chat/Chat.svelte';
import { initializeStores } from "@skeletonlabs/skeleton";
initializeStores();
</script>
<Chat />

View file

@ -4,7 +4,7 @@
import Card from '$lib/components/ui/cards/card.svelte';
import { Youtube } from 'svelte-youtube-lite';
import PostCard from '$lib/components/posts/PostCard.svelte';
import { InputChip } from '@skeletonlabs/skeleton';
import TagsInputField from '$lib/components/ui/tagsInput/TagsInputField.svelte';
import Connections from '$lib/components/ui/connections/Connections.svelte';
import Button from '$lib/components/ui/button/button.svelte';
@ -148,7 +148,7 @@
</div>
</div>
<br>
<div class="rounded-tl-container-token m-auto grid grid-cols-1 gap-4 mt-8">
<div class="rounded-tl-container m-auto grid grid-cols-1 gap-4 mt-8">
<div class="mx-auto max-h-52 max-w-52"><img src="/fabric-logo.png" alt="fabric-logo"></div>
</div>
<br>
@ -160,7 +160,7 @@
</div>
<div class="container py-12">
<div class="my-4">
<InputChip
<TagsInputField
name="tags"
placeholder="Filter by tags..."
validation={validateTag}

View file

@ -4,7 +4,7 @@
import Card from '$lib/components/ui/cards/card.svelte';
import { slide } from 'svelte/transition';
import { elasticOut, quintOut } from 'svelte/easing';
import { InputChip } from '@skeletonlabs/skeleton';
import TagsInputField from '$lib/components/ui/tagsInput/TagsInputField.svelte';
let cards = false;
let searchQuery = '';
@ -48,7 +48,7 @@ Could this be the new component for the search bar?
import { Youtube } from 'svelte-youtube-lite';
import { slide } from 'svelte/transition';
import { elasticOut, quintOut } from 'svelte/easing';
import { InputChip } from '@skeletonlabs/skeleton';
import TagsInputField from '$lib/components/ui/tagsInput/TagsInputField.svelte';
let cards = false;
let searchQuery = '';
@ -158,12 +158,11 @@ Could this be the new component for the search bar?
<div class="mb-6">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<InputChip
<TagsInputField
bind:value={selectedTags}
name="tags"
placeholder="Search and press Enter to add tags..."
validation={validateTag}
allowDuplicates={false}
class="input"
/>
<div class="tags-container overflow-x-auto pb-2">
@ -193,7 +192,7 @@ Could this be the new component for the search bar?
{#if filteredPosts.length === 0}
{#if !visible}
<aside class="alert variant-ghost">
<aside class="alert preset-tonal">
<div>(icon)</div>
<slot:fragment href="./+error.svelte" />
<div class="alert-actions">(buttons)</div>

View file

@ -0,0 +1,253 @@
/* Skeleton v5 port of the v2 my-custom-theme.
* The colors come from the v2 theme without change. The 950 shade did not
* exist in v2 and continues the 800-to-900 ramp. The structure of the token
* set comes from the Skeleton legacy theme. */
[data-theme='my-custom-theme'] {
--spacing: 0.25rem;
--text-scaling: 1.067;
--typo-base--font-family: Inter, Roboto, 'Helvetica Neue', 'Arial Nova', 'Nimbus Sans', Arial, sans-serif;
--typo-base--font-size: inherit;
--typo-base--color-light: var(--color-surface-950);
--typo-base--color-dark: var(--color-surface-50);
--typo-base--line-height: inherit;
--typo-base--font-weight: normal;
--typo-base--font-style: normal;
--typo-base--letter-spacing: 0em;
--typo-base--font-stretch: inherit;
--typo-base--font-kerning: inherit;
--typo-base--text-shadow: inherit;
--typo-base--word-spacing: inherit;
--typo-base--hyphens: inherit;
--typo-base--text-transform: inherit;
--typo-heading--font-family: inherit;
--typo-heading--color-light: inherit;
--typo-heading--color-dark: inherit;
--typo-heading--font-weight: bold;
--typo-heading--font-style: normal;
--typo-heading--letter-spacing: inherit;
--typo-heading--font-stretch: inherit;
--typo-heading--font-kerning: inherit;
--typo-heading--text-shadow: inherit;
--typo-heading--word-spacing: inherit;
--typo-heading--hyphens: inherit;
--typo-heading--text-transform: inherit;
--typo-anchor--font-family: inherit;
--typo-anchor--font-size: inherit;
--typo-anchor--color-light: var(--color-primary-500);
--typo-anchor--color-dark: var(--color-primary-500);
--typo-anchor--line-height: inherit;
--typo-anchor--font-weight: inherit;
--typo-anchor--font-style: inherit;
--typo-anchor--letter-spacing: inherit;
--typo-anchor--font-stretch: inherit;
--typo-anchor--font-kerning: inherit;
--typo-anchor--text-shadow: inherit;
--typo-anchor--word-spacing: inherit;
--typo-anchor--hyphens: inherit;
--typo-anchor--text-transform: inherit;
--typo-anchor--text-decoration-line: none;
--typo-anchor--text-decoration-color: inherit;
--typo-anchor--text-decoration-style: inherit;
--typo-anchor--text-decoration-thickness: inherit;
--typo-anchor--text-underline-offset: inherit;
--typo-anchor--text-underline-position: inherit;
--typo-anchor--hover--text-decoration-line: underline;
--typo-anchor--hover--text-decoration-color: inherit;
--typo-anchor--hover--text-decoration-style: inherit;
--typo-anchor--hover--text-decoration-thickness: inherit;
--typo-anchor--hover--text-underline-offset: inherit;
--typo-anchor--hover--text-underline-position: inherit;
--typo-anchor--active--text-decoration-line: none;
--typo-anchor--active--text-decoration-color: inherit;
--typo-anchor--active--text-decoration-style: inherit;
--typo-anchor--active--text-decoration-thickness: inherit;
--typo-anchor--active--text-underline-offset: inherit;
--typo-anchor--active--text-underline-position: inherit;
--typo-anchor--focus--text-decoration-line: none;
--typo-anchor--focus--text-decoration-color: inherit;
--typo-anchor--focus--text-decoration-style: inherit;
--typo-anchor--focus--text-decoration-thickness: inherit;
--typo-anchor--focus--text-underline-offset: inherit;
--typo-anchor--focus--text-underline-position: inherit;
--radius-base: 9999px;
--radius-container: 8px;
--default-border-width: 1px;
--default-outline-width: 1px;
--default-ring-width: 1px;
--corner-shape-base: inherit;
--corner-shape-container: inherit;
--color-root-bg-light: oklch(1 0 0 / 1);
--color-root-bg-dark: var(--color-surface-950);
--color-brand-light: var(--color-primary-500);
--color-brand-contrast-light: var(--color-primary-contrast-500);
--color-brand-dark: var(--color-primary-500);
--color-brand-contrast-dark: var(--color-primary-contrast-500);
--color-primary-50: rgb(231 226 254);
--color-primary-100: rgb(223 216 253);
--color-primary-200: rgb(216 206 253);
--color-primary-300: rgb(192 177 252);
--color-primary-400: rgb(144 118 249);
--color-primary-500: rgb(97 59 247);
--color-primary-600: rgb(87 53 222);
--color-primary-700: rgb(73 44 185);
--color-primary-800: rgb(58 35 148);
--color-primary-900: rgb(48 29 121);
--color-primary-950: rgb(40 24 101);
--color-primary-contrast-dark: var(--color-primary-950);
--color-primary-contrast-light: var(--color-primary-50);
--color-primary-contrast-50: var(--color-primary-contrast-dark);
--color-primary-contrast-100: var(--color-primary-contrast-dark);
--color-primary-contrast-200: var(--color-primary-contrast-dark);
--color-primary-contrast-300: var(--color-primary-contrast-dark);
--color-primary-contrast-400: var(--color-primary-contrast-dark);
--color-primary-contrast-500: var(--color-primary-contrast-dark);
--color-primary-contrast-600: var(--color-primary-contrast-dark);
--color-primary-contrast-700: var(--color-primary-contrast-light);
--color-primary-contrast-800: var(--color-primary-contrast-light);
--color-primary-contrast-900: var(--color-primary-contrast-light);
--color-primary-contrast-950: var(--color-primary-contrast-light);
--color-secondary-50: rgb(240 251 243);
--color-secondary-100: rgb(235 249 239);
--color-secondary-200: rgb(231 248 235);
--color-secondary-300: rgb(216 243 223);
--color-secondary-400: rgb(186 234 198);
--color-secondary-500: rgb(157 225 174);
--color-secondary-600: rgb(141 203 157);
--color-secondary-700: rgb(118 169 131);
--color-secondary-800: rgb(94 135 104);
--color-secondary-900: rgb(77 110 85);
--color-secondary-950: rgb(64 91 71);
--color-secondary-contrast-dark: var(--color-secondary-950);
--color-secondary-contrast-light: var(--color-secondary-50);
--color-secondary-contrast-50: var(--color-secondary-contrast-dark);
--color-secondary-contrast-100: var(--color-secondary-contrast-dark);
--color-secondary-contrast-200: var(--color-secondary-contrast-dark);
--color-secondary-contrast-300: var(--color-secondary-contrast-dark);
--color-secondary-contrast-400: var(--color-secondary-contrast-dark);
--color-secondary-contrast-500: var(--color-secondary-contrast-light);
--color-secondary-contrast-600: var(--color-secondary-contrast-light);
--color-secondary-contrast-700: var(--color-secondary-contrast-light);
--color-secondary-contrast-800: var(--color-secondary-contrast-light);
--color-secondary-contrast-900: var(--color-secondary-contrast-light);
--color-secondary-contrast-950: var(--color-secondary-contrast-light);
--color-tertiary-50: rgb(226 241 242);
--color-tertiary-100: rgb(217 236 237);
--color-tertiary-200: rgb(207 231 233);
--color-tertiary-300: rgb(178 217 219);
--color-tertiary-400: rgb(121 189 193);
--color-tertiary-500: rgb(63 160 166);
--color-tertiary-600: rgb(57 144 149);
--color-tertiary-700: rgb(47 120 125);
--color-tertiary-800: rgb(38 96 100);
--color-tertiary-900: rgb(31 78 81);
--color-tertiary-950: rgb(26 64 67);
--color-tertiary-contrast-dark: var(--color-tertiary-950);
--color-tertiary-contrast-light: var(--color-tertiary-50);
--color-tertiary-contrast-50: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-100: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-200: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-300: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-400: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-500: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-600: var(--color-tertiary-contrast-dark);
--color-tertiary-contrast-700: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-800: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-900: var(--color-tertiary-contrast-light);
--color-tertiary-contrast-950: var(--color-tertiary-contrast-light);
--color-success-50: rgb(225 244 255);
--color-success-100: rgb(215 240 254);
--color-success-200: rgb(205 236 254);
--color-success-300: rgb(175 225 254);
--color-success-400: rgb(115 202 253);
--color-success-500: rgb(55 179 252);
--color-success-600: rgb(50 161 227);
--color-success-700: rgb(41 134 189);
--color-success-800: rgb(33 107 151);
--color-success-900: rgb(27 88 123);
--color-success-950: rgb(22 74 102);
--color-success-contrast-dark: var(--color-success-950);
--color-success-contrast-light: var(--color-success-50);
--color-success-contrast-50: var(--color-success-contrast-dark);
--color-success-contrast-100: var(--color-success-contrast-dark);
--color-success-contrast-200: var(--color-success-contrast-dark);
--color-success-contrast-300: var(--color-success-contrast-dark);
--color-success-contrast-400: var(--color-success-contrast-dark);
--color-success-contrast-500: var(--color-success-contrast-dark);
--color-success-contrast-600: var(--color-success-contrast-dark);
--color-success-contrast-700: var(--color-success-contrast-light);
--color-success-contrast-800: var(--color-success-contrast-light);
--color-success-contrast-900: var(--color-success-contrast-light);
--color-success-contrast-950: var(--color-success-contrast-light);
--color-warning-50: rgb(248 218 254);
--color-warning-100: rgb(246 206 254);
--color-warning-200: rgb(244 194 253);
--color-warning-300: rgb(237 157 252);
--color-warning-400: rgb(224 83 250);
--color-warning-500: rgb(210 9 248);
--color-warning-600: rgb(189 8 223);
--color-warning-700: rgb(158 7 186);
--color-warning-800: rgb(126 5 149);
--color-warning-900: rgb(103 4 122);
--color-warning-950: rgb(86 3 102);
--color-warning-contrast-dark: var(--color-warning-950);
--color-warning-contrast-light: var(--color-warning-50);
--color-warning-contrast-50: var(--color-warning-contrast-dark);
--color-warning-contrast-100: var(--color-warning-contrast-dark);
--color-warning-contrast-200: var(--color-warning-contrast-dark);
--color-warning-contrast-300: var(--color-warning-contrast-dark);
--color-warning-contrast-400: var(--color-warning-contrast-dark);
--color-warning-contrast-500: var(--color-warning-contrast-dark);
--color-warning-contrast-600: var(--color-warning-contrast-dark);
--color-warning-contrast-700: var(--color-warning-contrast-light);
--color-warning-contrast-800: var(--color-warning-contrast-light);
--color-warning-contrast-900: var(--color-warning-contrast-light);
--color-warning-contrast-950: var(--color-warning-contrast-light);
--color-error-50: rgb(238 250 220);
--color-error-100: rgb(233 249 208);
--color-error-200: rgb(227 247 197);
--color-error-300: rgb(211 242 162);
--color-error-400: rgb(177 233 92);
--color-error-500: rgb(144 223 22);
--color-error-600: rgb(130 201 20);
--color-error-700: rgb(108 167 17);
--color-error-800: rgb(86 134 13);
--color-error-900: rgb(71 109 11);
--color-error-950: rgb(60 90 10);
--color-error-contrast-dark: var(--color-error-950);
--color-error-contrast-light: var(--color-error-50);
--color-error-contrast-50: var(--color-error-contrast-dark);
--color-error-contrast-100: var(--color-error-contrast-dark);
--color-error-contrast-200: var(--color-error-contrast-dark);
--color-error-contrast-300: var(--color-error-contrast-dark);
--color-error-contrast-400: var(--color-error-contrast-light);
--color-error-contrast-500: var(--color-error-contrast-light);
--color-error-contrast-600: var(--color-error-contrast-light);
--color-error-contrast-700: var(--color-error-contrast-light);
--color-error-contrast-800: var(--color-error-contrast-light);
--color-error-contrast-900: var(--color-error-contrast-light);
--color-error-contrast-950: var(--color-error-contrast-light);
--color-surface-50: rgb(227 241 252);
--color-surface-100: rgb(218 236 251);
--color-surface-200: rgb(209 232 251);
--color-surface-300: rgb(181 217 248);
--color-surface-400: rgb(126 189 242);
--color-surface-500: rgb(70 161 237);
--color-surface-600: rgb(63 145 213);
--color-surface-700: rgb(53 121 178);
--color-surface-800: rgb(42 97 142);
--color-surface-900: rgb(34 79 116);
--color-surface-950: rgb(28 66 96);
--color-surface-contrast-dark: var(--color-surface-950);
--color-surface-contrast-light: var(--color-surface-50);
--color-surface-contrast-50: var(--color-surface-contrast-dark);
--color-surface-contrast-100: var(--color-surface-contrast-dark);
--color-surface-contrast-200: var(--color-surface-contrast-dark);
--color-surface-contrast-300: var(--color-surface-contrast-dark);
--color-surface-contrast-400: var(--color-surface-contrast-light);
--color-surface-contrast-500: var(--color-surface-contrast-light);
--color-surface-contrast-600: var(--color-surface-contrast-light);
--color-surface-contrast-700: var(--color-surface-contrast-light);
--color-surface-contrast-800: var(--color-surface-contrast-light);
--color-surface-contrast-900: var(--color-surface-contrast-light);
--color-surface-contrast-950: var(--color-surface-contrast-light);
}

View file

@ -19,7 +19,9 @@ const initializeHighlighter = async () => {
try {
return await getSingletonHighlighter({
themes: ['dracula'],
langs: ['javascript', 'typescript', 'svelte', 'markdown', 'bash', 'go', 'text', 'python', 'rust', 'c', 'c++', 'shell', 'ruby', 'json', 'html', 'css', 'java', 'sql', 'toml', 'yaml']
// "powershell" is necessary for the code example in about/README.md.
// Shiki gives an error and shows plain text if a language is not here.
langs: ['javascript', 'typescript', 'svelte', 'markdown', 'bash', 'go', 'text', 'python', 'rust', 'c', 'c++', 'shell', 'ruby', 'json', 'html', 'css', 'java', 'sql', 'toml', 'yaml', 'powershell']
});
} catch (error) {
console.error('Failed to initialize Shiki highlighter:', error);

View file

@ -1,142 +0,0 @@
import { join } from 'path';
import type { Config } from 'tailwindcss';
import forms from '@tailwindcss/forms';
import typography from '@tailwindcss/typography';
import { skeleton } from '@skeletonlabs/tw-plugin';
import { myCustomTheme } from './my-custom-theme.ts'
export default {
darkMode: 'class',
content: [
'./src/**/*.{html,js,svelte,svx,md,ts}',
join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts,svx,md}')
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
fontFamily: {
mono: ['Fira Code', 'monospace'],
},
animation: {
fabGradient: 'fabGradient 15s ease infinite',
blink: 'blink 1s step-end infinite',
},
keyframes: {
fabGradient: {
'0%, 100%': { 'background-size': '200% 200%, background-position: left center' },
'50%': { 'background-size': '200% 200%, background-position: right center' },
},
blink: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0' },
},
},
typography: {
DEFAULT: {
css: {
'code::before': {
content: '""'
},
'code::after': {
content: '""'
}
}
}
}
},
},
plugins: [
forms,
typography,
skeleton({
themes: {
preset: [
{
name: 'skeleton',
enhancements: true
},
{
name: 'modern',
enhancements: true
},
{
name: 'crimson',
enhancements: true
},
{
name: 'hamlindigo',
enhancements: true
},
{
name: 'gold-nouveau',
enhancements: true
},
{
name: 'seafoam',
enhancements: true
},
{
name: 'rocket',
enhancements: true
},
{
name: 'sahara',
enhancements: true
},
{
name: 'wintry',
enhancements: true
},
{
name: 'vintage',
enhancements: true
},
],
custom: [
myCustomTheme
]
}
})
]
}

View file

@ -1,20 +1,28 @@
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
// Get the Fabric base URL from environment variable with fallback
const FABRIC_BASE_URL = process.env.FABRIC_BASE_URL || 'http://localhost:8080';
// CAUTION: Lightning CSS minifies the CSS, and it refuses a pseudo-class that
// comes after a pseudo-element. Do not use a Tailwind class that puts the two
// in that order, such as `file:disabled:opacity-50`, because Lightning CSS
// stops the build on the `::file-selector-button:disabled` selector that the
// class produces. See https://github.com/parcel-bundler/lightningcss/issues/1292
// The Skeleton 2 Tailwind plugin made that selector on its own, and Skeleton 5
// does not, so the project no longer needs a different minifier.
export default defineConfig({
plugins: [sveltekit(), purgeCss()],
// Tailwind 4 gives a Vite plugin, which replaces the PostCSS setup. It must
// come before sveltekit() so that it can process the styles in components.
plugins: [tailwindcss(), sveltekit()],
optimizeDeps: {
include: ['pdfjs-dist'],
esbuildOptions: {
target: 'esnext',
supported: {
'top-level-await': true
}
}
// Vite 8 optimizes dependencies with Rolldown, which supports top-level
// await and modern syntax. The `esbuildOptions` block that was here set
// `target` and `supported`, and Vite 8 reads neither key. Rolldown needs no
// replacement option. `build.target` below keeps the setting for the build.
include: ['pdfjs-dist']
},
define: {
'process.env': {