@shipload/item-renderer 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.claude/settings.local.json +6 -0
  2. package/bun.lock +2 -2
  3. package/package.json +8 -4
  4. package/scripts/check-bundle-size.ts +21 -21
  5. package/scripts/copy-fonts.ts +19 -19
  6. package/scripts/preview.ts +13 -15
  7. package/src/assets/stardust-base64.ts +1 -1
  8. package/src/errors.ts +8 -8
  9. package/src/fonts/index.ts +25 -26
  10. package/src/fonts/load-bun.ts +9 -9
  11. package/src/index.ts +21 -21
  12. package/src/links.ts +11 -11
  13. package/src/meta.ts +16 -16
  14. package/src/payload/base64url.ts +16 -16
  15. package/src/payload/codec.ts +13 -13
  16. package/src/primitives/category-icon.ts +69 -48
  17. package/src/primitives/compact-row.ts +13 -13
  18. package/src/primitives/divider.ts +9 -9
  19. package/src/primitives/icon-hex.ts +18 -16
  20. package/src/primitives/module-slot.ts +73 -73
  21. package/src/primitives/panel.ts +10 -10
  22. package/src/primitives/quantity-badge.ts +13 -13
  23. package/src/primitives/span-paragraph.ts +48 -50
  24. package/src/primitives/stat-bar.ts +24 -24
  25. package/src/primitives/svg.ts +13 -13
  26. package/src/primitives/text.ts +25 -25
  27. package/src/primitives/wrap.ts +12 -12
  28. package/src/render.ts +15 -19
  29. package/src/templates/_shared.ts +6 -7
  30. package/src/templates/component.ts +68 -63
  31. package/src/templates/index.ts +17 -17
  32. package/src/templates/item-cell.ts +48 -41
  33. package/src/templates/module.ts +84 -83
  34. package/src/templates/packed-entity.ts +12 -14
  35. package/src/templates/resource.ts +63 -65
  36. package/src/templates/ship-panel.ts +67 -72
  37. package/src/templates/social-card.ts +27 -25
  38. package/src/tokens/colors.ts +29 -29
  39. package/src/tokens/index.ts +6 -6
  40. package/src/tokens/spacing.ts +1 -1
  41. package/src/tokens/typography.ts +1 -1
  42. package/test/__image_snapshots__/component-hull-plates.diff.png +0 -0
  43. package/test/__image_snapshots__/module-engine-t1.diff.png +0 -0
  44. package/test/__image_snapshots__/module-storage-t1.diff.png +0 -0
  45. package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.diff.png +0 -0
  46. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
  47. package/test/__image_snapshots__/resource-ore-t1.diff.png +0 -0
  48. package/test/base64url.test.ts +22 -22
  49. package/test/codec.test.ts +26 -35
  50. package/test/errors.test.ts +21 -21
  51. package/test/fixtures/cargo-items.ts +43 -43
  52. package/test/fonts.test.ts +23 -23
  53. package/test/links-meta.test.ts +37 -37
  54. package/test/pixel.test.ts +44 -41
  55. package/test/primitives-category-icon.test.ts +74 -67
  56. package/test/primitives-compact-row.test.ts +29 -29
  57. package/test/primitives-domain.test.ts +61 -50
  58. package/test/primitives-layout.test.ts +47 -47
  59. package/test/primitives-module-slot.test.ts +58 -58
  60. package/test/render.test.ts +38 -35
  61. package/test/sanity.test.ts +5 -5
  62. package/test/sdk-link.test.ts +13 -13
  63. package/test/svg.test.ts +24 -22
  64. package/test/templates-component.test.ts +32 -32
  65. package/test/templates-dispatch.test.ts +29 -29
  66. package/test/templates-item-cell.test.ts +79 -79
  67. package/test/templates-module.test.ts +52 -52
  68. package/test/templates-packed-entity.test.ts +42 -42
  69. package/test/templates-resource.test.ts +61 -61
  70. package/test/templates-ship-panel.test.ts +69 -65
  71. package/test/tokens.test.ts +28 -26
