featureparity 0.0.22 → 0.0.24

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b06053deae8d893e793e13d2dd56773db5ff1ce04d2cc5f2be40b65da3520d11
4
- data.tar.gz: 0c7d0b2ae20e481fedab84bf188d7a3b8c3ae47d91c0b16c816d49e697d2f16a
3
+ metadata.gz: 4e68b70dce16d28e928ead4413623002ccda97f4508f62bbe6d980c8bb29a21c
4
+ data.tar.gz: e630a9bd6f49db70c343f6374cd3c049a9c9fb4b786251dd313b76a177e0a1f9
5
5
  SHA512:
6
- metadata.gz: b8f6ae3cedd0abb1c6a1cc248092d83f1643858d08aee76bd4d6707f1a5072bbc1f06d0ba83ed35cc79cd36afd24b543e625d58c6f9044c102f47b77a4cc2d79
7
- data.tar.gz: c938e91cd9bbf92197bb0a85dcb6488d0c0621ed6760f12bdac0e4c23002bac09ca7232d3a12d93618011261d90150f98a02dc861e0f2b893bc8ac9f003d0b10
6
+ metadata.gz: 55bde2507b4cfccbe49b8d799d0b3e7efba22c0298450e823a3ac86299ec4d392b0e51f13c3c643a2abc1d60f1d68d091074420c0928fbbcf9da6947fea296d1
7
+ data.tar.gz: c5d9754313a59721bfc78f5d4e65d4924ef1f5120659037cace19e6ee816a96dea0e467f47ad285cf03003ba37ed836501754c5592f9b22385f5a52315be5cae
data/lib/fp/client.rb CHANGED
@@ -96,6 +96,54 @@ module Fp
96
96
  get("/api/projects/#{project_id}/showroom/share_link")
97
97
  end
98
98
 
99
+ # --- Showroom media ---
100
+ #
101
+ # Screenshots and videos on the client showroom: on a surface's card, or in the
102
+ # project gallery under the welcome note (surface_id nil). Uploaded the same way as
103
+ # attachments — presign, PUT the bytes, then confirm.
104
+
105
+ # GET /projects/:id/showroom/media
106
+ def list_showroom_media(project_id)
107
+ get("/api/projects/#{project_id}/showroom/media")
108
+ end
109
+
110
+ # POST /projects/:id/showroom/media/presign
111
+ def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil, purpose: nil)
112
+ body = { filename: filename, content_type: content_type, size_bytes: size_bytes }
113
+ body[:surface_id] = surface_id if surface_id
114
+ body[:purpose] = purpose if purpose
115
+ post("/api/projects/#{project_id}/showroom/media/presign", body)
116
+ end
117
+
118
+ # POST /projects/:id/showroom/media
119
+ # Confirm an upload and create the media record.
120
+ def create_showroom_media(project_id, media_id:, s3_key:, filename:, content_type:, size_bytes:,
121
+ surface_id: nil, caption: nil, position: nil, purpose: nil)
122
+ body = {
123
+ media_id: media_id,
124
+ s3_key: s3_key,
125
+ filename: filename,
126
+ content_type: content_type,
127
+ size_bytes: size_bytes
128
+ }
129
+ body[:surface_id] = surface_id if surface_id
130
+ body[:caption] = caption if caption
131
+ body[:position] = position if position
132
+ body[:purpose] = purpose if purpose
133
+ post("/api/projects/#{project_id}/showroom/media", body)
134
+ end
135
+
136
+ # PUT /projects/:id/showroom/media/:media_id
137
+ # { caption?, position? } — an empty caption clears it, position 0 clears it.
138
+ def update_showroom_media(project_id, media_id, params)
139
+ put("/api/projects/#{project_id}/showroom/media/#{media_id}", params)
140
+ end
141
+
142
+ # DELETE /projects/:id/showroom/media/:media_id
143
+ def delete_showroom_media(project_id, media_id)
144
+ delete("/api/projects/#{project_id}/showroom/media/#{media_id}")
145
+ end
146
+
99
147
  # GET /requirements
100
148
  def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
101
149
  params = {}
@@ -212,13 +212,6 @@ module Fp
212
212
  output.error(message)
213
213
  1
214
214
  end
215
-
216
- def format_size(bytes)
217
- return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
218
- return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
219
-
220
- "#{bytes} bytes"
221
- end
222
215
  end
223
216
  end
224
217
  end
@@ -239,6 +239,14 @@ module Fp
239
239
  opts[:project]
240
240
  end
241
241
 
242
+ # "12.3 MB" / "4.0 KB" / "512 bytes", for upload limits and file listings.
243
+ def format_size(bytes)
244
+ return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
245
+ return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
246
+
247
+ "#{bytes} bytes"
248
+ end
249
+
242
250
  def truncate(str, max)
243
251
  return str if str.nil? || str.length <= max
244
252
 
@@ -50,6 +50,10 @@ module Fp
50
50
  surfaces edit <key> --project ... Change a surface (name, kind, repo, key, showroom card...)
51
51
  surfaces delete <key> --project . Delete a surface (asks first; --yes to skip)
