@shipload/item-renderer 0.1.0

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 (86) hide show
  1. package/.github/workflows/ci.yml +14 -0
  2. package/.gitignore +6 -0
  3. package/Makefile +50 -0
  4. package/biome.json +18 -0
  5. package/bun.lock +123 -0
  6. package/package.json +51 -0
  7. package/scripts/check-bundle-size.ts +37 -0
  8. package/scripts/copy-fonts.ts +41 -0
  9. package/scripts/preview.ts +43 -0
  10. package/src/errors.ts +22 -0
  11. package/src/fonts/index.ts +36 -0
  12. package/src/fonts/inter-400.woff2 +0 -0
  13. package/src/fonts/inter-600.woff2 +0 -0
  14. package/src/fonts/jetbrains-500.woff2 +0 -0
  15. package/src/fonts/load-bun.ts +16 -0
  16. package/src/fonts/orbitron-700.woff2 +0 -0
  17. package/src/index.ts +46 -0
  18. package/src/links.ts +19 -0
  19. package/src/meta.ts +42 -0
  20. package/src/payload/base64url.ts +27 -0
  21. package/src/payload/codec.ts +26 -0
  22. package/src/primitives/category-icon.ts +87 -0
  23. package/src/primitives/compact-row.ts +38 -0
  24. package/src/primitives/divider.ts +20 -0
  25. package/src/primitives/icon-hex.ts +39 -0
  26. package/src/primitives/module-slot.ts +147 -0
  27. package/src/primitives/panel.ts +24 -0
  28. package/src/primitives/quantity-badge.ts +37 -0
  29. package/src/primitives/span-paragraph.ts +72 -0
  30. package/src/primitives/stat-bar.ts +85 -0
  31. package/src/primitives/svg.ts +25 -0
  32. package/src/primitives/text.ts +42 -0
  33. package/src/primitives/wrap.ts +24 -0
  34. package/src/render.ts +33 -0
  35. package/src/templates/_shared.ts +15 -0
  36. package/src/templates/component.ts +139 -0
  37. package/src/templates/index.ts +30 -0
  38. package/src/templates/item-cell.ts +96 -0
  39. package/src/templates/module.ts +190 -0
  40. package/src/templates/packed-entity.ts +30 -0
  41. package/src/templates/resource.ts +151 -0
  42. package/src/templates/ship-panel.ts +145 -0
  43. package/src/tokens/colors.ts +45 -0
  44. package/src/tokens/index.ts +7 -0
  45. package/src/tokens/spacing.ts +10 -0
  46. package/src/tokens/typography.ts +19 -0
  47. package/test/__image_snapshots__/.gitkeep +0 -0
  48. package/test/__image_snapshots__/component-hull-plates.png +0 -0
  49. package/test/__image_snapshots__/module-engine-t1.png +0 -0
  50. package/test/__image_snapshots__/module-storage-t1.png +0 -0
  51. package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.png +0 -0
  52. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
  53. package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.png +0 -0
  54. package/test/__image_snapshots__/resource-iron.diff.png +0 -0
  55. package/test/__image_snapshots__/resource-iron.png +0 -0
  56. package/test/__snapshots__/templates-component.test.ts.snap +5 -0
  57. package/test/__snapshots__/templates-item-cell.test.ts.snap +9 -0
  58. package/test/__snapshots__/templates-module.test.ts.snap +17 -0
  59. package/test/__snapshots__/templates-packed-entity.test.ts.snap +5 -0
  60. package/test/__snapshots__/templates-resource.test.ts.snap +7 -0
  61. package/test/base64url.test.ts +33 -0
  62. package/test/codec.test.ts +43 -0
  63. package/test/errors.test.ts +24 -0
  64. package/test/fixtures/cargo-items.ts +122 -0
  65. package/test/fonts.test.ts +28 -0
  66. package/test/links-meta.test.ts +34 -0
  67. package/test/pixel.test.ts +66 -0
  68. package/test/primitives-category-icon.test.ts +79 -0
  69. package/test/primitives-compact-row.test.ts +44 -0
  70. package/test/primitives-domain.test.ts +72 -0
  71. package/test/primitives-layout.test.ts +56 -0
  72. package/test/primitives-module-slot.test.ts +88 -0
  73. package/test/render.test.ts +40 -0
  74. package/test/sanity.test.ts +6 -0
  75. package/test/sdk-link.test.ts +19 -0
  76. package/test/snapshots/.gitkeep +0 -0
  77. package/test/svg.test.ts +28 -0
  78. package/test/templates-component.test.ts +36 -0
  79. package/test/templates-dispatch.test.ts +35 -0
  80. package/test/templates-item-cell.test.ts +94 -0
  81. package/test/templates-module.test.ts +63 -0
  82. package/test/templates-packed-entity.test.ts +47 -0
  83. package/test/templates-resource.test.ts +71 -0
  84. package/test/templates-ship-panel.test.ts +87 -0
  85. package/test/tokens.test.ts +32 -0
  86. package/tsconfig.json +20 -0