@@ -1,72 +1,70 @@
1
- import { wrapText } from './wrap.ts'
2
- import { escapeXml as escapeAttr } from './svg.ts'
3
- import { tokens } from '../tokens/index.ts'
4
- import type { TextSpan } from '@shipload/sdk'
1
+ import { wrapText } from "./wrap.ts";
2
+ import { escapeXml as escapeAttr } from "./svg.ts";
3
+ import { tokens } from "../tokens/index.ts";
4
+ import type { TextSpan } from "@shipload/sdk";
5
5
 
6
6
  export interface SpanParagraphProps {
7
- x: number
8
- y: number
9
- spans: TextSpan[]
10
- charsPerLine?: number
11
- lineHeight?: number
12
- bodyColor?: string
13
- highlightColor?: string
14
- fontSize?: number
7
+ x: number;
8
+ y: number;
9
+ spans: TextSpan[];
10
+ charsPerLine?: number;
11
+ lineHeight?: number;
12
+ bodyColor?: string;
13
+ highlightColor?: string;
14
+ fontSize?: number;
15
15
  }
16
16
 
17
17
  function escapeXml(s: string): string {
18
18
  return s
19
- .replace(/&/g, '&')
20
- .replace(/</g, '&lt;')
21
- .replace(/>/g, '&gt;')
22
- .replace(/"/g, '&quot;')
19
+ .replace(/&/g, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;");
23
23
  }
24
24
 
25
25
  function sliceSpans(spans: TextSpan[], start: number, end: number): TextSpan[] {
26
- const out: TextSpan[] = []
27
- let cursor = 0
26
+ const out: TextSpan[] = [];
27
+ let cursor = 0;
28
28
  for (const span of spans) {
29
- const spanStart = cursor
30
- const spanEnd = cursor + span.text.length
31
- cursor = spanEnd
32
- if (spanEnd <= start || spanStart >= end) continue
33
- const sliceStart = Math.max(0, start - spanStart)
34
- const sliceEnd = span.text.length - Math.max(0, spanEnd - end)
35
- const txt = span.text.slice(sliceStart, sliceEnd)
36
- if (txt.length === 0) continue
37
- out.push(span.highlight ? { text: txt, highlight: true } : { text: txt })
29
+ const spanStart = cursor;
30
+ const spanEnd = cursor + span.text.length;
31
+ cursor = spanEnd;
32
+ if (spanEnd <= start || spanStart >= end) continue;
33
+ const sliceStart = Math.max(0, start - spanStart);
34
+ const sliceEnd = span.text.length - Math.max(0, spanEnd - end);
35
+ const txt = span.text.slice(sliceStart, sliceEnd);
36
+ if (txt.length === 0) continue;
37
+ out.push(span.highlight ? { text: txt, highlight: true } : { text: txt });
38
38
  }
39
- return out
39
+ return out;
40
40
  }
41
41
 
42
- export function spanParagraph(
43
- props: SpanParagraphProps,
44
- ): { svg: string; lineCount: number } {
45
- const chars = props.charsPerLine ?? 36
46
- const lh = props.lineHeight ?? 14
47
- const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
48
- const highlightColor = props.highlightColor ?? tokens.colors.text.accent
49
- const size = props.fontSize ?? tokens.typography.sizes.body
42
+ export function spanParagraph(props: SpanParagraphProps): { svg: string; lineCount: number } {
43
+ const chars = props.charsPerLine ?? 36;
44
+ const lh = props.lineHeight ?? 14;
45
+ const bodyColor = props.bodyColor ?? tokens.colors.text.secondary;
46
+ const highlightColor = props.highlightColor ?? tokens.colors.text.accent;
47
+ const size = props.fontSize ?? tokens.typography.sizes.body;
50
48
 
51
- const plain = props.spans.map((s) => s.text).join('')
52
- const lines = wrapText({ value: plain, charsPerLine: chars })
49
+ const plain = props.spans.map((s) => s.text).join("");
50
+ const lines = wrapText({ value: plain, charsPerLine: chars });
53
51
 
54
- let charOffset = 0
52
+ let charOffset = 0;
55
53
  const out = lines
56
54
  .map((line, i) => {
57
- const lineStart = charOffset
58
- const lineEnd = lineStart + line.length
59
- charOffset = lineEnd + 1
60
- const lineSpans = sliceSpans(props.spans, lineStart, lineEnd)
61
- const y = props.y + i * lh
55
+ const lineStart = charOffset;
56
+ const lineEnd = lineStart + line.length;
57
+ charOffset = lineEnd + 1;
58
+ const lineSpans = sliceSpans(props.spans, lineStart, lineEnd);
59
+ const y = props.y + i * lh;
62
60
  const tspans = lineSpans
63
61
  .map((s) => {
64
- const fill = s.highlight ? highlightColor : bodyColor
65
- return `<tspan fill="${fill}">${escapeXml(s.text)}</tspan>`
62
+ const fill = s.highlight ? highlightColor : bodyColor;
63
+ return `<tspan fill="${fill}">${escapeXml(s.text)}</tspan>`;
66
64
  })
67
- .join('')
68
- return `<text x="${props.x}" y="${y}" font-family="${escapeAttr(tokens.typography.sans)}" font-size="${size}">${tspans}</text>`
65
+ .join("");
66
+ return `<text x="${props.x}" y="${y}" font-family="${escapeAttr(tokens.typography.sans)}" font-size="${size}">${tspans}</text>`;
69
67
  })
70
- .join('')
71
- return { svg: out, lineCount: lines.length }
68
+ .join("");
69
+ return { svg: out, lineCount: lines.length };
72
70
  }
@@ -1,16 +1,16 @@
1
- import { el } from './svg.ts'
2
- import { text } from './text.ts'
3
- import { tokens } from '../tokens/index.ts'
1
+ import { el } from "./svg.ts";
2
+ import { text } from "./text.ts";
3
+ import { tokens } from "../tokens/index.ts";
4
4
 
5
5
  export interface StatBarProps {
6
- x: number
7
- y: number
8
- width: number
9
- label: string
10
- abbreviation: string
11
- value: number | null // 0..1023, or null for ranges mode (no value text, no fill)
12
- color: string
13
- inverted?: boolean
6
+ x: number;
7
+ y: number;
8
+ width: number;
9
+ label: string;
10
+ abbreviation: string;
11
+ value: number | null; // 0..1023, or null for ranges mode (no value text, no fill)
12
+ color: string;
13
+ inverted?: boolean;
14
14
  }
15
15
 
16
16
  export function statBar({
@@ -23,7 +23,7 @@ export function statBar({
23
23
  color,
24
24
  inverted,
25
25
  }: StatBarProps): string {
26
- const h = tokens.spacing.statBarHeight
26
+ const h = tokens.spacing.statBarHeight;
27
27
 
28
28
  let labelOut =
29
29
  text({
@@ -42,9 +42,9 @@ export function statBar({
42
42
  size: tokens.typography.sizes.stat,
43
43
  weight: 400,
44
44
  color: tokens.colors.text.primary,
45
- })
45
+ });
46
46
 
47
- const track = el('rect', {
47
+ const track = el("rect", {
48
48
  x,
49
49
  y,
50
50
  width,
@@ -52,12 +52,12 @@ export function statBar({
52
52
  rx: h / 2,
53
53
  ry: h / 2,
54
54
  fill: tokens.colors.surface.panelBorder,
55
- })
55
+ });
56
56
 
57
57
  if (value !== null) {
58
- const clamped = Math.max(0, Math.min(1023, value))
59
- const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
- const filled = Math.floor(width * displayFraction)
58
+ const clamped = Math.max(0, Math.min(1023, value));
59
+ const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023;
60
+ const filled = Math.floor(width * displayFraction);
61
61
 
62
62
  labelOut += text({
63
63
  x: x + width,
@@ -66,10 +66,10 @@ export function statBar({
66
66
  size: tokens.typography.sizes.statValue,
67
67
  weight: 700,
68
68
  color,
69
- anchor: 'end',
70
- })
69
+ anchor: "end",
70
+ });
71
71
 
72
- const bar = el('rect', {
72
+ const bar = el("rect", {
73
73
  x,
74
74
  y,
75
75
  width: filled,
@@ -77,9 +77,9 @@ export function statBar({
77
77
  rx: h / 2,
78
78
  ry: h / 2,
79
79
  fill: color,
80
- })
81
- return labelOut + track + bar
80
+ });
81
+ return labelOut + track + bar;
82
82
  }
83
83
 
84
- return labelOut + track
84
+ return labelOut + track;
85
85
  }
@@ -1,25 +1,25 @@
1
1
  export function escapeXml(input: string): string {
2
2
  return input
3
- .replaceAll('&', '&amp;')
4
- .replaceAll('<', '&lt;')
5
- .replaceAll('>', '&gt;')
6
- .replaceAll('"', '&quot;')
7
- .replaceAll("'", '&apos;')
3
+ .replaceAll("&", "&amp;")
4
+ .replaceAll("<", "&lt;")
5
+ .replaceAll(">", "&gt;")
6
+ .replaceAll('"', "&quot;")
7
+ .replaceAll("'", "&apos;");
8
8
  }
9
9
 
10
- export type AttrValue = string | number | null | undefined
10
+ export type AttrValue = string | number | null | undefined;
11
11
 
12
12
  export function attr(attrs: Record<string, AttrValue>): string {
13
- let out = ''
13
+ let out = "";
14
14
  for (const [k, v] of Object.entries(attrs)) {
15
- if (v === undefined || v === null) continue
16
- const value = typeof v === 'number' ? String(v) : escapeXml(v)
17
- out += ` ${k}="${value}"`
15
+ if (v === undefined || v === null) continue;
16
+ const value = typeof v === "number" ? String(v) : escapeXml(v);
17
+ out += ` ${k}="${value}"`;
18
18
  }
19
- return out
19
+ return out;
20
20
  }
21
21
 
22
22
  export function el(tag: string, attrs: Record<string, AttrValue>, children?: string): string {
23
- if (children === undefined) return `<${tag}${attr(attrs)}/>`
24
- return `<${tag}${attr(attrs)}>${children}</${tag}>`
23
+ if (children === undefined) return `<${tag}${attr(attrs)}/>`;
24
+ return `<${tag}${attr(attrs)}>${children}</${tag}>`;
25
25
  }
@@ -1,42 +1,42 @@
1
- import { el } from './svg.ts'
2
- import { tokens } from '../tokens/index.ts'
1
+ import { el } from "./svg.ts";
2
+ import { tokens } from "../tokens/index.ts";
3
3
 
4
4
  export interface TextProps {
5
- x: number
6
- y: number
7
- value: string
8
- size?: number
9
- weight?: 400 | 600 | 700 | 500
10
- family?: string
11
- color?: string
12
- anchor?: 'start' | 'middle' | 'end'
13
- letterSpacing?: number
14
- dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
5
+ x: number;
6
+ y: number;
7
+ value: string;
8
+ size?: number;
9
+ weight?: 400 | 600 | 700 | 500;
10
+ family?: string;
11
+ color?: string;
12
+ anchor?: "start" | "middle" | "end";
13
+ letterSpacing?: number;
14
+ dominantBaseline?: "auto" | "middle" | "central" | "hanging" | "text-top" | "text-bottom";
15
15
  }
16
16
 
17
17
  export function text(props: TextProps): string {
18
18
  return el(
19
- 'text',
19
+ "text",
20
20
  {
21
21
  x: props.x,
22
22
  y: props.y,
23
- 'font-family': props.family ?? tokens.typography.sans,
24
- 'font-size': props.size ?? tokens.typography.sizes.body,
25
- 'font-weight': props.weight ?? 400,
23
+ "font-family": props.family ?? tokens.typography.sans,
24
+ "font-size": props.size ?? tokens.typography.sizes.body,
25
+ "font-weight": props.weight ?? 400,
26
26
  fill: props.color ?? tokens.colors.text.primary,
27
- 'text-anchor': props.anchor,
28
- 'letter-spacing': props.letterSpacing,
29
- 'dominant-baseline': props.dominantBaseline,
27
+ "text-anchor": props.anchor,
28
+ "letter-spacing": props.letterSpacing,
29
+ "dominant-baseline": props.dominantBaseline,
30
30
  },
31
31
  escapeValue(props.value),
32
- )
32
+ );
33
33
  }
34
34
 
35
35
  function escapeValue(v: string): string {
36
36
  return v
37
- .replaceAll('&', '&amp;')
38
- .replaceAll('<', '&lt;')
39
- .replaceAll('>', '&gt;')
40
- .replaceAll('"', '&quot;')
41
- .replaceAll("'", '&apos;')
37
+ .replaceAll("&", "&amp;")
38
+ .replaceAll("<", "&lt;")
39
+ .replaceAll(">", "&gt;")
40
+ .replaceAll('"', "&quot;")
41
+ .replaceAll("'", "&apos;");
42
42
  }
@@ -1,24 +1,24 @@
1
1
  export interface WrapProps {
2
- value: string
3
- charsPerLine: number
2
+ value: string;
3
+ charsPerLine: number;
4
4
  }
5
5
 
6
6
  export function wrapText({ value, charsPerLine }: WrapProps): string[] {
7
- const words = value.split(/\s+/).filter((w) => w.length > 0)
8
- const lines: string[] = []
9
- let current = ''
7
+ const words = value.split(/\s+/).filter((w) => w.length > 0);
8
+ const lines: string[] = [];
9
+ let current = "";
10
10
  for (const word of words) {
11
11
  if (current.length === 0) {
12
- current = word
13
- continue
12
+ current = word;
13
+ continue;
14
14
  }
15
15
  if (current.length + 1 + word.length <= charsPerLine) {
16
- current += ` ${word}`
16
+ current += ` ${word}`;
17
17
  } else {
18
- lines.push(current)
19
- current = word
18
+ lines.push(current);
19
+ current = word;
20
20
  }
21
21
  }
22
- if (current.length > 0) lines.push(current)
23
- return lines
22
+ if (current.length > 0) lines.push(current);
23
+ return lines;
24
24
  }
package/src/render.ts CHANGED
@@ -1,33 +1,29 @@
1
- import { resolveItem, type ResolvedItem } from '@shipload/sdk'
2
- import type { CargoItem } from './payload/codec.ts'
3
- import { decodePayload } from './payload/codec.ts'
4
- import { renderByType } from './templates/index.ts'
5
- import { UnknownItemError } from './errors.ts'
1
+ import { resolveItem, type ResolvedItem } from "@shipload/sdk";
2
+ import type { CargoItem } from "./payload/codec.ts";
3
+ import { decodePayload } from "./payload/codec.ts";
4
+ import { renderByType } from "./templates/index.ts";
5
+ import { UnknownItemError } from "./errors.ts";
6
6
 
7
7
  export interface RenderOptions {
8
- width?: number
9
- theme?: 'dark' | 'light'
8
+ width?: number;
9
+ theme?: "dark" | "light";
10
10
  }
11
11
 
12
- export function renderItem(
13
- item: CargoItem,
14
- resolved: ResolvedItem,
15
- _opts?: RenderOptions,
16
- ): string {
17
- return renderByType(item, resolved)
12
+ export function renderItem(item: CargoItem, resolved: ResolvedItem, _opts?: RenderOptions): string {
13
+ return renderByType(item, resolved);
18
14
  }
19
15
 
20
16
  export async function renderFromPayload(
21
17
  payload: string,
22
18
  opts?: RenderOptions,
23
19
  ): Promise<{ svg: string; item: ResolvedItem }> {
24
- const cargoItem = decodePayload(payload)
25
- let resolved: ResolvedItem
20
+ const cargoItem = decodePayload(payload);
21
+ let resolved: ResolvedItem;
26
22
  try {
27
- resolved = resolveItem(cargoItem.item_id, cargoItem.stats, cargoItem.modules)
23
+ resolved = resolveItem(cargoItem.item_id, cargoItem.stats, cargoItem.modules);
28
24
  } catch {
29
- throw new UnknownItemError(Number(BigInt(cargoItem.item_id.toString())))
25
+ throw new UnknownItemError(Number(BigInt(cargoItem.item_id.toString())));
30
26
  }
31
- const svg = renderItem(cargoItem, resolved, opts)
32
- return { svg, item: resolved }
27
+ const svg = renderItem(cargoItem, resolved, opts);
28
+ return { svg, item: resolved };
33
29
  }
@@ -1,15 +1,14 @@
1
- import { tokens } from '../tokens/index.ts'
1
+ import { tokens } from "../tokens/index.ts";
2
2
 
3
3
  export function formatMass(n: number): string {
4
- return n.toLocaleString('en-US')
4
+ return n.toLocaleString("en-US");
5
5
  }
6
6
 
7
- export function tierBorder(tier: string): string {
8
- const key = tier.toLowerCase() as keyof typeof tokens.colors.tier
9
- return tokens.colors.tier[key] ?? tokens.colors.surface.panelBorder
7
+ export function tierBorder(tier: number): string {
8
+ return tokens.colors.tier[tier] ?? tokens.colors.surface.panelBorder;
10
9
  }
11
10
 
12
11
  export function shortCode(itemId: number): string {
13
- const str = itemId.toString(10)
14
- return str.slice(-2).padStart(2, '0')
12
+ const str = itemId.toString(10);
13
+ return str.slice(-2).padStart(2, "0");
15
14
  }
@@ -1,78 +1,84 @@
1
- import type { ResolvedItem } from '@shipload/sdk'
2
- import { getComponentById, getStatDefinitions, categoryColors } from '@shipload/sdk'
3
- import type { CargoItem } from '../payload/codec.ts'
4
- import { panel } from '../primitives/panel.ts'
5
- import { iconHex } from '../primitives/icon-hex.ts'
6
- import { text } from '../primitives/text.ts'
7
- import { divider } from '../primitives/divider.ts'
8
- import { statBar } from '../primitives/stat-bar.ts'
9
- import { quantityBadge } from '../primitives/quantity-badge.ts'
10
- import { tokens } from '../tokens/index.ts'
11
- import { shortCode, formatMass, tierBorder } from './_shared.ts'
1
+ import type { ResolvedItem, ResourceCategory } from "@shipload/sdk";
2
+ import { formatTier, getRecipe, getStatDefinitions, categoryColors } from "@shipload/sdk";
3
+ import type { CargoItem } from "../payload/codec.ts";
4
+ import { panel } from "../primitives/panel.ts";
5
+ import { iconHex } from "../primitives/icon-hex.ts";
6
+ import { text } from "../primitives/text.ts";
7
+ import { divider } from "../primitives/divider.ts";
8
+ import { statBar } from "../primitives/stat-bar.ts";
9
+ import { quantityBadge } from "../primitives/quantity-badge.ts";
10
+ import { tokens } from "../tokens/index.ts";
11
+ import { shortCode, formatMass, tierBorder } from "./_shared.ts";
12
12
 
13
13
  export interface RenderComponentOpts {
14
- mode?: 'values' | 'ranges'
14
+ mode?: "values" | "ranges";
15
15
  }
16
16
 
17
17
  type StatRow = {
18
- label: string
19
- abbreviation: string
20
- value: number | null
21
- color: string
22
- inverted?: boolean
23
- }
18
+ label: string;
19
+ abbreviation: string;
20
+ value: number | null;
21
+ color: string;
22
+ inverted?: boolean;
23
+ };
24
24
 
25
25
  export function renderComponent(
26
26
  item: CargoItem,
27
27
  resolved: ResolvedItem,
28
28
  opts?: RenderComponentOpts,
29
29
  ): string {
30
- const mode = opts?.mode ?? 'values'
31
- const w = tokens.spacing.panelWidth
32
- const pad = tokens.spacing.panelPadding
33
- const innerW = w - pad * 2
30
+ const mode = opts?.mode ?? "values";
31
+ const w = tokens.spacing.panelWidth;
32
+ const pad = tokens.spacing.panelPadding;
33
+ const innerW = w - pad * 2;
34
34
 
35
- let rows: StatRow[]
36
- if (mode === 'values') {
37
- rows = (resolved.stats ?? []).map(s => ({
35
+ let rows: StatRow[];
36
+ if (mode === "values") {
37
+ rows = (resolved.stats ?? []).map((s) => ({
38
38
  label: s.label,
39
39
  abbreviation: s.abbreviation,
40
40
  value: s.value,
41
41
  color: s.color,
42
42
  inverted: s.inverted,
43
- }))
43
+ }));
44
44
  } else {
45
- const comp = getComponentById(resolved.itemId)
46
- rows = (comp?.stats ?? []).flatMap(stat => {
47
- const defs = getStatDefinitions(stat.source)
48
- const def = defs.find(d => d.key === stat.key)
49
- if (!def) return []
50
- return [{
51
- label: def.label,
52
- abbreviation: def.abbreviation,
53
- value: null,
54
- color: categoryColors[stat.source],
55
- inverted: def.inverted,
56
- }]
57
- })
45
+ const recipe = getRecipe(resolved.itemId);
46
+ rows = (recipe?.statSlots ?? []).flatMap((slot) => {
47
+ const src = slot.sources[0];
48
+ if (!src) return [];
49
+ const input = recipe!.inputs[src.inputIndex];
50
+ if (!input || !("category" in input)) return [];
51
+ const category = input.category as ResourceCategory;
52
+ const def = getStatDefinitions(category)[src.statIndex];
53
+ if (!def) return [];
54
+ return [
55
+ {
56
+ label: def.label,
57
+ abbreviation: def.abbreviation,
58
+ value: null,
59
+ color: categoryColors[category],
60
+ inverted: def.inverted,
61
+ },
62
+ ];
63
+ });
58
64
  }
59
65
 
60
- const headerH = 48
61
- const metaRowH = 28
62
- const statsH = rows.length * 26 + 8
63
- const height = headerH + metaRowH + 14 + statsH + pad
66
+ const headerH = 48;
67
+ const metaRowH = 28;
68
+ const statsH = rows.length * 26 + 8;
69
+ const height = headerH + metaRowH + 14 + statsH + pad;
64
70
 
65
- const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) })
71
+ const chrome = panel({ width: w, height, borderColor: tierBorder(resolved.tier) });
66
72
 
67
- const quantity = Number(BigInt(item.quantity.toString()))
68
- const badge = quantityBadge({ x: w - pad, y: pad, quantity })
73
+ const quantity = Number(BigInt(item.quantity.toString()));
74
+ const badge = quantityBadge({ x: w - pad, y: pad, quantity });
69
75
 
70
76
  const icon = iconHex({
71
77
  x: pad,
72
78
  y: pad + 4,
73
79
  color: tokens.colors.accent.component,
74
80
  code: shortCode(resolved.itemId),
75
- })
81
+ });
76
82
 
77
83
  const name = text({
78
84
  x: pad + 34,
@@ -81,42 +87,41 @@ export function renderComponent(
81
87
  size: tokens.typography.sizes.title,
82
88
  weight: 700,
83
89
  family: tokens.typography.display,
84
- })
90
+ });
85
91
 
86
- const tierNum = resolved.tier.replace(/^t/i, '')
87
92
  const subtitleText = text({
88
93
  x: pad,
89
94
  y: pad + headerH + 4,
90
- value: 'Type',
95
+ value: "Type",
91
96
  size: tokens.typography.sizes.body,
92
97
  color: tokens.colors.text.secondary,
93
- })
98
+ });
94
99
  const subtitleValue = text({
95
100
  x: w - pad,
96
101
  y: pad + headerH + 4,
97
- value: `COMPONENT · T${tierNum}`,
102
+ value: `COMPONENT · ${formatTier(resolved.tier)}`,
98
103
  size: tokens.typography.sizes.body,
99
104
  weight: 600,
100
- anchor: 'end',
101
- })
105
+ anchor: "end",
106
+ });
102
107
  const massLabel = text({
103
108
  x: pad,
104
109
  y: pad + headerH + metaRowH - 8,
105
- value: 'Mass',
110
+ value: "Mass",
106
111
  size: tokens.typography.sizes.body,
107
112
  color: tokens.colors.text.secondary,
108
- })
113
+ });
109
114
  const massValue = text({
110
115
  x: w - pad,
111
116
  y: pad + headerH + metaRowH - 8,
112
117
  value: formatMass(resolved.mass),
113
118
  size: tokens.typography.sizes.body,
114
119
  weight: 600,
115
- anchor: 'end',
116
- })
120
+ anchor: "end",
121
+ });
117
122
 
118
- const sepY = pad + headerH + metaRowH + 6
119
- const sep = divider({ x: pad, y: sepY, width: innerW })
123
+ const sepY = pad + headerH + metaRowH + 6;
124
+ const sep = divider({ x: pad, y: sepY, width: innerW });
120
125
 
121
126
  const statsSvg = rows
122
127
  .map((row, i) =>
@@ -131,9 +136,9 @@ export function renderComponent(
131
136
  inverted: row.inverted,
132
137
  }),
133
138
  )
134
- .join('')
139
+ .join("");
135
140
 
136
- const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`
141
+ const inner = `${chrome}${icon}${name}${badge}${subtitleText}${subtitleValue}${massLabel}${massValue}${sep}${statsSvg}`;
137
142
 
138
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
143
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`;
139
144
  }