@crvouga/atlas 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +21 -2
  3. package/dist/reports/src/evidence-live.d.ts +6 -0
  4. package/dist/reports/src/evidence-live.js +35 -3
  5. package/dist/reports/src/evidence-types.d.ts +2 -0
  6. package/dist/reports/src/evidence-view.d.ts +15 -2
  7. package/dist/reports/src/evidence-view.js +31 -3
  8. package/dist/reports/src/export-evidence.js +2 -10
  9. package/dist/reports/src/frames.d.ts +3 -1
  10. package/dist/reports/src/frames.js +21 -0
  11. package/dist/reports/src/layer-axes.d.ts +18 -0
  12. package/dist/reports/src/layer-axes.js +51 -0
  13. package/dist/reports/src/layer-catalog.js +1 -1
  14. package/dist/reports/src/layer-merge.d.ts +1 -1
  15. package/dist/reports/src/layer-merge.js +9 -4
  16. package/dist/reports/src/layer-source.js +3 -0
  17. package/dist/reports/src/layer-types.d.ts +2 -0
  18. package/dist/reports/src/layers-route.js +2 -5
  19. package/dist/reports/src/protocol.d.ts +4 -0
  20. package/dist/reports/src/publish.d.ts +2 -0
  21. package/dist/reports/src/publish.js +6 -11
  22. package/dist/reports/src/recording-keys.js +4 -0
  23. package/dist/reports/src/report-parts.js +3 -0
  24. package/dist/reports/src/report-types.d.ts +19 -2
  25. package/dist/reports/src/source-entries.js +2 -12
  26. package/package.json +5 -4
  27. package/packages/reports/README.md +4 -2
  28. package/packages/reports/STORAGE.md +1 -1
  29. package/packages/visualizer/README.md +9 -6
  30. package/packages/visualizer/example/specs/coffee/ordering/cart.qnt +7 -0
  31. package/packages/visualizer/example/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  32. package/packages/visualizer/example/specs/coffee/ordering/drink-price.dmn +24 -0
  33. package/packages/visualizer/example/specs/coffee/ordering/loyalty.feature +7 -0
  34. package/packages/visualizer/example/specs/coffee/ordering/ordering.feature +36 -0
  35. package/packages/visualizer/example/specs/coffee/ordering/payment.als +4 -0
  36. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/cart.qnt +7 -0
  37. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  38. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/drink-price.dmn +24 -0
  39. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/loyalty.feature +7 -0
  40. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/ordering.feature +36 -0
  41. package/packages/visualizer/fixtures/failures/specs/coffee/ordering/payment.als +4 -0
  42. package/packages/visualizer/fixtures/full/specs/coffee/ordering/cart.qnt +7 -0
  43. package/packages/visualizer/fixtures/full/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  44. package/packages/visualizer/fixtures/full/specs/coffee/ordering/drink-price.dmn +24 -0
  45. package/packages/visualizer/fixtures/full/specs/coffee/ordering/loyalty.feature +7 -0
  46. package/packages/visualizer/fixtures/full/specs/coffee/ordering/ordering.feature +36 -0
  47. package/packages/visualizer/fixtures/full/specs/coffee/ordering/payment.als +4 -0
  48. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/cart.qnt +7 -0
  49. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  50. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/drink-price.dmn +24 -0
  51. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/loyalty.feature +7 -0
  52. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/ordering.feature +36 -0
  53. package/packages/visualizer/fixtures/malformed/specs/coffee/ordering/payment.als +4 -0
  54. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/cart.qnt +7 -0
  55. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  56. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/drink-price.dmn +24 -0
  57. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/loyalty.feature +7 -0
  58. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/ordering.feature +36 -0
  59. package/packages/visualizer/fixtures/partial/specs/coffee/ordering/payment.als +4 -0
  60. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/cart.qnt +7 -0
  61. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  62. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/drink-price.dmn +24 -0
  63. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/loyalty.feature +7 -0
  64. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/ordering.feature +36 -0
  65. package/packages/visualizer/fixtures/removed/specs/coffee/ordering/payment.als +4 -0
  66. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/cart.qnt +7 -0
  67. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/checkout.openslo.yaml +10 -0
  68. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/drink-price.dmn +24 -0
  69. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/loyalty.feature +7 -0
  70. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/ordering.feature +36 -0
  71. package/packages/visualizer/fixtures/spec-only/specs/coffee/ordering/payment.als +4 -0
  72. package/packages/visualizer/scripts/make-fixtures.ts +2 -0
  73. package/packages/visualizer/scripts/publish-data.ts +1 -2
  74. package/packages/visualizer/src/app/AppShell.tsx +11 -12
  75. package/packages/visualizer/src/app/gazetteer.module.css +12 -2
  76. package/packages/visualizer/src/app/gazetteer.tsx +38 -26
  77. package/packages/visualizer/src/components/Header.module.css +20 -4
  78. package/packages/visualizer/src/components/Header.tsx +7 -4
  79. package/packages/visualizer/src/data/model/build.ts +17 -5
  80. package/packages/visualizer/src/data/model/types.ts +10 -1
  81. package/packages/visualizer/src/evidence/api.ts +6 -1
  82. package/packages/visualizer/src/evidence/check-detail.tsx +35 -3
  83. package/packages/visualizer/src/evidence/evidence.module.css +20 -0
  84. package/packages/visualizer/src/evidence/marks.tsx +2 -0
  85. package/packages/visualizer/src/evidence/overview.tsx +3 -3
  86. package/packages/visualizer/src/evidence/recording-page.tsx +19 -2
  87. package/packages/visualizer/src/evidence/recording.module.css +4 -8
  88. package/packages/visualizer/src/evidence/recording.tsx +74 -83
  89. package/packages/visualizer/src/evidence/snapshot.tsx +5 -4
  90. package/packages/visualizer/src/evidence/sources-panel.tsx +88 -34
  91. package/packages/visualizer/src/evidence/spec-index.tsx +3 -11
  92. package/packages/visualizer/src/find/Finder.module.css +168 -0
  93. package/packages/visualizer/src/find/Finder.tsx +303 -0
  94. package/packages/visualizer/src/find/corpus.test.ts +77 -0
  95. package/packages/visualizer/src/find/corpus.ts +368 -0
  96. package/packages/visualizer/src/find/open.ts +51 -0
  97. package/packages/visualizer/src/find/rank.test.ts +237 -0
  98. package/packages/visualizer/src/find/rank.ts +405 -0
  99. package/packages/visualizer/src/find/semantic.test.ts +62 -0
  100. package/packages/visualizer/src/find/semantic.ts +93 -0
  101. package/packages/visualizer/src/find/semantic.worker.ts +40 -0
  102. package/packages/visualizer/src/looks/Stage.tsx +322 -0
  103. package/packages/visualizer/src/looks/collect.test.ts +158 -0
  104. package/packages/visualizer/src/looks/collect.ts +245 -0
  105. package/packages/visualizer/src/looks/stage.module.css +300 -0
  106. package/packages/visualizer/src/reachable.test.ts +1 -1
  107. package/packages/visualizer/src/state/ui-store.ts +6 -2
  108. package/packages/visualizer/src/views/SidePanel.tsx +39 -49
  109. package/packages/visualizer/vite/example-reports.ts +475 -0
  110. package/packages/visualizer/vite.config.ts +9 -1
  111. package/packages/visualizer/src/app/search-query.tsx +0 -14