52
52
  showroom --project <slug> Preview the client showroom as a client sees it
53
+ showroom media --project <slug> List the showroom's screenshots and videos
54
+ showroom media add <file> ... Add an image/video to a surface card (--surface) or the gallery
55
+ showroom media edit <id> ... Change a media item's --caption or --position
56
+ showroom media remove <id> --yes Take a media item off the showroom
53
57
  list --project <slug> List active requirements
54
58
  show <slug> --project <slug> Show requirement details
55
59
  propose --project <slug> ... Propose a new requirement (as draft)
@@ -106,10 +110,13 @@ module Fp
106
110
  fp surfaces edit web --showroom-url https://dev.example.com \\
107
111
  --showroom-label "Open the web app" \\
108
112
  --showroom-instructions-file docs/showroom/web.txt
113
+ fp showroom media add e2e/checkout.webm --surface web --caption "Checking out"
114
+ fp showroom media add overview.png # no --surface: the gallery under the welcome
109
115
  fp showroom # what the client will see, and which surfaces are hidden
110
116
 
111
- A surface is shown once it has a link or instructions; pass an empty value
112
- to clear a field (`--showroom-url ""`) and a surface with neither drops out.
117
+ A surface is shown once it has a link, instructions, or media; pass an empty
118
+ value to clear a field (`--showroom-url ""`) and a surface with none drops out.
119
+ Media is images and video only (png, jpg, gif, webp, mp4, webm, mov; 100 MB).
113
120
  Leaving feedback and turning the share link on or off are for humans, in the
114
121
  web app under Project Settings → Showroom.
115
122
 
@@ -14,15 +14,38 @@ module Fp
14
14
  #
15
15
  # Whether the share link is on is a human's business — the API refuses an agent's
16
16
  # key — so it is reported as unknown rather than guessed at.
17
+ #
18
+ # `fp showroom media ...` manages the screenshots and videos on the showroom — a
19
+ # surface's card, or the gallery under the welcome note — and is handled by
20
+ # ShowroomMedia; its flags are documented here so `fp showroom --help` covers both.
17
21
  class Showroom < Base
18
- help_summary 'Preview the client showroom exactly as a client will see it'
19
- help_usage 'fp showroom --project <slug>'
22
+ help_summary 'Preview the client showroom exactly as a client will see it, and manage its media'
23
+ help_usage 'fp showroom --project <slug>',
24
+ 'fp showroom media --project <slug>',
25
+ 'fp showroom media add <file> --project <slug> [--surface <key>] [--caption "..."] [--position N]',
26
+ 'fp showroom media add <image> --project <slug> --surface <key> --icon',
27
+ 'fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]',
28
+ 'fp showroom media remove <id> --project <slug> [--yes]'
20
29
  help_flag :project, 'Project slug (falls back to .featureparity.yml)'
30
+ help_flag :surface, "media add: the surface whose card shows it (omit for the project gallery)"
31
+ help_flag :caption, 'media add/edit: text shown under the image or video (edit: empty clears)'
32
+ help_flag :position, 'media add/edit: slot within its card or the gallery, 1 is first; others shift (edit: empty moves it last)'
33
+ help_flag :yes, 'media remove: skip the typed-filename confirmation'
21
34
  help_example 'fp showroom --project stowzilla'
22
- help_note "Prints the welcome note and each surface's card, then the surfaces that are NOT shown and why.\n" \
35
+ help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
36
+ help_example 'fp showroom media add overview.png --project stowzilla --position 1'
37
+ help_example 'fp showroom media add app-icon.png --project stowzilla --surface ios --icon'
38
+ help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
39
+ help_example 'fp showroom media remove <id> --project stowzilla --yes'
40
+ help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
23
41
  "Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
42
+ "Media: png, jpg, gif, webp, mp4, webm, mov; max 100 MB. SVG and PDF are refused.\n" \
24
43
  'The share link is reported as unknown: only a person can see or change it.'
25
44
  def run(args)
45
+ if args.first == 'media'
46
+ return ShowroomMedia.new(client: client, output: output, config: config).run(args.drop(1))
47
+ end
48
+
26
49
  opts, = parse_flags(args)
27
50
  require_project!(opts)
28
51
 
@@ -46,15 +69,16 @@ module Fp
46
69
 
47
70
  private
48
71
 
49
- # Surfaces with neither a link nor instructions. The showroom payload leaves them
50
- # out, so they are read from the surface list instead.
72
+ # Surfaces with no link, no instructions, and no media. The showroom payload leaves
73
+ # them out (and includes a media-only surface), so whatever the payload doesn't
74
+ # carry is hidden; they are read from the surface list.
51
75
  def hidden_surfaces(project_id, shown_ids)
52
76
  result = client.list_surfaces(project_id)
53
77
  return [] unless result[:ok]
54
78
 
55
79
  (result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
56
80
  { 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
57
- 'reason' => 'no showroom link or instructions' }
81
+ 'reason' => 'no showroom link or instructions, and no media' }
58
82
  end
59
83
  end
60
84
 
@@ -88,9 +112,16 @@ module Fp
88
112
  end
89
113
  puts
90
114
 
