featureparity 0.0.22 → 0.0.23
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 +4 -4
- data/lib/fp/client.rb +46 -0
- data/lib/fp/commands/attach.rb +0 -7
- data/lib/fp/commands/base.rb +8 -0
- data/lib/fp/commands/help.rb +9 -2
- data/lib/fp/commands/showroom.rb +60 -7
- data/lib/fp/commands/showroom_media.rb +334 -0
- data/lib/fp/commands/surfaces.rb +28 -2
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +20 -2
- data/skills/fp/references/cli.md +32 -6
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3fd282368d3fb2417adaf990518352726bc46fe816d70e73ec04f57e2a5ea067
|
|
4
|
+
data.tar.gz: 7edf6bda3e44e4b8cce99efbe59d1adff0cbfe9ab2b8c24613a4b0eaf456cb7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83b7560fa29083b77581005c176b3c01f9a2d60201616cbb97dc8b60ede719f8029b4a02b7d5012310a3a57ef6cd642bd21af32344c5e42c8c300ecb76617356
|
|
7
|
+
data.tar.gz: 1d2b3bf3e6500b1bed84696588225ad718f3aaa9016ffbeffc410226c631c8e55fb48cdafa30446f41814ac2869eb166b98e10fbb21d00b7fb8c2a4d73731802
|
data/lib/fp/client.rb
CHANGED
|
@@ -96,6 +96,52 @@ 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)
|
|
112
|
+
body = { filename: filename, content_type: content_type, size_bytes: size_bytes }
|
|
113
|
+
body[:surface_id] = surface_id if surface_id
|
|
114
|
+
post("/api/projects/#{project_id}/showroom/media/presign", body)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# POST /projects/:id/showroom/media
|
|
118
|
+
# Confirm an upload and create the media record.
|
|
119
|
+
def create_showroom_media(project_id, media_id:, s3_key:, filename:, content_type:, size_bytes:,
|
|
120
|
+
surface_id: nil, caption: nil, position: nil)
|
|
121
|
+
body = {
|
|
122
|
+
media_id: media_id,
|
|
123
|
+
s3_key: s3_key,
|
|
124
|
+
filename: filename,
|
|
125
|
+
content_type: content_type,
|
|
126
|
+
size_bytes: size_bytes
|
|
127
|
+
}
|
|
128
|
+
body[:surface_id] = surface_id if surface_id
|
|
129
|
+
body[:caption] = caption if caption
|
|
130
|
+
body[:position] = position if position
|
|
131
|
+
post("/api/projects/#{project_id}/showroom/media", body)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# PUT /projects/:id/showroom/media/:media_id
|
|
135
|
+
# { caption?, position? } — an empty caption clears it, position 0 clears it.
|
|
136
|
+
def update_showroom_media(project_id, media_id, params)
|
|
137
|
+
put("/api/projects/#{project_id}/showroom/media/#{media_id}", params)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# DELETE /projects/:id/showroom/media/:media_id
|
|
141
|
+
def delete_showroom_media(project_id, media_id)
|
|
142
|
+
delete("/api/projects/#{project_id}/showroom/media/#{media_id}")
|
|
143
|
+
end
|
|
144
|
+
|
|
99
145
|
# GET /requirements
|
|
100
146
|
def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
|
|
101
147
|
params = {}
|
data/lib/fp/commands/attach.rb
CHANGED
|
@@ -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
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -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
|
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -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
|
|
112
|
-
to clear a field (`--showroom-url ""`) and a surface with
|
|
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
|
|
data/lib/fp/commands/showroom.rb
CHANGED
|
@@ -14,15 +14,36 @@ 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 edit <id> --project <slug> [--caption "..."] [--position N]',
|
|
27
|
+
'fp showroom media remove <id> --project <slug> [--yes]'
|
|
20
28
|
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
29
|
+
help_flag :surface, "media add: the surface whose card shows it (omit for the project gallery)"
|
|
30
|
+
help_flag :caption, 'media add/edit: text shown under the image or video (edit: empty clears)'
|
|
31
|
+
help_flag :position, 'media add/edit: slot within its card or the gallery, 1 is first; others shift (edit: empty moves it last)'
|
|
32
|
+
help_flag :yes, 'media remove: skip the typed-filename confirmation'
|
|
21
33
|
help_example 'fp showroom --project stowzilla'
|
|
22
|
-
|
|
34
|
+
help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
|
|
35
|
+
help_example 'fp showroom media add overview.png --project stowzilla --position 1'
|
|
36
|
+
help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
|
|
37
|
+
help_example 'fp showroom media remove <id> --project stowzilla --yes'
|
|
38
|
+
help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
|
|
23
39
|
"Write the content with `fp projects edit --showroom-welcome` and `fp surfaces edit --showroom-*`.\n" \
|
|
40
|
+
"Media: png, jpg, gif, webp, mp4, webm, mov; max 100 MB. SVG and PDF are refused.\n" \
|
|
24
41
|
'The share link is reported as unknown: only a person can see or change it.'
|
|
25
42
|
def run(args)
|
|
43
|
+
if args.first == 'media'
|
|
44
|
+
return ShowroomMedia.new(client: client, output: output, config: config).run(args.drop(1))
|
|
45
|
+
end
|
|
46
|
+
|
|
26
47
|
opts, = parse_flags(args)
|
|
27
48
|
require_project!(opts)
|
|
28
49
|
|
|
@@ -46,15 +67,16 @@ module Fp
|
|
|
46
67
|
|
|
47
68
|
private
|
|
48
69
|
|
|
49
|
-
# Surfaces with
|
|
50
|
-
# out
|
|
70
|
+
# Surfaces with no link, no instructions, and no media. The showroom payload leaves
|
|
71
|
+
# them out (and includes a media-only surface), so whatever the payload doesn't
|
|
72
|
+
# carry is hidden; they are read from the surface list.
|
|
51
73
|
def hidden_surfaces(project_id, shown_ids)
|
|
52
74
|
result = client.list_surfaces(project_id)
|
|
53
75
|
return [] unless result[:ok]
|
|
54
76
|
|
|
55
77
|
(result[:data]['surfaces'] || []).reject { |surface| shown_ids.include?(surface['id']) }.map do |surface|
|
|
56
78
|
{ 'key' => surface['key'], 'name' => surface['name'], 'kind' => surface['kind'],
|
|
57
|
-
'reason' => 'no showroom link or instructions' }
|
|
79
|
+
'reason' => 'no showroom link or instructions, and no media' }
|
|
58
80
|
end
|
|
59
81
|
end
|
|
60
82
|
|
|
@@ -88,9 +110,16 @@ module Fp
|
|
|
88
110
|
end
|
|
89
111
|
puts
|
|
90
112
|
|
|
113
|
+
gallery = showroom['gallery'] || []
|
|
114
|
+
unless gallery.empty?
|
|
115
|
+
puts "Gallery (#{media_summary(gallery)}):"
|
|
116
|
+
print_media(gallery, indent: ' ')
|
|
117
|
+
puts
|
|
118
|
+
end
|
|
119
|
+
|
|
91
120
|
surfaces = showroom['surfaces'] || []
|
|
92
121
|
if surfaces.empty?
|
|
93
|
-
puts
|
|
122
|
+
puts "Surfaces shown: none. The client sees only the welcome note#{gallery.empty? ? '' : ' and the gallery'}."
|
|
94
123
|
else
|
|
95
124
|
puts "Surfaces shown (#{surfaces.size}):"
|
|
96
125
|
surfaces.each { |surface| print_card(surface) }
|
|
@@ -104,6 +133,7 @@ module Fp
|
|
|
104
133
|
end
|
|
105
134
|
puts " Add one with: fp surfaces edit <key> --project #{project} --showroom-url ... " \
|
|
106
135
|
'--showroom-instructions-file ...'
|
|
136
|
+
puts " or: fp showroom media add <file> --project #{project} --surface <key>"
|
|
107
137
|
end
|
|
108
138
|
|
|
109
139
|
puts
|
|
@@ -130,6 +160,29 @@ module Fp
|
|
|
130
160
|
else
|
|
131
161
|
puts ' Instructions: (none)'
|
|
132
162
|
end
|
|
163
|
+
media = surface['media'] || []
|
|
164
|
+
if media.empty?
|
|
165
|
+
puts ' Media: (none)'
|
|
166
|
+
else
|
|
167
|
+
puts " Media: #{media_summary(media)}"
|
|
168
|
+
print_media(media, indent: ' ')
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# "2 images, 1 video"
|
|
173
|
+
def media_summary(media)
|
|
174
|
+
counts = media.group_by { |item| item['media_kind'] == 'video' ? 'video' : 'image' }
|
|
175
|
+
%w[image video].filter_map do |kind|
|
|
176
|
+
count = counts.fetch(kind, []).size
|
|
177
|
+
"#{count} #{kind}#{count == 1 ? '' : 's'}" if count.positive?
|
|
178
|
+
end.join(', ')
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def print_media(media, indent:)
|
|
182
|
+
media.each do |item|
|
|
183
|
+
caption = text_or_nil(item['caption'])
|
|
184
|
+
puts "#{indent}- #{item['media_kind'] || 'image'}: #{caption || '(no caption)'}"
|
|
185
|
+
end
|
|
133
186
|
end
|
|
134
187
|
|
|
135
188
|
def text_or_nil(value)
|
|
@@ -0,0 +1,334 @@
|
|
|
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 edit <id> [--caption "..."] [--position N]
|
|
11
|
+
# fp showroom media remove <id> [--yes]
|
|
12
|
+
#
|
|
13
|
+
# Plenty of clients never open the demo, so the showroom can show them the thing
|
|
14
|
+
# instead: screenshots or a short clip on a surface's card, or in the project gallery
|
|
15
|
+
# under the welcome note (no --surface). The agent that ran the e2e suite already has
|
|
16
|
+
# the captures, so it puts them there rather than leaving a human to re-upload them.
|
|
17
|
+
#
|
|
18
|
+
# Dispatched from `fp showroom media ...`; its help lives on `fp showroom --help`.
|
|
19
|
+
# Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
|
|
20
|
+
# types — images and videos only. SVG can carry script and PDFs don't render inline,
|
|
21
|
+
# so both are refused before anything is sent.
|
|
22
|
+
class ShowroomMedia < Base
|
|
23
|
+
CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
|
|
24
|
+
MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
|
|
25
|
+
MAX_CAPTION_LENGTH = 500
|
|
26
|
+
|
|
27
|
+
LIST_FLAGS = { project: :string }.freeze
|
|
28
|
+
ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string }.freeze
|
|
29
|
+
EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
|
|
30
|
+
REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
|
|
31
|
+
|
|
32
|
+
def run(args)
|
|
33
|
+
case args.first
|
|
34
|
+
when 'add'
|
|
35
|
+
add(args[1..])
|
|
36
|
+
when 'edit'
|
|
37
|
+
edit(args[1..])
|
|
38
|
+
when 'remove', 'rm', 'delete'
|
|
39
|
+
remove(args[1..])
|
|
40
|
+
else
|
|
41
|
+
list(args)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Gallery first, then each surface's media in the order a client sees the cards.
|
|
48
|
+
def list(args)
|
|
49
|
+
opts, = parse_flags(args, LIST_FLAGS)
|
|
50
|
+
require_project!(opts)
|
|
51
|
+
project_id = resolve_project_id(opts[:project])
|
|
52
|
+
|
|
53
|
+
media = fetch!(client.list_showroom_media(project_id))['media'] || []
|
|
54
|
+
groups = group_media(media, surfaces_for(project_id))
|
|
55
|
+
|
|
56
|
+
flat = groups.flat_map do |group|
|
|
57
|
+
group[:items].map { |item| item.merge('surface_key' => group[:key]) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
output.success({ media: flat, project: opts[:project] }) do
|
|
61
|
+
if media.empty?
|
|
62
|
+
puts "No showroom media in project '#{opts[:project]}'."
|
|
63
|
+
puts "Add some with: fp showroom media add <file> --project #{opts[:project]} [--surface <key>]"
|
|
64
|
+
else
|
|
65
|
+
puts "Showroom media for '#{opts[:project]}' (#{media.size}):"
|
|
66
|
+
groups.each do |group|
|
|
67
|
+
puts
|
|
68
|
+
puts "#{group[:title]} (#{group[:items].size}):"
|
|
69
|
+
group[:items].each { |item| print_item_line(item) }
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def add(args)
|
|
76
|
+
opts, positional = parse_flags(args, ADD_FLAGS)
|
|
77
|
+
path = positional.first
|
|
78
|
+
if path.nil? || path.strip.empty?
|
|
79
|
+
output.error('Usage: fp showroom media add <file> --project <slug> [--surface <key>] ' \
|
|
80
|
+
'[--caption "..."] [--position N]')
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Everything that can be checked locally is checked before a byte is sent.
|
|
85
|
+
file = prepare_file!(path)
|
|
86
|
+
caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
|
|
87
|
+
caption = nil if caption&.empty?
|
|
88
|
+
check_caption!(caption) if caption
|
|
89
|
+
position = position!(opts[:position], clear: nil) if opts.key?(:position)
|
|
90
|
+
if opts.key?(:surface) && flag_text(opts[:surface]).strip.empty?
|
|
91
|
+
output.error('--surface needs a surface key. Omit it to add to the project gallery.')
|
|
92
|
+
exit 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
require_project!(opts)
|
|
96
|
+
project_id = resolve_project_id(opts[:project])
|
|
97
|
+
surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
|
|
98
|
+
|
|
99
|
+
presign = fetch!(client.presign_showroom_media(
|
|
100
|
+
project_id,
|
|
101
|
+
filename: file[:filename], content_type: file[:content_type],
|
|
102
|
+
size_bytes: file[:size], surface_id: surface&.dig('id')
|
|
103
|
+
), prefix: 'Failed to get upload URL')['presign'] || {}
|
|
104
|
+
|
|
105
|
+
uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
|
|
106
|
+
fetch!(uploaded, prefix: 'Upload failed')
|
|
107
|
+
|
|
108
|
+
item = fetch!(client.create_showroom_media(
|
|
109
|
+
project_id,
|
|
110
|
+
media_id: presign['media_id'], s3_key: presign['s3_key'],
|
|
111
|
+
filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
|
|
112
|
+
surface_id: surface&.dig('id'), caption: caption, position: position
|
|
113
|
+
), prefix: 'Failed to confirm upload')['media'] || {}
|
|
114
|
+
|
|
115
|
+
where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
|
|
116
|
+
output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
|
|
117
|
+
puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
|
|
118
|
+
print_item_detail(item)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def edit(args)
|
|
123
|
+
opts, positional = parse_flags(args, EDIT_FLAGS)
|
|
124
|
+
media_id = positional.first
|
|
125
|
+
if media_id.nil? || media_id.strip.empty?
|
|
126
|
+
output.error('Usage: fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]')
|
|
127
|
+
exit 1
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Only what was passed is sent. Empty caption clears it. Positions are kept dense
|
|
131
|
+
# (1..n) per card or gallery: N inserts at slot N and shifts the rest, and an empty
|
|
132
|
+
# value — sent as 0, since the API types it as an integer — moves the item to the end.
|
|
133
|
+
params = {}
|
|
134
|
+
if opts.key?(:caption)
|
|
135
|
+
params[:caption] = flag_text(opts[:caption]).strip
|
|
136
|
+
check_caption!(params[:caption])
|
|
137
|
+
end
|
|
138
|
+
params[:position] = position!(opts[:position], clear: 0) if opts.key?(:position)
|
|
139
|
+
|
|
140
|
+
if params.empty?
|
|
141
|
+
output.error('Nothing to change. Pass --caption and/or --position (an empty value clears either).')
|
|
142
|
+
exit 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
require_project!(opts)
|
|
146
|
+
project_id = resolve_project_id(opts[:project])
|
|
147
|
+
item = fetch!(client.update_showroom_media(project_id, media_id, params))['media'] || {}
|
|
148
|
+
|
|
149
|
+
output.success({ media: item, project: opts[:project] }) do
|
|
150
|
+
puts "Updated showroom media #{media_id} in project '#{opts[:project]}'."
|
|
151
|
+
print_item_detail(item)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def remove(args)
|
|
156
|
+
opts, positional = parse_flags(args, REMOVE_FLAGS)
|
|
157
|
+
media_id = positional.first
|
|
158
|
+
if media_id.nil? || media_id.strip.empty?
|
|
159
|
+
output.error('Usage: fp showroom media remove <id> --project <slug> [--yes]')
|
|
160
|
+
exit 1
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
require_project!(opts)
|
|
164
|
+
project_id = resolve_project_id(opts[:project])
|
|
165
|
+
|
|
166
|
+
# Read it first: the confirmation names the file, and an unknown id fails here
|
|
167
|
+
# rather than as a bare 404 from the delete.
|
|
168
|
+
media = fetch!(client.list_showroom_media(project_id))['media'] || []
|
|
169
|
+
item = media.find { |m| m['id'] == media_id }
|
|
170
|
+
unless item
|
|
171
|
+
output.error("Showroom media '#{media_id}' not found in project '#{opts[:project]}'. " \
|
|
172
|
+
"List it with: fp showroom media --project #{opts[:project]}", status: 404)
|
|
173
|
+
exit 1
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
confirm_remove!(item, opts)
|
|
177
|
+
fetch!(client.delete_showroom_media(project_id, media_id), prefix: 'Failed to remove showroom media')
|
|
178
|
+
|
|
179
|
+
output.success({ deleted: true, media: { 'id' => media_id, 'filename' => item['filename'] },
|
|
180
|
+
project: opts[:project] }) do
|
|
181
|
+
puts "Removed #{item['filename']} (#{media_id}) from the showroom of project '#{opts[:project]}'."
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Same guard as `fp surfaces delete`: --yes skips the prompt, an interactive
|
|
186
|
+
# terminal types the filename back, and anything else is refused.
|
|
187
|
+
def confirm_remove!(item, opts)
|
|
188
|
+
return if opts[:yes]
|
|
189
|
+
|
|
190
|
+
unless interactive?
|
|
191
|
+
output.error("Refusing to remove '#{item['filename']}' without confirmation. " \
|
|
192
|
+
'Re-run with --yes to confirm from a non-interactive session.')
|
|
193
|
+
exit 1
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
warn "This will remove '#{item['filename']}' from the showroom of project '#{opts[:project]}'. " \
|
|
197
|
+
'Clients stop seeing it at once.'
|
|
198
|
+
$stderr.print "Type '#{item['filename']}' to confirm: "
|
|
199
|
+
$stderr.flush
|
|
200
|
+
|
|
201
|
+
typed = $stdin.gets&.strip
|
|
202
|
+
return if typed == item['filename']
|
|
203
|
+
|
|
204
|
+
output.error('Aborted. The filename did not match; nothing was removed.')
|
|
205
|
+
exit 1
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def interactive?
|
|
209
|
+
$stdin.respond_to?(:tty?) && $stdin.tty?
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# { path:, filename:, content_type:, size: }, or exit with the reason it can't go up.
|
|
213
|
+
def prepare_file!(path)
|
|
214
|
+
unless File.file?(path)
|
|
215
|
+
output.error("File not found: #{path}")
|
|
216
|
+
exit 1
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
filename = File.basename(path)
|
|
220
|
+
extension = File.extname(filename).delete('.').downcase
|
|
221
|
+
content_type = CONTENT_TYPES[extension]
|
|
222
|
+
unless content_type
|
|
223
|
+
why = extension == 'svg' ? ' (SVG can carry script)' : ''
|
|
224
|
+
output.error("Unsupported file type: .#{extension}#{why}. The showroom takes images " \
|
|
225
|
+
"(png, jpg, jpeg, gif, webp) and videos (mp4, webm, mov) only.")
|
|
226
|
+
exit 1
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
size = File.size(path)
|
|
230
|
+
if size.zero?
|
|
231
|
+
output.error("File is empty: #{path}")
|
|
232
|
+
exit 1
|
|
233
|
+
end
|
|
234
|
+
if size > MAX_FILE_SIZE
|
|
235
|
+
output.error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
|
|
236
|
+
exit 1
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
{ path: path, filename: filename, content_type: content_type, size: size }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def check_caption!(caption)
|
|
243
|
+
return if caption.length <= MAX_CAPTION_LENGTH
|
|
244
|
+
|
|
245
|
+
output.error("--caption is #{caption.length} characters; the limit is #{MAX_CAPTION_LENGTH}.")
|
|
246
|
+
exit 1
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# 1 is first. An empty value returns `clear` (nil on add — it goes at the end
|
|
250
|
+
# anyway — and 0 on edit, which moves it to the end); anything else must be a whole
|
|
251
|
+
# number of 1 or more.
|
|
252
|
+
def position!(value, clear:)
|
|
253
|
+
text = flag_text(value).strip
|
|
254
|
+
return clear if text.empty?
|
|
255
|
+
|
|
256
|
+
position = Integer(text, 10, exception: false)
|
|
257
|
+
return position if position&.positive?
|
|
258
|
+
|
|
259
|
+
output.error("Invalid --position '#{text}'. Must be a whole number of 1 or more; " \
|
|
260
|
+
'pass an empty value to clear it.')
|
|
261
|
+
exit 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# A string flag given with no value parses as `true`; treat that as empty.
|
|
265
|
+
def flag_text(value)
|
|
266
|
+
value == true ? '' : value.to_s
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def surfaces_for(project_id)
|
|
270
|
+
result = client.list_surfaces(project_id)
|
|
271
|
+
result[:ok] ? (result[:data]['surfaces'] || []) : []
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def find_surface!(project_id, key)
|
|
275
|
+
fetch!(client.find_surface_by_key(project_id, key))['surface']
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# [{ title:, key:, items: }] — the gallery, then each surface with media in showroom
|
|
279
|
+
# order (placed surfaces lowest first, then by name, as Surface#showroom_sort_key
|
|
280
|
+
# does). Within a group the server's order stands: it already sorts by position.
|
|
281
|
+
def group_media(media, surfaces)
|
|
282
|
+
groups = []
|
|
283
|
+
gallery = media.select { |m| m['surface_id'].to_s.empty? }
|
|
284
|
+
groups << { title: 'Gallery (under the welcome note)', key: nil, items: gallery } unless gallery.empty?
|
|
285
|
+
|
|
286
|
+
by_surface = media.reject { |m| m['surface_id'].to_s.empty? }.group_by { |m| m['surface_id'] }
|
|
287
|
+
surfaces.sort_by { |s| surface_sort_key(s) }.each do |surface|
|
|
288
|
+
items = by_surface.delete(surface['id'])
|
|
289
|
+
next unless items
|
|
290
|
+
|
|
291
|
+
groups << { title: "#{surface['name'] || surface['key']} (#{surface['key']})", key: surface['key'],
|
|
292
|
+
items: items }
|
|
293
|
+
end
|
|
294
|
+
by_surface.each do |surface_id, items|
|
|
295
|
+
groups << { title: "Unknown surface #{surface_id}", key: nil, items: items }
|
|
296
|
+
end
|
|
297
|
+
groups
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def surface_sort_key(surface)
|
|
301
|
+
position = surface['showroom_position'].to_i
|
|
302
|
+
position = nil if position < 1
|
|
303
|
+
[position ? 0 : 1, position || 0, surface['name'].to_s.downcase, surface['id'].to_s]
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def print_item_line(item)
|
|
307
|
+
position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
|
|
308
|
+
caption = item['caption'].to_s.strip
|
|
309
|
+
line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
|
|
310
|
+
line += " \"#{caption}\"" unless caption.empty?
|
|
311
|
+
puts line
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def print_item_detail(item)
|
|
315
|
+
puts
|
|
316
|
+
puts " ID: #{item['id']}"
|
|
317
|
+
puts " Kind: #{item['media_kind']} (#{item['content_type']})"
|
|
318
|
+
puts " File: #{item['filename']}#{item['size_bytes'] ? " (#{format_size(item['size_bytes'].to_i)})" : ''}"
|
|
319
|
+
caption = item['caption'].to_s.strip
|
|
320
|
+
puts " Caption: #{caption.empty? ? '(none)' : caption}"
|
|
321
|
+
position = item['position'].to_i
|
|
322
|
+
puts " Position: #{position.positive? ? position : '(last)'}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The response data, or exit with the API's error.
|
|
326
|
+
def fetch!(result, prefix: nil)
|
|
327
|
+
return result[:data] || {} if result[:ok]
|
|
328
|
+
|
|
329
|
+
output.error([prefix, result[:error]].compact.join(': '), status: result[:status])
|
|
330
|
+
exit 1
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
end
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -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
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.
|
|
361
|
-
|
|
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
|
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -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
|
|
82
|
-
for signing in and trying it
|
|
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
|
-
|
|
103
|
-
|
|
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.
|
|
4
|
+
version: 0.0.23
|
|
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
|