@@ -0,0 +1,7 @@
1
+ Feature: Loyalty stamps
2
+ A regular earns a free drink. No layer has recorded this yet.
3
+
4
+ Scenario: Tenth drink is free
5
+ Given nine stamps
6
+ When the customer orders
7
+ Then the drink is free
@@ -0,0 +1,36 @@
1
+ Feature: Order a drink
2
+ A customer picks a drink, pays, and picks it up.
3
+
4
+ Rule: Paying for the order
5
+
6
+ Scenario: Pays by card
7
+ Given a drink in the cart
8
+ When the customer pays by card
9
+ Then the barista can start the drink
10
+
11
+ Scenario: Pays with a wallet
12
+ Given a drink in the cart
13
+ When the customer pays with a wallet
14
+ Then the order is paid
15
+
16
+ Scenario: Card declined, then a wallet works
17
+ Given a drink in the cart
18
+ When the card is declined
19
+ And the customer pays with a wallet
20
+ Then the order is paid
21
+
22
+ Scenario: Slow payment
23
+ Given a drink in the cart
24
+ When the payment takes longer than a minute
25
+ Then the customer can check the payment again
26
+
27
+ Scenario: Leaves checkout and comes back
28
+ Given a drink in the cart
29
+ When the customer closes checkout
30
+ And the customer starts checkout again
31
+ Then checkout is open
32
+
33
+ Scenario: Drink left too long
34
+ Given a finished drink
35
+ When nobody picks it up
36
+ Then the drink is thrown away and refunded
@@ -0,0 +1,4 @@
1
+ // An order is paid once.
2
+ sig Order { paid: lone Payment }
3
+ sig Payment {}
4
+ fact { all o: Order | one o.paid }
@@ -0,0 +1,7 @@
1
+ # Cart
2
+ module cart {
3
+ var items: int
4
+ var paid: bool
5
+ action add = items' = items + 1
6
+ action pay = paid' = true
7
+ }
@@ -0,0 +1,10 @@
1
+ # Checkout latency
2
+ apiVersion: openslo/v1
3
+ kind: SLO
4
+ metadata:
5
+ name: checkout-latency
6
+ spec:
7
+ description: Checkout answers within a second.
8
+ objectives:
9
+ - displayName: Checkout latency
10
+ target: 0.99
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/">
3
+ <decision id="drink-price" name="Drink price">
4
+ <decisionTable id="Drink price" hitPolicy="UNIQUE">
5
+ <input label="Size">
6
+ <inputExpression typeRef="string"><text>size</text></inputExpression>
7
+ </input>
8
+ <input label="Milk">
9
+ <inputExpression typeRef="string"><text>milk</text></inputExpression>
10
+ </input>
11
+ <output label="Price" name="price" />
12
+ <rule>
13
+ <inputEntry><text>"Small"</text></inputEntry>
14
+ <inputEntry><text>"None"</text></inputEntry>
15
+ <outputEntry><text>3</text></outputEntry>
16
+ </rule>
17
+ <rule>
18
+ <inputEntry><text>"Large"</text></inputEntry>
19
+ <inputEntry><text>"Oat"</text></inputEntry>
20
+ <outputEntry><text>5</text></outputEntry>
21
+ </rule>
22
+ </decisionTable>
23
+ </decision>
24
+ </definitions>
@@ -0,0 +1,7 @@
1
+ Feature: Loyalty stamps
2
+ A regular earns a free drink. No layer has recorded this yet.
3
+
4
+ Scenario: Tenth drink is free
5
+ Given nine stamps
6
+ When the customer orders
7
+ Then the drink is free
@@ -0,0 +1,36 @@
1
+ Feature: Order a drink
2
+ A customer picks a drink, pays, and picks it up.
3
+
4
+ Rule: Paying for the order
5
+
6
+ Scenario: Pays by card
7
+ Given a drink in the cart
8
+ When the customer pays by card
9
+ Then the barista can start the drink
10
+
11
+ Scenario: Pays with a wallet
12
+ Given a drink in the cart
13
+ When the customer pays with a wallet
14
+ Then the order is paid
15
+
16
+ Scenario: Card declined, then a wallet works
17
+ Given a drink in the cart
18
+ When the card is declined
19
+ And the customer pays with a wallet
20
+ Then the order is paid
21
+
22
+ Scenario: Slow payment
23
+ Given a drink in the cart
24
+ When the payment takes longer than a minute
25
+ Then the customer can check the payment again
26
+
27
+ Scenario: Leaves checkout and comes back
28
+ Given a drink in the cart
29
+ When the customer closes checkout
30
+ And the customer starts checkout again
31
+ Then checkout is open
32
+
33
+ Scenario: Drink left too long
34
+ Given a finished drink
35
+ When nobody picks it up
36
+ Then the drink is thrown away and refunded
@@ -0,0 +1,4 @@
1
+ // An order is paid once.
2
+ sig Order { paid: lone Payment }
3
+ sig Payment {}
4
+ fact { all o: Order | one o.paid }
@@ -0,0 +1,7 @@
1
+ # Cart
2
+ module cart {
3
+ var items: int
4
+ var paid: bool
5
+ action add = items' = items + 1
6
+ action pay = paid' = true
7
+ }
@@ -0,0 +1,10 @@
1
+ # Checkout latency
2
+ apiVersion: openslo/v1
3
+ kind: SLO
4
+ metadata:
5
+ name: checkout-latency
6
+ spec:
7
+ description: Checkout answers within a second.
8
+ objectives:
9
+ - displayName: Checkout latency
10
+ target: 0.99
@@ -0,0 +1,24 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/">
3
+ <decision id="drink-price" name="Drink price">
4
+ <decisionTable id="Drink price" hitPolicy="UNIQUE">
5
+ <input label="Size">
6
+ <inputExpression typeRef="string"><text>size</text></inputExpression>
7
+ </input>
8
+ <input label="Milk">
9
+ <inputExpression typeRef="string"><text>milk</text></inputExpression>
10
+ </input>
11
+ <output label="Price" name="price" />
12
+ <rule>
13
+ <inputEntry><text>"Small"</text></inputEntry>
14
+ <inputEntry><text>"None"</text></inputEntry>
15
+ <outputEntry><text>3</text></outputEntry>
16
+ </rule>
17
+ <rule>
18
+ <inputEntry><text>"Large"</text></inputEntry>
19
+ <inputEntry><text>"Oat"</text></inputEntry>
20
+ <outputEntry><text>5</text></outputEntry>
21
+ </rule>
22
+ </decisionTable>
23
+ </decision>
24
+ </definitions>
@@ -0,0 +1,7 @@
1
+ Feature: Loyalty stamps
2
+ A regular earns a free drink. No layer has recorded this yet.
3
+
4
+ Scenario: Tenth drink is free
5
+ Given nine stamps
6
+ When the customer orders
7
+ Then the drink is free
@@ -0,0 +1,36 @@
1
+ Feature: Order a drink
2
+ A customer picks a drink, pays, and picks it up.
3
+
4
+ Rule: Paying for the order
5
+
6
+ Scenario: Pays by card
7
+ Given a drink in the cart
8
+ When the customer pays by card
9
+ Then the barista can start the drink
10
+
11
+ Scenario: Pays with a wallet
12
+ Given a drink in the cart
13
+ When the customer pays with a wallet
14
+ Then the order is paid
15
+
16
+ Scenario: Card declined, then a wallet works
17
+ Given a drink in the cart
18
+ When the card is declined
19
+ And the customer pays with a wallet
20
+ Then the order is paid
21
+
22
+ Scenario: Slow payment
23
+ Given a drink in the cart
24
+ When the payment takes longer than a minute
25
+ Then the customer can check the payment again
26
+
27
+ Scenario: Leaves checkout and comes back
28
+ Given a drink in the cart
29
+ When the customer closes checkout
30
+ And the customer starts checkout again
31
+ Then checkout is open
32
+
33
+ Scenario: Drink left too long
34
+ Given a finished drink
35
+ When nobody picks it up
36
+ Then the drink is thrown away and refunded
@@ -0,0 +1,4 @@
1
+ // An order is paid once.
2
+ sig Order { paid: lone Payment }
3
+ sig Payment {}
4
+ fact { all o: Order | one o.paid }
@@ -30,6 +30,8 @@ import { APP_ROOT, FIXTURES_ROOT, listSpecFiles } from '../vite/atlas-files';
30
30
  * Regenerates every fixture from the example spec in `example/specs` (a coffee-ordering app: an
31
31
  * XState root chart with a parallel region that invokes an SCXML checkout chart) and a generated
