@shipload/item-renderer 1.0.0-next.19 → 1.0.0-next.20

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipload/item-renderer",
3
- "version": "1.0.0-next.19",
3
+ "version": "1.0.0-next.20",
4
4
  "description": "Deterministic SVG rendering for Shipload items",
5
5
  "homepage": "https://github.com/shipload/toolkit/tree/master/packages/item-renderer",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  "fonts:copy": "bun run scripts/copy-fonts.ts"
46
46
  },
47
47
  "dependencies": {
48
- "@shipload/sdk": "^1.0.0-next.19",
48
+ "@shipload/sdk": "^1.0.0-next.20",
49
49
  "@wharfkit/antelope": "1.2.0"
50
50
  },
51
51
  "devDependencies": {
@@ -71,7 +71,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
71
71
  )
72
72
  }
73
73
 
74
- const accent = props.accentColor ?? tokens.colors.text.accent
74
+ const accent = props.accentColor ?? tokens.colors.accent.component
75
75
  const label = `${props.capability ?? 'Module'}: `
76
76
 
77
77
  const desc = props.description
@@ -89,7 +89,7 @@ export function moduleSlot(props: ModuleSlotProps): string {
89
89
  value: label.trimEnd(),
90
90
  size: tokens.typography.sizes.body,
91
91
  weight: 600,
92
- color: tokens.colors.text.primary,
92
+ color: accent,
93
93
  })
94
94
  )
95
95
  }