115
+ gallery = showroom['gallery'] || []
116
+ unless gallery.empty?
117
+ puts "Gallery (#{media_summary(gallery)}):"
118
+ print_media(gallery, indent: ' ')
119
+ puts
120
+ end
121
+
91
122
  surfaces = showroom['surfaces'] || []
92
123
  if surfaces.empty?
93
- puts 'Surfaces shown: none. The client sees only the welcome note.'
124
+ puts "Surfaces shown: none. The client sees only the welcome note#{gallery.empty? ? '' : ' and the gallery'}."
94
125
  else
95
126
  puts "Surfaces shown (#{surfaces.size}):"
96
127
  surfaces.each { |surface| print_card(surface) }
@@ -104,6 +135,7 @@ module Fp
104
135
  end
105
136
  puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
106
137
  '--showroom-instructions-file ...'
138
+ puts " or: fp showroom media add <file> --project #{project} --surface <key>"
107
139
  end
108
140
 
109
141
  puts
@@ -130,6 +162,29 @@ module Fp
130
162
  else
131
163
  puts ' Instructions: (none)'
132
164
  end
165
+ media = surface['media'] || []
166
+ if media.empty?
167
+ puts ' Media: (none)'
168
+ else
169
+ puts " Media: #{media_summary(media)}"
170
+ print_media(media, indent: ' ')
171
+ end
172
+ end
173
+
174
+ # "2 images, 1 video"
175
+ def media_summary(media)
176
+ counts = media.group_by { |item| item['media_kind'] == 'video' ? 'video' : 'image' }
177
+ %w[image video].filter_map do |kind|
178
+ count = counts.fetch(kind, []).size
179
+ "#{count} #{kind}#{count == 1 ? '' : 's'}" if count.positive?
180
+ end.join(', ')
181
+ end
182
+
183
+ def print_media(media, indent:)
184
+ media.each do |item|
185
+ caption = text_or_nil(item['caption'])
186
+ puts "#{indent}- #{item['media_kind'] || 'image'}: #{caption || '(no caption)'}"
187
+ end
133
188
  end
134
189
 
135
190
  def text_or_nil(value)
