@shipload/item-renderer 1.0.0-next.2 → 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.
@@ -1,23 +1,30 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {describeModuleForItem, formatTier, 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 {divider} from '../primitives/divider.ts'
8
- import {compactRow} from '../primitives/compact-row.ts'
9
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
8
  import {spanParagraph} from '../primitives/span-paragraph.ts'
11
9
  import {tokens} from '../tokens/index.ts'
12
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
13
-
14
- function capabilityColor(name: string): string {
15
- const key = name.toLowerCase().replace(/\s+/g, '') as keyof typeof tokens.colors.capability
16
- return tokens.colors.capability[key] ?? tokens.colors.accent.component
17
- }
10
+ import {
11
+ shortCode,
12
+ formatMass,
13
+ tierBorder,
14
+ metaRowBlock,
15
+ titleText,
16
+ capabilityColor,
17
+ BADGE_Y,
18
+ HEADER_H,
19
+ ICON_Y,
20
+ META_BLOCK_GAP,
21
+ CAP_HEADER_H,
22
+ BODY_TAIL,
23
+ } from './_shared.ts'
18
24
 
19
25
  export interface RenderModuleOpts {
20
26
  mode?: 'values' | 'ranges'
27
+ location?: {x: number; y: number}
21
28
  }
22
29
 
23
30
  export function renderModule(
@@ -31,162 +38,69 @@ export function renderModule(
31
38
  const innerW = w - pad * 2
32
39
 
33
40
  const group = resolved.attributes?.[0]
34
- const attrs = group?.attributes ?? []
35
41
  const desc = mode === 'values' ? describeModuleForItem(resolved) : undefined
36
42
 
37
43
  const capabilityName = group?.capability ?? resolved.name.replace(/\s+T\d+$/i, '')
38
44
 
39
- const headerH = 48
40
- const metaRowH = 28
41
- const sepY = pad + headerH + metaRowH + 6
42
-
43
- let bodyHeight = 0
44
- if (mode === 'ranges') {
45
- bodyHeight = 20 + 8
46
- } else if (desc && group) {
47
- const plain = renderDescription(desc)
48
- .map((s) => s.text)
49
- .join('')
50
- const lines = plain.split(/\s+/).reduce(
51
- (acc, word) => {
52
- const last = acc[acc.length - 1] ?? ''
53
- if (last.length === 0) return [...acc.slice(0, -1), word]
54
- if (last.length + 1 + word.length <= 36)
55
- return [...acc.slice(0, -1), `${last} ${word}`]
56
- return [...acc, word]
57
- },
58
- ['']
59
- )
60
- const lineCount = lines.filter((l) => l.length > 0).length
61
- bodyHeight = 20 + lineCount * 14 + 8
62
- } else if (group && attrs.length > 0) {
63
- const capHeaderH = 22
64
- const attrsH = attrs.length * 18
65
- bodyHeight = capHeaderH + attrsH + 8
66
- }
67
-
68
- const height = headerH + metaRowH + 14 + bodyHeight + pad
69
-
70
- const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
71
-
72
45
  const quantity = Number(BigInt(item.quantity.toString()))
73
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
46
+ const metaRows = [
47
+ {label: 'Mass', value: formatMass(resolved.mass * Math.max(quantity, 1))},
48
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
49
+ ]
74
50
 
75
- const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
76
- const icon = iconHex({
77
- x: pad,
78
- y: pad + 4,
79
- color: iconColor,
80
- code: shortCode(resolved.itemId),
81
- })
82
-
83
- const name = text({
84
- x: pad + 34,
85
- y: pad + 22,
86
- value: resolved.name,
87
- size: tokens.typography.sizes.title,
88
- weight: 700,
89
- family: tokens.typography.display,
90
- })
51
+ const metaYStart = pad + HEADER_H
52
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
53
+ const bodyYStart = metaYStart + metaH + META_BLOCK_GAP
91
54
 
92
- const subtitleLabel = text({
93
- x: pad,
94
- y: pad + headerH + 4,
95
- value: 'Type',
96
- size: tokens.typography.sizes.body,
97
- color: tokens.colors.text.secondary,
98
- })
99
- const subtitleValue = text({
100
- x: w - pad,
101
- y: pad + headerH + 4,
102
- value: `MODULE · ${formatTier(resolved.tier)}`,
103
- size: tokens.typography.sizes.body,
104
- weight: 600,
105
- anchor: 'end',
106
- })
55
+ const iconColor = group ? capabilityColor(group.capability) : capabilityColor(capabilityName)
107
56
 
108
- const massLabel = text({
57
+ const capLabel = (group?.capability ?? capabilityName).toUpperCase()
58
+ const capHeader = text({
109
59
  x: pad,
110
- y: pad + headerH + metaRowH - 8,
111
- value: 'Mass',
112
- size: tokens.typography.sizes.body,
113
- color: tokens.colors.text.secondary,
114
- })
115
- const massValue = text({
116
- x: w - pad,
117
- y: pad + headerH + metaRowH - 8,
118
- value: formatMass(resolved.mass),
119
- size: tokens.typography.sizes.body,
120
- weight: 600,
121
- anchor: 'end',
60
+ y: bodyYStart + 16,
61
+ value: capLabel,
62
+ size: tokens.typography.sizes.subtitle,
63
+ weight: 700,
64
+ family: tokens.typography.sans,
65
+ color: iconColor,
66
+ letterSpacing: 1,
122
67
  })
123
68
 
124
- const sep = divider({x: pad, y: sepY, width: innerW})
125
-
126
- let capSection = ''
127
- if (mode === 'ranges') {
128
- const accentColor = capabilityColor(capabilityName)
129
- capSection = text({
130
- x: pad,
131
- y: sepY + 16,
132
- value: capabilityName.toUpperCase(),
133
- size: tokens.typography.sizes.subtitle,
134
- weight: 700,
135
- family: tokens.typography.sans,
136
- color: accentColor,
137
- letterSpacing: 1,
138
- })
139
- } else if (desc && group) {
140
- const accentColor = capabilityColor(group.capability)
141
- const capHeader = text({
142
- x: pad,
143
- y: sepY + 16,
144
- value: group.capability.toUpperCase(),
145
- size: tokens.typography.sizes.subtitle,
146
- weight: 700,
147
- family: tokens.typography.sans,
148
- color: accentColor,
149
- letterSpacing: 1,
150
- })
69
+ let bodyHeight: number
70
+ let capSection: string
71
+ if (mode === 'values' && desc && group) {
151
72
  const spans = renderDescription(desc)
152
- const {svg: paraSvg} = spanParagraph({
73
+ const {svg: paraSvg, lineCount} = spanParagraph({
153
74
  x: pad,
154
- y: sepY + 36,
75
+ y: bodyYStart + 36,
155
76
  spans,
156
77
  charsPerLine: 36,
157
78
  lineHeight: 14,
79
+ highlightColor: tokens.colors.text.primary,
158
80
  })
81
+ bodyHeight = CAP_HEADER_H + lineCount * 14 + BODY_TAIL
159
82
  capSection = capHeader + paraSvg
160
- } else if (group && attrs.length > 0) {
161
- const capY = sepY + 22
162
- const capHeader = text({
163
- x: pad,
164
- y: capY,
165
- value: group.capability.toUpperCase(),
166
- size: 10,
167
- weight: 700,
168
- family: tokens.typography.sans,
169
- color: capabilityColor(group.capability),
170
- letterSpacing: 0.8,
171
- })
172
-
173
- const attrRows = attrs
174
- .map((attr, i) => {
175
- const displayValue = String(attr.value)
176
- return compactRow({
177
- x: pad,
178
- y: capY + 14 + i * 18,
179
- width: innerW,
180
- label: attr.label,
181
- value: displayValue,
182
- })
183
- })
184
- .join('')
185
-
186
- capSection = capHeader + attrRows
83
+ } else {
84
+ bodyHeight = CAP_HEADER_H + BODY_TAIL
85
+ capSection = capHeader
187
86
  }
188
87
 
189
- const inner = `${chrome}${icon}${name}${badge}${subtitleLabel}${subtitleValue}${massLabel}${massValue}${sep}${capSection}`
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
+
103
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${capSection}`
190
104
 
191
105
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
192
106
  }
@@ -1,27 +1,47 @@
1
1
  import type {ResolvedItem, ResolvedModuleSlot} from '@shipload/sdk'
2
- import {describeModuleForSlot, 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}
26
+ }
27
+
28
+ export interface RenderPackedEntityOpts {
29
+ mode?: 'values' | 'ranges'
30
+ location?: {x: number; y: number}
16
31
  }
17
32
 
18
- export function renderPackedEntity(item: CargoItem, resolved: ResolvedItem): string {
33
+ export function renderPackedEntity(
34
+ item: CargoItem,
35
+ resolved: ResolvedItem,
36
+ opts?: RenderPackedEntityOpts
37
+ ): string {
19
38
  const quantity = Number(BigInt(item.quantity.toString()))
20
39
  const slots = (resolved.moduleSlots ?? []).map(slotToPanelSlot)
21
40
  return renderShipPanel({
22
- name: `${resolved.name} (Packed)`,
41
+ name: baseName(resolved),
23
42
  tier: resolved.tier,
24
43
  quantity,
44
+ location: opts?.location,
25
45
  attributes: resolved.attributes ?? [],
26
46
  slots,
27
47
  })
@@ -1,22 +1,24 @@
1
1
  import type {ResolvedItem} from '@shipload/sdk'
2
- import {getStatDefinitions, categoryColors, displayName} 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
- import {divider} from '../primitives/divider.ts'
8
6
  import {statBar} from '../primitives/stat-bar.ts'
9
7
  import {quantityBadge} from '../primitives/quantity-badge.ts'
10
8
  import {tokens} from '../tokens/index.ts'
11
- import {shortCode, formatMass, tierBorder} from './_shared.ts'
12
-
13
- const CATEGORY_LABELS: Record<string, string> = {
14
- ore: 'Ore',
15
- crystal: 'Crystal',
16
- gas: 'Gas',
17
- regolith: 'Regolith',
18
- biomass: 'Biomass',
19
- }
9
+ import {
10
+ shortCode,
11
+ formatMass,
12
+ tierBorder,
13
+ metaRowBlock,
14
+ titleText,
15
+ BADGE_Y,
16
+ HEADER_H,
17
+ ICON_Y,
18
+ STAT_BLOCK_GAP,
19
+ STAT_ROW_H,
20
+ BOTTOM_PAD,
21
+ } from './_shared.ts'
20
22
 
21
23
  function categoryColor(category?: string): string {
22
24
  if (!category) return tokens.colors.text.muted
@@ -26,6 +28,7 @@ function categoryColor(category?: string): string {
26
28
 
27
29
  export interface RenderResourceOpts {
28
30
  mode?: 'values' | 'ranges'
31
+ location?: {x: number; y: number}
29
32
  }
30
33
 
31
34
  type StatRow = {
@@ -69,72 +72,44 @@ export function renderResource(
69
72
  }))
70
73
  }
71
74
 
72
- const headerH = 48
73
- const metaRowH = 28
74
- const statsH = rows.length * 26 + 8
75
- const height = headerH + metaRowH + 14 + statsH + pad
75
+ const metaRows = [
76
+ ...(opts?.location ? [{label: 'Location', value: formatLocation(opts.location)}] : []),
77
+ ]
78
+
79
+ const metaYStart = pad + HEADER_H
80
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
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
76
85
 
77
86
  const chrome = panel({width: w, height, borderColor: tierBorder(resolved.tier)})
78
87
 
88
+ const identity = categoryColor(resolved.category)
89
+
79
90
  const quantity = Number(BigInt(item.quantity.toString()))
80
- const badge = quantityBadge({x: w - pad, y: pad, 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
+ })
81
98
 
82
99
  const icon = iconHex({
83
100
  x: pad,
84
- y: pad + 4,
85
- color: categoryColor(resolved.category),
101
+ y: pad + ICON_Y,
102
+ color: identity,
86
103
  code: shortCode(resolved.itemId),
87
104
  })
88
105
 
89
- const name = text({
90
- x: pad + 34,
91
- y: pad + 22,
92
- value: displayName(resolved),
93
- size: tokens.typography.sizes.title,
94
- weight: 700,
95
- family: tokens.typography.display,
96
- })
97
-
98
- const catLabel = resolved.category ? (CATEGORY_LABELS[resolved.category] ?? 'Item') : 'Item'
99
- const catText = text({
100
- x: pad,
101
- y: pad + headerH + 4,
102
- value: 'Category',
103
- size: tokens.typography.sizes.body,
104
- color: tokens.colors.text.secondary,
105
- })
106
- const catValue = text({
107
- x: w - pad,
108
- y: pad + headerH + 4,
109
- value: catLabel,
110
- size: tokens.typography.sizes.body,
111
- weight: 600,
112
- anchor: 'end',
113
- })
114
- const massLabel = text({
115
- x: pad,
116
- y: pad + headerH + metaRowH - 8,
117
- value: 'Mass',
118
- size: tokens.typography.sizes.body,
119
- color: tokens.colors.text.secondary,
120
- })
121
- const massValue = text({
122
- x: w - pad,
123
- y: pad + headerH + metaRowH - 8,
124
- value: formatMass(resolved.mass),
125
- size: tokens.typography.sizes.body,
126
- weight: 600,
127
- anchor: 'end',
128
- })
129
-
130
- const sepY = pad + headerH + metaRowH + 6
131
- const sep = divider({x: pad, y: sepY, width: innerW})
106
+ const name = titleText(pad + 34, pad + 22, resolved)
132
107
 
133
108
  const statsSvg = rows
134
109
  .map((row, i) =>
135
110
  statBar({
136
111
  x: pad,
137
- y: sepY + 18 + i * 26,
112
+ y: statsYStart + i * STAT_ROW_H,
138
113
  width: innerW,
139
114
  label: row.label,
140
115
  abbreviation: row.abbreviation,
@@ -145,7 +120,7 @@ export function renderResource(
145
120
  )
146
121
  .join('')
147
122
 
148
- const inner = `${chrome}${icon}${name}${badge}${catText}${catValue}${massLabel}${massValue}${sep}${statsSvg}`
123
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${statsSvg}`
149
124
 
150
125
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
151
126
  }
@@ -1,16 +1,30 @@
1
1
  import type {TextSpan} from '@shipload/sdk'
2
+ import {formatLocation, formatMassScaled} from '@shipload/sdk'
2
3
  import {panel} from '../primitives/panel.ts'
3
4
  import {iconHex} from '../primitives/icon-hex.ts'
4
- import {text} from '../primitives/text.ts'
5
- import {divider} from '../primitives/divider.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'
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'
19
+
20
+ const HULL_MASS_LABELS = new Set(['mass', 'capacity'])
21
+
22
+ const ENTITY_COLOR = tokens.colors.brand.cyan
10
23
 
11
24
  export interface ShipPanelSlot {
12
25
  name?: string
13
26
  installed: boolean
27
+ capability?: string
14
28
  description?: string | TextSpan[]
15
29
  }
16
30
 
@@ -18,22 +32,20 @@ export interface ShipPanelProps {
18
32
  name: string
19
33
  tier: number
20
34
  quantity?: number
35
+ location?: {x: number; y: number}
21
36
  attributes: {capability: string; attributes: {label: string; value: number}[]}[]
22
37
  slots: ShipPanelSlot[]
23
38
  }
24
39
 
25
- function formatNumber(n: number): string {
26
- return n.toLocaleString('en-US')
27
- }
28
-
29
- function tierBorder(tier: number): string {
30
- return tokens.colors.tier[tier] ?? tokens.colors.surface.panelBorder
40
+ function formatHullValue(label: string, value: number): string {
41
+ return HULL_MASS_LABELS.has(label.toLowerCase())
42
+ ? formatMassScaled(value)
43
+ : value.toLocaleString('en-US')
31
44
  }
32
45
 
33
46
  const MODULE_LABEL_PREFIX = (capability: string) => `${capability}: `
34
47
 
35
- function rowHeightFor(slot: ShipPanelSlot): number {
36
- if (!slot.installed) return 24
48
+ function lineCountFor(slot: ShipPanelSlot): number {
37
49
  const desc = slot.description
38
50
  const plain =
39
51
  typeof desc === 'string'
@@ -41,12 +53,25 @@ function rowHeightFor(slot: ShipPanelSlot): number {
41
53
  : Array.isArray(desc)
42
54
  ? desc.map((s) => s.text).join('')
43
55
  : ''
44
- if (plain.length === 0) return 24
45
- const combined = MODULE_LABEL_PREFIX(slot.name ?? 'Module') + plain
46
- 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
47
65
  return 10 + lineCount * 14
48
66
  }
49
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
+
50
75
  export function renderShipPanel(props: ShipPanelProps): string {
51
76
  const w = tokens.spacing.panelWidth
52
77
  const pad = tokens.spacing.panelPadding
@@ -54,91 +79,61 @@ export function renderShipPanel(props: ShipPanelProps): string {
54
79
  const quantity = props.quantity ?? 0
55
80
 
56
81
  const hullGroup = props.attributes?.find((g) => g.capability.toLowerCase() === 'hull')
57
- const hullRows = hullGroup?.attributes ?? []
82
+ const hullAttrs = (hullGroup?.attributes ?? []).map((a) => ({
83
+ label: a.label,
84
+ value: formatHullValue(a.label, a.value),
85
+ }))
86
+ const metaRows = props.location
87
+ ? [{label: 'Location', value: formatLocation(props.location)}, ...hullAttrs]
88
+ : hullAttrs
58
89
 
59
- const headerH = 48
60
- const hullHeaderH = 20
61
- const hullRowH = 22
62
90
  const sectionGap = 12
63
- const rowHeights = props.slots.map(rowHeightFor)
64
- const modulesHeight = rowHeights.reduce((a, b) => a + b, 0)
65
- const height =
66
- headerH + hullHeaderH + hullRows.length * hullRowH + sectionGap + modulesHeight + pad
67
91
 
68
- const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
92
+ const metaYStart = pad + HEADER_H
93
+ const {svg: metaSvg, height: metaH} = metaRowBlock(pad, metaYStart, innerW, metaRows)
69
94
 
70
- const icon = iconHex({
71
- x: pad,
72
- y: pad + 4,
73
- color: tokens.colors.text.accent,
74
- code: 'SH',
75
- })
95
+ const slotsYStart = metaYStart + metaH + sectionGap
76
96
 
77
- const name = text({
78
- x: pad + 34,
79
- y: pad + 22,
80
- value: props.name,
81
- size: tokens.typography.sizes.title,
82
- weight: 700,
83
- family: tokens.typography.display,
84
- })
85
-
86
- const badge = quantityBadge({x: w - pad, y: pad, quantity})
87
-
88
- const hullHeader = text({
89
- x: pad,
90
- y: pad + headerH,
91
- value: 'HULL',
92
- size: tokens.typography.sizes.subtitle,
93
- weight: 700,
94
- color: tokens.colors.text.secondary,
95
- letterSpacing: 1,
96
- })
97
-
98
- let y = pad + headerH + 6
99
- let hullSvg = ''
100
- for (const row of hullRows) {
101
- hullSvg +=
102
- text({
103
- x: pad,
104
- y: y + 12,
105
- value: row.label,
106
- size: tokens.typography.sizes.body,
107
- color: tokens.colors.text.secondary,
108
- }) +
109
- text({
110
- x: w - pad,
111
- y: y + 12,
112
- value: formatNumber(row.value),
113
- size: tokens.typography.sizes.body,
114
- weight: 700,
115
- anchor: 'end',
116
- }) +
117
- divider({
118
- x: pad,
119
- y: y + hullRowH - 4,
120
- width: innerW,
121
- color: tokens.colors.surface.panelBorderBright,
122
- })
123
- y += hullRowH
124
- }
125
-
126
- y += sectionGap
97
+ let y = slotsYStart
127
98
  let modulesSvg = ''
128
- for (let i = 0; i < props.slots.length; i++) {
129
- const slot = props.slots[i]!
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
130
104
  modulesSvg += moduleSlot({
131
105
  x: pad,
132
106
  y,
133
107
  width: innerW,
134
108
  installed: slot.installed,
135
- capability: slot.name,
109
+ capability: slot.capability ?? slot.name,
136
110
  description: slot.description,
137
- accentColor: tokens.colors.brand.teal,
111
+ accentColor,
138
112
  })
139
- y += rowHeights[i]!
140
- }
113
+ lastContentBottom = y + contentBottomOffsetFor(slot)
114
+ if (i < props.slots.length - 1) y += rowHeightFor(slot)
115
+ })
116
+
117
+ const height = lastContentBottom + BOTTOM_PAD
118
+
119
+ const chrome = panel({width: w, height, borderColor: tierBorder(props.tier)})
120
+
121
+ const icon = iconHex({
122
+ x: pad,
123
+ y: pad + ICON_Y,
124
+ color: ENTITY_COLOR,
125
+ code: 'SH',
126
+ })
127
+
128
+ const name = titleParts(pad + 34, pad + 22, props.name, props.tier)
129
+
130
+ const badge = quantityBadge({
131
+ x: w - pad,
132
+ y: pad + BADGE_Y,
133
+ quantity,
134
+ tone: ENTITY_COLOR,
135
+ })
141
136
 
142
- const inner = `${chrome}${icon}${name}${badge}${hullHeader}${hullSvg}${modulesSvg}`
137
+ const inner = `${chrome}${icon}${name}${badge}${metaSvg}${modulesSvg}`
143
138
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${height}" viewBox="0 0 ${w} ${height}">${inner}</svg>`
144
139
  }
@@ -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',