@@ -99,9 +99,9 @@ export function moduleSlot(props: ModuleSlotProps): string {
99
99
  const combined = label + descPlain
100
100
  const lines = wrapText({value: combined, charsPerLine: 36})
101
101
 
102
- const highlightColor = tokens.colors.text.accent
102
+ const highlightColor = tokens.colors.text.primary
103
103
  const bodyColor = tokens.colors.text.secondary
104
- const labelColor = tokens.colors.text.primary
104
+ const labelColor = accent
105
105
  const size = tokens.typography.sizes.body
106
106
  const fontFamily = escapeXml(tokens.typography.sans)
107
107
  const labelEnd = label.length
@@ -6,11 +6,19 @@ export interface QuantityBadgeProps {
6
6
  x: number
7
7
  y: number
8
8
  quantity: number
9
+ label?: string
10
+ tone: string
9
11
  }
10
12
 
11
- export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
13
+ export function quantityBadge({
14
+ x,
15
+ y,
16
+ quantity,
17
+ label: labelOverride,
18
+ tone,
19
+ }: QuantityBadgeProps): string {
12
20
  if (quantity <= 0) return ''
13
- const label = `×${quantity}`
21
+ const label = labelOverride ?? `×${quantity}`
14
22
  const w = label.length * 7 + 12
15
23
  const h = tokens.spacing.quantityBadgeHeight
16
24
  return (
@@ -21,7 +29,9 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
21
29
  height: h,
22
30
  rx: h / 2,
23
31
  ry: h / 2,
24
- fill: tokens.colors.text.accent,
32
+ fill: tokens.colors.surface.panel,
33
+ stroke: tone,
34
+ 'stroke-width': 1.5,
25
35
  }) +
26
36
  text({
27
37
  x: x - w / 2,
@@ -30,7 +40,7 @@ export function quantityBadge({x, y, quantity}: QuantityBadgeProps): string {
30
40
  size: tokens.typography.sizes.label,
31
41
  weight: 700,
32
42
  family: tokens.typography.mono,
33
- color: tokens.colors.surface.background,
43
+ color: tokens.colors.text.primary,
34
44
  anchor: 'middle',
35
45
  })
36
46
  )
@@ -43,7 +43,7 @@ export function spanParagraph(props: SpanParagraphProps): {svg: string; lineCoun
43
43
  const chars = props.charsPerLine ?? 36
44
44
  const lh = props.lineHeight ?? 14
45
45
  const bodyColor = props.bodyColor ?? tokens.colors.text.secondary
46
- const highlightColor = props.highlightColor ?? tokens.colors.text.accent
46
+ const highlightColor = props.highlightColor ?? tokens.colors.text.primary
47
47
  const size = props.fontSize ?? tokens.typography.sizes.body
48
48
 
49
49
  const plain = props.spans.map((s) => s.text).join('')
@@ -59,13 +59,15 @@ export function statBar({
59
59
  const displayFraction = inverted ? 1 - clamped / 1023 : clamped / 1023
60
60
  const filled = Math.floor(width * displayFraction)
61
61
 
62
+ // value text = primary; identity color = bar + code + chrome
62
63
  labelOut += text({
63
64
  x: x + width,
64
65
  y: y - 6,
65
66
  value: String(clamped),
66
67
  size: tokens.typography.sizes.statValue,
67
68
  weight: 700,
68
- color,
69
+ family: tokens.typography.mono,
70
+ color: tokens.colors.text.primary,
69
71
  anchor: 'end',
70
72
  })
71
73
 
@@ -1,6 +1,14 @@
1
1
  import {el} from './svg.ts'
2
2
  import {tokens} from '../tokens/index.ts'
3
3
 
4
+ export interface TextSpan {
5
+ value: string
6
+ size?: number
7
+ weight?: 400 | 600 | 700 | 500
8
+ color?: string
9
+ dx?: number
10
+ }
11
+
4
12
  export interface TextProps {
5
13
  x: number
6
14
  y: number
@@ -12,9 +20,14 @@ export interface TextProps {
12
20
  anchor?: 'start' | 'middle' | 'end'
13
21
  letterSpacing?: number
14
22
  dominantBaseline?: 'auto' | 'middle' | 'central' | 'hanging' | 'text-top' | 'text-bottom'
23
+ // Optional trailing tspans that flow inline after value (no manual width math).
24
+ spans?: TextSpan[]
15
25
  }
16
26
 
17
27
  export function text(props: TextProps): string {
28
+ const body = props.spans?.length
29
+ ? escapeValue(props.value) + props.spans.map(renderSpan).join('')
30
+ : escapeValue(props.value)
18
31
  return el(
19
32
  'text',
20
33
  {
@@ -28,7 +41,20 @@ export function text(props: TextProps): string {
28
41
  'letter-spacing': props.letterSpacing,
29
42
  'dominant-baseline': props.dominantBaseline,
30
43
  },
31
- escapeValue(props.value)
44
+ body
45
+ )
46
+ }
47
+
48
+ function renderSpan(span: TextSpan): string {
49
+ return el(
50
+ 'tspan',
51
+ {
52
+ dx: span.dx,
53
+ 'font-size': span.size,
54
+ 'font-weight': span.weight,
55
+ fill: span.color,
56
+ },
57
+ escapeValue(span.value)
32
58
  )
33
59
  }
34
60
 
@@ -1,4 +1,5 @@
1
- import {formatMassScaled} from '@shipload/sdk'
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {baseName, formatMassScaled} from '@shipload/sdk'
2
3
  import {text} from '../primitives/text.ts'
3
4
  import {divider} from '../primitives/divider.ts'
4
5
  import {tokens} from '../tokens/index.ts'
@@ -16,12 +17,58 @@ export function shortCode(itemId: number): string {
16
17
  return str.slice(-2).padStart(2, '0')
17
18
  }
18
19
 
20
+ export function capabilityColor(name: string): string {
21
+ const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
22
+ return tokens.colors.capability[key] ?? tokens.colors.accent.component
23
+ }
24
+
19
25
  export const META_ROW_H = 22
20
26
  export const HEADER_H = 48
21
27
  export const ICON_Y = 4
22
28
  export const BADGE_Y = 6
23
29
  export const META_BLOCK_GAP = 16
24
30
 
31
+ export const STAT_ROW_H = 26
32
+ export const CAP_HEADER_H = 22
33
+ export const CAP_ROW_H = 18
34
+ export const BODY_TAIL = 8
35
+
36
+ // Gap from the meta block to the first stat row. Resources/components have no
37
+ // body sub-header, and statBar draws its label 6px above its y, so this is
38
+ // META_BLOCK_GAP plus the offset that puts the first stat label level with
39
+ // where module/entity body sections begin — keeping the meta→body gap uniform.
40
+ export const STAT_BLOCK_GAP = META_BLOCK_GAP + 22
41
+
42
+ // Uniform gap between a card's last body element and the bottom frame edge.
43
+ // Cards size their height to (last element bottom) + BOTTOM_PAD so trailing
44
+ // space is consistent across resource / component / module / entity types.
45
+ export const BOTTOM_PAD = 22
46
+
47
+ export function titleParts(x: number, y: number, name: string, tier: number): string {
48
+ return text({
49
+ x,
50
+ y,
51
+ value: name,
52
+ size: tokens.typography.sizes.title,
53
+ weight: 700,
54
+ family: tokens.typography.display,
55
+ spans: [
56
+ {
57
+ value: `T${tier}`,
58
+ dx: 6,
59
+ size: tokens.typography.sizes.subtitle,
60
+ weight: 700,
61
+ color: tokens.colors.text.secondary,
62
+ },
63
+ ],
64
+ })
65
+ }
66
+
67
+ export function titleText(x: number, y: number, resolved: ResolvedItem): string {
68
+ // Prominent base name; tier rendered as a smaller, muted inline suffix.
69
+ return titleParts(x, y, baseName(resolved), resolved.tier)
70
+ }
71
+
25
72
  export interface MetaRowProps {
26
73
  x: number
27
74
  y: number
@@ -1,16 +1,8 @@
1
- import type {ResolvedItem, ResourceCategory} from '@shipload/sdk'
2
- import {
3
- formatTier,
4
- getRecipe,
5
- getStatDefinitions,
6
- categoryColors,
7
- displayName,
8
- formatLocation,
9
- } from '@shipload/sdk'
1
+ import type {ResolvedItem} from '@shipload/sdk'
2
+ import {getRecipe, getStatDefinitions, resolveItemCategory, formatLocation} from '@shipload/sdk'
10
3
  import type {CargoItem} from '../payload/codec.ts'
11
4
  import {panel} from '../primitives/panel.ts'
12
5
  import {iconHex} from '../primitives/icon-hex.ts'
13
- import {text} from '../primitives/text.ts'
14
6
  import {statBar} from '../primitives/stat-bar.ts'
15
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
16
8
  import {tokens} from '../tokens/index.ts'
@@ -19,10 +11,13 @@ import {
19
11
  formatMass,
20
12
  tierBorder,
21
13
  metaRowBlock,
14
+ titleText,
22
15
  BADGE_Y,
23
16
  HEADER_H,
24
17
  ICON_Y,
25
- META_BLOCK_GAP,
18
+ STAT_BLOCK_GAP,
19
+ STAT_ROW_H,
20
+ BOTTOM_PAD,
26
21
  } from './_shared.ts'
27
22
 
28
23
  export interface RenderComponentOpts {
@@ -48,13 +43,15 @@ export function renderComponent(
48
43
  const pad = tokens.spacing.panelPadding
49
44
  const innerW = w - pad * 2
50
45
 
46
+ const identity = tokens.colors.accent.component
47
+
51
48
  let rows: StatRow[]
52
49
  if (mode === 'values') {
53
50
  rows = (resolved.stats ?? []).map((s) => ({
54
51
  label: s.label,
55
52
  abbreviation: s.abbreviation,
56
53
  value: s.value,
57
- color: s.color,
54
+ color: identity,
58
55
  inverted: s.inverted,
59
56
  }))
60
57
  } else {
@@ -63,8 +60,9 @@ export function renderComponent(
63
60
  const src = slot.sources[0]
64
61
  if (!src) return []
65
62
  const input = recipe!.inputs[src.inputIndex]
66
- if (!input || !('category' in input)) return []
67
- const category = input.category as ResourceCategory
63
+ if (!input) return []
64
+ const category = resolveItemCategory(input.itemId)
65
+ if (!category) return []
68
66
  const def = getStatDefinitions(category)[src.statIndex]
69
67
  if (!def) return []
70
68
  return [
@@ -72,51 +70,44 @@ export function renderComponent(
72
70
  label: def.label,
73
71
  abbreviation: def.abbreviation,
74
72
  value: null,
75
- color: categoryColors[category],
73
+ color: identity,
76
74
  inverted: def.inverted,
77
75
  },
78
76
  ]
79
77
  })
80
78
  }
81
79
 
80
+ const quantity = Number(BigInt(item.quantity.toString()))
82
81
  const metaRows = [
83
- {label: 'Type', value: `COMPONENT · ${formatTier(resolved.tier)}`},
84
- {label: 'Mass', value: formatMass(resolved.mass)},
82
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
85
83
  ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
86
84
  ]
87
85
 
88
86
  const metaYStart = pad + HEADER_H
89
87
  const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
90
- const statsYStart = metaYStart + metaH + META_BLOCK_GAP
91
- const statsH = rows.length * 26 + 8
92
- const height = statsYStart + statsH + pad
88
+ const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
89
+ const statsBottom =
90
+ statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
91
+ const height = statsBottom + BOTTOM_PAD
93
92
 
94
93
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
95
94
 
96
- const quantity = Number(BigInt(item.quantity.toString()))
97
- const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
95
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: identity})
98
96
 
99
97
  const icon = iconHex({
100
98
  x: pad,
101
99
  y: pad + ICON_Y,
102
- color: tokens.colors.accent.component,
100
+ color: identity,
103
101
  code: shortCode(resolved.itemId),
104
102
  })
105
103
 
106
- const name = text({
107
- x: pad + 34,
108
- y: pad + 22,
109
- value: displayName(resolved),
110
- size: tokens.typography.sizes.title,
111
- weight: 700,
112
- family: tokens.typography.display,
113
- })
104
+ const name = titleText(pad + 34, pad + 22, resolved)
114
105
 
115
106
  const statsSvg = rows
116
107
  .map((row, i) =>
117
108
  statBar({
118
109
  x: pad,
119
- y: statsYStart + i * 26,
110
+ y: statsYStart + i * STAT_ROW_H,
120
111
  width: innerW,
121
112
  label: row.label,
122
113
  abbreviation: row.abbreviation,
@@ -1,10 +1,9 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {describeModuleForItem, displayName, formatLocation, renderDescription} from '@shipload/sdk'
2
+ import {describeModuleForItem, formatLocation, renderDescription} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {panel} from '../primitives/panel.ts'
5
5
  import {iconHex} from '../primitives/icon-hex.ts'
6
6
  import {text} from '../primitives/text.ts'
7
- import {compactRow} from '../primitives/compact-row.ts'
8
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
9
8
  import {spanParagraph} from '../primitives/span-paragraph.ts'
10
9
  import {tokens} from '../tokens/index.ts'
@@ -13,17 +12,16 @@ import {
13
12
  formatMass,
14
13
  tierBorder,
15
14
  metaRowBlock,
15
+ titleText,
16
+ capabilityColor,
16
17
  BADGE_Y,
17
18
  HEADER_H,
18
19
  ICON_Y,
19
20
  META_BLOCK_GAP,
21
+ CAP_HEADER_H,
22
+ BODY_TAIL,
20
23
  } from './_shared.ts'
21
24
 
22
- function capabilityColor(name: string): string {
23
- const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
24
- return tokens.colors.capability[key] ?? tokens.colors.accent.component
25
- }
26
-
27
25
  export interface RenderModuleOpts {
28
26
  mode?: 'values' | 'ranges'
29
27
  location?: {x: number; y: number}
@@ -40,13 +38,13 @@ export function renderModule(
40
38
  const innerW = w - pad * 2
41
39
 
42
40
  const group = resolved.attributes?.[0]
43
- const attrs = group?.attributes ?? []
44
41
  const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
45
42
 
46
43
  const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
47
44
 
45
+ const quantity = Number(BigInt(item.quantity.toString()))
48
46
  const metaRows = [
49
- {label: 'Mass', value: formatMass(resolved.mass)},
47
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
50
48
  ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
51
49
  ]
52
50
 
@@ -54,118 +52,54 @@ export function renderModule(
54
52
  const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
55
53
  const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
56
54
 
57
- let bodyHeight = 0
58
- if (mode === 'ranges') {
59
- bodyHeight = 20 + 8
60
- } else if (desc && group) {
61
- const plain = renderDescription(desc)
62
- .map((s) => s.text)
63
- .join('')
64
- const lines = plain.split(/\s+/).reduce(
65
- (acc, word) => {
66
- const last = acc[acc.length - 1] ?? ''
67
- if (last.length === 0) return [...acc.slice(0, -1), word]
68
- if (last.length + 1 + word.length <= 36)
69
- return [...acc.slice(0, -1), `${last} ${word}`]
70
- return [...acc, word]
71
- },
72
- ['']
73
- )
74
- const lineCount = lines.filter((l) => l.length > 0).length
75
- bodyHeight = 20 + lineCount * 14 + 8
76
- } else if (group && attrs.length > 0) {
77
- const capHeaderH = 22
78
- const attrsH = attrs.length * 18
79
- bodyHeight = capHeaderH + attrsH + 8
80
- }
81
-
82
- const height = bodyYStart + bodyHeight + pad
83
-
84
- const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
85
-
86
- const quantity = Number(BigInt(item.quantity.toString()))
87
- const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
88
-
89
55
  const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
90
- const icon = iconHex({
91
- x: pad,
92
- y: pad + ICON_Y,
93
- color: iconColor,
94
- code: shortCode(resolved.itemId),
95
- })
96
56
 
97
- const name = text({
98
- x: pad + 34,
99
- y: pad + 22,
100
- value: displayName(resolved),
101
- size: tokens.typography.sizes.title,
57
+ const capLabel = (group?.capability ?? capabilityName).toUpperCase()
58
+ const capHeader = text({
59
+ x: pad,
60
+ y: bodyYStart + 16,
61
+ value: capLabel,
62
+ size: tokens.typography.sizes.subtitle,
102
63
  weight: 700,
103
- family: tokens.typography.display,
64
+ family: tokens.typography.sans,
65
+ color: iconColor,
66
+ letterSpacing: 1,
104
67
  })
105
68
 
106
- let capSection = ''
107
- if (mode === 'ranges') {
108
- const accentColor = capabilityColor(capabilityName)
109
- capSection = text({
110
- x: pad,
111
- y: bodyYStart + 16,
112
- value: capabilityName.toUpperCase(),
113
- size: tokens.typography.sizes.subtitle,
114
- weight: 700,
115
- family: tokens.typography.sans,
116
- color: accentColor,
117
- letterSpacing: 1,
118
- })
119
- } else if (desc && group) {
120
- const accentColor = capabilityColor(group.capability)
121
- const capHeader = text({
122
- x: pad,
123
- y: bodyYStart + 16,
124
- value: group.capability.toUpperCase(),
125
- size: tokens.typography.sizes.subtitle,
126
- weight: 700,
127
- family: tokens.typography.sans,
128
- color: accentColor,
129
- letterSpacing: 1,
130
- })
69
+ let bodyHeight: number
70
+ let capSection: string
71
+ if (mode === 'values' && desc && group) {
131
72
  const spans = renderDescription(desc)
132
- const {svg: paraSvg} = spanParagraph({
73
+ const {svg: paraSvg, lineCount} = spanParagraph({
133
74
  x: pad,
134
75
  y: bodyYStart + 36,
135
76
  spans,
136
77
  charsPerLine: 36,
137
78
  lineHeight: 14,
79
+ highlightColor: tokens.colors.text.primary,
138
80
  })
81
+ bodyHeight = CAP_HEADER_H + lineCount * 14 + BODY_TAIL
139
82
  capSection = capHeader + paraSvg
140
- } else if (group && attrs.length > 0) {
141
- const capY = bodyYStart + 22
142
- const capHeader = text({
143
- x: pad,
144
- y: capY,
145
- value: group.capability.toUpperCase(),
146
- size: 10,
147
- weight: 700,
148
- family: tokens.typography.sans,
149
- color: capabilityColor(group.capability),
150
- letterSpacing: 0.8,
151
- })
152
-
153
- const attrRows = attrs
154
- .map((attr, i) => {
155
- const displayValue = String(attr.value)
156
- return compactRow({
157
- x: pad,
158
- y: capY + 14 + i * 18,
159
- width: innerW,
160
- label: attr.label,
161
- value: displayValue,
162
- })
163
- })
164
- .join('')
165
-
166
- capSection = capHeader + attrRows
83
+ } else {
84
+ bodyHeight = CAP_HEADER_H + BODY_TAIL
85
+ capSection = capHeader
167
86
  }
168
87
 
88
+ const height = bodyYStart + bodyHeight + pad
89
+
90
+ const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
91
+
92
+ const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity, tone: iconColor})
93
+
94
+ const icon = iconHex({
95
+ x: pad,
96
+ y: pad + ICON_Y,
97
+ color: iconColor,
98
+ code: shortCode(resolved.itemId),
99
+ })
100
+
101
+ const name = titleText(pad + 34, pad + 22, resolved)
102
+
169
103
  const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
170
104
 
171
105
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
@@ -1,18 +1,28 @@
1
1
  import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
2
- import {describeModuleForSlot, displayName, renderDescription} from '@shipload/sdk'
2
+ import {baseName, describeModuleForSlot, renderDescription} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {renderShipPanel, type ShipPanelSlot} from './ship-panel.ts'
5
5
 
6
+ function capabilityFromName(name: string): string {
7
+ return name.replace(/\s+T\d+\s*$/i, '').trim()
8
+ }
9
+
6
10
  function slotToPanelSlot(slot: ResolvedModuleSlot): ShipPanelSlot {
7
11
  if (!slot.installed || !slot.attributes || !slot.name) {
8
12
  return {installed: false}
9
13
  }
14
+ const capability = capabilityFromName(slot.name)
10
15
  const desc = describeModuleForSlot(slot)
11
16
  if (desc) {
12
- return {name: slot.name, installed: true, description: renderDescription(desc)}
17
+ return {
18
+ name: slot.name,
19
+ installed: true,
20
+ capability,
21
+ description: renderDescription(desc),
22
+ }
13
23
  }
14
24
  const shorthand = slot.attributes.map((a) => `${a.value} ${a.label.toLowerCase()}`).join(' · ')
15
- return {name: slot.name, installed: true, description: shorthand}
25
+ return {name: slot.name, installed: true, capability, description: shorthand}
16
26
  }
17
27
 
18
28
  export interface RenderPackedEntityOpts {
@@ -28,7 +38,7 @@ export function renderPackedEntity(
28
38
  const quantity = Number(BigInt(item.quantity.toString()))
29
39
  const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
30
40
  return renderShipPanel({
31
- name: `${displayName(resolved)} (Packed)`,
41
+ name: baseName(resolved),
32
42
  tier: resolved.tier,
33
43
  quantity,
34
44
  location: opts?.location,
@@ -1,9 +1,8 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {getStatDefinitions, categoryColors, displayName, formatLocation} from '@shipload/sdk'
2
+ import {getStatDefinitions, categoryColors, formatLocation} from '@shipload/sdk'
3
3
  import type {CargoItem} from '../payload/codec.ts'
4
4
  import {panel} from '../primitives/panel.ts'
5
5
  import {iconHex} from '../primitives/icon-hex.ts'
6
- import {text} from '../primitives/text.ts'
7
6
  import {statBar} from '../primitives/stat-bar.ts'
8
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
9
8
  import {tokens} from '../tokens/index.ts'
@@ -12,10 +11,13 @@ import {
12
11
  formatMass,
13
12
  tierBorder,
14
13
  metaRowBlock,
14
+ titleText,
15
15
  BADGE_Y,
16
16
  HEADER_H,
17
17
  ICON_Y,
18
- META_BLOCK_GAP,
18
+ STAT_BLOCK_GAP,
19
+ STAT_ROW_H,
20
+ BOTTOM_PAD,
19
21
  } from './_shared.ts'
20
22
 
21
23
  function categoryColor(category?: string): string {
@@ -71,42 +73,43 @@ export function renderResource(
71
73
  }
72
74
 
73
75
  const metaRows = [
74
- {label: 'Mass', value: formatMass(resolved.mass)},
75
76
  ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
76
77
  ]
77
78
 
78
79
  const metaYStart = pad + HEADER_H
79
80
  const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
80
- const statsYStart = metaYStart + metaH + META_BLOCK_GAP
81
- const statsH = rows.length * 26 + 8
82
- const height = statsYStart + statsH + pad
81
+ const statsYStart = metaYStart + metaH + STAT_BLOCK_GAP
82
+ const statsBottom =
83
+ statsYStart + Math.max(0, rows.length - 1) * STAT_ROW_H + tokens.spacing.statBarHeight
84
+ const height = statsBottom + BOTTOM_PAD
83
85
 
84
86
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
85
87
 
88
+ const identity = categoryColor(resolved.category)
89
+
86
90
  const quantity = Number(BigInt(item.quantity.toString()))
87
- const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
91
+ const badge = quantityBadge({
92
+ x: w - pad,
93
+ y: pad + BADGE_Y,
94
+ quantity,
95
+ label: formatMass(quantity * resolved.mass),
96
+ tone: identity,
97
+ })
88
98
 
89
99
  const icon = iconHex({
90
100
  x: pad,
91
101
  y: pad + ICON_Y,
92
- color: categoryColor(resolved.category),
102
+ color: identity,
93
103
  code: shortCode(resolved.itemId),
94
104
  })
95
105
 
96
- const name = text({
97
- x: pad + 34,
98
- y: pad + 22,
99
- value: displayName(resolved),
100
- size: tokens.typography.sizes.title,
101
- weight: 700,
102
- family: tokens.typography.display,
103
- })
106
+ const name = titleText(pad + 34, pad + 22, resolved)
104
107
 
105
108
  const statsSvg = rows
106
109
  .map((row, i) =>
107
110
  statBar({
108
111
  x: pad,
109
- y: statsYStart + i * 26,
112
+ y: statsYStart + i * STAT_ROW_H,
110
113
  width: innerW,
111
114
  label: row.label,
112
115
  abbreviation: row.abbreviation,
@@ -2,18 +2,29 @@ import type {TextSpan} from '@shipload/sdk'
2
2
  import {formatLocation, formatMassScaled} from '@shipload/sdk'
3
3
  import {panel} from '../primitives/panel.ts'
4
4
  import {iconHex} from '../primitives/icon-hex.ts'
5
- import {text} from '../primitives/text.ts'
6
5
  import {moduleSlot} from '../primitives/module-slot.ts'
7
6
  import {quantityBadge} from '../primitives/quantity-badge.ts'
8
7
  import {wrapText} from '../primitives/wrap.ts'
9
8
  import {tokens} from '../tokens/index.ts'
10
- import {tierBorder, metaRowBlock, BADGE_Y, HEADER_H, ICON_Y} from './_shared.ts'
9
+ import {
10
+ tierBorder,
11
+ metaRowBlock,
12
+ titleParts,
13
+ capabilityColor,
14
+ BADGE_Y,
15
+ HEADER_H,
16
+ ICON_Y,
17
+ BOTTOM_PAD,
18
+ } from './_shared.ts'
11
19
 
12
20
  const HULL_MASS_LABELS = new Set(['mass', 'capacity'])
13
21
 
22
+ const ENTITY_COLOR = tokens.colors.brand.cyan
23
+
14
24
  export interface ShipPanelSlot {
15
25
  name?: string
16
26
  installed: boolean
27
+ capability?: string
17
28
  description?: string | TextSpan[]
18
29
  }
19
30
 
@@ -34,8 +45,7 @@ function formatHullValue(label: string, value: number): string {
34
45
 
35
46
  const MODULE_LABEL_PREFIX = (capability: string) => `${capability}: `
36
47
 
37
- function rowHeightFor(slot: ShipPanelSlot): number {
38
- if (!slot.installed) return 24
48
+ function lineCountFor(slot: ShipPanelSlot): number {
39
49
  const desc = slot.description
40
50
  const plain =
41
51
  typeof desc === 'string'
@@ -43,12 +53,25 @@ function rowHeightFor(slot: ShipPanelSlot): number {
43
53
  : Array.isArray(desc)
44
54
  ? desc.map((s) => s.text).join('')
45
55
  : ''
46
- if (plain.length === 0) return 24
47
- const combined = MODULE_LABEL_PREFIX(slot.name ?? 'Module') + plain
48
- const lineCount = Math.max(1, wrapText({value: combined, charsPerLine: 36}).length)
56
+ if (plain.length === 0) return 0
57
+ const combined = MODULE_LABEL_PREFIX(slot.capability ?? slot.name ?? 'Module') + plain
58
+ return Math.max(1, wrapText({value: combined, charsPerLine: 36}).length)
59
+ }
60
+
61
+ function rowHeightFor(slot: ShipPanelSlot): number {
62
+ if (!slot.installed) return 24
63
+ const lineCount = lineCountFor(slot)
64
+ if (lineCount === 0) return 24
49
65
  return 10 + lineCount * 14
50
66
  }
51
67
 
68
+ function contentBottomOffsetFor(slot: ShipPanelSlot): number {
69
+ if (!slot.installed) return 14
70
+ const lineCount = lineCountFor(slot)
71
+ if (lineCount === 0) return 14
72
+ return 9 + (lineCount - 1) * 14 + 5
73
+ }
74
+
52
75
  export function renderShipPanel(props: ShipPanelProps): string {
53
76
  const w = tokens.spacing.panelWidth
54
77
  const pad = tokens.spacing.panelPadding
@@ -69,45 +92,47 @@ export function renderShipPanel(props: ShipPanelProps): string {
69
92
  const metaYStart = pad + HEADER_H
70
93
  const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
71
94
 
72
- const rowHeights = props.slots.map(rowHeightFor)
73
- const modulesHeight = rowHeights.reduce((a, b) => a + b, 0)
74
- const height = metaYStart + metaH + sectionGap + modulesHeight + pad
95
+ const slotsYStart = metaYStart + metaH + sectionGap
96
+
97
+ let y = slotsYStart
98
+ let modulesSvg = ''
99
+ let lastContentBottom = slotsYStart
100
+ props.slots.forEach((slot, i) => {
101
+ const accentColor = slot.installed
102
+ ? capabilityColor(slot.capability ?? slot.name ?? 'Module')
103
+ : ENTITY_COLOR
104
+ modulesSvg += moduleSlot({
105
+ x: pad,
106
+ y,
107
+ width: innerW,
108
+ installed: slot.installed,
109
+ capability: slot.capability ?? slot.name,
110
+ description: slot.description,
111
+ accentColor,
112
+ })
113
+ lastContentBottom = y + contentBottomOffsetFor(slot)
114
+ if (i < props.slots.length - 1) y += rowHeightFor(slot)
115
+ })
116
+
117
+ const height = lastContentBottom + BOTTOM_PAD
75
118
 
76
119
  const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
77
120
 
78
121
  const icon = iconHex({
79
122
  x: pad,
80
123
  y: pad + ICON_Y,
81
- color: tokens.colors.text.accent,
124
+ color: ENTITY_COLOR,
82
125
  code: 'SH',
83
126
  })
84
127
 
85
- const name = text({
86
- x: pad + 34,
87
- y: pad + 22,
88
- value: props.name,
89
- size: tokens.typography.sizes.title,
90
- weight: 700,
91
- family: tokens.typography.display,
92
- })
93
-
94
- const badge = quantityBadge({x: w - pad, y: pad + BADGE_Y, quantity})
128
+ const name = titleParts(pad + 34, pad + 22, props.name, props.tier)
95
129
 
96
- let y = metaYStart + metaH + sectionGap
97
- let modulesSvg = ''
98
- for (let i = 0; i < props.slots.length; i++) {
99
- const slot = props.slots[i]!
100
- modulesSvg += moduleSlot({
101
- x: pad,
102
- y,
103
- width: innerW,
104
- installed: slot.installed,
105
- capability: slot.name,
106
- description: slot.description,
107
- accentColor: tokens.colors.brand.teal,
108
- })
109
- y += rowHeights[i]!
110
- }
130
+ const badge = quantityBadge({
131
+ x: w - pad,
132
+ y: pad + BADGE_Y,
133
+ quantity,
134
+ tone: ENTITY_COLOR,
135
+ })
111
136
 
112
137
  const inner = `${chrome}${icon}${name}${badge}${metaSvg}${modulesSvg}`
113
138
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
@@ -10,7 +10,7 @@ export const colors = {
10
10
  text: {
11
11
  primary: '#e6e8ec',
12
12
  secondary: '#8f98a8',
13
- muted: '#5b6373',
13
+ muted: '#7c8698',
14
14
  accent: '#f4c96b',
15
15
  },
16
16
  brand: {
@@ -27,7 +27,7 @@ export const colors = {
27
27
  },
28
28
  tier: tierColors,
29
29
  accent: {
30
- component: '#8f98a8',
30
+ component: '#7E93C4',
31
31
  },
32
32
  capability: {
33
33
  engine: '#4a8abf',
@@ -1,38 +0,0 @@
1
- import {text} from './text.ts'
2
- import {tokens} from '../tokens/index.ts'
3
-
4
- export interface CompactRowProps {
5
- x: number
6
- y: number
7
- width: number
8
- label: string
9
- value: string
10
- labelColor?: string
11
- valueColor?: string
12
- }
13
-
14
- export function compactRow(p: CompactRowProps): string {
15
- const labelColor = p.labelColor ?? tokens.colors.text.secondary
16
- const valueColor = p.valueColor ?? tokens.colors.text.primary
17
- return (
18
- text({
19
- x: p.x,
20
- y: p.y,
21
- value: p.label,
22
- size: 11,
23
- weight: 500,
24
- family: tokens.typography.sans,
25
- color: labelColor,
26
- }) +
27
- text({
28
- x: p.x + p.width,
29
- y: p.y,
30
- value: p.value,
31
- size: 11,
32
- weight: 700,
33
- family: tokens.typography.sans,
34
- color: valueColor,
35
- anchor: 'end',
36
- })
37
- )
38
- }