@@ -0,0 +1,366 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+ require_relative 'attach'
5
+
6
+ module Fp
7
+ module Commands
8
+ # fp showroom media [--project <slug>] - list the showroom's media
9
+ # fp showroom media add <file> [--surface <key>] [--caption] [--position N]
10
+ # fp showroom media add <image> --surface <key> --icon - set the surface's icon
11
+ # fp showroom media edit <id> [--caption "..."] [--position N]
12
+ # fp showroom media remove <id> [--yes]
13
+ #
14
+ # Plenty of clients never open the demo, so the showroom can show them the thing
15
+ # instead: screenshots or a short clip on a surface's card, or in the project gallery
16
+ # under the welcome note (no --surface). The agent that ran the e2e suite already has
17
+ # the captures, so it puts them there rather than leaving a human to re-upload them.
18
+ #
19
+ # Dispatched from `fp showroom media ...`; its help lives on `fp showroom --help`.
20
+ # Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
21
+ # types — images and videos only. SVG can carry script and PDFs don't render inline,
22
+ # so both are refused before anything is sent.
23
+ #
24
+ # `--icon` (Fizzy #1499) makes the image the surface's own icon in place of the
25
+ # built-in platform logo: one per surface, a new one replaces the old, and removing
26
+ # it brings the built-in logo back.
27
+ class ShowroomMedia < Base
28
+ CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
29
+ MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
30
+ MAX_CAPTION_LENGTH = 500
31
+ ICON_EXTENSIONS = %w[png jpg jpeg gif webp].freeze
32
+ MAX_ICON_SIZE = 5 * 1024 * 1024
33
+
34
+ LIST_FLAGS = { project: :string }.freeze
35
+ ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string, icon: :boolean }.freeze
36
+ EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
37
+ REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
38
+
39
+ def run(args)
40
+ case args.first
41
+ when 'add'
42
+ add(args[1..])
43
+ when 'edit'
44
+ edit(args[1..])
45
+ when 'remove', 'rm', 'delete'
46
+ remove(args[1..])
47
+ else
48
+ list(args)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ # Gallery first, then each surface's media in the order a client sees the cards.
55
+ def list(args)
56
+ opts, = parse_flags(args, LIST_FLAGS)
57
+ require_project!(opts)
58
+ project_id = resolve_project_id(opts[:project])
59
+
60
+ media = fetch!(client.list_showroom_media(project_id))['media'] || []
61
+ groups = group_media(media, surfaces_for(project_id))
62
+
63
+ flat = groups.flat_map do |group|
64
+ group[:items].map { |item| item.merge('surface_key' => group[:key]) }
65
+ end
66
+
67
+ output.success({ media: flat, project: opts[:project] }) do
68
+ if media.empty?
69
+ puts "No showroom media in project '#{opts[:project]}'."
70
+ puts "Add some with: fp showroom media add <file> --project #{opts[:project]} [--surface <key>]"
71
+ else
72
+ puts "Showroom media for '#{opts[:project]}' (#{media.size}):"
73
+ groups.each do |group|
74
+ puts
75
+ puts "#{group[:title]} (#{group[:items].size}):"
76
+ group[:items].each { |item| print_item_line(item) }
77
+ end
78
+ end
79
+ end
80
+ end
81
+
82
+ def add(args)
83
+ opts, positional = parse_flags(args, ADD_FLAGS)
84
+ path = positional.first
85
+ if path.nil? || path.strip.empty?
86
+ output.error('Usage: fp showroom media add <file> --project <slug> [--surface <key>] ' \
87
+ '[--caption "..."] [--position N]')
88
+ exit 1
89
+ end
90
+
91
+ # Everything that can be checked locally is checked before a byte is sent.
92
+ file = prepare_file!(path)
93
+ check_icon!(file, opts) if opts[:icon]
94
+ caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
95
+ caption = nil if caption&.empty?
96
+ check_caption!(caption) if caption
97
+ position = position!(opts[:position], clear: nil) if opts.key?(:position)
98
+ if opts.key?(:surface) && flag_text(opts[:surface]).strip.empty?
99
+ output.error('--surface needs a surface key. Omit it to add to the project gallery.')
100
+ exit 1
101
+ end
102
+
103
+ require_project!(opts)
104
+ project_id = resolve_project_id(opts[:project])
105
+ surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
106
+ purpose = opts[:icon] ? 'icon' : nil
107
+
108
+ presign = fetch!(client.presign_showroom_media(
109
+ project_id,
110
+ filename: file[:filename], content_type: file[:content_type],
111
+ size_bytes: file[:size], surface_id: surface&.dig('id'), purpose: purpose
112
+ ), prefix: 'Failed to get upload URL')['presign'] || {}
113
+
114
+ uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
115
+ fetch!(uploaded, prefix: 'Upload failed')
116
+
117
+ item = fetch!(client.create_showroom_media(
118
+ project_id,
119
+ media_id: presign['media_id'], s3_key: presign['s3_key'],
120
+ filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
121
+ surface_id: surface&.dig('id'), caption: caption, position: position, purpose: purpose
122
+ ), prefix: 'Failed to confirm upload')['media'] || {}
123
+
124
+ where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
125
+ output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
126
+ if purpose
127
+ puts "Set #{file[:filename]} as the icon for #{where} in project '#{opts[:project]}'."
128
+ puts "Remove it to go back to the built-in logo: fp showroom media remove #{item['id']} " \
129
+ "--project #{opts[:project]}"
130
+ else
131
+ puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
132
+ print_item_detail(item)
133
+ end
134
+ end
135
+ end
136
+
137
+ def edit(args)
138
+ opts, positional = parse_flags(args, EDIT_FLAGS)
139
+ media_id = positional.first
140
+ if media_id.nil? || media_id.strip.empty?
141
+ output.error('Usage: fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]')
142
+ exit 1
143
+ end
144
+
145
+ # Only what was passed is sent. Empty caption clears it. Positions are kept dense
146
+ # (1..n) per card or gallery: N inserts at slot N and shifts the rest, and an empty
147
+ # value — sent as 0, since the API types it as an integer — moves the item to the end.
148
+ params = {}
149
+ if opts.key?(:caption)
150
+ params[:caption] = flag_text(opts[:caption]).strip
151
+ check_caption!(params[:caption])
152
+ end
153
+ params[:position] = position!(opts[:position], clear: 0) if opts.key?(:position)
154
+
155
+ if params.empty?
156
+ output.error('Nothing to change. Pass --caption and/or --position (an empty value clears either).')
157
+ exit 1
158
+ end
159
+
160
+ require_project!(opts)
161
+ project_id = resolve_project_id(opts[:project])
162
+ item = fetch!(client.update_showroom_media(project_id, media_id, params))['media'] || {}
163
+
164
+ output.success({ media: item, project: opts[:project] }) do
165
+ puts "Updated showroom media #{media_id} in project '#{opts[:project]}'."
166
+ print_item_detail(item)
167
+ end
168
+ end
169
+
170
+ def remove(args)
171
+ opts, positional = parse_flags(args, REMOVE_FLAGS)
172
+ media_id = positional.first
173
+ if media_id.nil? || media_id.strip.empty?
174
+ output.error('Usage: fp showroom media remove <id> --project <slug> [--yes]')
175
+ exit 1
176
+ end
177
+
178
+ require_project!(opts)
179
+ project_id = resolve_project_id(opts[:project])
180
+
181
+ # Read it first: the confirmation names the file, and an unknown id fails here
182
+ # rather than as a bare 404 from the delete.
183
+ media = fetch!(client.list_showroom_media(project_id))['media'] || []
184
+ item = media.find { |m| m['id'] == media_id }
185
+ unless item
186
+ output.error("Showroom media '#{media_id}' not found in project '#{opts[:project]}'. " \
187
+ "List it with: fp showroom media --project #{opts[:project]}", status: 404)
188
+ exit 1
189
+ end
190
+
191
+ confirm_remove!(item, opts)
192
+ fetch!(client.delete_showroom_media(project_id, media_id), prefix: 'Failed to remove showroom media')
193
+
194
+ output.success({ deleted: true, media: { 'id' => media_id, 'filename' => item['filename'] },
195
+ project: opts[:project] }) do
196
+ puts "Removed #{item['filename']} (#{media_id}) from the showroom of project '#{opts[:project]}'."
197
+ end
198
+ end
199
+
200
+ # Same guard as `fp surfaces delete`: --yes skips the prompt, an interactive
201
+ # terminal types the filename back, and anything else is refused.
202
+ def confirm_remove!(item, opts)
203
+ return if opts[:yes]
204
+
205
+ unless interactive?
206
+ output.error("Refusing to remove '#{item['filename']}' without confirmation. " \
207
+ 'Re-run with --yes to confirm from a non-interactive session.')
208
+ exit 1
209
+ end
210
+
211
+ warn "This will remove '#{item['filename']}' from the showroom of project '#{opts[:project]}'. " \
212
+ 'Clients stop seeing it at once.'
213
+ $stderr.print "Type '#{item['filename']}' to confirm: "
214
+ $stderr.flush
215
+
216
+ typed = $stdin.gets&.strip
217
+ return if typed == item['filename']
218
+
219
+ output.error('Aborted. The filename did not match; nothing was removed.')
220
+ exit 1
221
+ end
222
+
223
+ def interactive?
224
+ $stdin.respond_to?(:tty?) && $stdin.tty?
225
+ end
226
+
227
+ # { path:, filename:, content_type:, size: }, or exit with the reason it can't go up.
228
+ def prepare_file!(path)
229
+ unless File.file?(path)
230
+ output.error("File not found: #{path}")
231
+ exit 1
232
+ end
233
+
234
+ filename = File.basename(path)
235
+ extension = File.extname(filename).delete('.').downcase
236
+ content_type = CONTENT_TYPES[extension]
237
+ unless content_type
238
+ why = extension == 'svg' ? ' (SVG can carry script)' : ''
239
+ output.error("Unsupported file type: .#{extension}#{why}. The showroom takes images " \
240
+ "(png, jpg, jpeg, gif, webp) and videos (mp4, webm, mov) only.")
241
+ exit 1
242
+ end
243
+
244
+ size = File.size(path)
245
+ if size.zero?
246
+ output.error("File is empty: #{path}")
247
+ exit 1
248
+ end
249
+ if size > MAX_FILE_SIZE
250
+ output.error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
251
+ exit 1
252
+ end
253
+
254
+ { path: path, filename: filename, content_type: content_type, size: size }
255
+ end
256
+
257
+ # An icon is a still picture on one surface, with nothing to caption or order.
258
+ def check_icon!(file, opts)
259
+ problem =
260
+ if flag_text(opts[:surface]).strip.empty? then '--icon needs --surface <key>: an icon belongs to a surface.'
261
+ elsif opts.key?(:caption) || opts.key?(:position) then '--icon takes no --caption or --position.'
262
+ elsif !ICON_EXTENSIONS.include?(File.extname(file[:filename]).delete('.').downcase)
263
+ 'An icon must be an image (png, jpg, jpeg, gif, webp).'
264
+ elsif file[:size] > MAX_ICON_SIZE
265
+ "Icon too large: #{format_size(file[:size])} (max #{format_size(MAX_ICON_SIZE)})"
266
+ end
267
+ return unless problem
268
+
269
+ output.error(problem)
270
+ exit 1
271
+ end
272
+
273
+ def check_caption!(caption)
274
+ return if caption.length <= MAX_CAPTION_LENGTH
275
+
276
+ output.error("--caption is #{caption.length} characters; the limit is #{MAX_CAPTION_LENGTH}.")
277
+ exit 1
278
+ end
279
+
280
+ # 1 is first. An empty value returns `clear` (nil on add — it goes at the end
281
+ # anyway — and 0 on edit, which moves it to the end); anything else must be a whole
282
+ # number of 1 or more.
283
+ def position!(value, clear:)
284
+ text = flag_text(value).strip
285
+ return clear if text.empty?
286
+
287
+ position = Integer(text, 10, exception: false)
288
+ return position if position&.positive?
289
+
290
+ output.error("Invalid --position '#{text}'. Must be a whole number of 1 or more; " \
291
+ 'pass an empty value to clear it.')
292
+ exit 1
293
+ end
294
+
295
+ # A string flag given with no value parses as `true`; treat that as empty.
296
+ def flag_text(value)
297
+ value == true ? '' : value.to_s
298
+ end
299
+
300
+ def surfaces_for(project_id)
301
+ result = client.list_surfaces(project_id)
302
+ result[:ok] ? (result[:data]['surfaces'] || []) : []
303
+ end
304
+
305
+ def find_surface!(project_id, key)
306
+ fetch!(client.find_surface_by_key(project_id, key))['surface']
307
+ end
308
+
309
+ # [{ title:, key:, items: }] — the gallery, then each surface with media in showroom
310
+ # order (placed surfaces lowest first, then by name, as Surface#showroom_sort_key
311
+ # does). Within a group the server's order stands: it already sorts by position.
312
+ def group_media(media, surfaces)
313
+ groups = []
314
+ gallery = media.select { |m| m['surface_id'].to_s.empty? }
315
+ groups << { title: 'Gallery (under the welcome note)', key: nil, items: gallery } unless gallery.empty?
316
+
317
+ by_surface = media.reject { |m| m['surface_id'].to_s.empty? }.group_by { |m| m['surface_id'] }
318
+ surfaces.sort_by { |s| surface_sort_key(s) }.each do |surface|
319
+ items = by_surface.delete(surface['id'])
320
+ next unless items
321
+
322
+ groups << { title: "#{surface['name'] || surface['key']} (#{surface['key']})", key: surface['key'],
323
+ items: items }
324
+ end
325
+ by_surface.each do |surface_id, items|
326
+ groups << { title: "Unknown surface #{surface_id}", key: nil, items: items }
327
+ end
328
+ groups
329
+ end
330
+
331
+ def surface_sort_key(surface)
332
+ position = surface['showroom_position'].to_i
333
+ position = nil if position < 1
334
+ [position ? 0 : 1, position || 0, surface['name'].to_s.downcase, surface['id'].to_s]
335
+ end
336
+
337
+ def print_item_line(item)
338
+ position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
339
+ position = 'icon' if item['purpose'] == 'icon'
340
+ caption = item['caption'].to_s.strip
341
+ line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
342
+ line += " \"#{caption}\"" unless caption.empty?
343
+ puts line
344
+ end
345
+
346
+ def print_item_detail(item)
347
+ puts
348
+ puts " ID: #{item['id']}"
349
+ puts " Kind: #{item['media_kind']} (#{item['content_type']})"
350
+ puts " File: #{item['filename']}#{item['size_bytes'] ? " (#{format_size(item['size_bytes'].to_i)})" : ''}"
351
+ caption = item['caption'].to_s.strip
352
+ puts " Caption: #{caption.empty? ? '(none)' : caption}"
353
+ position = item['position'].to_i
354
+ puts " Position: #{position.positive? ? position : '(last)'}"
355
+ end
356
+
357
+ # The response data, or exit with the API's error.
358
+ def fetch!(result, prefix: nil)
359
+ return result[:data] || {} if result[:ok]
360
+
361
+ output.error([prefix, result[:error]].compact.join(': '), status: result[:status])
362
+ exit 1
363
+ end
364
+ end
365
+ end
366
+ end
@@ -39,7 +39,8 @@ module Fp
39
39
  showroom_url: :string,
40
40
  showroom_label: :string,
41
41
  showroom_instructions: :string,
42
- showroom_instructions_file: :string
42
+ showroom_instructions_file: :string,
43
+ showroom_position: :string
43
44
  }.freeze
