mkit-cli 0.3.1__py3-none-any.whl

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.
mkit/commands/store.py ADDED
@@ -0,0 +1,263 @@
1
+ """Store listing, what's new, data safety and testers."""
2
+ import json
3
+ from datetime import datetime
4
+
5
+ from .. import datasafety, kinds, safety
6
+ from ..cloud import auth, play, store
7
+ from ..errors import DRIFT, UsageError
8
+ from ..git import Repo
9
+ from ..registry import Arg, command, group
10
+
11
+ group('listing', 'store listing text, images and contact details')
12
+ group('whatsnew', "release notes (what's new) files")
13
+ group('datasafety', 'the data safety declaration')
14
+ group('testers', 'Google Groups on testing tracks')
15
+
16
+ SCOPE_ARGS = (Arg('text_only', 'only the text', kind='flag'),
17
+ Arg('images_only', 'only the images', kind='flag'))
18
+ LOCALES_ARG = Arg('locales', 'only these Play locales, comma separated, e.g. de-DE,es-ES (a staged rollout)')
19
+
20
+
21
+ def _only(args):
22
+ if not getattr(args, 'locales', None):
23
+ return None
24
+ return {loc.strip() for loc in args.locales.split(',') if loc.strip()}
25
+
26
+
27
+ def _scope(args):
28
+ if args.text_only and args.images_only:
29
+ raise UsageError('pass only one of --text-only / --images-only')
30
+ return not args.images_only, not args.text_only
31
+
32
+
33
+ def _show_plan(out, result):
34
+ if out.json:
35
+ return
36
+ for c in result['text']:
37
+ out.line(f' text {c["locale"]:8} {"new locale" if c["new"] else ", ".join(c["fields"])}')
38
+ for c in result['images']:
39
+ out.line(f' images {c["locale"]:8} {c["type"]}: {c["remote"]} on Play -> {c["local"]} local')
40
+ for k, v in result['details'].items():
41
+ out.line(f' detail {k}: {v["from"] or "(empty)"} -> {v["to"]}')
42
+ if result['remote_only']:
43
+ out.line(f' on Play but not in the files (left alone): {", ".join(result["remote_only"])}', 'dim')
44
+ if result.get('no_listing'):
45
+ out.line(f' images not planned, no listing on Play yet (apply the text first): '
46
+ f'{", ".join(result["no_listing"])}', 'yellow')
47
+ if not store.plan_size(result):
48
+ out.line(' no changes: Play matches the files', 'green')
49
+
50
+
51
+ def _compare(ctx, edit, text, images, only=None):
52
+ out = ctx.out
53
+ out.step('Compare with Play' + (f' ({len(only)} locale(s) only)' if only else ''))
54
+ result = store.plan(edit, ctx.project, ctx.project.kind, with_images=images, with_text=text,
55
+ progress=lambda d, t, item: out.progress(d, t, item), only=only)
56
+ out.done(f'{store.plan_size(result)} change(s), {store.upload_count(result)} image(s) to send')
57
+ return result
58
+
59
+
60
+ @command('listing plan', 'Show how the listing files differ from Play (changes nothing)',
61
+ risk='plan', needs=('play', 'store'), args=SCOPE_ARGS + (LOCALES_ARG,),
62
+ examples=('mkit listing plan', 'mkit listing plan --text-only',
63
+ 'mkit listing plan --locales de-DE,es-ES'))
64
+ def listing_plan(ctx, args):
65
+ text, images = _scope(args)
66
+ ctx.out.start(f'{ctx.project.name} listing plan', 1)
67
+ with play.Edit(ctx) as edit:
68
+ result = _compare(ctx, edit, text, images, _only(args))
69
+ _show_plan(ctx.out, result)
70
+ if store.plan_size(result):
71
+ ctx.exit_code = DRIFT
72
+ return store.public(result)
73
+
74
+
75
+ @command('listing apply', 'Push listing changes (only what differs) to Play', risk='publish',
76
+ needs=('play', 'store'),
77
+ args=SCOPE_ARGS + (LOCALES_ARG,
78
+ Arg('resubmit', 'cancel a review already running and resubmit everything',
79
+ kind='flag')),
80
+ examples=('mkit listing apply', 'mkit listing apply --text-only --dry-run',
81
+ 'mkit listing apply --locales de-DE,es-ES,es-419,pt-BR'))
82
+ def listing_apply(ctx, args):
83
+ text, images = _scope(args)
84
+ out = ctx.out
85
+ out.start(f'{ctx.project.name} listing', 4)
86
+ with play.Edit(ctx) as edit:
87
+ result = _compare(ctx, edit, text, images, _only(args))
88
+ _show_plan(out, result)
89
+ if not store.plan_size(result):
90
+ return dict(store.public(result), changed=False)
91
+ safety.confirm(ctx, f'Publish {store.plan_size(result)} listing change(s) for {ctx.project.name}')
92
+ workers = store._workers(ctx.project)
93
+ out.step(f'Send text ({len(result["text"])} language(s))')
94
+ progress = lambda d, t, item: out.progress(d, t, item) # noqa: E731
95
+ store.apply(edit, {**result, 'images': [], 'details': {}}, workers, on_text=progress)
96
+ out.done()
97
+ out.step(f'Send images ({store.upload_count(result)} file(s), {workers} at a time)')
98
+ store.apply(edit, {**result, 'text': []}, workers, on_image=progress)
99
+ out.done()
100
+ out.step('Validate and commit')
101
+ edit.finish(ctx.dry_run, in_review='resubmit' if args.resubmit else 'error')
102
+ out.done()
103
+ if not ctx.out.json:
104
+ if ctx.dry_run:
105
+ ctx.out.line('validated on Play; nothing committed (dry run)', 'green')
106
+ elif edit.manual_review:
107
+ ctx.out.line('committed; send it for review in Play Console (see the warning)', 'green')
108
+ else:
109
+ ctx.out.line('committed; Play reviews listing changes before they show', 'green')
110
+ return dict(store.public(result), changed=not ctx.dry_run, dry_run=ctx.dry_run,
111
+ sent_for_review=not ctx.dry_run and not edit.manual_review)
112
+
113
+
114
+ @command('listing pull', 'Save the live listing (all locales) to a JSON backup', risk='read',
115
+ needs=('play',), args=(Arg('out', 'file to write (default: a timestamped backup)'),))
116
+ def listing_pull(ctx, args):
117
+ with play.Edit(ctx) as edit:
118
+ listings = edit.call(lambda e, p, i: e.listings().list(packageName=p, editId=i),
119
+ 'read listings').get('listings', [])
120
+ details = edit.call(lambda e, p, i: e.details().get(packageName=p, editId=i), 'read details')
121
+ data = {'package': ctx.project.package, 'details': details,
122
+ 'listings': {l['language']: l for l in listings}}
123
+ if args.out:
124
+ path = ctx.project.root / args.out
125
+ else:
126
+ folder = ctx.project.path('store.backup_dir', 'console')
127
+ folder.mkdir(parents=True, exist_ok=True)
128
+ path = folder / f'backup-listings-{datetime.now():%Y%m%d-%H%M%S}.json'
129
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
130
+ if not ctx.out.json:
131
+ ctx.out.line(f'{len(listings)} locale(s) saved to {path}')
132
+ return {'file': str(path), 'locales': sorted(data['listings'])}
133
+
134
+
135
+ @command('whatsnew check', "Check the what's new files: lengths and locales", risk='read', needs=('store',))
136
+ def whatsnew_check(ctx, args):
137
+ notes = play.release_notes(ctx.project)
138
+ configured = set()
139
+ if ctx.project.section('store').get('locales'):
140
+ configured = {loc for t in store.locales(ctx.project).values() for loc in t}
141
+ have = {n['language'] for n in notes}
142
+ missing = sorted(configured - have)
143
+ style = {n['language']: why for n in notes if (why := play.note_problems(n['text']))}
144
+ repo = Repo(ctx.project.root)
145
+ last = repo.last_release() if notes and repo.is_repo() else ''
146
+ unchanged = bool(last) and not repo.changed_since(last, ctx.project.path('store.whatsnew_dir'))
147
+ if not ctx.out.json:
148
+ for n in notes:
149
+ ctx.out.line(f' {n["language"]:8} {len(n["text"]):3}/{play.WHATSNEW_LIMIT} '
150
+ f'{n["text"].splitlines()[0][:60]}')
151
+ for lang, why in sorted(style.items()):
152
+ ctx.out.line(f' {lang}: {", ".join(why)}', 'red')
153
+ if missing:
154
+ ctx.out.line(f' no note for: {", ".join(missing)} (those languages see the default)', 'yellow')
155
+ if unchanged:
156
+ ctx.out.line(f' unchanged since the last release ({last[:7]}): deploy will refuse it '
157
+ 'without --same-notes', 'yellow')
158
+ if style:
159
+ ctx.exit_code = 1
160
+ return {'notes': notes, 'missing': missing, 'style': style, 'unchanged_since_release': unchanged}
161
+
162
+
163
+ @command('datasafety check', 'Check the data safety CSV (no upload)', risk='read', needs=('store',))
164
+ def datasafety_check(ctx, args):
165
+ info = store.read_datasafety(ctx.project)
166
+ info.pop('text')
167
+ if not ctx.out.json:
168
+ ctx.out.line(f'{info["path"]}: {info["rows"]} rows, {info["answered"]} answered')
169
+ ctx.out.line(f' collects data: {info["collects_data"] or "?"}')
170
+ ctx.out.line(f' data types: {", ".join(info["data_types"]) or "none"}')
171
+ for q in info['required_blank']:
172
+ ctx.out.line(f' REQUIRED but blank: {q}', 'yellow')
173
+ if info['required_blank']:
174
+ ctx.exit_code = 1
175
+ return info
176
+
177
+
178
+ @command('datasafety build', 'Write the data safety CSV from [datasafety] in mkit.toml',
179
+ risk='write', needs=('store',),
180
+ examples=('mkit datasafety build', 'mkit datasafety build --show'),
181
+ args=(Arg('show', 'print the answers without writing the file', kind='flag'),))
182
+ def datasafety_build(ctx, args):
183
+ """The 782 questions are the same for every app; only the answers differ.
184
+
185
+ So the question set ships with the engine and an app declares what it
186
+ collects, rather than each repo carrying its own filled copy of Google's
187
+ sample CSV and drifting from the others.
188
+ """
189
+ text, info = datasafety.build(ctx.project.section('datasafety'))
190
+ path = ctx.project.path('store.datasafety')
191
+ if not path:
192
+ raise UsageError('no datasafety path set under [store]',
193
+ hint='Add datasafety = "docs/data-safety/<app>.csv" under [store].',
194
+ code='NO_DATASAFETY_PATH')
195
+ if not args.show:
196
+ path.parent.mkdir(parents=True, exist_ok=True)
197
+ path.write_text(text, encoding='utf-8', newline='')
198
+ if not ctx.out.json:
199
+ ctx.out.line(f'{"would write" if args.show else "wrote"} {path}: {info["rows"]} rows')
200
+ ctx.out.line(f' collects: {", ".join(info["data_types"]) or "nothing"}')
201
+ return {'path': str(path), 'written': not args.show, **info}
202
+
203
+
204
+ @command('datasafety types', 'Every data type a declaration can name', risk='read')
205
+ def datasafety_types(ctx, args):
206
+ """What [datasafety.collected] accepts, so nobody has to guess a spelling."""
207
+ types = [{'id': t['id'], 'names': t['names'], 'label': t['label']}
208
+ for t in datasafety.data_types().values()]
209
+ if not ctx.out.json:
210
+ for t in types:
211
+ ctx.out.line(f' {t["names"][0]:26s} {t["label"]}')
212
+ ctx.out.line('')
213
+ ctx.out.line(' purposes: ' + ', '.join(sorted(datasafety.PURPOSES)))
214
+ return {'types': types, 'purposes': sorted(datasafety.PURPOSES)}
215
+
216
+
217
+ @command('datasafety apply', 'Upload the data safety declaration (replaces the one on Play)',
218
+ risk='publish', needs=('play', 'store'))
219
+ def datasafety_apply(ctx, args):
220
+ info = store.read_datasafety(ctx.project)
221
+ if info['required_blank']:
222
+ raise UsageError(f'{len(info["required_blank"])} REQUIRED question(s) are blank; Play would reject it',
223
+ code='DATASAFETY_INCOMPLETE')
224
+ safety.confirm(ctx, f'Replace the data safety declaration of {ctx.project.package}',
225
+ [f'collects data: {info["collects_data"]}',
226
+ f'data types: {", ".join(info["data_types"]) or "none"}'])
227
+ if ctx.dry_run:
228
+ return {'dry_run': True, 'rows': info['rows']}
229
+ svc = auth.play(ctx)
230
+ auth.retry(lambda: svc.applications().dataSafety(packageName=ctx.project.package,
231
+ body={'safetyLabels': info['text']}), 'data safety')
232
+ if not ctx.out.json:
233
+ ctx.out.line('data safety declaration uploaded', 'green')
234
+ return {'uploaded': True, 'rows': info['rows']}
235
+
236
+
237
+ @command('testers show', 'Google Groups on each testing track', risk='read', needs=('play',))
238
+ def testers_show(ctx, args):
239
+ rows = []
240
+ with play.Edit(ctx) as edit:
241
+ for t in edit.tracks():
242
+ if t['track'] == 'production':
243
+ continue
244
+ groups = edit.call(lambda e, p, i, tr=t['track']: e.testers().get(
245
+ packageName=p, editId=i, track=tr), f'testers {t["track"]}').get('googleGroups', [])
246
+ rows.append({'track': t['track'], 'googleGroups': groups})
247
+ if not ctx.out.json:
248
+ ctx.out.table([(r['track'], ', '.join(r['googleGroups']) or '-') for r in rows], ('track', 'groups'))
249
+ ctx.out.line('email tester lists are Console-only (see `mkit manual`)', 'dim')
250
+ return {'tracks': rows}
251
+
252
+
253
+ @command('testers set', 'Set the Google Groups on a testing track', risk='publish', needs=('play',),
254
+ args=(Arg('track', 'testing track', kind='positional'),
255
+ Arg('groups', 'group emails', kind='positional', nargs='+')))
256
+ def testers_set(ctx, args):
257
+ track = play.track_id(args.track)
258
+ safety.confirm(ctx, f'Set testers of {track} to {", ".join(args.groups)}')
259
+ with play.Edit(ctx) as edit:
260
+ edit.call(lambda e, p, i: e.testers().update(packageName=p, editId=i, track=track,
261
+ body={'googleGroups': args.groups}), 'testers')
262
+ edit.finish(ctx.dry_run, in_review='error')
263
+ return {'track': track, 'googleGroups': args.groups, 'dry_run': ctx.dry_run}
mkit/config.py ADDED
@@ -0,0 +1,146 @@
1
+ """Project settings: find mkit.toml, read it, and answer "is this feature on?".
2
+
3
+ A project is the folder holding mkit.toml. mkit walks up from where it was
4
+ started, so it works from any subfolder of the app, the same way git does.
5
+
6
+ Settings are layered, first match wins:
7
+
8
+ command flags > MKIT_* environment > <project>/mkit.toml
9
+ > ~/.mkit/config.toml > built-in defaults
10
+
11
+ A table in mkit.toml switches a feature on. `[firebase]` present means this app
12
+ uses Firebase; absent means every Firebase step is skipped without a word, since
13
+ the app simply does not use it. That is how one pipeline serves apps that need
14
+ different parts of it.
15
+ """
16
+ import os
17
+ import re
18
+ import tomllib
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+ from .errors import MkitError, NotFound, UsageError
24
+
25
+ PROJECT_FILE = 'mkit.toml'
26
+ KINDS = ('flutter', 'android', 'android-wear')
27
+
28
+ # Tables that switch a feature on by being present. Order is how doctor lists them.
29
+ FEATURES = ('play', 'store', 'firebase', 'notify.telegram', 'aso', 'art', 'hooks')
30
+
31
+
32
+ def home():
33
+ """User-level folder shared by every project: config.toml and .env."""
34
+ override = os.environ.get('MKIT_HOME')
35
+ return Path(override) if override else Path.home() / '.mkit'
36
+
37
+
38
+ def find_root(start=None):
39
+ """The nearest folder at or above `start` that holds mkit.toml, or None."""
40
+ here = Path(start or os.getcwd()).resolve()
41
+ for folder in (here, *here.parents):
42
+ if (folder / PROJECT_FILE).is_file():
43
+ return folder
44
+ return None
45
+
46
+
47
+ def _read_toml(path):
48
+ try:
49
+ with open(path, 'rb') as f:
50
+ return tomllib.load(f)
51
+ except tomllib.TOMLDecodeError as e:
52
+ raise UsageError(f'{path} is not valid TOML: {e}',
53
+ hint='Fix the file; `mkit doctor` re-checks it.',
54
+ code='BAD_CONFIG') from e
55
+
56
+
57
+ def _version_tuple(text):
58
+ return tuple(int(p) for p in re.findall(r'\d+', text)[:3])
59
+
60
+
61
+ def check_requires(spec, have=__version__):
62
+ """True when the running engine satisfies `requires = ">=x.y"`."""
63
+ m = re.fullmatch(r'\s*>=\s*([\d.]+)\s*', spec or '')
64
+ if not m:
65
+ raise UsageError(f'requires = "{spec}" is not understood',
66
+ hint='Use the form requires = ">=0.1".', code='BAD_CONFIG')
67
+ return _version_tuple(have) >= _version_tuple(m.group(1))
68
+
69
+
70
+ def _dig(data, dotted):
71
+ node = data
72
+ for part in dotted.split('.'):
73
+ if not isinstance(node, dict) or part not in node:
74
+ return None
75
+ node = node[part]
76
+ return node
77
+
78
+
79
+ @dataclass
80
+ class Project:
81
+ root: Path
82
+ data: dict = field(default_factory=dict)
83
+ user: dict = field(default_factory=dict)
84
+
85
+ @property
86
+ def name(self):
87
+ return self.get('app.name') or self.root.name
88
+
89
+ @property
90
+ def kind(self):
91
+ return self.get('app.kind')
92
+
93
+ @property
94
+ def package(self):
95
+ return self.get('app.package')
96
+
97
+ def has(self, feature):
98
+ """Whether the feature's table is in mkit.toml (the app opted in)."""
99
+ return isinstance(_dig(self.data, feature), dict)
100
+
101
+ def section(self, feature):
102
+ found = _dig(self.data, feature)
103
+ return found if isinstance(found, dict) else {}
104
+
105
+ def get(self, dotted, default=None):
106
+ """Environment first (MKIT_APP_PACKAGE for app.package), then the
107
+ project file, then the user file."""
108
+ env = os.environ.get('MKIT_' + dotted.replace('.', '_').replace('-', '_').upper())
109
+ if env is not None:
110
+ return env
111
+ for source in (self.data, self.user):
112
+ value = _dig(source, dotted)
113
+ if value is not None:
114
+ return value
115
+ return default
116
+
117
+ def path(self, dotted, default=None):
118
+ """A project-relative path setting, resolved against the project root."""
119
+ value = self.get(dotted, default)
120
+ return (self.root / value) if value else None
121
+
122
+
123
+ def load(start=None, project=None):
124
+ """Load the project containing `start` (or the explicit `project` folder)."""
125
+ root = Path(project).resolve() if project else find_root(start)
126
+ if root is None or not (root / PROJECT_FILE).is_file():
127
+ where = project or os.getcwd()
128
+ raise NotFound(f'no {PROJECT_FILE} in {where} or any folder above it',
129
+ hint='Run `mkit init` in the app folder to create one.',
130
+ code='NO_PROJECT')
131
+ data = _read_toml(root / PROJECT_FILE)
132
+ user_file = home() / 'config.toml'
133
+ user = _read_toml(user_file) if user_file.is_file() else {}
134
+
135
+ spec = data.get('requires')
136
+ if spec and not check_requires(spec):
137
+ raise MkitError(f'this project needs mkit {spec}, but {__version__} is installed',
138
+ hint='Run `mkit self update`: it upgrades a git checkout, or names '
139
+ 'the command for the package manager that installed mkit.',
140
+ code='ENGINE_TOO_OLD')
141
+
142
+ kind = _dig(data, 'app.kind')
143
+ if kind is not None and kind not in KINDS:
144
+ raise UsageError(f'app.kind = "{kind}" is not one of {", ".join(KINDS)}',
145
+ code='BAD_CONFIG')
146
+ return Project(root=root, data=data, user=user)
mkit/data/art/art.toml ADDED
@@ -0,0 +1,119 @@
1
+ # Store art for this app, built by `mkit art build` (docs/store-art.md).
2
+ # Paths are relative to the app folder (where mkit.toml is).
3
+
4
+ layout = "classic" # "classic" (words over one phone, soft gradient) or "pop"
5
+ # (vivid ground, bold underlined titles, big phones, pills; see below)
6
+ reference = "en" # the captions file every language is measured against
7
+
8
+ font_dir = "assets/fonts" # holds <font>-Regular/-Medium/-SemiBold/-Bold.ttf
9
+ font = "Barlow" # the app's own typeface; draws every Latin-script language
10
+ mascot = "assets/icon/icon_foreground.svg" # SVG or PNG: hero brand, feature graphic, notification icon
11
+ decoration = "contour" # classic's backdrop: "contour" (topographic rings), "leaves" (sprigs) or "none"
12
+
13
+ # The status bar. mode = "redraw" (classic's default): the phone's own icons
14
+ # are erased and a clean 9:41 bar is drawn over the capture. Boxes are [left,
15
+ # top, right, bottom] in capture pixels.
16
+ # mode = "clean" (pop's default): the capture keeps its own bar, painted over
17
+ # up to `height` (default 96) in the colour at `sample`, with a 9:41, signal
18
+ # and battery drawn in dark or light ink to suit it.
19
+ [status_bar]
20
+ height = 100
21
+ erase = [[36, 8, 250, 96], [760, 8, 1048, 96]]
22
+ # mode = "clean"
23
+ # sample = [540, 60]
24
+
25
+ # Anything private in a capture (an account name, an email) is erased the same way.
26
+ # [shots.home]
27
+ # erase = [[394, 166, 568, 226]]
28
+
29
+ # Colours, when the app's look differs from the default green. Any key can be left out.
30
+ # [theme.light]
31
+ # bg1 = "#F4F7EF" # background gradient, top
32
+ # bg2 = "#DCEAD4" # background gradient, bottom
33
+ # line = "#4E9150" # contour lines, or the sprigs
34
+ # line_op = 0.20
35
+ # ink = "#1F2A21" # headline
36
+ # sub = "#4D5C50" # subline
37
+ # accent = "#2F7A3E" # [accent] words, brand name
38
+ # tile = "#E3EFDD" # notification icon tile
39
+ # chip_border = "rgba(78,145,80,.28)"
40
+ # [theme.dark] # for slides with dark = true
41
+ # bg1 = "#0E1210"
42
+ # bg2 = "#16261A"
43
+ # ink = "#F3F6F1"
44
+ # accent = "#A5D69B"
45
+ #
46
+ # With layout = "pop" the same tables hold pop's colours instead (defaults shown;
47
+ # [theme.dark] defaults to the light ones with bg1/bg2/bg3 = #1F3D29/#122419/#0B140E,
48
+ # bg_mid = 50 and title_shadow = "none"):
49
+ # [theme.light]
50
+ # bg1 = "#55D27E" # the ground: a radial glow from the top...
51
+ # bg2 = "#26AC52"
52
+ # bg3 = "#168A3E" # ...to the bottom
53
+ # bg_mid = 48 # where bg2 sits, in percent
54
+ # blob_dark = "#0F7A34" # soft shade, bottom left
55
+ # blob_light = "#7BE39B" # soft light, top right
56
+ # glow = "#FFFFFF" # halo and ring behind the phones, sprigs, dots, chip glass
57
+ # ink = "#FFFFFF" # titles, the name, chips
58
+ # title_shadow = "0 4px 18px rgba(5,60,25,.25)"
59
+ # underline = "#FFD95A" # the hand-drawn line under the [word]
60
+ # accent = "#1E9E4A" # ticks, the go pill, the notification tile
61
+ # pill = "#FFFFFF" # pills and the notification card
62
+ # pill_ink = "#1B2A1F"
63
+ # shade = "#043214" # every shadow, at its own strength
64
+ # phone = "#141816" # the phone frame
65
+
66
+ # Fallback fonts for other scripts (Windows names), by listing code:
67
+ # [script_fonts]
68
+ # ka = "Sylfaen"
69
+
70
+ # The screenshots, in Play order (up to 8). Each id needs an entry in every
71
+ # captions file. kind = "landscape" is the 1920x1080 hero with three phones;
72
+ # the rest are 1080x1920 slides with one phone. shot = a file in shots/.
73
+ [[slide]]
74
+ id = "hero"
75
+ kind = "landscape"
76
+ shots = ["detail", "home", "home_dark"] # left, middle (front), right
77
+
78
+ [[slide]]
79
+ id = "home"
80
+ shot = "home"
81
+
82
+ [[slide]]
83
+ id = "alerts"
84
+ shot = "alerts"
85
+ notify = true # a phone notification card over the phone (captions: notify)
86
+
87
+ [[slide]]
88
+ id = "detail"
89
+ shot = "detail"
90
+
91
+ [[slide]]
92
+ id = "home_dark"
93
+ shot = "home_dark"
94
+ dark = true
95
+
96
+ # offset = 520 on a slide scrolls its shot up by that many capture pixels.
97
+
98
+ # Pop only: pills and pictures on a slide, in page pixels (left, top);
99
+ # right-to-left pages mirror them. The hero takes them too, and the feature
100
+ # graphic as [[feature.pill]] / [[feature.art]]. In pop the hero fans two or
101
+ # three `shots`, and a slide may set notify_y (the notification card's top, default 600).
102
+ # [[slide.pill]]
103
+ # text = "Lemons" # literal, never translated (an item named on the capture)...
104
+ # key = "add" # ...or translated: captions <slide>.pills.add
105
+ # go = true # the green action pill
106
+ # x = 40
107
+ # y = 1130
108
+ # size = 50 # font size (default 44)
109
+ # rotate = -5
110
+ # [[slide.art]]
111
+ # src = "store/art/mascot.svg" # PNG or SVG in the app repo
112
+ # x = 760
113
+ # y = 360
114
+ # height = 240
115
+ # rotate = 10
116
+ # z = 6 # stacking (default 4; phones 1-2, pills 5)
117
+
118
+ [feature] # the 1024x500 feature graphic
119
+ shot = "home" # pop: shots = ["back", "front"] for two phones
mkit/data/art/en.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "locale": "en-US",
3
+ "dir": "ltr",
4
+ "hero": {
5
+ "name": "My App",
6
+ "title": "The one line that says what the app is [for you]",
7
+ "sub": "What it does for people, in about ten words",
8
+ "chips": ["First feature", "Second feature", "Third feature"]
9
+ },
10
+ "home": {
11
+ "title": "A benefit, not a feature [in bold]",
12
+ "sub": "One sentence that backs the headline up"
13
+ },
14
+ "alerts": {
15
+ "title": "Never miss [the moment] again",
16
+ "sub": "What the notification does for them",
17
+ "notify": {
18
+ "app": "My App",
19
+ "when": "now",
20
+ "title": "The app's real notification title",
21
+ "body": "The app's real notification text."
22
+ }
23
+ },
24
+ "detail": {
25
+ "title": "Everything [in one place]",
26
+ "sub": "What this screen gives them"
27
+ },
28
+ "home_dark": {
29
+ "title": "Easy on the eyes, [day or night]",
30
+ "sub": "A full dark theme with the same clear view"
31
+ },
32
+ "feature": {
33
+ "name": "My App",
34
+ "title": "The promise [in five words]",
35
+ "sub": "The search term people use for this kind of app",
36
+ "chips": ["First feature", "Second feature", "Third feature"]
37
+ }
38
+ }