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
diff --git a/js/app/assets/images/splash.png b/js/app/assets/images/splash.png
deleted file mode 100644
index d91db852a052f07e461ffeefc9d84c3fe2e6be69..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 11378
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
diff --git a/js/app/components/brand/logo.tsx b/js/app/components/brand/logo.tsx
index 48f4b4f9a..92111245b 100644
--- a/js/app/components/brand/logo.tsx
+++ b/js/app/components/brand/logo.tsx
@@ -1,77 +1,33 @@
import { Text, useTheme } from "@streamplace/components";
-import {
- borderAlphas,
- colors,
- fontFamilies,
- surfaces,
-} from "@streamplace/components/src/lib/theme/tokens";
+import { fontFamilies } from "@streamplace/components/src/lib/theme/tokens";
+import { Fragment } from "react";
import { View, type ViewProps } from "react-native";
-import Svg, { G, Path } from "react-native-svg";
+import { SvgXml } from "react-native-svg";
+import { BRAND } from "../../assets/generated/brand";
/**
- * Streamplace mark, drawn on a 24-unit grid.
- *
- * An S built from two plays. A single rounded square holds two triangular
- * play voids in 180-degree rotational symmetry: one opens through the right
- * edge, its mirror opens through the left. The ink that remains reads as an
- * S (Streamplace); the two voids read as play (video). One points out, its
- * mirror points back — watch and broadcast, the two places of a stream.
- *
- * The whole mark is one solid color; the plays are negative space. It is
- * pure figure-ground in the Logo Modernism spirit, and holds down to
- * favicon size because the dominant form fills the field.
+ * Brand logo components. All artwork comes from the generated brand module
+ * (js/app/assets/generated/brand.ts), which js/brand/generate.mjs derives
+ * from the active brand directory — see brand/README.md. Nothing here is
+ * specific to any one identity; forks swap the brand dir, not this file.
*/
-const TILE = { x: 3, y: 3, size: 18, radius: 3.5 };
-// Full play triangles (used as construction guides). Upper opens right;
-// lower is its exact 180-degree rotation about the center (12, 12).
-const UPPER_PLAY: [number, number][] = [
- [10.2, 5.4],
- [10.2, 11.8],
- [23, 8.6],
-];
-const LOWER_PLAY: [number, number][] = UPPER_PLAY.map(
- ([x, y]) => [24 - x, 24 - y] as [number, number],
-);
+export type { Brand, BrandStory } from "../../assets/generated/brand";
+export { BRAND };
-// The rounded-square field, as a path so it can carry evenodd holes. Its
-// 3.5u corners are tightened toward the sharp play voids so the whole mark
-// shares one crisp corner language rather than reading half-soft, half-sharp.
-const TILE_PATH =
- "M 6.5 3 H 17.5 A 3.5 3.5 0 0 1 21 6.5 V 17.5 A 3.5 3.5 0 0 1 17.5 21 H 6.5 A 3.5 3.5 0 0 1 3 17.5 V 6.5 A 3.5 3.5 0 0 1 6.5 3 Z";
+// Monochrome marks are authored with fill="currentColor"; tint by
+// substitution. A multi-color mark has no currentColor, so this no-ops and
+// the art renders as drawn.
+const tinted = (svg: string, color: string) =>
+ svg.replaceAll("currentColor", color);
-// The play voids, pre-clipped to the field's edge so each breaches cleanly
-// (a ~1u opening) with no overhang under an evenodd fill.
-const UPPER_HOLE = "M 10.2 5.4 L 10.2 11.8 L 21 9.1 L 21 8.1 Z";
-const LOWER_HOLE = "M 13.8 18.6 L 13.8 12.2 L 3 14.9 L 3 15.9 Z";
-
-export const MARK = {
- grid: 24,
- tile: TILE,
- center: [12, 12] as [number, number],
- tilePath: TILE_PATH,
- upperPlay: UPPER_PLAY,
- lowerPlay: LOWER_PLAY,
- upperHole: UPPER_HOLE,
- lowerHole: LOWER_HOLE,
-};
-
-export const MARK_WITH_HOLE = `${TILE_PATH} ${UPPER_HOLE} ${LOWER_HOLE}`;
-
-// Standalone SVG source for the "copy as SVG" brand menu. Mono ink by default
-// (reads on light surfaces / code editors); mirrors js/app/public/brand/*.svg.
-const BRAND_SVG_INK = "#0A0A0B"; // token-ok: brand SVG ink, mirrors public/brand/*.svg
-
-export function markSvgString(color = BRAND_SVG_INK) {
- return ``;
-}
-
-export function wordmarkSvgString(color = BRAND_SVG_INK) {
- return ``;
+/** Standalone SVG source for the "copy as SVG" brand menu. */
+export function markSvgString(color = BRAND.colors.ink) {
+ return tinted(BRAND.markSvg, color);
}
-function MarkPath({ color }: { color: string }) {
- return ;
+export function wordmarkSvgString(color = BRAND.colors.ink) {
+ return tinted(BRAND.wordmarkSvg, color);
}
export function LogoMark({
@@ -82,50 +38,25 @@ export function LogoMark({
color?: string;
}) {
const { theme } = useTheme();
- // Monochrome brand: the mark defaults to the ink/paper text color and
- // matches the wordmark exactly. Pink is an interface accent, never the
- // mark's own color — pass `color` explicitly for the rare colored variant.
+ // Monochrome brands default the mark to the ink/paper text color so it
+ // matches the wordmark exactly; pass `color` explicitly for the rare
+ // colored variant.
return (
-
+
);
}
/**
- * App icon / avatar tile: the mark reversed to paper inside a
- * continuous-curvature square, with enough inset to survive icon masks.
+ * App icon / avatar tile: the mark inside a continuous-curvature square,
+ * with enough inset to survive icon masks. Colors come from the brand's
+ * tile palette, not the running theme, so it matches the installed icon.
*/
-export function LogoTile({
- size = 32,
- background,
- foreground,
-}: {
- size?: number;
- background?: string;
- foreground?: string;
-}) {
- // Mono brand: a dark tile with the paper-white S. A faint hairline
- // keeps the dark tile legible on dark surfaces. Pink lives in the
- // running UI (buttons, links, focus) — never in the brand mark itself.
- const squircle =
- "M 0 16 C 0 4 4 0 16 0 C 28 0 32 4 32 16 C 32 28 28 32 16 32 C 4 32 0 28 0 16 Z";
- const bg = background ?? surfaces.dark[1];
- const fg = foreground ?? colors.white;
- return (
-
- );
+export function LogoTile({ size = 32 }: { size?: number }) {
+ return ;
}
export function Wordmark({
@@ -137,7 +68,7 @@ export function Wordmark({
}: {
size?: number;
color?: string;
- /** Optional accent color for the "." — omit to keep the wordmark mono. */
+ /** Optional accent color for any "." — omit to keep the wordmark mono. */
dotColor?: string;
/** Wordmark weight — "semibold" for the public signature, "medium" for chrome like the nav. */
weight?: "medium" | "semibold";
@@ -154,11 +85,17 @@ export function Wordmark({
fontWeight: (isMedium ? "500" : "600") as "500" | "600",
color: color ?? theme.colors.text1,
};
+ const parts = BRAND.wordmark.split(".");
return (
- stream
- .
- place
+ {parts.map((part, i) => (
+
+ {i > 0 && (
+ .
+ )}
+ {part}
+
+ ))}
);
}
diff --git a/js/app/package.json b/js/app/package.json
index 54c08bcdd..4f1b42706 100644
--- a/js/app/package.json
+++ b/js/app/package.json
@@ -15,11 +15,12 @@
"ios": "expo run:ios --no-bundler",
"web": "npx expo start --web --port 38081",
"test": "jest --watchAll",
+ "brand": "pnpm --filter @streamplace/brand generate",
"build": "pnpm run build:web && pnpm run prebuild",
- "build:web": "node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json",
+ "build:web": "pnpm run brand && node scripts/generate-build-info.js && pnpm run export && node exportClientExpoConfig.js > dist/expoConfig.json",
"export": "expo export --dump-sourcemap || expo export --dump-sourcemap",
"check": "pnpm --filter @streamplace/i18n compile && bash -c 'set -euo pipefail;export OUT=$(mktemp -d); npx tsc -p . --outDir $OUT; rm -rf $OUT'",
- "prebuild": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
+ "prebuild": "pnpm run brand && EXPO_NO_GIT_STATUS=1 expo prebuild --clean && sed -i.bak 's/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m/' android/gradle.properties && pnpm run find-node",
"prepare": "which pod && pnpm run prepare-ios || echo 'not a mac, not installing pods' && mkdir -p ./public && cp -r ../i18n/public/locales ./public/",
"prepare-ios": "cd ios && pod install && pnpm run find-node",
"find-node": "node -p '`NODE_BINARY=${process.argv[0]}`' > ios/.xcode.env.local",
diff --git a/js/app/public/brand/streamplace-lockup.svg b/js/app/public/brand/streamplace-lockup.svg
deleted file mode 100644
index 7de84c274..000000000
--- a/js/app/public/brand/streamplace-lockup.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-
diff --git a/js/app/public/brand/streamplace-mark.svg b/js/app/public/brand/streamplace-mark.svg
deleted file mode 100644
index ee314051f..000000000
--- a/js/app/public/brand/streamplace-mark.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
diff --git a/js/app/public/brand/streamplace-tile.svg b/js/app/public/brand/streamplace-tile.svg
deleted file mode 100644
index f20862049..000000000
--- a/js/app/public/brand/streamplace-tile.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/js/app/public/brand/streamplace-wordmark.svg b/js/app/public/brand/streamplace-wordmark.svg
deleted file mode 100644
index 42308453d..000000000
--- a/js/app/public/brand/streamplace-wordmark.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/js/app/public/favicon.png b/js/app/public/favicon.png
deleted file mode 100644
index 04089c06e985b4142a7ad8066113fd23cfb95f3f..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 6583
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
B#nb=*
diff --git a/js/app/src/screens/brand.tsx b/js/app/src/screens/brand.tsx
new file mode 100644
index 000000000..95d3a34d5
--- /dev/null
+++ b/js/app/src/screens/brand.tsx
@@ -0,0 +1,814 @@
+import { Button, Text, useTheme } from "@streamplace/components";
+import {
+ colors,
+ fontFamilies,
+ spacing,
+ statusColors,
+ surfaces,
+} from "@streamplace/components/src/lib/theme/tokens";
+import {
+ BRAND,
+ type BrandStory,
+ LogoLockup,
+ LogoMark,
+ LogoTile,
+ Wordmark,
+} from "components/brand/logo";
+import Container from "components/container";
+import { Download } from "lucide-react-native";
+import { type ReactNode, useState } from "react";
+import {
+ Linking,
+ Pressable,
+ ScrollView,
+ View,
+ useWindowDimensions,
+} from "react-native";
+import Svg, { Circle, G, Line, Path, Polygon, Rect } from "react-native-svg";
+
+const BRAND_INK = surfaces.dark[0];
+const BRAND_PAPER = colors.white;
+
+const polyStr = (pts: number[][]) => pts.map((p) => p.join(",")).join(" ");
+
+type Geometry = NonNullable;
+
+function Construction({
+ geometry,
+ size = 320,
+}: {
+ geometry: Geometry;
+ size?: number;
+}) {
+ const { theme } = useTheme();
+ const guide = theme.colors.text3;
+ const faint = theme.colors.borderSubtle;
+ const units = geometry.grid;
+ const grid = Array.from({ length: units + 1 }, (_, i) => i);
+
+ return (
+
+ );
+}
+
+function Section({
+ title,
+ kicker,
+ children,
+}: {
+ title: string;
+ kicker?: string;
+ children: ReactNode;
+}) {
+ const { theme } = useTheme();
+ return (
+
+
+ {kicker ? (
+
+ {kicker}
+
+ ) : null}
+
+ {title}
+
+
+ {children}
+
+ );
+}
+
+function Panel({
+ children,
+ background,
+ bordered = true,
+ height = 176,
+ grow = true,
+}: {
+ children: ReactNode;
+ background?: string;
+ bordered?: boolean;
+ height?: number;
+ grow?: boolean;
+}) {
+ const { theme } = useTheme();
+ return (
+
+ {children}
+
+ );
+}
+
+function Spec({ label, value }: { label: string; value: string }) {
+ const { theme } = useTheme();
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+// A single button variant with its name + when-to-use note.
+function ButtonSpec({
+ label,
+ note,
+ children,
+}: {
+ label: string;
+ note: string;
+ children: ReactNode;
+}) {
+ const { theme } = useTheme();
+ return (
+
+ {children}
+
+
+ {label}
+
+
+ {note}
+
+
+
+ );
+}
+
+function Swatch({
+ name,
+ value,
+ note,
+}: {
+ name: string;
+ value: string;
+ note: string;
+}) {
+ const { theme } = useTheme();
+ return (
+
+
+
+
+ {name}
+
+
+ {note}
+
+
+
+ {value.toUpperCase()}
+
+
+ );
+}
+
+function Rule({ ok, children }: { ok: boolean; children: ReactNode }) {
+ const { theme } = useTheme();
+ return (
+
+
+ {ok ? "Do" : "Don't"}
+
+
+ {children}
+
+
+ );
+}
+
+const BRAND_ASSETS = BRAND.assets;
+
+// A downloadable brand asset: a hover-lit row that saves the SVG. On web it
+// renders as an , so left-click downloads and right-click
+// offers "Save link as". A no-op affordance on native.
+function AssetDownload({ file, label }: { file: string; label: string }) {
+ const { theme } = useTheme();
+ const [hovered, setHovered] = useState(false);
+ const path = `/brand/${file}`;
+ return (
+ setHovered(true)}
+ onHoverOut={() => setHovered(false)}
+ style={{
+ flexDirection: "row",
+ alignItems: "center",
+ gap: spacing[3],
+ borderWidth: 1,
+ borderColor: hovered
+ ? theme.colors.borderStrong
+ : theme.colors.borderSubtle,
+ borderRadius: theme.borderRadius.md,
+ paddingHorizontal: spacing[4],
+ paddingVertical: spacing[3],
+ backgroundColor: hovered
+ ? theme.colors.surface2
+ : theme.colors.surface1,
+ }}
+ >
+
+
+ {label}
+
+
+ {path}
+
+
+
+
+ );
+}
+
+export default function BrandScreen() {
+ const { theme } = useTheme();
+ const { width } = useWindowDimensions();
+ const compact = width < 760;
+ const story = BRAND.story;
+
+ return (
+
+
+
+
+
+
+ {story?.tagline ?? `Brand guidelines for ${BRAND.name}.`}
+
+
+
+
+
+
+
+
+
+
+
+
+ {story?.readings?.length ? (
+
+ {story.readingsIntro ? (
+
+ {story.readingsIntro}
+
+ ) : null}
+
+ {story.readings.map((reading, i) => (
+
+
+ {String(i + 1).padStart(2, "0")}
+
+
+ {reading.title}
+ {reading.body}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The icon carries the idea; the wordmark carries the address. The
+ lockup is the default public signature, while the tile is reserved
+ for app icons, avatars, and surfaces that require a contained
+ square.
+
+
+
+ {story?.geometry ? (
+
+
+
+
+
+
+ {story.constructionNotes ? (
+
+ {story.constructionNotes}
+
+ ) : null}
+
+ {(story.specs ?? []).map((spec) => (
+
+ ))}
+
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+ Set the mark at 1.3x the wordmark size. The gap is one quarter of
+ the mark height. Keep at least half a mark of clear space around the
+ whole lockup. Minimum sizes: 16px for the mark, 20px wordmark size
+ for the full lockup.
+
+
+
+
+
+
+
+
+ Aa
+
+
+ Geist
+
+
+
+
+ {[
+ {
+ fam: fontFamilies.regular,
+ label: "Regular · 400 — body copy",
+ },
+ {
+ fam: fontFamilies.medium,
+ label: "Medium · 500 — UI labels, buttons, nav",
+ },
+ {
+ fam: fontFamilies.semiBold,
+ label: "SemiBold · 600 — headings, the wordmark",
+ },
+ ].map((w) => (
+
+
+ {w.label}
+
+
+ The video layer for everything
+
+
+ ))}
+
+
+
+
+
+
+ rtmp://stream.place/live · sk_live_9f2a…c7
+
+
+ Geist Mono
+
+
+
+
+
+ One typeface carries the whole system. Geist — a clean, contemporary
+ grotesque — in just three weights: Regular for text, Medium for
+ interface, SemiBold for headings and the wordmark. Its monospace
+ companion, Geist Mono, is reserved for the technical register:
+ stream keys, code, and the small labels above each section here.
+
+
+ Geist is free and open source under the SIL Open Font License,
+ designed by{" "}
+ Linking.openURL("https://basement.studio")}
+ style={{ color: theme.colors.primary }}
+ >
+ basement.studio
+
+ .
+
+
+
+
+
+ The brand is monochrome — ink and paper carry the identity. Indigo
+ is a secondary accent reserved for interface state (links, focus,
+ active nav, selected items) — not button fills, and never the mark.
+ Red signals state, not decoration: a filled red dot is on-air, and
+ that hero use stays reserved; destructive and error states borrow
+ red as ink (outline, text, icon) so a glance still separates "live"
+ from "careful." Red never becomes a brand or accent color.
+
+
+
+
+
+
+
+
+
+ {BRAND.monochrome ? (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+
+
+ Emphasis comes from contrast, not hue — the way ink and paper carry
+ the mark. The single most important action per view is Paper
+ (near-white on dark, ink on light): the highest-contrast element on
+ screen. Everything else steps down in weight. Indigo stays off
+ button fills so it keeps its meaning as a state color; live red is
+ never a button.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Exactly one Paper (primary) button per view — it marks the single
+ most important action.
+
+
+ Step down to tonal, then ghost, for supporting actions. Hierarchy
+ reads by contrast alone.
+
+
+ Reserve the indigo accent variant for a deliberate brand moment,
+ not routine primaries.
+
+
+ Don't fill a button with solid red — a red fill reads as on-air.
+ Destructive actions use red ink (an outline), never a fill.
+
+
+ Don't place two Paper buttons side by side — nothing signals which
+ one is primary.
+
+
+
+
+
+
+ {BRAND_ASSETS.map((a) => (
+
+ ))}
+
+
+
+ {story?.usage?.length ? (
+
+
+ {story.usage.map((rule) => (
+
+ {rule.text}
+
+ ))}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/js/brand/generate.mjs b/js/brand/generate.mjs
new file mode 100644
index 000000000..1e2acea9c
--- /dev/null
+++ b/js/brand/generate.mjs
@@ -0,0 +1,472 @@
+#!/usr/bin/env node
+// Streamplace brand asset generator.
+//
+// Reads a flat directory of brand inputs (see brand/README.md for the
+// contract) and generates every derived brand asset in the repo: the Expo
+// icon/splash/adaptive-icon/favicon PNGs (Expo prebuild derives all native
+// iOS/Android formats from those), the OG link banner, desktop ICO/ICNS,
+// docs logos, the downloadable /brand SVGs, and a TypeScript module the
+// app's logo components render from. Every output is gitignored; the brand
+// directory is the only source of truth.
+//
+// Brand dir resolution: $SP_BRAND_DIR, else brand/custom/ (gitignored — for
+// first-party or private art), else brand/ (the open-source default).
+
+import {
+ copyFileSync,
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+} from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import sharp from "sharp";
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
+
+const brandDir = process.env.SP_BRAND_DIR
+ ? resolve(process.env.SP_BRAND_DIR)
+ : existsSync(join(repoRoot, "brand", "custom", "brand.json"))
+ ? join(repoRoot, "brand", "custom")
+ : join(repoRoot, "brand");
+
+function fail(msg) {
+ console.error(`brand: ${msg}`);
+ process.exit(1);
+}
+
+if (!existsSync(join(brandDir, "brand.json"))) {
+ fail(`no brand.json in ${brandDir}`);
+}
+
+const config = JSON.parse(readFileSync(join(brandDir, "brand.json"), "utf8"));
+if (!config.name) fail("brand.json must set `name`");
+
+const colors = {
+ ink: "#0A0A0B",
+ paper: "#ffffff",
+ iconBackground: "#ffffff",
+ iconForeground: null, // defaults to ink
+ adaptiveIconBackground: "#111113",
+ adaptiveIconForeground: null, // defaults to ink
+ splashBackground: "#ffffff",
+ splashForeground: null, // defaults to ink
+ tileBackground: "#111113",
+ tileForeground: "#ffffff",
+ tileHairline: "rgba(255,255,255,0.10)",
+ bannerBackground: null, // defaults to paper
+ bannerForeground: null, // defaults to ink
+ ...config.colors,
+};
+colors.iconForeground ??= colors.ink;
+colors.adaptiveIconForeground ??= colors.ink;
+colors.splashForeground ??= colors.ink;
+colors.bannerBackground ??= colors.paper;
+colors.bannerForeground ??= colors.ink;
+
+const name = config.name;
+const wordmark = config.wordmark ?? name;
+const mono = config.monochrome === true;
+const story = config.story ?? null;
+const slug = name
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+
+// ---------------------------------------------------------------- svg utils
+
+const stripSize = (svg) =>
+ svg.replace(
+ /
/g, ">");
+
+// Nest an SVG inside a box of a larger composition. preserveAspectRatio
+// keeps non-square art centered without distortion. (Raster-only outputs —
+// runtime SVG for react-native-svg uses group transforms instead.)
+function embed(svg, x, y, w, h) {
+ const vb = viewBoxOf(svg);
+ return `${innerOf(svg)}`;
+}
+
+// Same, but as a : react-native-svg's SvgXml handles nested
+// poorly on native, so the runtime tile uses translate+scale.
+function embedGroup(svg, x, y, w, h) {
+ const vb = viewBoxOf(svg);
+ const s = Math.min(w / vb.w, h / vb.h);
+ const tx = x + (w - vb.w * s) / 2 - vb.x * s;
+ const ty = y + (h - vb.h * s) / 2 - vb.y * s;
+ return `${innerOf(svg)}`;
+}
+
+const round = (n) => Math.round(n * 1000) / 1000;
+
+const compose = (w, h, parts) =>
+ `${parts.join("")}`;
+
+const rasterize = (svg) => sharp(Buffer.from(svg)).png().toBuffer();
+
+// ------------------------------------------------------------ brand inputs
+
+function findArt(base) {
+ for (const ext of ["svg", "png"]) {
+ const p = join(brandDir, `${base}.${ext}`);
+ if (existsSync(p)) {
+ return ext === "svg"
+ ? { svg: stripSize(readFileSync(p, "utf8")) }
+ : { png: p };
+ }
+ }
+ return null;
+}
+
+const markArt = findArt("mark");
+if (!markArt?.svg) fail(`${brandDir}/mark.svg is required`);
+const markSvg = markArt.svg;
+
+const TRANSPARENT = { r: 0, g: 0, b: 0, alpha: 0 };
+
+// Render brand art onto a size×size canvas: `scale` insets the art,
+// `background` fills the canvas, `color` resolves currentColor for
+// monochrome SVG art.
+async function squarePng(art, size, { background, scale = 1, color } = {}) {
+ if (art.svg) {
+ const box = size * scale;
+ const off = (size - box) / 2;
+ const parts = [];
+ if (background)
+ parts.push(
+ ``,
+ );
+ parts.push(embed(withColor(art.svg, color), off, off, box, box));
+ return rasterize(compose(size, size, parts));
+ }
+ const box = Math.round(size * scale);
+ const img = await sharp(art.png)
+ .resize(box, box, { fit: "contain", background: TRANSPARENT })
+ .png()
+ .toBuffer();
+ return sharp({
+ create: {
+ width: size,
+ height: size,
+ channels: 4,
+ background: background ?? TRANSPARENT,
+ },
+ })
+ .composite([{ input: img }])
+ .png()
+ .toBuffer();
+}
+
+// -------------------------------------------------------- container formats
+
+// ICO: a directory of embedded PNGs (supported everywhere since Vista).
+function buildIco(pngs) {
+ const header = Buffer.alloc(6);
+ header.writeUInt16LE(1, 2);
+ header.writeUInt16LE(pngs.length, 4);
+ const entries = [];
+ const blobs = [];
+ let offset = 6 + 16 * pngs.length;
+ for (const { size, buf } of pngs) {
+ const e = Buffer.alloc(16);
+ e.writeUInt8(size >= 256 ? 0 : size, 0);
+ e.writeUInt8(size >= 256 ? 0 : size, 1);
+ e.writeUInt16LE(1, 4);
+ e.writeUInt16LE(32, 6);
+ e.writeUInt32LE(buf.length, 8);
+ e.writeUInt32LE(offset, 12);
+ offset += buf.length;
+ entries.push(e);
+ blobs.push(buf);
+ }
+ return Buffer.concat([header, ...entries, ...blobs]);
+}
+
+// ICNS: typed chunks of embedded PNGs.
+function buildIcns(chunks) {
+ const body = Buffer.concat(
+ chunks.map(({ type, buf }) => {
+ const h = Buffer.alloc(8);
+ h.write(type, 0, 4, "ascii");
+ h.writeUInt32BE(8 + buf.length, 4);
+ return Buffer.concat([h, buf]);
+ }),
+ );
+ const h = Buffer.alloc(8);
+ h.write("icns", 0, 4, "ascii");
+ h.writeUInt32BE(8 + body.length, 4);
+ return Buffer.concat([h, body]);
+}
+
+// ------------------------------------------------------------- compositions
+
+function tileSvgString() {
+ const squircle =
+ "M 0 16 C 0 4 4 0 16 0 C 28 0 32 4 32 16 C 32 28 28 32 16 32 C 4 32 0 28 0 16 Z";
+ const mark = withColor(markSvg, mono ? colors.tileForeground : null);
+ return compose(32, 32, [
+ ``,
+ ``,
+ embedGroup(mark, 4, 4, 24, 24),
+ ]).replace(/^]*) width="32" height="32"/, "${escapeXml(wordmark)}`;
+}
+
+function lockupSvgString() {
+ const wm = withColor(wordmarkSvgString(), colors.ink);
+ const wmVb = viewBoxOf(wm);
+ const textW = Math.round((wmVb.w / wmVb.h) * 96);
+ const markW = 96;
+ const gap = 24;
+ return compose(markW + gap + textW, 128, [
+ embed(withColor(markSvg, mono ? colors.ink : null), 0, 16, markW, markW),
+ embed(wm, markW + gap, 16, textW, 96),
+ ]);
+}
+
+// -------------------------------------------------------------------- main
+
+const outApp = join(repoRoot, "js/app/assets/generated");
+const outPublic = join(repoRoot, "js/app/public");
+const outPublicBrand = join(outPublic, "brand");
+const outDesktop = join(repoRoot, "js/desktop/assets/images");
+const outDocs = join(repoRoot, "js/docs/src/assets");
+for (const d of [outApp, outPublicBrand, outDesktop, outDocs]) {
+ mkdirSync(d, { recursive: true });
+}
+
+const written = [];
+function write(path, data) {
+ writeFileSync(path, data);
+ written.push(path.slice(repoRoot.length + 1));
+}
+
+// Expo sources: prebuild derives every native iOS/Android icon from these.
+const iconArt = findArt("icon") ?? {
+ synth: {
+ background: colors.iconBackground,
+ scale: 0.62,
+ color: colors.iconForeground,
+ },
+};
+const iconPng = (size) =>
+ iconArt.synth
+ ? squarePng(markArt, size, iconArt.synth)
+ : squarePng(iconArt, size, {});
+write(join(outApp, "icon.png"), await iconPng(1024));
+
+const adaptiveArt = findArt("icon-foreground");
+write(
+ join(outApp, "adaptive-icon.png"),
+ adaptiveArt
+ ? await squarePng(adaptiveArt, 1024, {})
+ : await squarePng(markArt, 1024, {
+ scale: 0.45,
+ color: colors.adaptiveIconForeground,
+ }),
+);
+
+const splashArt = findArt("splash");
+write(
+ join(outApp, "splash.png"),
+ splashArt
+ ? await squarePng(splashArt, 1024, {})
+ : await squarePng(markArt, 1024, {
+ scale: 0.5,
+ color: colors.splashForeground,
+ }),
+);
+
+const favicon = await squarePng(markArt, 256, {
+ scale: 0.92,
+ color: colors.ink,
+});
+write(join(outApp, "favicon.png"), favicon);
+write(join(outPublic, "favicon.png"), favicon);
+
+// OG / social card.
+const bannerArt = findArt("linkbanner");
+if (bannerArt?.png) {
+ copyFileSync(bannerArt.png, join(outPublic, "linkbanner.png"));
+ written.push("js/app/public/linkbanner.png");
+} else if (bannerArt?.svg) {
+ write(
+ join(outPublic, "linkbanner.png"),
+ await rasterize(
+ compose(1200, 630, [embed(bannerArt.svg, 0, 0, 1200, 630)]),
+ ),
+ );
+} else {
+ write(
+ join(outPublic, "linkbanner.png"),
+ await rasterize(
+ compose(1200, 630, [
+ ``,
+ embed(withColor(markSvg, colors.bannerForeground), 450, 165, 300, 300),
+ ]),
+ ),
+ );
+}
+
+// Downloadable brand SVGs (served from /brand, listed on the brand screen).
+const brandAssets = [
+ { file: `${slug}-mark.svg`, label: "Mark" },
+ { file: `${slug}-tile.svg`, label: "App tile" },
+ { file: `${slug}-wordmark.svg`, label: "Wordmark" },
+ { file: `${slug}-lockup.svg`, label: "Lockup" },
+];
+write(
+ join(outPublicBrand, `${slug}-mark.svg`),
+ withColor(markSvg, mono ? colors.ink : null),
+);
+write(join(outPublicBrand, `${slug}-tile.svg`), tileSvgString());
+write(
+ join(outPublicBrand, `${slug}-wordmark.svg`),
+ withColor(wordmarkSvgString(), colors.ink),
+);
+write(join(outPublicBrand, `${slug}-lockup.svg`), lockupSvgString());
+
+// Desktop (Electron Forge): window/taskbar PNG plus ICO and ICNS.
+write(join(outDesktop, "streamplace-logo.png"), await iconPng(512));
+const icoSizes = [16, 24, 32, 48, 64, 128, 256];
+write(
+ join(outDesktop, "streamplace-logo.ico"),
+ buildIco(
+ await Promise.all(
+ icoSizes.map(async (size) => ({ size, buf: await iconPng(size) })),
+ ),
+ ),
+);
+const icnsTypes = [
+ ["ic11", 32],
+ ["ic12", 64],
+ ["ic07", 128],
+ ["ic08", 256],
+ ["ic13", 256],
+ ["ic09", 512],
+ ["ic14", 512],
+ ["ic10", 1024],
+];
+write(
+ join(outDesktop, "streamplace-logo.icns"),
+ buildIcns(
+ await Promise.all(
+ icnsTypes.map(async ([type, size]) => ({
+ type,
+ buf: await iconPng(size),
+ })),
+ ),
+ ),
+);
+
+// Docs (Starlight header logo, light/dark themes).
+const docsLight = await squarePng(markArt, 512, {
+ color: mono ? colors.ink : null,
+});
+const docsDark = mono
+ ? await squarePng(markArt, 512, { color: colors.paper })
+ : docsLight;
+write(join(outDocs, "logo-light.png"), docsLight);
+write(join(outDocs, "logo-dark.png"), docsDark);
+
+// Resolved config for app.config.ts (splash/adaptive-icon colors).
+write(
+ join(outApp, "brand.json"),
+ JSON.stringify({ name, slug, wordmark, monochrome: mono, colors }, null, 2) +
+ "\n",
+);
+
+// TypeScript module the app's logo components render from.
+const brandTs = `// AUTO-GENERATED by js/brand/generate.mjs — do not edit.
+// Regenerate with \`pnpm run brand\` from the repo root.
+
+export type BrandStory = {
+ tagline?: string;
+ readingsIntro?: string;
+ readings?: { title: string; body: string }[];
+ geometry?: {
+ grid: number;
+ tilePath: string;
+ markPath: string;
+ upperPlay: number[][];
+ lowerPlay: number[][];
+ center: number[];
+ };
+ constructionNotes?: string;
+ specs?: { label: string; value: string }[];
+ usage?: { ok: boolean; text: string }[];
+};
+
+export type Brand = {
+ name: string;
+ slug: string;
+ wordmark: string;
+ monochrome: boolean;
+ colors: {
+ ink: string;
+ paper: string;
+ tileBackground: string;
+ tileForeground: string;
+ tileHairline: string;
+ };
+ markSvg: string;
+ tileSvg: string;
+ wordmarkSvg: string;
+ assets: { file: string; label: string }[];
+ story: BrandStory | null;
+};
+
+export const BRAND: Brand = {
+ name: ${JSON.stringify(name)},
+ slug: ${JSON.stringify(slug)},
+ wordmark: ${JSON.stringify(wordmark)},
+ monochrome: ${mono},
+ colors: {
+ ink: ${JSON.stringify(colors.ink)},
+ paper: ${JSON.stringify(colors.paper)},
+ tileBackground: ${JSON.stringify(colors.tileBackground)},
+ tileForeground: ${JSON.stringify(colors.tileForeground)},
+ tileHairline: ${JSON.stringify(colors.tileHairline)},
+ },
+ markSvg: ${JSON.stringify(markSvg)},
+ tileSvg: ${JSON.stringify(tileSvgString())},
+ wordmarkSvg: ${JSON.stringify(wordmarkSvgString())},
+ assets: ${JSON.stringify(brandAssets)},
+ story: ${story ? JSON.stringify(story, null, 2) : "null"},
+};
+`;
+write(join(outApp, "brand.ts"), brandTs);
+
+console.log(
+ `brand: generated ${written.length} assets for "${name}" from ${brandDir.slice(repoRoot.length + 1) || brandDir}`,
+);
diff --git a/js/brand/package.json b/js/brand/package.json
new file mode 100644
index 000000000..3f243df32
--- /dev/null
+++ b/js/brand/package.json
@@ -0,0 +1,12 @@
+{
+ "name": "@streamplace/brand",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "generate": "node generate.mjs"
+ },
+ "dependencies": {
+ "sharp": "^0.34.3"
+ }
+}
diff --git a/js/desktop/.gitignore b/js/desktop/.gitignore
index 418a6fa48..b75ebd132 100644
--- a/js/desktop/.gitignore
+++ b/js/desktop/.gitignore
@@ -93,3 +93,8 @@ out/
# auto-version
src/version.ts
+
+# generated brand icons (pnpm run brand)
+assets/images/streamplace-logo.png
+assets/images/streamplace-logo.ico
+assets/images/streamplace-logo.icns
diff --git a/js/desktop/assets/images/streamplace-logo.icns b/js/desktop/assets/images/streamplace-logo.icns
deleted file mode 100644
index 8d81bbd5c2b44aea5139bf60550502857a58f64a..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 95162
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