32
32
  * large atlas. All media is synthetic: ImageMagick draws the screens and ffmpeg the clips.
33
+ * Gherkin, Quint, DMN, Alloy and OpenSLO documents in that spec are copied with the charts.
34
+ * The visualizer dev server serves the layered report for them; this script does not rebuild it.
33
35
  */
34
36
 
35
37
  const EXAMPLE_SPECS = path.join(APP_ROOT, 'example/specs');
@@ -5,8 +5,7 @@ import { publishData } from '../vite/publish';
5
5
 
6
6
  /**
7
7
  * `pnpm publish-data`: the spec and the newest runs into `public/data` (or `ATLAS_OUT`), where
8
- * `pnpm build` picks them up. Roots come from `ATLAS_SPECS_ROOT` / `ATLAS_RUNS_ROOT` or
9
- * `ATLAS_FIXTURE`, as for the dev server.
8
+ * `pnpm build` picks them up. Roots come from `ATLAS_SPECS_ROOT` / `ATLAS_RUNS_ROOT`, as for the dev server.
10
9
  */
11
10
  try {
12
11
  const roots = resolveRoots();
@@ -6,9 +6,9 @@ import { SourcesPanel } from '../evidence/sources-panel';
6
6
  import { SpecIndex } from '../evidence/spec-index';
7
7
  import { kindOfAddress } from '../spec-kind';
8
8
  import styles from '../views/views.module.css';
9
+ import { Finder } from '../find/Finder';
9
10
  import { AtlasProvider, useAtlas } from './atlas-context';
10
11
  import { Gazetteer } from './gazetteer';
11
- import { SearchQueryProvider } from './search-query';
12
12
 
13
13
  function Index() {
14
14
  const { pathname } = useLocation();
@@ -38,18 +38,17 @@ export function AppShell() {
38
38
  return (
39
39
  <AtlasProvider run={run}>
40
40
  <EvidenceProvider>
41
- <SearchQueryProvider>
42
- <div className={styles.app}>
43
- <Header />
44
- <div className={styles.frame}>
45
- <Index />
46
- <main className={styles.main}>
47
- <Body />
48
- </main>
49
- </div>
50
- <SourcesPanel />
41
+ <div className={styles.app}>
42
+ <Header />
43
+ <div className={styles.frame}>
44
+ <Index />
45
+ <main className={styles.main}>
46
+ <Body />
47
+ </main>
51
48
  </div>
52
- </SearchQueryProvider>
49
+ <SourcesPanel />
50
+ <Finder />
51
+ </div>
53
52
  </EvidenceProvider>
54
53
  </AtlasProvider>
55
54
  );
@@ -29,8 +29,8 @@
29
29
  .row {
30
30
  display: flex;
31
31
  justify-content: space-between;
32
- align-items: baseline;
33
- gap: 12px;
32
+ align-items: center;
33
+ gap: 10px;
34
34
  padding: 6px 10px;
35
35
  border-radius: 8px;
36
36
  color: inherit;
@@ -38,6 +38,16 @@
38
38
  text-decoration: none;
39
39
  }
40
40
 
41
+ .thumb {
42
+ flex: none;
43
+ width: 28px;
44
+ }
45
+
46
+ .name {
47
+ flex: 1;
48
+ min-width: 0;
49
+ }
50
+
41
51
  .chart {
42
52
  color: var(--ink-2);
43
53
  font-weight: 600;
@@ -1,13 +1,17 @@
1
1
  import { Link, useLocation, useParams, useSearch } from '@tanstack/react-router';
2
2
 
3
+ import { ScreenImage } from '../components/Screen';
3
4
  import { STATUS_LABEL, type AtlasView, type StateView } from '../data/model';
4
5
  import { useAtlas } from './atlas-context';
5
- import { useSearchQuery } from './search-query';
6
6
  import styles from './gazetteer.module.css';
7
7
 
8
- function hit(query: string, text: string) {
9
- const needle = query.trim().toLowerCase();
10
- return needle.length === 0 || text.toLowerCase().includes(needle);
8
+ function openingScreen(view: AtlasView, steps: readonly { transitionIds: readonly string[] }[]) {
9
+ for (const step of steps) {
10
+ const transition = view.transitions.get(step.transitionIds[0] ?? '');
11
+ const landing = transition?.target ? view.states.get(transition.target) : undefined;
12
+ if (landing && (landing.kind === 'screen' || landing.kind === 'final')) return landing;
13
+ }
14
+ return undefined;
11
15
  }
12
16
 
13
17
  function screensOf(view: AtlasView, chartId: string) {
@@ -19,7 +23,6 @@ function screensOf(view: AtlasView, chartId: string) {
19
23
  }
20
24
 
21
25
  export function Gazetteer() {
22
- const { query } = useSearchQuery();
23
26
  const { view } = useAtlas();
24
27
  const params = useParams({ strict: false });
25
28
  const search = useSearch({ strict: false });
@@ -27,7 +30,7 @@ export function Gazetteer() {
27
30
  const screen = typeof search.screen === 'string' ? search.screen : undefined;
28
31
  const journeyId = typeof search.journey === 'string' ? search.journey : undefined;
29
32
  const group = typeof search.group === 'string' ? search.group : undefined;
30
- const journeys = (view?.journeys ?? []).filter((journey) => journey.chartIds.length > 0 && hit(query, journey.name));
33
+ const journeys = (view?.journeys ?? []).filter((journey) => journey.chartIds.length > 0);
31
34
  return (
32
35
  <nav className={styles.index} aria-label="Places">
33
36
  <h2>Places</h2>
@@ -38,9 +41,7 @@ export function Gazetteer() {
38
41
  {context.chartIds.map((chartId) => {
39
42
  const chart = view.charts.get(chartId);
40
43
  if (!chart) return null;
41
- const screens = screensOf(view, chartId).filter((state) => hit(query, `${state.name} ${chart.name} ${context.name}`));
42
- const chartHit = hit(query, `${chart.name} ${context.name}`);
43
- if (query.trim() && screens.length === 0 && !chartHit) return null;
44
+ const screens = screensOf(view, chartId);
44
45
  return (
45
46
  <div key={chartId}>
46
47
  <Link className={styles.chart} to="/chart/$chartId" params={{ chartId }} search={(prev) => ({ run: prev.run, screen: undefined, journey: undefined, group: undefined })} activeOptions={{ includeSearch: true, explicitUndefined: true }} aria-current={params.chartId === chartId && !screen && !journeyId && !group ? 'page' : undefined}>
@@ -57,7 +58,10 @@ export function Gazetteer() {
57
58
  search={(prev) => ({ run: prev.run, screen: state.name, journey: prev.journey })}
58
59
  aria-current={params.chartId === chartId && screen === state.name ? 'page' : undefined}
59
60
  >
60
- <span>{state.name}</span>
61
+ <span className={styles.thumb} aria-hidden="true">
62
+ <ScreenImage state={state} size="thumb" description={false} lines={false} caption={false} />
63
+ </span>
64
+ <span className={styles.name}>{state.name}</span>
61
65
  <span className={styles.status}>{STATUS_LABEL[state.status]}</span>
62
66
  </Link>
63
67
  </li>
@@ -72,21 +76,29 @@ export function Gazetteer() {
72
76
  <section>
73
77
  <h3>Journeys</h3>
74
78
  <ul>
75
- {journeys.map((journey) => (
76
- <li key={journey.id}>
77
- <Link
78
- className={styles.row}
79
- data-status={journey.status}
80
- to="/chart/$chartId"
81
- params={{ chartId: journey.chartIds[0] ?? '' }}
82
- search={(prev) => ({ run: prev.run, journey: journey.id })}
83
- aria-current={journeyId === journey.id && pathname.startsWith('/chart') ? 'page' : undefined}
84
- >
85
- <span>{journey.name}</span>
86
- <span className={styles.status}>{STATUS_LABEL[journey.status]}</span>
87
- </Link>
88
- </li>
89
- ))}
79
+ {journeys.map((journey) => {
80
+ const opening = view ? openingScreen(view, journey.steps) : undefined;
81
+ return (
82
+ <li key={journey.id}>
83
+ <Link
84
+ className={styles.row}
85
+ data-status={journey.status}
86
+ to="/chart/$chartId"
87
+ params={{ chartId: journey.chartIds[0] ?? '' }}
88
+ search={(prev) => ({ run: prev.run, journey: journey.id })}
89
+ aria-current={journeyId === journey.id && pathname.startsWith('/chart') ? 'page' : undefined}
90
+ >
91
+ {opening && (
92
+ <span className={styles.thumb} aria-hidden="true">
93
+ <ScreenImage state={opening} size="thumb" description={false} lines={false} caption={false} />
94
+ </span>
95
+ )}
96
+ <span className={styles.name}>{journey.name}</span>
97
+ <span className={styles.status}>{STATUS_LABEL[journey.status]}</span>
98
+ </Link>
99
+ </li>
100
+ );
101
+ })}
90
102
  </ul>
91
103
  </section>
92
104
  )}
@@ -94,7 +106,7 @@ export function Gazetteer() {
94
106
  <section>
95
107
  <h2>Not in the spec yet</h2>
96
108
  <ul>
97
- {view.excluded.filter((item) => hit(query, item)).map((item) => (
109
+ {view.excluded.map((item) => (
98
110
  <li key={item} className={styles.excluded}>{item}</li>
99
111
  ))}
100
112
  </ul>
@@ -137,23 +137,39 @@
137
137
  }
138
138
 
139
139
  .search {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ gap: 10px;
140
143
  width: min(15rem, 28vw);
141
144
  height: 2.125rem;
142
- padding: 0 14px;
145
+ padding: 0 8px 0 14px;
143
146
  border: 1px solid transparent;
144
147
  border-radius: var(--pill);
145
148
  background: var(--bg-2);
149
+ color: var(--ink-3);
146
150
  font-size: 0.875rem;
151
+ font-weight: 500;
152
+ text-align: left;
153
+ cursor: pointer;
147
154
  }
148
155
 
149
- .search::placeholder {
156
+ .search kbd {
157
+ margin-left: auto;
158
+ padding: 0 0.35rem;
159
+ border: 1px solid var(--line-2);
160
+ border-radius: 0.35rem;
150
161
  color: var(--ink-3);
162
+ font-family: var(--font);
163
+ font-size: 0.75rem;
164
+ font-weight: 600;
165
+ line-height: 1.35rem;
151
166
  }
152
167
 
153
- .search:focus {
168
+ .search:hover,
169
+ .search:focus-visible {
154
170
  border-color: var(--place);
155
171
  background: var(--bg);
156
- outline: none;
172
+ color: var(--ink);
157
173
  }
158
174
 
159
175
  @media (max-width: 760px) {
@@ -2,7 +2,8 @@ import { Link, useLocation, useNavigate, useSearch } from '@tanstack/react-route
2
2
  import { useEffect, useMemo } from 'react';
3
3
 
4
4
  import { useAtlas } from '../app/atlas-context';
5
- import { useSearchQuery } from '../app/search-query';
5
+ import { commandKey } from '../find/Finder';
6
+ import { setFinderOpen } from '../find/open';
6
7
  import type { AtlasView } from '../data/model';
7
8
  import { LATEST } from '../data/queries';
8
9
  import { useEvidence } from '../evidence/evidence-context';
@@ -76,7 +77,7 @@ function SourcesButton() {
76
77
  return (
77
78
  <button type="button" className={styles.sources} data-filtered={off > 0 || undefined} onClick={() => setOpen(true)}>
78
79
  Sources
79
- <span>{unavailable ? 'unavailable' : off > 0 ? `${off} off` : plural(layers.length, 'run', 'runs')}</span>
80
+ <span>{unavailable ? 'unavailable' : off > 0 ? `${off} off` : plural(layers.length, 'layer', 'layers')}</span>
80
81
  </button>
81
82
  );
82
83
  }
@@ -85,7 +86,6 @@ export function Header() {
85
86
  const { view } = useAtlas();
86
87
  const evidence = useEvidence();
87
88
  const { pathname } = useLocation();
88
- const { query, setQuery } = useSearchQuery();
89
89
  const kind = kindOfAddress(pathname);
90
90
  const product = productName(view?.title);
91
91
  useEffect(() => {
@@ -120,7 +120,10 @@ export function Header() {
120
120
  {!kind && view && <RunPicker view={view} />}
121
121
  {!kind && view && <DataIssues issues={view.issues} />}
122
122
  <SourcesButton />
123
- <input className={styles.search} type="search" value={query} aria-label="Find" placeholder={kind ? `Find a ${kind.one}` : 'Find a place'} onChange={(event) => setQuery(event.target.value)} />
123
+ <button type="button" className={styles.search} aria-keyshortcuts="Meta+K Control+K" onClick={() => setFinderOpen(true)}>
124
+ Find a flow
125
+ <kbd>{commandKey()}</kbd>
126
+ </button>
124
127
  </div>
125
128
  </header>
126
129
  );
@@ -108,12 +108,24 @@ export function buildAtlasView(input: BuildInput): AtlasView {
108
108
 
109
109
  const processing = input.runProgress === 'running' || run?.info.finishedAt === null;
110
110
  const itemStatus = (status: ItemStatus | undefined) => processing && status === 'not-reached' ? 'not-yet-run' : status ?? unrun;
111
+ const shotOf = (img: { png: string; webp?: string | undefined; client?: string | undefined }) => {
112
+ const full = input.resolveMedia(run!.id, img.png) || null;
113
+ const thumb = img.webp ? input.resolveMedia(run!.id, img.webp) || full : full;
114
+ if (!full && !thumb) return null;
115
+ return { client: img.client ?? null, thumb, full };
116
+ };
111
117
  const image = (img: Image | null | undefined, marked: string | undefined): ImageMedia => {
112
- if (!run || !img) return { state: marked === 'processing' || processing ? 'processing' : 'missing', thumb: null, full: null };
113
- if (marked === 'processing') return { state: 'processing', thumb: null, full: null };
114
- const full = input.resolveMedia(run.id, img.png) || null;
115
- const thumb = img.webp ? input.resolveMedia(run.id, img.webp) || full : full;
116
- return { state: full || thumb ? 'available' : 'missing', thumb, full };
118
+ const empty = (state: ImageMedia['state']): ImageMedia => ({ state, thumb: null, full: null, client: img?.client ?? null, also: [] });
119
+ if (!run || !img) return empty(marked === 'processing' || processing ? 'processing' : 'missing');
120
+ if (marked === 'processing') return empty('processing');
121
+ const main = shotOf(img);
122
+ const also = (img.also ?? []).flatMap((extra) => {
123
+ if (!extra.png) return [];
124
+ const shot = shotOf({ png: extra.png, webp: extra.webp, client: extra.client });
125
+ return shot ? [shot] : [];
126
+ });
127
+ if (!main) return { state: 'missing', thumb: null, full: null, client: img.client ?? null, also };
128
+ return { state: 'available', thumb: main.thumb, full: main.full, client: main.client, also };
117
129
  };
118
130
  const pathRef = (id: string): PathRef => ({ id, name: run?.paths.find((p) => p.id === id)?.name ?? id });
119
131
 
@@ -25,7 +25,16 @@ export type SpecChange = 'same' | 'added' | 'removed' | 'changed';
25
25
 
26
26
  export type MediaState = 'available' | 'missing' | 'processing';
27
27
 
28
- export type ImageMedia = { state: MediaState; thumb: string | null; full: string | null };
28
+ /** One client's still of a screen. `also` on ImageMedia holds the other clients at the same moment. */
29
+ export type ImageShot = { client: string | null; thumb: string | null; full: string | null };
30
+
31
+ export type ImageMedia = {
32
+ state: MediaState;
33
+ thumb: string | null;
34
+ full: string | null;
35
+ client: string | null;
36
+ also: ImageShot[];
37
+ };
29
38
 
30
39
  export type VideoMedia = {
31
40
  state: MediaState;
@@ -19,8 +19,13 @@ function explain(status: number) {
19
19
  return `Evidence could not be read (${status}).`;
20
20
  }
21
21
 
22
+ /** A captured file's address in the browser. Pictures and video use it directly; fetches use the same address. */
23
+ export function reportAssetUrl(path: string): string {
24
+ return staticEvidence ? `${reportsBase}/${evidenceFile(path).split('/').map(encodeURIComponent).join('/')}` : `${reportsBase}${path}`;
25
+ }
26
+
22
27
  export async function reportResponse(path: string, signal?: AbortSignal) {
23
- const url = staticEvidence ? `${reportsBase}/${evidenceFile(path).split('/').map(encodeURIComponent).join('/')}` : `${reportsBase}${path}`;
28
+ const url = reportAssetUrl(path);
24
29
  let response: Response;
25
30
  try {
26
31
  response = await fetch(url, { signal });