44
45
 
45
46
  EDIT_FLAGS = ADD_FLAGS.merge(key: :string).merge(SHOWROOM_FLAGS).freeze
@@ -68,11 +69,13 @@ module Fp
68
69
  help_flag :showroom_label, 'Client showroom card: the button text for that link (edit)'
69
70
  help_flag :showroom_instructions, 'Client showroom card: how to sign in and try the surface (edit)'
70
71
  help_flag :showroom_instructions_file, 'Read --showroom-instructions from a file (multi-line)'
72
+ help_flag :showroom_position, 'Client showroom order: 1 is first; empty clears (unplaced surfaces follow, A-Z) (edit)'
71
73
  help_flag :yes, 'Skip the typed-key confirmation on delete'
72
74
  help_example 'fp surfaces --project stowzilla'
73
75
  help_example 'fp surfaces add customer_android --project stowzilla --kind android --repo stowzilla/customer-android'
74
76
  help_example 'fp surfaces delete web --project stowzilla --yes'
75
77
  help_example 'fp surfaces edit web --project stowzilla --showroom-url https://dev.example.com --showroom-instructions-file docs/showroom/web.txt'
78
+ help_example 'fp surfaces edit web --project stowzilla --showroom-position 1'
76
79
 
77
80
  def run(args)
