diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a7829892..1764debc4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,7 @@ repos: - id: check-yaml args: ["--allow-multiple-documents"] - id: check-added-large-files - exclude: "^(sites/docs-devsy-sh/)" + exclude: "^(sites/docs-devsy-sh/|desktop/resources/)" - id: check-merge-conflict - repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook rev: v9.26.0 diff --git a/Taskfile.yml b/Taskfile.yml index 1abb174a2..0a43f2b27 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -233,6 +233,9 @@ tasks: dir: desktop cmd: npm run check + desktop:icons: + desc: generate application icon assets from canonical SVG source + cmd: go run ./hack/icons desktop:test: desc: run desktop unit tests dir: desktop diff --git a/desktop/resources/box-icon-app.svg b/desktop/resources/box-icon-app.svg index ebcc689fc..8ae68b365 100644 --- a/desktop/resources/box-icon-app.svg +++ b/desktop/resources/box-icon-app.svg @@ -1,6 +1,5 @@ - - - - - + + Devsy App Icon + Devsy application icon + diff --git a/desktop/resources/icon.icns b/desktop/resources/icon.icns index 5234e2555..f47c99c13 100644 Binary files a/desktop/resources/icon.icns and b/desktop/resources/icon.icns differ diff --git a/desktop/resources/icon.ico b/desktop/resources/icon.ico index 5d7dcaae6..25619f6b6 100644 Binary files a/desktop/resources/icon.ico and b/desktop/resources/icon.ico differ diff --git a/desktop/resources/icon.png b/desktop/resources/icon.png index e83e87a06..398e57ce8 100644 Binary files a/desktop/resources/icon.png and b/desktop/resources/icon.png differ diff --git a/desktop/resources/icon.svg b/desktop/resources/icon.svg new file mode 100644 index 000000000..8ae68b365 --- /dev/null +++ b/desktop/resources/icon.svg @@ -0,0 +1,5 @@ + + Devsy App Icon + Devsy application icon + + diff --git a/desktop/resources/icons/128x128.png b/desktop/resources/icons/128x128.png index 519dc54fc..9091ea791 100644 Binary files a/desktop/resources/icons/128x128.png and b/desktop/resources/icons/128x128.png differ diff --git a/desktop/resources/icons/32x32.png b/desktop/resources/icons/32x32.png index cf9c6767d..061aa7cea 100644 Binary files a/desktop/resources/icons/32x32.png and b/desktop/resources/icons/32x32.png differ diff --git a/desktop/src/main/__tests__/app-icons.test.ts b/desktop/src/main/__tests__/app-icons.test.ts new file mode 100644 index 000000000..4a4aacbd6 --- /dev/null +++ b/desktop/src/main/__tests__/app-icons.test.ts @@ -0,0 +1,126 @@ +import { existsSync, readFileSync } from "node:fs" +import { join, resolve } from "node:path" +import { describe, expect, it } from "vitest" + +const DESKTOP_ROOT = resolve(__dirname, "../../..") +const RESOURCES_DIR = join(DESKTOP_ROOT, "resources") +const ICONS_DIR = join(RESOURCES_DIR, "icons") + +const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) +const ICNS_MAGIC = Buffer.from("icns", "ascii") +const ICO_MAGIC = Buffer.from([0x00, 0x00, 0x01, 0x00]) + +function getPngDimensions(buf: Buffer): { width: number; height: number } { + expect(buf.subarray(0, 8)).toEqual(PNG_MAGIC) + const width = buf.readUInt32BE(16) + const height = buf.readUInt32BE(20) + return { width, height } +} + +describe("Devsy Application Icon Assets", () => { + describe("Canonical Vector Source", () => { + it("provides valid icon SVG canvas", () => { + const svgPath = join(RESOURCES_DIR, "icon.svg") + expect(existsSync(svgPath)).toBe(true) + const content = readFileSync(svgPath, "utf8") + expect(content).toContain(" { + const boxPath = join(RESOURCES_DIR, "box-icon-app.svg") + const iconPath = join(RESOURCES_DIR, "icon.svg") + expect(existsSync(boxPath)).toBe(true) + expect(existsSync(iconPath)).toBe(true) + expect(readFileSync(boxPath, "utf8")).toBe(readFileSync(iconPath, "utf8")) + }) + }) + + describe("macOS Icon Assets", () => { + it("provides valid multi-resolution ICNS file", () => { + const icnsPath = join(RESOURCES_DIR, "icon.icns") + expect(existsSync(icnsPath)).toBe(true) + const buf = readFileSync(icnsPath) + expect(buf.subarray(0, 4)).toEqual(ICNS_MAGIC) + const totalLen = buf.readUInt32BE(4) + expect(totalLen).toBe(buf.length) + + const types: string[] = [] + let offset = 8 + while (offset < buf.length) { + const type = buf.toString("ascii", offset, offset + 4) + const len = buf.readUInt32BE(offset + 4) + expect(len).toBeGreaterThanOrEqual(8) + expect(offset + len).toBeLessThanOrEqual(buf.length) + types.push(type) + offset += len + } + + expect(types).toContain("icp4") // 16x16 + expect(types).toContain("icp5") // 32x32 + expect(types).toContain("icp6") // 64x64 + expect(types).toContain("ic07") // 128x128 + expect(types).toContain("ic08") // 256x256 + expect(types).toContain("ic09") // 512x512 + expect(types).toContain("ic10") // 1024x1024 + }) + }) + + describe("Windows Icon Assets", () => { + it("provides valid multi-resolution ICO file with standard sizes", () => { + const icoPath = join(RESOURCES_DIR, "icon.ico") + expect(existsSync(icoPath)).toBe(true) + const buf = readFileSync(icoPath) + expect(buf.subarray(0, 4)).toEqual(ICO_MAGIC) + + const count = buf.readUInt16LE(4) + expect(count).toBe(7) + + const expectedSizes = [16, 24, 32, 48, 64, 128, 256] + const actualSizes: number[] = [] + + let offset = 6 + for (let i = 0; i < count; i++) { + const w = buf.readUInt8(offset) + const size = w === 0 ? 256 : w + actualSizes.push(size) + const imgSize = buf.readUInt32LE(offset + 8) + const imgOffset = buf.readUInt32LE(offset + 12) + const imgBuf = buf.subarray(imgOffset, imgOffset + imgSize) + expect(imgBuf.subarray(0, 8)).toEqual(PNG_MAGIC) + offset += 16 + } + + expect(actualSizes).toEqual(expectedSizes) + }) + }) + + describe("Linux and Master PNG Assets", () => { + it("provides valid 1024x1024 master icon.png", () => { + const pngPath = join(RESOURCES_DIR, "icon.png") + expect(existsSync(pngPath)).toBe(true) + const buf = readFileSync(pngPath) + const { width, height } = getPngDimensions(buf) + expect(width).toBe(1024) + expect(height).toBe(1024) + }) + + it("provides valid 32x32 Linux icon", () => { + const pngPath = join(ICONS_DIR, "32x32.png") + expect(existsSync(pngPath)).toBe(true) + const buf = readFileSync(pngPath) + const { width, height } = getPngDimensions(buf) + expect(width).toBe(32) + expect(height).toBe(32) + }) + + it("provides valid 128x128 Linux icon", () => { + const pngPath = join(ICONS_DIR, "128x128.png") + expect(existsSync(pngPath)).toBe(true) + const buf = readFileSync(pngPath) + const { width, height } = getPngDimensions(buf) + expect(width).toBe(128) + expect(height).toBe(128) + }) + }) +}) diff --git a/hack/icons/main.go b/hack/icons/main.go new file mode 100644 index 000000000..b2977d92d --- /dev/null +++ b/hack/icons/main.go @@ -0,0 +1,454 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "log" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var ( + allSizes = []int{16, 24, 32, 48, 64, 128, 256, 512, 1024} + icoSizes = []int{16, 24, 32, 48, 64, 128, 256} + icnsTags = []struct { + tag [4]byte + size int + }{ + {[4]byte{'i', 'c', 'p', '4'}, 16}, + {[4]byte{'i', 'c', 'p', '5'}, 32}, + {[4]byte{'i', 'c', 'p', '6'}, 64}, + {[4]byte{'i', 'c', '0', '7'}, 128}, + {[4]byte{'i', 'c', '0', '8'}, 256}, + {[4]byte{'i', 'c', '0', '9'}, 512}, + {[4]byte{'i', 'c', '1', '0'}, 1024}, + {[4]byte{'i', 'c', '1', '1'}, 32}, + {[4]byte{'i', 'c', '1', '2'}, 64}, + {[4]byte{'i', 'c', '1', '3'}, 256}, + {[4]byte{'i', 'c', '1', '4'}, 512}, + } +) + +const ( + fileURLScheme = "file" + docsWordmarkWidth = 1000 + docsWordmarkHeight = 329 +) + +func findChromeBinary() (string, error) { + home, _ := os.UserHomeDir() + candidates := []string{ + filepath.Join(home, ".cache", "ms-playwright", "chromium-1234", "chrome-linux64", "chrome"), + "/usr/bin/google-chrome", + "/usr/bin/chromium-browser", + "/usr/bin/chromium", + } + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c, nil + } + } + for _, name := range []string{"chrome", "chromium"} { + if path, err := exec.LookPath(name); err == nil { + return path, nil + } + } + return "", errors.New("chromium or chrome executable not found") +} + +func validateSVG(svgPath string) error { + data, err := os.ReadFile(svgPath) + if err != nil { + return fmt.Errorf("read svg: %w", err) + } + content := string(data) + if !strings.Contains(content, " +`, width, height, svgURL) +} + +func renderSVGPNG(svgPath, outPNG string, width, height int) error { + chrome, err := findChromeBinary() + if err != nil { + return err + } + absSVG, err := filepath.Abs(svgPath) + if err != nil { + return err + } + renderPage := outPNG + ".html" + pageContents := renderPageContents(absSVG, width, height) + if err := os.WriteFile(renderPage, []byte(pageContents), 0o644); err != nil { + return fmt.Errorf("write SVG render page: %w", err) + } + defer func() { _ = os.Remove(renderPage) }() + + cmd := exec.Command(chrome, + "--headless", + "--no-sandbox", + "--allow-file-access-from-files", + "--virtual-time-budget=1000", + "--default-background-color=00000000", + fmt.Sprintf("--screenshot=%s", outPNG), + fmt.Sprintf("--window-size=%d,%d", width, height), + (&url.URL{Scheme: fileURLScheme, Path: renderPage}).String(), + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("render svg with chrome: %w, output: %s", err, string(out)) + } + if _, err := os.Stat(outPNG); err != nil { + return fmt.Errorf("rendered png missing: %w", err) + } + return nil +} + +func renderMasterPNG(svgPath, outPNG string) error { + return renderSVGPNG(svgPath, outPNG, 1024, 1024) +} + +func resizePNG(inPNG, outPNG string, size int) error { + cmd := exec.Command("ffmpeg", + "-y", + "-i", inPNG, + "-vf", fmt.Sprintf("scale=%d:%d", size, size), + "-update", "1", + outPNG, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("resize to %dx%d: %w, output: %s", size, size, err, string(out)) + } + return nil +} + +func writeICNSChunk(body *bytes.Buffer, tag [4]byte, data []byte) error { + if err := binary.Write(body, binary.BigEndian, tag); err != nil { + return err + } + chunkLen := uint32(len(data) + 8) + if err := binary.Write(body, binary.BigEndian, chunkLen); err != nil { + return err + } + _, err := body.Write(data) + return err +} + +func packICNS(frames map[int][]byte) ([]byte, error) { + var body bytes.Buffer + for _, entry := range icnsTags { + data, ok := frames[entry.size] + if !ok || len(data) == 0 { + return nil, fmt.Errorf("missing frame for size %d", entry.size) + } + if err := writeICNSChunk(&body, entry.tag, data); err != nil { + return nil, err + } + } + + var result bytes.Buffer + magic := [4]byte{'i', 'c', 'n', 's'} + if err := binary.Write(&result, binary.BigEndian, magic); err != nil { + return nil, err + } + totalLen := uint32(body.Len() + 8) + if err := binary.Write(&result, binary.BigEndian, totalLen); err != nil { + return nil, err + } + if _, err := result.Write(body.Bytes()); err != nil { + return nil, err + } + return result.Bytes(), nil +} + +type icoDirEntry struct { + Width byte + Height byte + ColorCount byte + Reserved byte + Planes uint16 + BitCount uint16 + BytesInRes uint32 + ImageOffset uint32 +} + +func writeICOEntry(buf *bytes.Buffer, size int, dataLen, offset uint32) error { + w := byte(size) + if size == 256 { + w = 0 + } + entry := icoDirEntry{ + Width: w, + Height: w, + Planes: 1, + BitCount: 32, + BytesInRes: dataLen, + ImageOffset: offset, + } + return binary.Write(buf, binary.LittleEndian, entry) +} + +func writeICOHeader(buf *bytes.Buffer, count uint16) error { + if err := binary.Write(buf, binary.LittleEndian, uint16(0)); err != nil { + return err + } + if err := binary.Write(buf, binary.LittleEndian, uint16(1)); err != nil { + return err + } + return binary.Write(buf, binary.LittleEndian, count) +} + +func packICO(frames map[int][]byte) ([]byte, error) { + var buf bytes.Buffer + if err := writeICOHeader(&buf, uint16(len(icoSizes))); err != nil { + return nil, err + } + + offset := uint32(6 + 16*len(icoSizes)) + var imgData bytes.Buffer + + for _, s := range icoSizes { + data, ok := frames[s] + if !ok || len(data) == 0 { + return nil, fmt.Errorf("missing frame for ico size %d", s) + } + if err := writeICOEntry(&buf, s, uint32(len(data)), offset); err != nil { + return nil, err + } + imgData.Write(data) + offset += uint32(len(data)) + } + + buf.Write(imgData.Bytes()) + return buf.Bytes(), nil +} + +func resolveSVGPath(resourcesDir string) string { + svgPath := filepath.Join(resourcesDir, "icon.svg") + if _, err := os.Stat(svgPath); err != nil { + return filepath.Join(resourcesDir, "box-icon-app.svg") + } + return svgPath +} + +func renderAllFrames(svgPath, tmpDir string) (map[int][]byte, error) { + masterPNG := filepath.Join(tmpDir, "master-1024.png") + log.Println("Rendering 1024x1024 master PNG using headless browser...") + if err := renderMasterPNG(svgPath, masterPNG); err != nil { + return nil, err + } + + log.Println("Rescaling icon resolutions...") + frames := make(map[int][]byte) + masterBytes, err := os.ReadFile(masterPNG) + if err != nil { + return nil, err + } + frames[1024] = masterBytes + + for _, s := range allSizes { + if s == 1024 { + continue + } + outPNG := filepath.Join(tmpDir, fmt.Sprintf("%dx%d.png", s, s)) + if err := resizePNG(masterPNG, outPNG, s); err != nil { + return nil, err + } + b, err := os.ReadFile(outPNG) + if err != nil { + return nil, err + } + frames[s] = b + } + return frames, nil +} + +func writeMacIcons(resourcesDir string, frames map[int][]byte) error { + log.Println("Packing macOS .icns...") + icnsData, err := packICNS(frames) + if err != nil { + return err + } + icnsDst := filepath.Join(resourcesDir, "icon.icns") + if err := os.WriteFile(icnsDst, icnsData, 0o644); err != nil { + return err + } + log.Printf("✓ Updated %s (%d bytes)", icnsDst, len(icnsData)) + return nil +} + +func writeWindowsIcons(resourcesDir string, frames map[int][]byte) error { + log.Println("Packing Windows .ico...") + icoData, err := packICO(frames) + if err != nil { + return err + } + icoDst := filepath.Join(resourcesDir, "icon.ico") + if err := os.WriteFile(icoDst, icoData, 0o644); err != nil { + return err + } + log.Printf("✓ Updated %s (%d bytes)", icoDst, len(icoData)) + return nil +} + +func writeLinuxIcons(resourcesDir, repoRoot string, frames map[int][]byte) error { + log.Println("Updating Linux icons and master icon.png...") + pngDst := filepath.Join(resourcesDir, "icon.png") + if err := os.WriteFile(pngDst, frames[1024], 0o644); err != nil { + return err + } + iconsDir := filepath.Join(resourcesDir, "icons") + if err := os.MkdirAll(iconsDir, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(iconsDir, "32x32.png"), frames[32], 0o644); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(iconsDir, "128x128.png"), frames[128], 0o644); err != nil { + return err + } + log.Println("✓ Updated Linux icons (32x32, 128x128) and icon.png") + + docsMediaDir := filepath.Join( + repoRoot, + "sites", + "docs-devsy-sh", + "public", + "docs", + "media", + ) + if _, err := os.Stat(docsMediaDir); err == nil { + docsIconPNG := filepath.Join(docsMediaDir, "devsy-icon.png") + if err := os.WriteFile(docsIconPNG, frames[1024], 0o644); err != nil { + return fmt.Errorf("write docs icon: %w", err) + } + if err := writeDocsWordmarks(docsMediaDir, frames[256]); err != nil { + return err + } + } + return nil +} + +func docsWordmarkSVG(textColor string, iconPNG []byte) string { + iconDataURL := "data:image/png;base64," + base64.StdEncoding.EncodeToString(iconPNG) + wordmark := fmt.Sprintf(` + Devsy + + + devsy + +`, + docsWordmarkWidth, + docsWordmarkHeight, + docsWordmarkWidth, + docsWordmarkHeight, + iconDataURL, + textColor, + ) + return wordmark +} + +func writeDocsWordmarks(docsMediaDir string, iconPNG []byte) error { + variants := []struct { + svgName string + pngName string + textColor string + }{ + {"devsy-logo-horizontal.svg", "devsy.png", "#0B0B14"}, + {"devsy-logo-horizontal-dark.svg", "devsy-dark.png", "#FFFFFF"}, + } + + for _, variant := range variants { + svgPath := filepath.Join(docsMediaDir, variant.svgName) + wordmarkSVG := docsWordmarkSVG(variant.textColor, iconPNG) + if err := os.WriteFile(svgPath, []byte(wordmarkSVG), 0o644); err != nil { + return fmt.Errorf("write docs wordmark SVG: %w", err) + } + pngPath := filepath.Join(docsMediaDir, variant.pngName) + err := renderSVGPNG(svgPath, pngPath, docsWordmarkWidth, docsWordmarkHeight) + if err != nil { + return fmt.Errorf("render docs wordmark PNG: %w", err) + } + } + return nil +} + +func syncSVGDuplicates(svgPath, resourcesDir string) error { + boxSVG := filepath.Join(resourcesDir, "box-icon-app.svg") + if svgPath != boxSVG { + data, err := os.ReadFile(svgPath) + if err != nil { + return fmt.Errorf("read canonical svg: %w", err) + } + if err := os.WriteFile(boxSVG, data, 0o644); err != nil { + return fmt.Errorf("sync box-icon-app.svg: %w", err) + } + } + return nil +} + +func writeAllAssets(resourcesDir, repoRoot, svgPath string, frames map[int][]byte) error { + if err := writeMacIcons(resourcesDir, frames); err != nil { + return err + } + if err := writeWindowsIcons(resourcesDir, frames); err != nil { + return err + } + if err := writeLinuxIcons(resourcesDir, repoRoot, frames); err != nil { + return err + } + return syncSVGDuplicates(svgPath, resourcesDir) +} + +func run() error { + repoRoot, err := filepath.Abs(".") + if err != nil { + return err + } + resourcesDir := filepath.Join(repoRoot, "desktop", "resources") + svgPath := resolveSVGPath(resourcesDir) + + log.Printf("Validating source SVG: %s", svgPath) + if err := validateSVG(svgPath); err != nil { + return err + } + + tmpDir, err := os.MkdirTemp("", "devsy-icons-*") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(tmpDir) }() + + frames, err := renderAllFrames(svgPath, tmpDir) + if err != nil { + return err + } + if err := writeAllAssets(resourcesDir, repoRoot, svgPath, frames); err != nil { + return err + } + + log.Println("=== Icon generation complete! ===") + return nil +} + +func main() { + if err := run(); err != nil { + log.Fatalf("Error: %v", err) + } +} diff --git a/hack/icons/main_test.go b/hack/icons/main_test.go new file mode 100644 index 000000000..990d49c73 --- /dev/null +++ b/hack/icons/main_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/binary" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func dummyPNG() []byte { + return []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x01, 0x02, 0x03} +} + +func parseICNSTags(data []byte) []string { + offset := 8 + var found []string + for offset < len(data) { + tag := string(data[offset : offset+4]) + chunkLen := binary.BigEndian.Uint32(data[offset+4 : offset+8]) + found = append(found, tag) + offset += int(chunkLen) + } + return found +} + +func TestPackICNS(t *testing.T) { + frames := make(map[int][]byte) + for _, entry := range icnsTags { + frames[entry.size] = dummyPNG() + } + + data, err := packICNS(frames) + if err != nil { + t.Fatalf("packICNS failed: %v", err) + } + + if len(data) < 8 || string(data[:4]) != "icns" { + t.Fatalf("invalid icns header") + } + + totalLen := binary.BigEndian.Uint32(data[4:8]) + if int(totalLen) != len(data) { + t.Errorf("expected length %d, got %d", len(data), totalLen) + } + + foundTags := parseICNSTags(data) + expectedTags := []string{ + "icp4", "icp5", "icp6", "ic07", "ic08", "ic09", + "ic10", "ic11", "ic12", "ic13", "ic14", + } + for _, expected := range expectedTags { + if !slices.Contains(foundTags, expected) { + t.Errorf("missing expected ICNS tag: %s", expected) + } + } +} + +func checkICOEntry(t *testing.T, data []byte, idx, size int) { + entryOffset := 6 + idx*16 + w := int(data[entryOffset]) + if size == 256 && w != 0 { + t.Errorf("entry %d: expected 0 for width 256, got %d", idx, w) + } else if size != 256 && w != size { + t.Errorf("entry %d: expected width %d, got %d", idx, size, w) + } + + imgSize := binary.LittleEndian.Uint32(data[entryOffset+8 : entryOffset+12]) + imgOffset := binary.LittleEndian.Uint32(data[entryOffset+12 : entryOffset+16]) + + imgData := data[imgOffset : imgOffset+imgSize] + if !bytes.HasPrefix(imgData, []byte{0x89, 0x50, 0x4e, 0x47}) { + t.Errorf("entry %d: image data at %d is not PNG", idx, imgOffset) + } +} + +func TestPackICO(t *testing.T) { + frames := make(map[int][]byte) + for _, s := range icoSizes { + frames[s] = dummyPNG() + } + + data, err := packICO(frames) + if err != nil { + t.Fatalf("packICO failed: %v", err) + } + + if len(data) < 6 { + t.Fatalf("ico too short: %d", len(data)) + } + + reserved := binary.LittleEndian.Uint16(data[0:2]) + icoType := binary.LittleEndian.Uint16(data[2:4]) + count := binary.LittleEndian.Uint16(data[4:6]) + + if reserved != 0 || icoType != 1 || int(count) != len(icoSizes) { + t.Errorf("invalid ico header: reserved=%d, type=%d, count=%d", reserved, icoType, count) + } + + for i, s := range icoSizes { + checkICOEntry(t, data, i, s) + } +} + +func TestValidateSVG(t *testing.T) { + tmpDir := t.TempDir() + + validSVG := filepath.Join(tmpDir, "valid.svg") + validContent := []byte(``) + if err := os.WriteFile(validSVG, validContent, 0o644); err != nil { + t.Fatal(err) + } + if err := validateSVG(validSVG); err != nil { + t.Errorf("expected valid SVG to pass, got: %v", err) + } + + invalidSVG := filepath.Join(tmpDir, "invalid.svg") + if err := os.WriteFile( + invalidSVG, + []byte(`not svg`), + 0o644, + ); err != nil { + t.Fatal(err) + } + if err := validateSVG(invalidSVG); err == nil { + t.Error("expected invalid SVG to fail") + } +} + +func TestRenderPageContentsScalesSVGToCanvas(t *testing.T) { + svgPath := "/tmp/large icon.svg" + page := renderPageContents(svgPath, 1024, 1024) + + expectedURL := (&url.URL{Scheme: fileURLScheme, Path: svgPath}).String() + if !strings.Contains(page, `width: 1024px; height: 1024px`) { + t.Error("render page does not set a 1024x1024 canvas") + } + if !strings.Contains(page, `img src="`+expectedURL+`"`) { + t.Errorf("render page does not reference the SVG: %s", page) + } +} + +func TestDocsWordmarkSVG(t *testing.T) { + wordmark := docsWordmarkSVG("#FFFFFF", []byte("icon")) + if !strings.Contains(wordmark, `href="data:image/png;base64,aWNvbg=="`) { + t.Error("wordmark does not embed the generated application icon") + } + if !strings.Contains(wordmark, `fill="#FFFFFF"`) { + t.Error("wordmark does not use its requested text color") + } +} diff --git a/sites/docs-devsy-sh/app/(home)/home.css b/sites/docs-devsy-sh/app/(home)/home.css index 1f6d862c4..b2ae7e14c 100644 --- a/sites/docs-devsy-sh/app/(home)/home.css +++ b/sites/docs-devsy-sh/app/(home)/home.css @@ -133,7 +133,7 @@ html:has(.home-page) { letter-spacing: -0.02em; } -.home-page .brand svg { +.home-page .brand img { display: block; } diff --git a/sites/docs-devsy-sh/app/(home)/page.tsx b/sites/docs-devsy-sh/app/(home)/page.tsx index bd9c948f3..50ea6b88d 100644 --- a/sites/docs-devsy-sh/app/(home)/page.tsx +++ b/sites/docs-devsy-sh/app/(home)/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState } from 'react'; +import Image from 'next/image'; import { useTheme } from 'next-themes'; import './home.css'; @@ -31,12 +32,7 @@ export default function HomePage() {
- + devsy