@@ -0,0 +1,28 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { attr, el, escapeXml } from '../src/primitives/svg.ts'
3
+
4
+ test('escapeXml escapes the five XML entity chars', () => {
5
+ expect(escapeXml(`5 > 3 & 2 < 4 "hi" 'yo'`)).toBe(
6
+ '5 &gt; 3 &amp; 2 &lt; 4 &quot;hi&quot; &apos;yo&apos;',
7
+ )
8
+ })
9
+
10
+ test('attr emits name=value pairs with quoted values', () => {
11
+ expect(attr({ width: 10, height: 20, fill: '#123456' }))
12
+ .toBe(' width="10" height="20" fill="#123456"')
13
+ })
14
+
15
+ test('attr drops undefined and null values', () => {
16
+ expect(attr({ width: 10, height: undefined, stroke: null as unknown as string }))
17
+ .toBe(' width="10"')
18
+ })
19
+
20
+ test('attr escapes special chars in values', () => {
21
+ expect(attr({ label: `ship "fast"` })).toBe(' label="ship &quot;fast&quot;"')
22
+ })
23
+
24
+ test('el builds self-closing and wrapping elements', () => {
25
+ expect(el('rect', { width: 10, height: 10 })).toBe('<rect width="10" height="10"/>')
26
+ expect(el('g', {}, '<rect/><rect/>')).toBe('<g><rect/><rect/></g>')
27
+ expect(el('text', { x: 5 }, 'hi')).toBe('<text x="5">hi</text>')
28
+ })
@@ -0,0 +1,36 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { resolveItem } from '@shipload/sdk'
3
+ import { renderComponent } from '../src/templates/component.ts'
4
+ import { FIXTURES } from './fixtures/cargo-items.ts'
5
+
6
+ test('matches the committed Hull Plates snapshot', async () => {
7
+ const item = FIXTURES.hullPlates
8
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
9
+ const svg = renderComponent(item, resolved)
10
+ expect(svg).toMatchSnapshot('component-hull-plates.svg')
11
+ })
12
+
13
+ test('renderComponent ranges mode shows stat abbreviations with no values', () => {
14
+ const item = FIXTURES.hullPlates
15
+ const resolved = resolveItem(item.item_id)
16
+ const svg = renderComponent(item, resolved, { mode: 'ranges' })
17
+ expect(svg).toContain('STR')
18
+ expect(svg).toContain('DEN')
19
+ expect(svg).not.toMatch(/>\d{3}<\/text>/)
20
+ expect(svg).toContain('COMPONENT')
21
+ expect(svg).toContain('Mass')
22
+ })
23
+
24
+ test('renderComponent values mode (default) still shows concrete numbers', () => {
25
+ const item = FIXTURES.hullPlates
26
+ const resolved = resolveItem(item.item_id, item.stats)
27
+ const svg = renderComponent(item, resolved)
28
+ expect(svg).toMatch(/>\d+<\/text>/)
29
+ })
30
+
31
+ test('renderComponent ranges mode matches snapshot', () => {
32
+ const item = FIXTURES.hullPlates
33
+ const resolved = resolveItem(item.item_id)
34
+ const svg = renderComponent(item, resolved, { mode: 'ranges' })
35
+ expect(svg).toMatchSnapshot('component-ranges')
36
+ })
@@ -0,0 +1,35 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { resolveItem } from '@shipload/sdk'
3
+ import { renderByType } from '../src/templates/index.ts'
4
+ import { FIXTURES } from './fixtures/cargo-items.ts'
5
+
6
+ test('renderByType forwards mode=ranges to resource template', () => {
7
+ const item = FIXTURES.iron
8
+ const resolved = resolveItem(item.item_id)
9
+ const svg = renderByType(item, resolved, { mode: 'ranges' })
10
+ // No 3-digit stat values
11
+ expect(svg).not.toMatch(/>\d{3,}<\/text>/)
12
+ })
13
+
14
+ test('renderByType forwards mode=ranges to component template', () => {
15
+ const item = FIXTURES.hullPlates
16
+ const resolved = resolveItem(item.item_id)
17
+ const svg = renderByType(item, resolved, { mode: 'ranges' })
18
+ expect(svg).not.toMatch(/>\d{3,}<\/text>/)
19
+ expect(svg).toContain('COMPONENT')
20
+ })
21
+
22
+ test('renderByType forwards mode=ranges to module template', () => {
23
+ const item = FIXTURES.engineT1
24
+ const resolved = resolveItem(item.item_id)
25
+ const svg = renderByType(item, resolved, { mode: 'ranges' })
26
+ expect(svg).toContain('MODULE')
27
+ })
28
+
29
+ test('renderByType default mode is values (no opts)', () => {
30
+ const item = FIXTURES.iron
31
+ const resolved = resolveItem(item.item_id, item.stats)
32
+ const svg = renderByType(item, resolved)
33
+ // At least one numeric stat value
34
+ expect(svg).toMatch(/>\d+<\/text>/)
35
+ })
@@ -0,0 +1,94 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { resolveItem } from '@shipload/sdk'
3
+ import { ITEM_HULL_PLATES, ITEM_ENGINE_T1, ITEM_SHIP_T1_PACKED } from '@shipload/sdk'
4
+ import { renderItemCell, itemCellGroup } from '../src/templates/item-cell.ts'
5
+
6
+ test('renderItemCell returns a self-contained <svg>', () => {
7
+ const resolved = resolveItem(ITEM_HULL_PLATES)
8
+ const svg = renderItemCell({ resolved, size: 48 })
9
+ expect(svg.startsWith('<svg ')).toBe(true)
10
+ expect(svg).toContain('viewBox="0 0 48 60"')
11
+ expect(svg.endsWith('</svg>')).toBe(true)
12
+ })
13
+
14
+ test('component cell renders abbreviation', () => {
15
+ const resolved = resolveItem(ITEM_HULL_PLATES)
16
+ const svg = renderItemCell({ resolved, size: 48 })
17
+ expect(svg).toContain('>HP<')
18
+ })
19
+
20
+ test('module cell renders abbreviation', () => {
21
+ const resolved = resolveItem(ITEM_ENGINE_T1)
22
+ const svg = renderItemCell({ resolved, size: 48 })
23
+ expect(svg).toContain('>EN<')
24
+ })
25
+
26
+ test('entity cell renders abbreviation', () => {
27
+ const resolved = resolveItem(ITEM_SHIP_T1_PACKED)
28
+ const svg = renderItemCell({ resolved, size: 48 })
29
+ expect(svg).toContain('>SH<')
30
+ })
31
+
32
+ test('resource cell renders category icon (no abbreviation)', () => {
33
+ const resolved = resolveItem(26)
34
+ const svg = renderItemCell({ resolved, size: 48 })
35
+ expect(svg).not.toMatch(/>[A-Z]{2,3}</)
36
+ expect(svg).toMatch(/<(polygon|circle|rect)\b/)
37
+ })
38
+
39
+ test('quantity renders as plain bold number when quantity > 1', () => {
40
+ const resolved = resolveItem(ITEM_HULL_PLATES)
41
+ const svg = renderItemCell({ resolved, quantity: 42, size: 48 })
42
+ expect(svg).toContain('>42<')
43
+ expect(svg).not.toContain('×')
44
+ })
45
+
46
+ test('no quantity text when quantity is 1 or omitted', () => {
47
+ const resolved = resolveItem(ITEM_HULL_PLATES)
48
+ const svgNoQty = renderItemCell({ resolved, size: 48 })
49
+ const svgOne = renderItemCell({ resolved, quantity: 1, size: 48 })
50
+ expect(svgNoQty).not.toContain('>1<')
51
+ expect(svgOne).not.toContain('>1<')
52
+ })
53
+
54
+ test('itemCellGroup returns <g> with translate, no <svg> wrapper', () => {
55
+ const resolved = resolveItem(ITEM_HULL_PLATES)
56
+ const g = itemCellGroup({ resolved, size: 48, x: 100, y: 200 })
57
+ expect(g.startsWith('<g ')).toBe(true)
58
+ expect(g).toContain('transform="translate(100, 200)"')
59
+ expect(g.startsWith('<svg')).toBe(false)
60
+ })
61
+
62
+ test('tier border uses SDK tierColors for the resolved tier', () => {
63
+ const resolved = resolveItem(ITEM_HULL_PLATES)
64
+ const svg = renderItemCell({ resolved, size: 48 })
65
+ expect(svg).toContain('#8b8b8b')
66
+ })
67
+
68
+ test('abbreviation cell uses proportional font size for different sizes', () => {
69
+ const resolved = resolveItem(ITEM_HULL_PLATES)
70
+ const svg28 = renderItemCell({ resolved, size: 28 })
71
+ const svg80 = renderItemCell({ resolved, size: 80 })
72
+ expect(svg28).toContain('font-size="8"')
73
+ expect(svg80).toContain('font-size="22"')
74
+ })
75
+
76
+ test('resource icon renders in stroke-only mode', () => {
77
+ const resolved = resolveItem(26)
78
+ const svg = renderItemCell({ resolved, size: 48 })
79
+ expect(svg).toContain('fill="none"')
80
+ expect(svg).toContain('stroke-width="1.5"')
81
+ })
82
+
83
+ test('matches golden SVG snapshot per itemType', () => {
84
+ const cases: [number, string][] = [
85
+ [26, 'item-cell-resource'],
86
+ [ITEM_HULL_PLATES, 'item-cell-component'],
87
+ [ITEM_ENGINE_T1, 'item-cell-module'],
88
+ [ITEM_SHIP_T1_PACKED, 'item-cell-entity'],
89
+ ]
90
+ for (const [id, name] of cases) {
91
+ const svg = renderItemCell({ resolved: resolveItem(id), quantity: 3, size: 48 })
92
+ expect(svg).toMatchSnapshot(name)
93
+ }
94
+ })
@@ -0,0 +1,63 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { resolveItem } from '@shipload/sdk'
3
+ import { renderModule } from '../src/templates/module.ts'
4
+ import { FIXTURES } from './fixtures/cargo-items.ts'
5
+
6
+ const CASES = [
7
+ 'engineT1',
8
+ 'generatorT1',
9
+ 'gathererT1',
10
+ 'loaderT1',
11
+ 'crafterT1',
12
+ 'storageT1',
13
+ 'haulerT1',
14
+ ] as const
15
+
16
+ for (const name of CASES) {
17
+ test(`matches the committed ${name} snapshot`, async () => {
18
+ const item = FIXTURES[name]
19
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
20
+ const svg = renderModule(item, resolved)
21
+ expect(svg).toMatchSnapshot(`module-${name}.svg`)
22
+ })
23
+ }
24
+
25
+ test('Engine template embeds the narrative description', () => {
26
+ const item = FIXTURES.engineT1
27
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
28
+ const svg = renderModule(item, resolved)
29
+ expect(svg).toContain('generates')
30
+ expect(svg).toContain('thrust for travel')
31
+ expect(svg).toContain('while draining')
32
+ expect(svg).toContain('distance travelled')
33
+ })
34
+
35
+ test('Hauler template falls back to compact rows when description is null', () => {
36
+ const item = FIXTURES.haulerT1
37
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
38
+ const svg = renderModule(item, resolved)
39
+ expect(svg).toContain('Hauler')
40
+ })
41
+
42
+ test('renderModule ranges mode shows capability header without narrative or attribute values', () => {
43
+ const item = FIXTURES.engineT1
44
+ const resolved = resolveItem(item.item_id)
45
+ const svg = renderModule(item, resolved, { mode: 'ranges' })
46
+ expect(svg).toContain('ENGINE')
47
+ expect(svg).not.toMatch(/>\d{3,}<\/(text|tspan)>/)
48
+ expect(svg).toContain('MODULE')
49
+ })
50
+
51
+ test('renderModule values mode (default) still shows narrative', () => {
52
+ const item = FIXTURES.engineT1
53
+ const resolved = resolveItem(item.item_id, item.stats)
54
+ const svg = renderModule(item, resolved)
55
+ expect(svg).toMatch(/thrust|energy|generates/)
56
+ })
57
+
58
+ test('renderModule ranges mode matches snapshot', () => {
59
+ const item = FIXTURES.engineT1
60
+ const resolved = resolveItem(item.item_id)
61
+ const svg = renderModule(item, resolved, { mode: 'ranges' })
62
+ expect(svg).toMatchSnapshot('module-ranges')
63
+ })
@@ -0,0 +1,47 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { resolveItem } from '@shipload/sdk'
3
+ import { renderPackedEntity } from '../src/templates/packed-entity.ts'
4
+ import { FIXTURES } from './fixtures/cargo-items.ts'
5
+
6
+ test('renders Ship T1 with hull attributes and two modules', () => {
7
+ const item = FIXTURES.shipT1TwoModules
8
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
9
+ const svg = renderPackedEntity(item, resolved)
10
+ expect(svg).toContain('Ship T1 (Packed)')
11
+ expect(svg).toContain('HULL')
12
+ expect(svg).toContain('Mass')
13
+ expect(svg).toContain('Capacity')
14
+ expect(svg).toContain('Engine')
15
+ expect(svg).toContain('Generator')
16
+ })
17
+
18
+ test('renders empty-module rows when slots are unfilled', () => {
19
+ const item = FIXTURES.shipT1NoModules
20
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
21
+ const svg = renderPackedEntity(item, resolved)
22
+ expect(svg.match(/Empty module/g)?.length ?? 0).toBeGreaterThanOrEqual(1)
23
+ })
24
+
25
+ test('matches the committed Ship T1 (two modules) snapshot', async () => {
26
+ const item = FIXTURES.shipT1TwoModules
27
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
28
+ const svg = renderPackedEntity(item, resolved)
29
+ expect(svg).toMatchSnapshot('packed-entity-ship-t1-two-modules.svg')
30
+ })
31
+
32
+ test('matches the committed Ship T1 (only engine) snapshot', async () => {
33
+ const item = FIXTURES.shipT1OnlyEngine
34
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
35
+ const svg = renderPackedEntity(item, resolved)
36
+ expect(svg).toMatchSnapshot('packed-entity-ship-t1-only-engine.svg')
37
+ })
38
+
39
+ test('ship with two modules renders SDK-sourced narrative descriptions', () => {
40
+ const item = FIXTURES.shipT1TwoModules
41
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
42
+ const svg = renderPackedEntity(item, resolved)
43
+ expect(svg).toContain('Engine: ')
44
+ expect(svg).toContain('generates')
45
+ expect(svg).toContain('Generator: ')
46
+ expect(svg).toContain('holds')
47
+ })
@@ -0,0 +1,71 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { resolveItem, getStatDefinitions } from '@shipload/sdk'
3
+ import { renderResource } from '../src/templates/resource.ts'
4
+ import { FIXTURES } from './fixtures/cargo-items.ts'
5
+
6
+ test('renders Iron with category, mass, and three stat bars', () => {
7
+ const item = FIXTURES.iron
8
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
9
+ const svg = renderResource(item, resolved)
10
+ expect(svg).toContain('Iron')
11
+ expect(svg).toContain('Metals') // category label
12
+ expect(svg).toContain('30,000') // mass
13
+ expect(svg).toContain('STR') // strength abbreviation
14
+ expect(svg).toContain('TOL')
15
+ expect(svg).toContain('DEN')
16
+ })
17
+
18
+ test('renders quantity badge when stack > 1', () => {
19
+ const item = FIXTURES.ironStackOf50
20
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
21
+ const svg = renderResource(item, resolved)
22
+ expect(svg).toContain('×50')
23
+ })
24
+
25
+ test('does not render quantity badge when stack == 1', () => {
26
+ const item = FIXTURES.iron
27
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
28
+ const svg = renderResource(item, resolved)
29
+ expect(svg).not.toContain('×')
30
+ })
31
+
32
+ test('matches the committed Iron snapshot', async () => {
33
+ const item = FIXTURES.iron
34
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
35
+ const svg = renderResource(item, resolved)
36
+ expect(svg).toMatchSnapshot('resource-iron.svg')
37
+ })
38
+
39
+ test('matches the committed Helium snapshot', async () => {
40
+ const item = FIXTURES.helium
41
+ const resolved = resolveItem(item.item_id, item.stats, item.modules)
42
+ const svg = renderResource(item, resolved)
43
+ expect(svg).toMatchSnapshot('resource-helium.svg')
44
+ })
45
+
46
+ test('renderResource ranges mode shows stat abbreviations with no values', () => {
47
+ const item = FIXTURES.iron
48
+ const resolved = resolveItem(item.item_id)
49
+ const svg = renderResource(item, resolved, { mode: 'ranges' })
50
+ const defs = getStatDefinitions(resolved.category!)
51
+ for (const def of defs) {
52
+ expect(svg).toContain(def.abbreviation)
53
+ }
54
+ expect(svg).not.toMatch(/>\d{3}<\/text>/)
55
+ expect(svg).toContain('Category')
56
+ expect(svg).toContain('Mass')
57
+ })
58
+
59
+ test('renderResource values mode (default) still shows concrete numbers', () => {
60
+ const item = FIXTURES.iron
61
+ const resolved = resolveItem(item.item_id, item.stats)
62
+ const svg = renderResource(item, resolved)
63
+ expect(svg).toMatch(/>\d+<\/text>/)
64
+ })
65
+
66
+ test('renderResource ranges mode matches snapshot', () => {
67
+ const item = FIXTURES.iron
68
+ const resolved = resolveItem(item.item_id)
69
+ const svg = renderResource(item, resolved, { mode: 'ranges' })
70
+ expect(svg).toMatchSnapshot('resource-ranges')
71
+ })
@@ -0,0 +1,87 @@
1
+ import { test, expect } from 'bun:test'
2
+ import { renderShipPanel } from '../src/templates/ship-panel.ts'
3
+
4
+ test('renderShipPanel with empty slots renders empty module rows', () => {
5
+ const svg = renderShipPanel({
6
+ name: 'Ship T1 (Packed)',
7
+ tier: 't1',
8
+ attributes: [{
9
+ capability: 'Hull',
10
+ attributes: [
11
+ { label: 'Mass', value: 100 },
12
+ { label: 'Capacity', value: 5000 },
13
+ ],
14
+ }],
15
+ slots: [
16
+ { installed: false },
17
+ { installed: false },
18
+ { installed: false },
19
+ ],
20
+ })
21
+ expect(svg).toContain('Ship T1 (Packed)')
22
+ expect(svg).toContain('HULL')
23
+ expect(svg).toContain('Mass')
24
+ expect(svg).toContain('Capacity')
25
+ expect((svg.match(/Empty module/g) ?? []).length).toBe(3)
26
+ })
27
+
28
+ test('renderShipPanel with installed slots + string descriptions', () => {
29
+ const svg = renderShipPanel({
30
+ name: 'Ship T1 (Packed)',
31
+ tier: 't1',
32
+ attributes: [{
33
+ capability: 'Hull',
34
+ attributes: [{ label: 'Mass', value: 100 }],
35
+ }],
36
+ slots: [
37
+ { name: 'Engine', installed: true, description: 'generates 500 thrust for travel' },
38
+ { name: 'Generator', installed: true, description: 'holds 1000 energy' },
39
+ ],
40
+ })
41
+ expect(svg).toContain('Engine:')
42
+ expect(svg).toContain('generates 500 thrust')
43
+ expect(svg).toContain('Generator:')
44
+ expect(svg).toContain('holds 1000 energy')
45
+ })
46
+
47
+ test('renderShipPanel with TextSpan[] descriptions preserves highlights', () => {
48
+ const svg = renderShipPanel({
49
+ name: 'Ship T1 (Packed)',
50
+ tier: 't1',
51
+ attributes: [{
52
+ capability: 'Hull',
53
+ attributes: [{ label: 'Mass', value: 100 }],
54
+ }],
55
+ slots: [
56
+ {
57
+ name: 'Engine',
58
+ installed: true,
59
+ description: [
60
+ { text: 'generates ' },
61
+ { text: '700', highlight: true },
62
+ { text: ' thrust for travel' },
63
+ ],
64
+ },
65
+ ],
66
+ })
67
+ expect(svg).toContain('>700<')
68
+ expect(svg).toContain('generates')
69
+ })
70
+
71
+ test('renderShipPanel mixed slots (installed + empty)', () => {
72
+ const svg = renderShipPanel({
73
+ name: 'Ship T1 (Packed)',
74
+ tier: 't1',
75
+ attributes: [{
76
+ capability: 'Hull',
77
+ attributes: [{ label: 'Mass', value: 100 }],
78
+ }],
79
+ slots: [
80
+ { name: 'Engine', installed: true, description: 'generates 500 thrust' },
81
+ { installed: false },
82
+ { installed: false },
83
+ ],
84
+ })
85
+ expect(svg).toContain('Engine:')
86
+ expect((svg.match(/Empty module/g) ?? []).length).toBe(2)
87
+ })
@@ -0,0 +1,32 @@
1
+ import { expect, test } from 'bun:test'
2
+ import { tierColors as sdkTierColors } from '@shipload/sdk'
3
+ import { tokens } from '../src/tokens/index.ts'
4
+
5
+ test('colors include all resource categories', () => {
6
+ for (const cat of ['metal', 'gas', 'mineral', 'organic', 'precious']) {
7
+ expect(tokens.colors.category).toHaveProperty(cat)
8
+ expect(tokens.colors.category[cat as keyof typeof tokens.colors.category]).toMatch(/^#[0-9a-f]{6}$/i)
9
+ }
10
+ })
11
+
12
+ test('colors.tier is sourced from SDK tierColors', () => {
13
+ expect(tokens.colors.tier).toEqual(sdkTierColors)
14
+ })
15
+
16
+ test('colors include all tiers t1..t5 with SDK values', () => {
17
+ expect(tokens.colors.tier.t1).toBe('#8b8b8b')
18
+ expect(tokens.colors.tier.t2).toBe('#4ade80')
19
+ expect(tokens.colors.tier.t3).toBe('#818cf8')
20
+ expect(tokens.colors.tier.t4).toBe('#c084fc')
21
+ expect(tokens.colors.tier.t5).toBe('#fbbf24')
22
+ })
23
+
24
+ test('typography names three font stacks', () => {
25
+ expect(tokens.typography.display).toContain('Orbitron')
26
+ expect(tokens.typography.sans).toContain('Inter')
27
+ expect(tokens.typography.mono).toContain('JetBrains Mono')
28
+ })
29
+
30
+ test('spacing has a default panel width', () => {
31
+ expect(tokens.spacing.panelWidth).toBe(280)
32
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "noUncheckedIndexedAccess": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true,
12
+ "isolatedModules": true,
13
+ "resolveJsonModule": true,
14
+ "allowImportingTsExtensions": true,
15
+ "verbatimModuleSyntax": false,
16
+ "experimentalDecorators": true,
17
+ "types": ["bun-types"]
18
+ },
19
+ "include": ["src", "test", "scripts"]
20
+ }