78
81
  # Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
@@ -205,7 +208,8 @@ module Fp
205
208
  output.error('Usage: fp surfaces edit <key> --project <slug> [--name "Name"] [--kind ...] ' \
206
209
  '[--audience ...] [--repo org/repo] [--default-branch main] [--key new_key] ' \
207
210
  '[--showroom-url URL] [--showroom-label "..."] ' \
208
- '[--showroom-instructions "..." | --showroom-instructions-file PATH]')
211
+ '[--showroom-instructions "..." | --showroom-instructions-file PATH] ' \
212
+ '[--showroom-position N]')
209
213
  exit 1
210
214
  end
211
215
 
@@ -271,6 +275,8 @@ module Fp
271
275
  '--showroom-instructions-file')
272
276
  end
273
277
 
278
+ params[:showroom_position] = showroom_position!(opts[:showroom_position]) if opts.key?(:showroom_position)
279
+
274
280
  url = params[:showroom_url]
275
281
  if url && !url.empty? && url !~ %r{\Ahttps?://\S+\z}i
276
282
  output.error("Invalid --showroom-url '#{url}'. Must be an http:// or https:// link; " \
@@ -281,6 +287,21 @@ module Fp
281
287
  params
282
288
  end
283
289
 
290
+ # 1 is first. Empty clears it, sent as 0 because the API types the field as an
291
+ # integer (null would be refused at the gateway); the surface then follows the
292
+ # placed ones alphabetically.
293
+ def showroom_position!(value)
294
+ text = flag_text(value).strip
295
+ return 0 if text.empty?
296
+
297
+ position = Integer(text, 10, exception: false)
298
+ return position if position&.positive?
299
+
300
+ output.error("Invalid --showroom-position '#{text}'. Must be a whole number of 1 or more; " \
301
+ 'pass an empty value to clear it.')
302
+ exit 1
303
+ end
304
+
284
305
  # A string flag given with no value parses as `true`; treat that as "clear it",
285
306
  # the same as an explicit empty string.
286
307
  def flag_text(value)
@@ -417,9 +438,14 @@ module Fp
417
438
  instructions = text_or_nil(surface['showroom_instructions'])
418
439
  shown = !(url.nil? && instructions.nil?)
419
440
 
441
+ position = surface['showroom_position']
442
+ position = nil if position.to_s.strip.empty? || position.to_i < 1
443
+
420
444
  puts "#{indent}Showroom: #{shown ? 'shown to clients' : 'hidden (no link or instructions)'}"
421
445
  return unless shown
422
446
 
447
+ puts "#{indent} Position: #{position ? position.to_i : '(not set; ordered by name)'}"
448
+
423
449
  puts "#{indent} Link: #{url || '(none)'}#{label ? " [#{label}]" : ''}"
424
450
  puts "#{indent} Instructions: #{instructions ? '' : '(none)'}"
425
451
  instructions&.each_line { |line| puts "#{indent} #{line.rstrip}" }
data/lib/fp/commands.rb CHANGED
@@ -4,6 +4,7 @@ require_relative 'commands/base'
4
4
  require_relative 'commands/projects'
5
5
  require_relative 'commands/surfaces'
6
6
  require_relative 'commands/showroom'
7
+ require_relative 'commands/showroom_media'
7
8
  require_relative 'commands/list'
8
9
  require_relative 'commands/show'
9
10
  require_relative 'commands/propose'
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.22'
4
+ VERSION = '0.0.24'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -351,14 +351,30 @@ fp surfaces edit web --project X \
351
351
  --showroom-url https://dev.example.com \
352
352
  --showroom-label "Open the web app" \
353
353
  --showroom-instructions-file docs/showroom/web.txt
354
+ fp showroom media add e2e/results/checkout.webm --project X --surface web \
355
+ --caption "Checking out with a saved card" # on the web card
356
+ fp showroom media add docs/showroom/overview.png --project X # no --surface: the gallery
354
357
  ```
355
358
 
356
359
  Write instructions a non-technical reader can follow: where to click, which account to
357
360
  sign in as (a dedicated demo account, not the one your e2e suite mutates), what data is
358
361
  already there to look at, and one or two things worth trying. Keep the file in the repo
359
362
  (`docs/showroom/<surface>.txt`) so the next deploy can re-send it. A surface with neither
360
- a link nor instructions is not shown; clear both to take a card down. Feedback and the
361
- share link are human-only, so after writing the content tell a person it's ready.
363
+ a link nor instructions is not shown; clear both to take a card down. Cards appear in the
364
+ team's chosen order (`--showroom-position 1` is first); surfaces with no position follow,
365
+ alphabetically.
366
+
367
+ Plenty of clients never open the demo, so show them the thing: put a screenshot or a
368
+ short clip of each surface on its card, and an overview in the gallery under the welcome
369
+ note. The captures your e2e run already recorded are usually the right ones — pick the
370
+ ones that show a finished flow, not a test fixture, and caption them in the client's
371
+ words. Images and video only (png, jpg, gif, webp, mp4, webm, mov; 100 MB); SVG and PDF
372
+ are refused. `fp showroom media` lists what's there with ids, `fp showroom media edit
373
+ <id> --caption ... --position N` reorders or rewords (`--position N` takes slot N and
374
+ shifts the rest; an empty value clears the caption or moves the item last), and `fp showroom
375
+ media remove <id> --yes` takes one down. A surface with media but no link is still shown.
376
+ Feedback and the share link are human-only, so after writing the content tell a person
377
+ it's ready.
362
378
 
363
379
  ### Repo-local config (`.featureparity.yml`)
364
380
 
@@ -446,6 +462,8 @@ not as the fix — you still write the proposal.
446
462
  | Preview the client showroom | `fp showroom --project X` |
447
463
  | Write a surface's showroom card | `fp surfaces edit <key> --project X --showroom-url ... --showroom-instructions-file ...` |
448
464
  | Write the showroom welcome | `fp projects edit <slug> --showroom-welcome-file ...` |
465
+ | Add a screenshot/video to the showroom | `fp showroom media add <file> --project X [--surface <key>] [--caption "..."]` |
466
+ | List / edit / remove showroom media | `fp showroom media --project X`, `fp showroom media edit <id> ...`, `fp showroom media remove <id> --yes` |
449
467
 
450
468
  All commands support `--json` for machine-readable output.
451
469
 
@@ -62,7 +62,7 @@ fp surfaces --project stowzilla --json
62
62
  fp surfaces show <key> --project stowzilla
63
63
  fp surfaces add <key> --project stowzilla [--name "..."] [--kind api|web|android|ios|other] [--audience ...] [--repo org/repo] [--default-branch main]
64
64
  fp surfaces edit <key> --project stowzilla [--name "..."] [--kind ...] [--audience ...] [--repo ...] [--default-branch ...] [--key new_key]
65
- fp surfaces edit <key> --project stowzilla [--showroom-url URL] [--showroom-label "..."] [--showroom-instructions "..." | --showroom-instructions-file PATH]
65
+ fp surfaces edit <key> --project stowzilla [--showroom-url URL] [--showroom-label "..."] [--showroom-instructions "..." | --showroom-instructions-file PATH] [--showroom-position N]
66
66
  fp surfaces delete <key> --project stowzilla [--yes]
67
67
  ```
68
68
 
@@ -78,13 +78,14 @@ one rather than working around it.
78
78
  ### fp showroom
79
79
 
80
80
  The client showroom is the one page a client of the project sees: the project's
81
- welcome note, then a card per surface with a link, a button label, and instructions
82
- for signing in and trying it. The team's agents keep that content; this command
81
+ welcome note and a gallery of screenshots or videos under it, then a card per surface
82
+ with a link, a button label, instructions for signing in and trying it, and its own
83
+ media. The team's agents keep that content; this command
83
84
  shows the result.
84
85
 
85
86
  ```bash
86
87
  fp showroom --project stowzilla # what a client sees, plus which surfaces are hidden and why
87
- fp showroom --project stowzilla --json # { showroom, hidden, share_link }
88
+ fp showroom --project stowzilla --json # { showroom, hidden, share_link }; showroom.gallery and each surface's media
88
89
  ```
89
90
 
90
91
  Writing the content:
@@ -97,10 +98,35 @@ fp surfaces edit web --project stowzilla \
97
98
  --showroom-label "Open the web app" \
98
99
  --showroom-instructions-file docs/showroom/web.txt
99
100
  fp surfaces edit ios --project stowzilla --showroom-url "" --showroom-instructions "" # take the card down
101
+ fp surfaces edit web --project stowzilla --showroom-position 1 # clients see web first
102
+ fp surfaces edit web --project stowzilla --showroom-position "" # back to alphabetical
100
103
  ```
101
104
 
102
- A surface is on the showroom once it has a link or instructions, and leaves it when
103
- both are blank; there is no separate on/off switch, so `fp showroom` lists the hidden
105
+ Media — screenshots and videos on a surface's card, or in the project gallery under the
106
+ welcome note when `--surface` is omitted:
107
+
108
+ ```bash
109
+ fp showroom media --project stowzilla # gallery, then each surface in showroom order: id, kind, file, caption, position
110
+ fp showroom media --project stowzilla --json # { media: [...each with surface_key (null = gallery)], project }
111
+ fp showroom media add e2e/results/checkout.webm --project stowzilla --surface web \
112
+ --caption "Checking out with a saved card" --position 1
113
+ fp showroom media add docs/showroom/overview.png --project stowzilla # the gallery
114
+ fp showroom media edit <id> --project stowzilla --caption "New words" --position 2
115
+ fp showroom media edit <id> --project stowzilla --caption "" --position "" # clear the caption, move it last
116
+ fp showroom media remove <id> --project stowzilla --yes
117
+ ```
118
+
119
+ Accepted: png, jpg/jpeg, gif, webp, mp4, webm, mov, up to 100 MB. Anything else — SVG
120
+ (it can carry script) and PDF included — is refused with the reason before anything is
121
+ sent, as is a `--position` that isn't a whole number of 1 or more. Within a card or the
122
+ gallery, positions run 1..n with no gaps: `--position N` puts the item at slot N and
123
+ shifts the others, an empty `--position` moves it to the end, and `add` without one
124
+ appends. `remove`
125
+ asks you to type the filename back in a terminal and refuses non-interactively without
126
+ `--yes`.
127
+
128
+ A surface is on the showroom once it has a link, instructions, or media, and leaves it
129
+ when all three are empty; there is no separate on/off switch, so `fp showroom` lists the hidden
104
130
  surfaces so a card that dropped out is noticed. Links must be `http://` or `https://`
105
131
  (the CLI checks before sending). The `-file` variants are for anything longer than a
106
132
  line: the showroom keeps line breaks, so write real paragraphs. Only the flags you pass
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.22
4
+ version: 0.0.24
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
@@ -120,6 +120,7 @@ files:
120
120
  - lib/fp/commands/setup.rb
121
121
  - lib/fp/commands/show.rb
122
122
  - lib/fp/commands/showroom.rb
123
+ - lib/fp/commands/showroom_media.rb
123
124
  - lib/fp/commands/surfaces.rb
124
125
  - lib/fp/commands/version.rb
125
126
  - lib/fp/commands/whoami.rb