No files matched your search
@@ -32,4 +32,5 @@ storage
|
||||
pnpm-lock.yaml
|
||||
.env
|
||||
tailwind.panel.js
|
||||
LOCALIZATION.md
|
||||
LOCALIZATION.md
|
||||
promo/**
|
||||
@@ -1,5 +1,16 @@
|
||||
# Change Log
|
||||
|
||||
## [10.13.0] - 2026-10-12 - [Release notes](https://beta.frontmatter.codes/updates/v10.13.0)
|
||||
|
||||
### 🎨 Enhancements
|
||||
|
||||
- [#1061](https://github.com/estruyf/vscode-front-matter/issues/1061): Added the `searchable` property on `choice` fields to show a searchable input instead of a dropdown, useful when you have many choices
|
||||
|
||||
### 🐞 Fixes
|
||||
|
||||
- [#1062](https://github.com/estruyf/vscode-front-matter/issues/1062): Fix dropped characters and sluggish typing in `string` and `wysiwyg` fields when the front matter update round trip is slower than the typing speed
|
||||
- [#1064](https://github.com/estruyf/vscode-front-matter/issues/1064): Fix the extension hanging after initialization in Astro projects when the Node.js lookup fails thanks to [Matt Van Horn](https://github.com/mvanhorn)
|
||||
|
||||
## [10.12.0] - 2026-08-21 - [Release notes](https://beta.frontmatter.codes/updates/v10.12.0)
|
||||
|
||||
### 🎨 Enhancements
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1201 1201" enable-background="new 0 0 1201 1201" xml:space="preserve">
|
||||
<rect x="1" y="1" display="none" fill="none" stroke="#01AEB7" stroke-width="50" stroke-miterlimit="10" width="1200" height="1200"/>
|
||||
<g enable-background="new ">
|
||||
<path fill="#00eaff" d="M173.4,576.8H96.9V214.5h135.2v85.2h-58.7v52.8H228v81.9h-54.7V576.8z"/>
|
||||
<path fill="#00eaff" d="M463.3,576.8h-82.7l-37.8-129.1c-0.4-2.2-1-5.1-1.9-8.7c-0.9-3.6-2-7.9-3.4-12.9l0.6,23.9v126.7h-76.1
|
||||
V214.5h78.3c34.5,0,60.3,8.7,77.4,26.1c21.8,22.2,32.7,53.8,32.7,94.8c0,43.8-14.6,74.1-43.9,90.8L463.3,576.8z M338.3,382.3h5.1
|
||||
c8.8,0,16.1-4.3,22-12.9c5.9-8.6,8.8-19.6,8.8-32.9c0-27.1-11.1-40.6-33.2-40.6h-2.8V382.3z"/>
|
||||
<path fill="#00eaff" d="M693.2,396.8c0,55.2-9.8,100.1-29.5,134.7c-19.6,34.4-45.1,51.6-76.5,51.6c-27.9,0-51.8-13.2-71.7-39.7
|
||||
c-25.1-33.5-37.6-83.7-37.6-150.7c0-64.1,13.1-113.2,39.3-147.1c19.4-25.2,42.5-37.8,69.2-37.8c32.7,0,58.6,17.1,78,51.2
|
||||
C683.5,293.2,693.2,339.1,693.2,396.8z M613.7,397.5c0-65.6-9.1-98.3-27.2-98.3c-9.1,0-16.3,8.6-21.7,25.8
|
||||
c-5.3,16-7.9,39.4-7.9,70.4c0,30.5,2.5,54.2,7.4,71c5,16.8,11.8,25.2,20.6,25.2c9.1,0,16-8.3,20.9-24.9
|
||||
C611.1,450.2,613.7,427.1,613.7,397.5z"/>
|
||||
<path fill="#00eaff" d="M725.8,576.8V214.5h73l38.3,127.2c2.1,7.5,4.4,16.1,7.1,25.8c2.6,9.7,5.5,20.9,8.7,33.6l7.9,31.9
|
||||
c-2.8-31.4-5-58.4-6.4-80.7c-1.5-22.4-2.2-41.1-2.2-56.3v-81.4h73v362.4h-73l-38.5-133.3c-4-14.2-7.7-27.6-10.9-40
|
||||
c-3.2-12.4-6.1-24.3-8.5-35.6c1.6,25.5,2.8,47.7,3.5,66.7c0.7,18.9,1.1,35.5,1.1,49.8v92.5H725.8z"/>
|
||||
<path fill="#00eaff" d="M1063.8,576.8h-76.5V301.3h-42.2v-86.8h162.6v86.8h-43.9V576.8z"/>
|
||||
</g>
|
||||
<g enable-background="new ">
|
||||
<path fill="#00eaff" d="M123,657.8h35.8l27.4,133.7c3.4,16.7,6.4,33.4,9.2,50.2c2.7,16.8,5.3,34.4,7.5,52.7
|
||||
c0.3-2.4,0.5-4.3,0.6-5.6c0.1-1.3,0.3-2.3,0.4-2.9l5.8-37.6l5.2-35.7l4.6-23.6L244,657.8h36.2l28.6,327.7h-40l-7-111.2
|
||||
c-0.3-5.7-0.5-10.5-0.7-14.6c-0.2-4.1-0.3-7.5-0.3-10.2l-1.8-43.9l-1-40.3c0-0.3,0-0.9-0.1-1.8c-0.1-0.9-0.2-2.2-0.3-3.7l-1,6.6
|
||||
c-1.1,7.8-2,14.6-2.9,20.4c-0.9,5.8-1.6,10.7-2.3,14.6l-5.2,29.9l-2,11.2l-26.2,143.1h-28.2L165.1,862c-3.4-16.3-6.3-32.8-9-49.6
|
||||
c-2.6-16.8-5-34.3-7.1-52.7l-12.1,225.8H97.5L123,657.8z"/>
|
||||
<path fill="#00eaff" d="M395.5,657.8h32.6l66.6,327.7h-41.5l-11.7-63.9H380l-11.9,63.9h-40.2L395.5,657.8z M435.5,887.5l-9.3-52.2
|
||||
c-6.2-35.5-11.2-73.4-15.1-113.8c-1.9,19.7-4.3,39.6-7.2,59.8c-3,20.2-6.4,41.6-10.5,64.2l-7.6,42H435.5z"/>
|
||||
<path fill="#00eaff" d="M495.7,657.8h136.2v38.6H585v289.1H544V696.4h-48.3V657.8z"/>
|
||||
<path fill="#00eaff" d="M638.1,657.8h136.2v38.6h-46.9v289.1h-41.1V696.4h-48.3V657.8z"/>
|
||||
<path fill="#00eaff" d="M805.4,657.8h111.3v37.4h-69.4V799h61.6v37.4h-61.6v111.9h69.4v37.4H805.4V657.8z"/>
|
||||
<path fill="#00eaff" d="M962.9,657.8h55.1c22.8,0,39.8,5.7,50.9,17.2c14.1,14.9,21.1,37.6,21.1,68.1c0,23.5-3.7,42.3-11.2,56.6
|
||||
c-7.4,14.2-18.1,23-31.9,26.4l57.2,159.4h-42.5l-57-160.5v160.5h-41.9V657.8z M1004.8,803c16.2,0,27.7-4,34.4-11.9
|
||||
c6.7-7.9,10.1-21.3,10.1-40.1c0-10.2-0.7-18.9-2.1-26.1c-1.4-7.2-3.6-13.1-6.6-17.6c-3-4.5-6.9-7.9-11.7-10
|
||||
c-4.8-2.1-10.4-3.2-17-3.2h-7V803z"/>
|
||||
</g>
|
||||
<rect x="97" y="93.9" fill="#00eaff" width="169.7" height="43"/>
|
||||
<rect x="351.9" y="93.9" fill="#00eaff" width="169.7" height="43"/>
|
||||
<rect x="609.8" y="93.9" fill="#00eaff" width="169.7" height="43"/>
|
||||
<rect x="97" y="1060.7" fill="#00eaff" width="169.7" height="43"/>
|
||||
<rect x="351.9" y="1060.7" fill="#00eaff" width="169.7" height="43"/>
|
||||
<rect x="609.8" y="1060.7" fill="#00eaff" width="169.7" height="43"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -429,6 +429,7 @@
|
||||
|
||||
"panel.fields.choiceField.select": "Select {0}",
|
||||
"panel.fields.choiceField.clear": "Clear value",
|
||||
"panel.fields.choiceField.search": "Search {0}",
|
||||
|
||||
"panel.fields.contentTypeRelationshipField.loading": "Fetching possible values...",
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vscode-front-matter-beta",
|
||||
"version": "10.12.0",
|
||||
"version": "10.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vscode-front-matter-beta",
|
||||
"version": "10.12.0",
|
||||
"version": "10.13.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@actions/core": "^1.10.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"displayName": "Front Matter CMS",
|
||||
"description": "Front Matter is a CMS that runs within Visual Studio Code. It gives you the power and control of a full-blown CMS while also providing you the flexibility and speed of the static site generator of your choice like: Hugo, Jekyll, Docusaurus, NextJs, Gatsby, and many more...",
|
||||
"icon": "assets/frontmatter-teal-128x128.png",
|
||||
"version": "10.12.0",
|
||||
"version": "10.13.0",
|
||||
"preview": false,
|
||||
"publisher": "eliostruyf",
|
||||
"galleryBanner": {
|
||||
@@ -1488,6 +1488,11 @@
|
||||
"default": false,
|
||||
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.multiple.description%"
|
||||
},
|
||||
"searchable": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "%setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.searchable.description%"
|
||||
},
|
||||
"isPreviewImage": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
@@ -220,6 +220,7 @@
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.single.description": "Is a single line field",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.wysiwyg.description": "Is a WYSIWYG field. You can set it to markdown or HTML.",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.multiple.description": "Do you allow to select multiple values?",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.searchable.description": "Show a searchable input for the choice field instead of a dropdown. Useful when you have many choices.",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.isPreviewImage.description": "Specify if the image field can be used as preview. Be aware, you can only have one preview image per content type.",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.hidden.description": "Do you want to hide the field from the metadata section?",
|
||||
"setting.frontMatter.taxonomy.contentTypes.items.properties.fields.items.properties.taxonomyId.description": "The ID of your taxonomy field. It cannot contain the \"tags\" or \"categories\" value.",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
|
||||
# The renders, and everything `npm run capture` produces. All of it is
|
||||
# regenerable from demo/ and scripts/, and none of it is small. clips.json is the
|
||||
# exception: it is a manifest rather than media, and src/components/Shot.tsx
|
||||
# imports it, so a fresh checkout has to typecheck without a capture first.
|
||||
out/
|
||||
public/shots/
|
||||
public/clips/
|
||||
|
||||
# The pinned VS Code build, the packaged extension and the throwaway profiles.
|
||||
.cache/
|
||||
@@ -0,0 +1,126 @@
|
||||
# The promo videos
|
||||
|
||||
Two cuts of Front Matter CMS, made with [Remotion](https://www.remotion.dev) —
|
||||
React rendered to frames, so the timeline is a table of frame numbers in a
|
||||
`.tsx` file and changing the pace is changing a number and re-rendering.
|
||||
|
||||
| Composition | | |
|
||||
| --- | --- | --- |
|
||||
| `Promo` | 30s | The pitch: the dashboard, editing a post in the panel, media |
|
||||
| `Tour` | 90s | Every screen: contents, drafts, editing, content health, creating a post, media, snippets, data, taxonomies |
|
||||
|
||||
Nothing here is part of the extension. It has its own `package.json`, the root
|
||||
`tsconfig.json` excludes it, and `.vscodeignore` keeps it out of the VSIX.
|
||||
|
||||
## Building them
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npx playwright install chromium # only for `npm run covers`
|
||||
|
||||
npm run vsix # package the extension from the repo root into .cache/
|
||||
npm run capture # every still and every recorded beat, retaken
|
||||
npm start # the Remotion studio, with a scrubbable timeline
|
||||
npm run render # out/promo.mp4
|
||||
npm run render:tour # out/tour.mp4
|
||||
npm run web # out/web/: the copies the site and the README use
|
||||
```
|
||||
|
||||
`npm run capture shots` and `npm run capture clips` do half each; naming a shot
|
||||
or a beat (`npm run capture edit media`) does just that one.
|
||||
|
||||
A capture opens a VS Code window on your screen and drives it. Leave the mouse
|
||||
alone until it closes.
|
||||
|
||||
## Where the pictures come from
|
||||
|
||||
Everything on screen is the real extension, running in real VS Code, driven by
|
||||
Playwright. Nothing is a mock-up.
|
||||
|
||||
`scripts/harness.mjs` downloads a pinned VS Code build (`VSCODE_VERSION`) into
|
||||
`.cache/vscode`, installs the packaged VSIX into an empty extensions folder, and
|
||||
opens a fresh copy of `demo/tidewater` with a throwaway profile — so every run
|
||||
starts from the same state and nothing a beat writes ever reaches `demo/`. The
|
||||
VSIX runs in production mode, which is what loads the webviews from `dist/`;
|
||||
`--extensionDevelopmentPath` would load them from the webpack dev server.
|
||||
|
||||
`demo/tidewater` is a made-up studio's Astro site. The people, posts and
|
||||
pictures in it are invented; `npm run covers` redraws the images (abstract, and
|
||||
seeded, so a redraw gives the same pixels). Every date in it is written against
|
||||
Thursday 24 September 2026, and every file's modification time is set to that
|
||||
day before VS Code opens it.
|
||||
|
||||
Two settings in the demo's `frontmatter.json` are there for the video, and both
|
||||
are ones a real site could have:
|
||||
|
||||
- `frontMatter.snippets.wrapper.enabled: false` — so an inserted snippet is
|
||||
just the component, without the `FM:Snippet` comments that let Front Matter
|
||||
re-open it later.
|
||||
- `frontMatter.content.defaultSorting: PublishedDesc` — newest first, so the
|
||||
dashboard opens on the drafts and the latest posts.
|
||||
|
||||
**Stills** — `public/shots/*.png`, one per screen, at 2x (2560x1640 for a
|
||||
1280x820 window). The pointer is parked in the title bar before each one:
|
||||
hiding the dot is not enough, because the card it was left on keeps its hover
|
||||
state.
|
||||
|
||||
**Recorded beats** — `public/clips/*.mp4`, at a constant 30 fps. Each is
|
||||
`{ prepare, run }` and only `run` is recorded. Each beat gets a VS Code of its
|
||||
own, so the draft the `edit` beat publishes is still a draft in every other
|
||||
shot. `public/clips.json` records how long each one is and the compositions
|
||||
read it.
|
||||
|
||||
Things about driving VS Code that are worth knowing before changing any of it:
|
||||
|
||||
- **Webviews are not inside the editor or the sidebar in the DOM.** They sit in
|
||||
an overlay laid over them. The dashboard and the panel are found by what they
|
||||
rendered — both render `#app`, only the dashboard's has `data-webview-url` —
|
||||
and looked up afresh every time, because a webview that is hidden and shown
|
||||
can come back as a new frame.
|
||||
- **The pointer is drawn.** CDP's screencast leaves the cursor out, and mouse
|
||||
events inside a webview never reach the workbench document, so the dot cannot
|
||||
follow `mousemove`. `pointTo` moves the real mouse and the dot to the same
|
||||
coordinates, step for step. It is the only thing in any frame that VS Code did
|
||||
not draw.
|
||||
- **The command palette filters a beat after you type.** `runCommand` waits
|
||||
until the top row is the exact command before pressing Enter; before that it
|
||||
once opened the secondary side bar instead of closing the primary one. The
|
||||
side bars only have *toggle* commands, so reset toggles them only when they
|
||||
are showing.
|
||||
- **One date is not pinned.** A post made in the `create` beat is dated the day
|
||||
the capture ran — VS Code's extension host has its own clock. No caption
|
||||
mentions it.
|
||||
|
||||
## How a shot is put together
|
||||
|
||||
Each recorded beat **holds** on its first frame while the caption is read,
|
||||
**plays** at full speed, and holds where it comes to rest — see `Clip` in
|
||||
[`src/components/Shot.tsx`](src/components/Shot.tsx). Nothing is sped up; a beat
|
||||
that runs long gets its pauses shortened in `capture.mjs` and is re-recorded.
|
||||
|
||||
The shots both cuts use are in [`src/shots.tsx`](src/shots.tsx), with the two
|
||||
crops:
|
||||
|
||||
- `DESK` — the whole window.
|
||||
- `LEFT` — the left 1908px: the panel and the start of every editor line. Its
|
||||
right edge sits in the gutter between the third and fourth dashboard card.
|
||||
VS Code's quick input and its toasts cross it, which is why the `create` and
|
||||
`snippet` beats use `DESK`.
|
||||
|
||||
[`src/cut.tsx`](src/cut.tsx) lays scenes end to end and gives the outro what is
|
||||
left; it throws if a longer re-capture leaves the outro under four seconds.
|
||||
|
||||
## Re-cutting after the UI changes
|
||||
|
||||
`npm run vsix && npm run capture` replaces every frame. Three things it does not
|
||||
fix:
|
||||
|
||||
- **Crops.** If the dashboard's card grid moves, `LEFT` may no longer end in a
|
||||
gutter. Re-measure against `public/shots/contents.png`.
|
||||
- **Caption switch frames inside a beat.** They are the `at:` values in
|
||||
`src/shots.tsx`, with a note above them of where each thing happens in its
|
||||
clip. A caption that says "it comes back wrapped" over a dialog nobody has
|
||||
confirmed yet is the failure. Check with
|
||||
`npx remotion still src/index.ts Tour out/check.png --frame=N`.
|
||||
- **The VS Code build.** Bumping `VSCODE_VERSION` moves the workbench chrome;
|
||||
treat it like a UI change.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"taxonomy": {
|
||||
"tags": [
|
||||
"accessibility",
|
||||
"astro",
|
||||
"css",
|
||||
"design-systems",
|
||||
"images",
|
||||
"migration",
|
||||
"performance",
|
||||
"static-sites",
|
||||
"studio-life",
|
||||
"typography",
|
||||
"ux",
|
||||
"workflow",
|
||||
"writing"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tidewater
|
||||
|
||||
The studio website: work, the journal and the handbook. Content lives in `src/content` and is edited with Front Matter CMS.
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
|
||||
export default defineConfig({
|
||||
site: 'https://tidewater.example',
|
||||
});
|
||||
@@ -0,0 +1,313 @@
|
||||
{
|
||||
"$schema": "https://frontmatter.codes/frontmatter.schema.json",
|
||||
"frontMatter.framework.id": "astro",
|
||||
"frontMatter.content.publicFolder": "public",
|
||||
"frontMatter.dashboard.openOnStart": true,
|
||||
"frontMatter.panel.openOnSupportedFile": true,
|
||||
"frontMatter.content.pageFolders": [
|
||||
{
|
||||
"title": "Journal",
|
||||
"path": "[[workspace]]/src/content/blog",
|
||||
"contentTypes": [
|
||||
"post"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Handbook",
|
||||
"path": "[[workspace]]/src/content/handbook",
|
||||
"contentTypes": [
|
||||
"guide"
|
||||
]
|
||||
}
|
||||
],
|
||||
"frontMatter.taxonomy.contentTypes": [
|
||||
{
|
||||
"name": "post",
|
||||
"previewPath": "/blog",
|
||||
"pageBundle": false,
|
||||
"fields": [
|
||||
{
|
||||
"title": "Title",
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"single": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"title": "Description",
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Publishing date",
|
||||
"name": "pubDate",
|
||||
"type": "datetime",
|
||||
"default": "{{now}}",
|
||||
"isPublishDate": true
|
||||
},
|
||||
{
|
||||
"title": "Hero image",
|
||||
"name": "heroImage",
|
||||
"type": "image",
|
||||
"isPreviewImage": true
|
||||
},
|
||||
{
|
||||
"title": "Author",
|
||||
"name": "author",
|
||||
"type": "dataFile",
|
||||
"dataFileId": "authors",
|
||||
"dataFileKey": "id",
|
||||
"dataFileValue": "name"
|
||||
},
|
||||
{
|
||||
"title": "Category",
|
||||
"name": "category",
|
||||
"type": "choice",
|
||||
"choices": [
|
||||
"Engineering",
|
||||
"Design",
|
||||
"Studio",
|
||||
"Case study"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tags",
|
||||
"name": "tags",
|
||||
"type": "tags"
|
||||
},
|
||||
{
|
||||
"title": "Featured",
|
||||
"name": "featured",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"title": "Draft",
|
||||
"name": "draft",
|
||||
"type": "draft"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "guide",
|
||||
"previewPath": "/handbook",
|
||||
"pageBundle": false,
|
||||
"fields": [
|
||||
{
|
||||
"title": "Title",
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"single": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"title": "Description",
|
||||
"name": "description",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"title": "Last reviewed",
|
||||
"name": "lastReviewed",
|
||||
"type": "datetime",
|
||||
"default": "{{now}}",
|
||||
"isPublishDate": true
|
||||
},
|
||||
{
|
||||
"title": "Cover",
|
||||
"name": "cover",
|
||||
"type": "image",
|
||||
"isPreviewImage": true
|
||||
},
|
||||
{
|
||||
"title": "Order",
|
||||
"name": "order",
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"title": "Tags",
|
||||
"name": "tags",
|
||||
"type": "tags"
|
||||
},
|
||||
{
|
||||
"title": "Draft",
|
||||
"name": "draft",
|
||||
"type": "draft"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"frontMatter.content.snippets": {
|
||||
"Callout": {
|
||||
"title": "Callout",
|
||||
"description": "A highlighted note, tip or warning inside a post.",
|
||||
"body": [
|
||||
"<Callout type=\"[[type]]\">",
|
||||
"[[&selection]]",
|
||||
"</Callout>"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "type",
|
||||
"title": "Type",
|
||||
"type": "choice",
|
||||
"choices": [
|
||||
"note",
|
||||
"tip",
|
||||
"warning"
|
||||
],
|
||||
"default": "note"
|
||||
},
|
||||
{
|
||||
"name": "selection",
|
||||
"title": "Text",
|
||||
"type": "string",
|
||||
"default": "FM_SELECTED_TEXT"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Figure": {
|
||||
"title": "Figure",
|
||||
"description": "An image with a caption, inserted from the media dashboard.",
|
||||
"isMediaSnippet": true,
|
||||
"body": [
|
||||
"<figure>",
|
||||
" <img src=\"[[&mediaUrl]]\" alt=\"[[alt]]\" />",
|
||||
" <figcaption>[[caption]]</figcaption>",
|
||||
"</figure>"
|
||||
]
|
||||
},
|
||||
"Video embed": {
|
||||
"title": "Video embed",
|
||||
"description": "A responsive embed for a hosted video.",
|
||||
"body": "<VideoEmbed id=\"[[id]]\" title=\"[[title]]\" />",
|
||||
"fields": [
|
||||
{
|
||||
"name": "id",
|
||||
"title": "Video ID",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "title",
|
||||
"title": "Title",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Pull quote": {
|
||||
"title": "Pull quote",
|
||||
"description": "A quote set large, with who said it.",
|
||||
"body": [
|
||||
"<PullQuote cite=\"[[cite]]\">",
|
||||
" [[&selection]]",
|
||||
"</PullQuote>"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"name": "selection",
|
||||
"title": "Quote",
|
||||
"type": "string",
|
||||
"default": "FM_SELECTED_TEXT"
|
||||
},
|
||||
{
|
||||
"name": "cite",
|
||||
"title": "Who said it",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Related posts": {
|
||||
"title": "Related posts",
|
||||
"description": "Links to up to three other posts from the journal.",
|
||||
"body": "<RelatedPosts slugs={[[[slugs]]]} />",
|
||||
"fields": [
|
||||
{
|
||||
"name": "slugs",
|
||||
"title": "Post slugs",
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"Before / after": {
|
||||
"title": "Before / after",
|
||||
"description": "Two images side by side with a slider between them.",
|
||||
"body": "<BeforeAfter before=\"[[before]]\" after=\"[[after]]\" />",
|
||||
"fields": [
|
||||
{
|
||||
"name": "before",
|
||||
"title": "Before image",
|
||||
"type": "image"
|
||||
},
|
||||
{
|
||||
"name": "after",
|
||||
"title": "After image",
|
||||
"type": "image"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"frontMatter.data.files": [
|
||||
{
|
||||
"id": "authors",
|
||||
"title": "Authors",
|
||||
"file": "[[workspace]]/src/data/authors.json",
|
||||
"fileType": "json",
|
||||
"labelField": "name",
|
||||
"schema": {
|
||||
"title": "Author",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"title": "ID"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"title": "Role"
|
||||
},
|
||||
"avatar": {
|
||||
"type": "string",
|
||||
"title": "Avatar"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"title": "Bio"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "navigation",
|
||||
"title": "Navigation",
|
||||
"file": "[[workspace]]/src/data/navigation.json",
|
||||
"fileType": "json",
|
||||
"labelField": "label",
|
||||
"schema": {
|
||||
"title": "Link",
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"href"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"title": "Label"
|
||||
},
|
||||
"href": {
|
||||
"type": "string",
|
||||
"title": "URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"frontMatter.content.defaultSorting": "PublishedDesc",
|
||||
"frontMatter.snippets.wrapper.enabled": false
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "tidewater",
|
||||
"type": "module",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^5.14.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 83 KiB |
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Accessibility audits on a small budget"
|
||||
description: "What a two-day audit catches, and what it takes a screen reader user to find."
|
||||
pubDate: 2026-10-15T09:00:00.000Z
|
||||
heroImage: /images/audit.jpg
|
||||
author: aiko-tanaka
|
||||
category: Design
|
||||
tags: []
|
||||
featured: false
|
||||
draft: true
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Accessibility audits on a small budget started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Writing alt text people actually read"
|
||||
description: "Alt text is copy. We review it like copy, and our editors write it where they write everything else."
|
||||
pubDate: 2026-08-14T09:00:00.000Z
|
||||
heroImage: /images/alt-text.jpg
|
||||
author: aiko-tanaka
|
||||
category: Design
|
||||
tags:
|
||||
- accessibility
|
||||
- writing
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Writing alt text people actually read started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "A colour system that survives dark mode"
|
||||
description: "How we name colour tokens so a theme switch never needs a second design review."
|
||||
pubDate: 2026-09-10T09:00:00.000Z
|
||||
heroImage: /images/colour-tokens.jpg
|
||||
author: aiko-tanaka
|
||||
category: Design
|
||||
tags:
|
||||
- design-systems
|
||||
- css
|
||||
- accessibility
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
Every colour in our design system has two names: what it is, and what it is for. Only the second one is allowed in a component.
|
||||
|
||||
## Tokens by purpose
|
||||
|
||||
`surface`, `surface-raised`, `text`, `text-muted`, `accent`. A theme is nothing more than a table that maps those names to values.
|
||||
|
||||
## Checking contrast once
|
||||
|
||||
Because components never reference a raw colour, we check contrast on the table, not on every screen.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Our content review checklist"
|
||||
description: "Nine questions every page answers before it goes live, and who answers them."
|
||||
pubDate: 2026-07-30T09:00:00.000Z
|
||||
heroImage: /images/checklist.jpg
|
||||
author: mara-lindqvist
|
||||
category: Studio
|
||||
tags:
|
||||
- writing
|
||||
- workflow
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Our content review checklist started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Designing empty states"
|
||||
description: "The first screen a new user sees is usually the one we design last."
|
||||
pubDate: 2026-05-21T09:00:00.000Z
|
||||
heroImage: /images/empty-states.jpg
|
||||
author: aiko-tanaka
|
||||
category: Design
|
||||
tags:
|
||||
- design-systems
|
||||
- ux
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Designing empty states started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "How we estimate content migrations"
|
||||
description: "Count the templates, sample the content, and budget for the page nobody mentioned."
|
||||
pubDate: 2026-05-02T09:00:00.000Z
|
||||
heroImage: /images/migration-map.jpg
|
||||
author: mara-lindqvist
|
||||
category: Studio
|
||||
tags:
|
||||
- migration
|
||||
- workflow
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
How we estimate content migrations started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Image formats in 2026: a practical guide"
|
||||
description: "AVIF, WebP and JPEG, and the build step that picks between them so nobody else has to."
|
||||
pubDate: 2026-06-05T09:00:00.000Z
|
||||
heroImage: /images/image-formats.jpg
|
||||
author: jonas-peeters
|
||||
category: Engineering
|
||||
tags:
|
||||
- images
|
||||
- performance
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Image formats in 2026: a practical guide started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Markdown is a content model"
|
||||
description: "Front matter fields, folders and a few conventions are all the schema most sites need."
|
||||
pubDate: 2026-04-10T09:00:00.000Z
|
||||
heroImage: /images/markdown-model.jpg
|
||||
author: jonas-peeters
|
||||
category: Engineering
|
||||
tags:
|
||||
- static-sites
|
||||
- writing
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Markdown is a content model started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "What we learned redesigning a museum website"
|
||||
description: "Twelve hundred collection pages, three content editors and one very old CMS export."
|
||||
pubDate: 2026-06-25T09:00:00.000Z
|
||||
heroImage: /images/museum.jpg
|
||||
author: mara-lindqvist
|
||||
category: Case study
|
||||
tags:
|
||||
- static-sites
|
||||
- migration
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
What we learned redesigning a museum website started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Preview deployments for every pull request"
|
||||
description: "Every branch gets a URL, and every editor gets to see their change before it ships."
|
||||
pubDate: 2026-09-29T09:00:00.000Z
|
||||
heroImage: /images/preview-deploys.jpg
|
||||
author: jonas-peeters
|
||||
category: Engineering
|
||||
tags:
|
||||
- workflow
|
||||
- static-sites
|
||||
featured: false
|
||||
draft: true
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Preview deployments for every pull request started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Self-hosting fonts without the layout shift"
|
||||
description: "Subsetting, preloading and a fallback stack tuned until the swap is invisible."
|
||||
pubDate: 2026-07-16T09:00:00.000Z
|
||||
heroImage: /images/type-specimen.jpg
|
||||
author: jonas-peeters
|
||||
category: Engineering
|
||||
tags:
|
||||
- css
|
||||
- performance
|
||||
- typography
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Self-hosting fonts without the layout shift started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: "Shipping a 40 KB homepage"
|
||||
description: "What we cut, what we kept, and the one image that took longer than everything else."
|
||||
pubDate: 2026-08-28T09:00:00.000Z
|
||||
heroImage: /images/homepage-budget.jpg
|
||||
author: jonas-peeters
|
||||
category: Engineering
|
||||
tags:
|
||||
- performance
|
||||
- images
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Shipping a 40 KB homepage started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "Notes from our spring offsite"
|
||||
description: "Two days on the coast, one whiteboard, and the three things we decided to stop doing."
|
||||
pubDate: 2026-03-20T09:00:00.000Z
|
||||
heroImage: /images/offsite.jpg
|
||||
author: mara-lindqvist
|
||||
category: Studio
|
||||
tags:
|
||||
- studio-life
|
||||
featured: false
|
||||
draft: false
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Notes from our spring offsite started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
title: "Typography scales, explained"
|
||||
description: ""
|
||||
pubDate: 2026-10-06T09:00:00.000Z
|
||||
heroImage: /images/type-scale.jpg
|
||||
author: aiko-tanaka
|
||||
category: Design
|
||||
tags:
|
||||
- typography
|
||||
featured: false
|
||||
draft: true
|
||||
---
|
||||
|
||||
This post is part of our studio journal, where we write down what we learn while building websites for our clients.
|
||||
|
||||
## The short version
|
||||
|
||||
Typography scales, explained started as a note in a pull request. It grew into a checklist, and then into this post.
|
||||
|
||||
## What we do now
|
||||
|
||||
1. Write the decision down where the work happens.
|
||||
2. Link it from the template or component it affects.
|
||||
3. Revisit it when the next project disagrees with it.
|
||||
|
||||
## What we would do differently
|
||||
|
||||
Start earlier. Most of what is in here we learned the second time round.
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Why we moved our client sites to Astro"
|
||||
description: "Four years of build pipelines, and the one change that made every site faster to edit and faster to load."
|
||||
pubDate: 2026-09-18T09:00:00.000Z
|
||||
heroImage: /images/astro-move.jpg
|
||||
author: mara-lindqvist
|
||||
category: Engineering
|
||||
tags:
|
||||
- astro
|
||||
- performance
|
||||
- static-sites
|
||||
featured: true
|
||||
draft: false
|
||||
---
|
||||
|
||||
Most of our client sites are marketing sites with a blog attached. For years they ran on a mix of server-rendered frameworks, each with its own build, its own plugins and its own way of breaking on a Friday.
|
||||
|
||||
## What we wanted
|
||||
|
||||
- Pages that are HTML by default, with JavaScript only where a component needs it.
|
||||
- Content in Markdown, in the same repository as the code that renders it.
|
||||
- A build that a new developer can run on their first morning.
|
||||
|
||||
## What changed for editors
|
||||
|
||||
Nothing they had to learn. The posts are still Markdown files with front matter at the top, and they still edit them in VS Code. The fields they fill in are the same fields the templates read.
|
||||
|
||||
## What changed for visitors
|
||||
|
||||
The median page on our three largest sites now ships under 60 KB of JavaScript, down from just over 400 KB.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Content style guide"
|
||||
description: "Voice, punctuation and the words we avoid."
|
||||
lastReviewed: 2026-02-19T09:00:00.000Z
|
||||
cover: /images/handbook-voice.jpg
|
||||
order: 2
|
||||
tags:
|
||||
- writing
|
||||
draft: false
|
||||
---
|
||||
|
||||
Voice, punctuation and the words we avoid.
|
||||
|
||||
## Who this is for
|
||||
|
||||
Everyone at the studio, and anyone we work with who wants to know how we work.
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
title: "Image guidelines"
|
||||
description: "Sizes, formats, alt text and where images live in the repository."
|
||||
lastReviewed: 2026-03-05T09:00:00.000Z
|
||||
cover: /images/handbook-images.jpg
|
||||
order: 3
|
||||
tags:
|
||||
- images
|
||||
- accessibility
|
||||
draft: false
|
||||
---
|
||||
|
||||
Sizes, formats, alt text and where images live in the repository.
|
||||
|
||||
## Who this is for
|
||||
|
||||
Everyone at the studio, and anyone we work with who wants to know how we work.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Onboarding a new client"
|
||||
description: "The first two weeks, from kickoff call to the first page in staging."
|
||||
lastReviewed: 2026-02-12T09:00:00.000Z
|
||||
cover: /images/harbour-office.jpg
|
||||
order: 1
|
||||
tags:
|
||||
- workflow
|
||||
draft: false
|
||||
---
|
||||
|
||||
The first two weeks, from kickoff call to the first page in staging.
|
||||
|
||||
## Who this is for
|
||||
|
||||
Everyone at the studio, and anyone we work with who wants to know how we work.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Publishing workflow"
|
||||
description: "From draft to live: review, preview deployment, merge."
|
||||
lastReviewed: 2026-04-22T09:00:00.000Z
|
||||
cover: /images/handbook-publish.jpg
|
||||
order: 4
|
||||
tags:
|
||||
- workflow
|
||||
draft: false
|
||||
---
|
||||
|
||||
From draft to live: review, preview deployment, merge.
|
||||
|
||||
## Who this is for
|
||||
|
||||
Everyone at the studio, and anyone we work with who wants to know how we work.
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Tone of voice"
|
||||
description: "How we sound when we write for clients, and how we sound when we write for ourselves."
|
||||
lastReviewed: 2026-06-11T09:00:00.000Z
|
||||
cover: /images/studio-desk.jpg
|
||||
order: 5
|
||||
tags:
|
||||
- writing
|
||||
draft: false
|
||||
---
|
||||
|
||||
How we sound when we write for clients, and how we sound when we write for ourselves.
|
||||
|
||||
## Who this is for
|
||||
|
||||
Everyone at the studio, and anyone we work with who wants to know how we work.
|
||||
@@ -0,0 +1,30 @@
|
||||
[
|
||||
{
|
||||
"id": "mara-lindqvist",
|
||||
"name": "Mara Lindqvist",
|
||||
"role": "Founder, content strategy",
|
||||
"avatar": "/images/avatars/mara.jpg",
|
||||
"bio": "Plans content models and migrations. Has opinions about headings."
|
||||
},
|
||||
{
|
||||
"id": "jonas-peeters",
|
||||
"name": "Jonas Peeters",
|
||||
"role": "Lead developer",
|
||||
"avatar": "/images/avatars/jonas.jpg",
|
||||
"bio": "Builds the sites and the pipelines that deploy them."
|
||||
},
|
||||
{
|
||||
"id": "aiko-tanaka",
|
||||
"name": "Aiko Tanaka",
|
||||
"role": "Design lead",
|
||||
"avatar": "/images/avatars/aiko.jpg",
|
||||
"bio": "Runs the design system and every accessibility review."
|
||||
},
|
||||
{
|
||||
"id": "sam-okafor",
|
||||
"name": "Sam Okafor",
|
||||
"role": "Content editor",
|
||||
"avatar": "/images/avatars/sam.jpg",
|
||||
"bio": "Edits everything that goes out, including this."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
[
|
||||
{
|
||||
"label": "Work",
|
||||
"href": "/work"
|
||||
},
|
||||
{
|
||||
"label": "Journal",
|
||||
"href": "/blog"
|
||||
},
|
||||
{
|
||||
"label": "Handbook",
|
||||
"href": "/handbook"
|
||||
},
|
||||
{
|
||||
"label": "Contact",
|
||||
"href": "/contact"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import { getCollection } from 'astro:content';
|
||||
const posts = (await getCollection('blog', ({ data }) => !data.draft))
|
||||
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
|
||||
---
|
||||
<h1>Tidewater</h1>
|
||||
<ul>
|
||||
{posts.map((post) => <li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>)}
|
||||
</ul>
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "promo",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "The Front Matter CMS promo and tour, cut from a scripted capture of the real extension running in VS Code against a demo site, with Remotion.",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"capture": "node scripts/capture.mjs",
|
||||
"start": "remotion studio",
|
||||
"render": "remotion render Promo out/promo.mp4 --codec=h264 --crf=17",
|
||||
"render:tour": "remotion render Tour out/tour.mp4 --codec=h264 --crf=17",
|
||||
"web": "node scripts/web.mjs",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"vsix": "cd .. && npx --yes @vscode/vsce package --no-dependencies -o promo/.cache/front-matter.vsix",
|
||||
"covers": "node scripts/covers.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotion/cli": "4.0.518",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"remotion": "4.0.518"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.2.0",
|
||||
"playwright": "1.62.1",
|
||||
"typescript": "5.9.3",
|
||||
"@vscode/test-electron": "^2.5.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"edit": {
|
||||
"frames": 374,
|
||||
"fps": 30
|
||||
},
|
||||
"create": {
|
||||
"frames": 256,
|
||||
"fps": 30
|
||||
},
|
||||
"snippet": {
|
||||
"frames": 249,
|
||||
"fps": 30
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 26.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 1201 1201" enable-background="new 0 0 1201 1201" xml:space="preserve">
|
||||
<rect x="1" y="1" display="none" fill="none" stroke="#01AEB7" stroke-width="50" stroke-miterlimit="10" width="1200" height="1200"/>
|
||||
<g enable-background="new ">
|
||||
<path fill="#009aa3" d="M173.4,576.8H96.9V214.5h135.2v85.2h-58.7v52.8H228v81.9h-54.7V576.8z"/>
|
||||
<path fill="#009aa3" d="M463.3,576.8h-82.7l-37.8-129.1c-0.4-2.2-1-5.1-1.9-8.7c-0.9-3.6-2-7.9-3.4-12.9l0.6,23.9v126.7h-76.1
|
||||
V214.5h78.3c34.5,0,60.3,8.7,77.4,26.1c21.8,22.2,32.7,53.8,32.7,94.8c0,43.8-14.6,74.1-43.9,90.8L463.3,576.8z M338.3,382.3h5.1
|
||||
c8.8,0,16.1-4.3,22-12.9c5.9-8.6,8.8-19.6,8.8-32.9c0-27.1-11.1-40.6-33.2-40.6h-2.8V382.3z"/>
|
||||
<path fill="#009aa3" d="M693.2,396.8c0,55.2-9.8,100.1-29.5,134.7c-19.6,34.4-45.1,51.6-76.5,51.6c-27.9,0-51.8-13.2-71.7-39.7
|
||||
c-25.1-33.5-37.6-83.7-37.6-150.7c0-64.1,13.1-113.2,39.3-147.1c19.4-25.2,42.5-37.8,69.2-37.8c32.7,0,58.6,17.1,78,51.2
|
||||
C683.5,293.2,693.2,339.1,693.2,396.8z M613.7,397.5c0-65.6-9.1-98.3-27.2-98.3c-9.1,0-16.3,8.6-21.7,25.8
|
||||
c-5.3,16-7.9,39.4-7.9,70.4c0,30.5,2.5,54.2,7.4,71c5,16.8,11.8,25.2,20.6,25.2c9.1,0,16-8.3,20.9-24.9
|
||||
C611.1,450.2,613.7,427.1,613.7,397.5z"/>
|
||||
<path fill="#009aa3" d="M725.8,576.8V214.5h73l38.3,127.2c2.1,7.5,4.4,16.1,7.1,25.8c2.6,9.7,5.5,20.9,8.7,33.6l7.9,31.9
|
||||
c-2.8-31.4-5-58.4-6.4-80.7c-1.5-22.4-2.2-41.1-2.2-56.3v-81.4h73v362.4h-73l-38.5-133.3c-4-14.2-7.7-27.6-10.9-40
|
||||
c-3.2-12.4-6.1-24.3-8.5-35.6c1.6,25.5,2.8,47.7,3.5,66.7c0.7,18.9,1.1,35.5,1.1,49.8v92.5H725.8z"/>
|
||||
<path fill="#009aa3" d="M1063.8,576.8h-76.5V301.3h-42.2v-86.8h162.6v86.8h-43.9V576.8z"/>
|
||||
</g>
|
||||
<g enable-background="new ">
|
||||
<path fill="#009aa3" d="M123,657.8h35.8l27.4,133.7c3.4,16.7,6.4,33.4,9.2,50.2c2.7,16.8,5.3,34.4,7.5,52.7
|
||||
c0.3-2.4,0.5-4.3,0.6-5.6c0.1-1.3,0.3-2.3,0.4-2.9l5.8-37.6l5.2-35.7l4.6-23.6L244,657.8h36.2l28.6,327.7h-40l-7-111.2
|
||||
c-0.3-5.7-0.5-10.5-0.7-14.6c-0.2-4.1-0.3-7.5-0.3-10.2l-1.8-43.9l-1-40.3c0-0.3,0-0.9-0.1-1.8c-0.1-0.9-0.2-2.2-0.3-3.7l-1,6.6
|
||||
c-1.1,7.8-2,14.6-2.9,20.4c-0.9,5.8-1.6,10.7-2.3,14.6l-5.2,29.9l-2,11.2l-26.2,143.1h-28.2L165.1,862c-3.4-16.3-6.3-32.8-9-49.6
|
||||
c-2.6-16.8-5-34.3-7.1-52.7l-12.1,225.8H97.5L123,657.8z"/>
|
||||
<path fill="#009aa3" d="M395.5,657.8h32.6l66.6,327.7h-41.5l-11.7-63.9H380l-11.9,63.9h-40.2L395.5,657.8z M435.5,887.5l-9.3-52.2
|
||||
c-6.2-35.5-11.2-73.4-15.1-113.8c-1.9,19.7-4.3,39.6-7.2,59.8c-3,20.2-6.4,41.6-10.5,64.2l-7.6,42H435.5z"/>
|
||||
<path fill="#009aa3" d="M495.7,657.8h136.2v38.6H585v289.1H544V696.4h-48.3V657.8z"/>
|
||||
<path fill="#009aa3" d="M638.1,657.8h136.2v38.6h-46.9v289.1h-41.1V696.4h-48.3V657.8z"/>
|
||||
<path fill="#009aa3" d="M805.4,657.8h111.3v37.4h-69.4V799h61.6v37.4h-61.6v111.9h69.4v37.4H805.4V657.8z"/>
|
||||
<path fill="#009aa3" d="M962.9,657.8h55.1c22.8,0,39.8,5.7,50.9,17.2c14.1,14.9,21.1,37.6,21.1,68.1c0,23.5-3.7,42.3-11.2,56.6
|
||||
c-7.4,14.2-18.1,23-31.9,26.4l57.2,159.4h-42.5l-57-160.5v160.5h-41.9V657.8z M1004.8,803c16.2,0,27.7-4,34.4-11.9
|
||||
c6.7-7.9,10.1-21.3,10.1-40.1c0-10.2-0.7-18.9-2.1-26.1c-1.4-7.2-3.6-13.1-6.6-17.6c-3-4.5-6.9-7.9-11.7-10
|
||||
c-4.8-2.1-10.4-3.2-17-3.2h-7V803z"/>
|
||||
</g>
|
||||
<rect x="97" y="93.9" fill="#009aa3" width="169.7" height="43"/>
|
||||
<rect x="351.9" y="93.9" fill="#009aa3" width="169.7" height="43"/>
|
||||
<rect x="609.8" y="93.9" fill="#009aa3" width="169.7" height="43"/>
|
||||
<rect x="97" y="1060.7" fill="#009aa3" width="169.7" height="43"/>
|
||||
<rect x="351.9" y="1060.7" fill="#009aa3" width="169.7" height="43"/>
|
||||
<rect x="609.8" y="1060.7" fill="#009aa3" width="169.7" height="43"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,8 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
// The capture is 2x of a 1280x820 viewport and both compositions are 1920x1080,
|
||||
// so every shot is mounted below the pixels it was taken at and nothing is ever
|
||||
// enlarged. CRF 17 keeps the sidebar labels readable after a re-encode.
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(4);
|
||||
@@ -0,0 +1,377 @@
|
||||
// Retakes every picture the videos are cut from, in real VS Code, against the
|
||||
// demo site in ../demo and nothing else.
|
||||
//
|
||||
// node scripts/capture.mjs everything
|
||||
// node scripts/capture.mjs shots the stills only
|
||||
// node scripts/capture.mjs clips the recorded beats only
|
||||
// node scripts/capture.mjs edit media just those, by name
|
||||
//
|
||||
// Needs the packaged extension: `npm run vsix` first, and again after any change
|
||||
// to the extension that should show up in the video.
|
||||
//
|
||||
// Stills land in public/shots/<name>.png at 2x. Recorded beats land in
|
||||
// public/clips/<name>.mp4 at a constant 30 fps, with public/clips.json naming
|
||||
// how long each one is — the compositions read that rather than carrying a table
|
||||
// of frame numbers, so re-recording a beat does not mean re-cutting the piece
|
||||
// around it.
|
||||
//
|
||||
// The stills share one VS Code. Every recorded beat gets a VS Code of its own,
|
||||
// on a fresh copy of the site: a beat that edits a post would otherwise leave
|
||||
// that edit in every shot after it.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
dashboard, hidePointer, openApp, panel, pointTo, pressMouse, releaseMouse, runCommand, settle,
|
||||
showPointer, VIEWPORT,
|
||||
} from './harness.mjs';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const PUBLIC = resolve(HERE, '../public');
|
||||
const SHOTS_DIR = join(PUBLIC, 'shots');
|
||||
const CLIPS_DIR = join(PUBLIC, 'clips');
|
||||
const TMP = join(PUBLIC, '.frames');
|
||||
|
||||
export const CLIP_FPS = 30;
|
||||
|
||||
// ---- driving the app -----------------------------------------------------
|
||||
|
||||
const pause = (page, ms) => page.waitForTimeout(ms);
|
||||
|
||||
/** Glide the pointer to the middle of `target` — a locator in the workbench or
|
||||
* inside a webview; either way its box comes back in window coordinates. */
|
||||
async function moveTo(page, target, { steps = 26, dx = 0.5, dy = 0.5 } = {}) {
|
||||
await target.scrollIntoViewIfNeeded().catch(() => {});
|
||||
const box = await target.boundingBox();
|
||||
if (!box) {
|
||||
throw new Error('moveTo: target has no box');
|
||||
}
|
||||
await pointTo(page, box.x + box.width * dx, box.y + box.height * dy, { steps });
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Move, settle for a beat so the hover state reads, then click. */
|
||||
async function clickOn(page, target, { before = 320, after = 500, ...move } = {}) {
|
||||
await moveTo(page, target, move);
|
||||
await pause(page, before);
|
||||
await pressMouse(page);
|
||||
await pause(page, 90);
|
||||
await releaseMouse(page);
|
||||
await pause(page, after);
|
||||
}
|
||||
|
||||
/** Type at something near a human rate. */
|
||||
async function typeText(page, text, { delay = 55 } = {}) {
|
||||
await page.keyboard.type(text, { delay });
|
||||
}
|
||||
|
||||
/** Scroll whatever is under the pointer, in small wheel steps a hand would make. */
|
||||
async function wheel(page, dy, { steps = 10 } = {}) {
|
||||
for (let i = 0; i < steps; i++) {
|
||||
await page.mouse.wheel(0, dy / steps);
|
||||
await pause(page, 30);
|
||||
}
|
||||
}
|
||||
|
||||
/** Send the pointer somewhere nothing reacts to it: the middle of the title
|
||||
* bar. */
|
||||
async function park(page) {
|
||||
await pointTo(page, VIEWPORT.width / 2 + 180, 12, { steps: 6 });
|
||||
await pause(page, 260);
|
||||
}
|
||||
|
||||
/** The dashboard on its Contents tab, the sidebar closed, nothing else open.
|
||||
* Every shot starts here so one shot's leftovers never reach the next. */
|
||||
async function reset(page) {
|
||||
await page.keyboard.press('Escape');
|
||||
await runCommand(page, 'File: Save All Files');
|
||||
await runCommand(page, 'View: Close All Editor Groups');
|
||||
await runCommand(page, 'View: Single Column Editor Layout');
|
||||
await runCommand(page, 'Front Matter: Open dashboard');
|
||||
// The palette offers only toggles for the side bars, so each is toggled only
|
||||
// when it is actually showing.
|
||||
if (await page.locator('.part.sidebar').isVisible()) {
|
||||
await runCommand(page, 'View: Toggle Primary Side Bar Visibility');
|
||||
}
|
||||
if (await page.locator('.part.auxiliarybar').isVisible()) {
|
||||
await runCommand(page, 'View: Toggle Secondary Side Bar Visibility');
|
||||
}
|
||||
const dash = await dashboard(page);
|
||||
await dash.locator('img').first().waitFor({ state: 'visible' });
|
||||
await park(page);
|
||||
await settle(page, 800);
|
||||
return dash;
|
||||
}
|
||||
|
||||
/** A dashboard tab, by its label. */
|
||||
const tab = (dash, name) => dash.getByRole('button', { name, exact: true }).or(dash.getByText(name, { exact: true })).first();
|
||||
|
||||
/** Open a post the way a user does from the dashboard, without recording it. */
|
||||
async function openPost(page, title) {
|
||||
const dash = await reset(page);
|
||||
await dash.getByText(title, { exact: true }).first().click();
|
||||
const side = await panel(page);
|
||||
await side.locator('.metadata_field__input').first().waitFor({ state: 'visible' });
|
||||
await settle(page, 1500);
|
||||
return side;
|
||||
}
|
||||
|
||||
// ---- stills --------------------------------------------------------------
|
||||
//
|
||||
// One per screen.
|
||||
|
||||
const SHOTS = {
|
||||
async contents(page) {
|
||||
await reset(page);
|
||||
},
|
||||
|
||||
async drafts(page) {
|
||||
const dash = await reset(page);
|
||||
await dash.getByRole('button', { name: /In draft/ }).first().click();
|
||||
await settle(page, 800);
|
||||
},
|
||||
|
||||
async editor(page) {
|
||||
await openPost(page, 'Why we moved our client sites to Astro');
|
||||
},
|
||||
|
||||
async media(page) {
|
||||
const dash = await reset(page);
|
||||
await tab(dash, 'Media').click();
|
||||
await dash.getByText('images', { exact: true }).first().click();
|
||||
await dash.locator('img').first().waitFor({ state: 'visible' });
|
||||
await settle(page, 2500);
|
||||
},
|
||||
|
||||
async snippets(page) {
|
||||
const dash = await reset(page);
|
||||
await tab(dash, 'Snippets').click();
|
||||
await settle(page, 1200);
|
||||
},
|
||||
|
||||
async data(page) {
|
||||
const dash = await reset(page);
|
||||
await tab(dash, 'Data').click();
|
||||
await dash.getByText('Authors', { exact: true }).first().click();
|
||||
await settle(page, 800);
|
||||
// One entry open in the form, so the shot shows a filled-in record rather
|
||||
// than an empty "add new" form.
|
||||
await dash.getByText('Mara Lindqvist', { exact: true }).first().click();
|
||||
await settle(page, 1200);
|
||||
},
|
||||
|
||||
async taxonomy(page) {
|
||||
const dash = await reset(page);
|
||||
await tab(dash, 'Taxonomies').click();
|
||||
await settle(page, 1500);
|
||||
},
|
||||
};
|
||||
|
||||
// ---- recorded beats ------------------------------------------------------
|
||||
//
|
||||
// Three, no more: the moments where something has to *happen*. Everything else
|
||||
// is a still with a caption.
|
||||
//
|
||||
// Each is `{ prepare, run }` and only `run` is recorded.
|
||||
|
||||
const CLIPS = {
|
||||
/** A draft, opened from the dashboard, given a description and published —
|
||||
* in the panel, with the YAML in the editor following along. */
|
||||
edit: {
|
||||
prepare: reset,
|
||||
async run(page) {
|
||||
await pause(page, 600);
|
||||
const dash = await dashboard(page);
|
||||
await clickOn(page, dash.getByText('Typography scales, explained', { exact: true }).first(), { after: 1800 });
|
||||
const side = await panel(page);
|
||||
await side.locator('.metadata_field__textarea').first().waitFor({ state: 'visible' });
|
||||
await clickOn(page, side.locator('.metadata_field__textarea').first(), { after: 300 });
|
||||
await typeText(page, 'A modular scale in four lines of CSS, and how to pick its ratio.');
|
||||
await pause(page, 1400);
|
||||
// Down to the Draft switch, by wheel over the panel.
|
||||
await moveTo(page, side.locator('.metadata_field__label', { hasText: 'Author' }).first(), { steps: 14 });
|
||||
await wheel(page, 520);
|
||||
await pause(page, 500);
|
||||
const draft = side.locator('.metadata_field__label', { hasText: /^Draft$/ }).locator('xpath=following::label[contains(@class,"field__toggle")][1]');
|
||||
await clickOn(page, draft, { after: 2200 });
|
||||
},
|
||||
},
|
||||
|
||||
/** A new post from the dashboard: pick the folder, name it, and the file opens
|
||||
* with its front matter already filled in. */
|
||||
create: {
|
||||
prepare: reset,
|
||||
async run(page) {
|
||||
await pause(page, 600);
|
||||
const dash = await dashboard(page);
|
||||
await clickOn(page, dash.getByRole('button', { name: /Create content/ }).first(), { after: 900 });
|
||||
await clickOn(page, page.locator('.quick-input-list .monaco-list-row', { hasText: 'Journal' }).first(), { after: 900 });
|
||||
await typeText(page, 'Choosing a static site generator in 2026');
|
||||
await pause(page, 500);
|
||||
await page.keyboard.press('Enter');
|
||||
const side = await panel(page);
|
||||
await side.locator('.metadata_field__input').first().waitFor({ state: 'visible' });
|
||||
await pause(page, 2600);
|
||||
},
|
||||
},
|
||||
|
||||
/** A snippet into the post: the sentence under the cursor becomes a callout. */
|
||||
snippet: {
|
||||
async prepare(page) {
|
||||
await openPost(page, 'Why we moved our client sites to Astro');
|
||||
// Put the selection on the paragraph the callout will wrap.
|
||||
await page.locator('.monaco-editor .view-lines').first().click();
|
||||
await page.keyboard.press('Control+G');
|
||||
await typeText(page, '26', { delay: 0 });
|
||||
await page.keyboard.press('Enter');
|
||||
// The whole paragraph, not the first wrapped line of it: Home/End follow
|
||||
// the soft wrap. Expand to the line, then give back its line break.
|
||||
await page.keyboard.press('Meta+L');
|
||||
await page.keyboard.press('Shift+ArrowLeft');
|
||||
await park(page);
|
||||
await settle(page, 600);
|
||||
},
|
||||
async run(page) {
|
||||
await pause(page, 600);
|
||||
await clickOn(page, page.locator('.editor-actions .action-label[aria-label*="snippet" i]').first(), { after: 1600 });
|
||||
const dash = await dashboard(page);
|
||||
// The card's own insert button: the title opens nothing, and a loose
|
||||
// match on "Insert" finds whichever card comes last.
|
||||
const card = dash.locator('li', { has: dash.getByText('Callout', { exact: true }) }).first();
|
||||
await moveTo(page, card, { steps: 18 });
|
||||
await pause(page, 400);
|
||||
await clickOn(page, card.locator('button[title="Insert snippet"]'), { after: 1400 });
|
||||
// The dialog comes up with the selected sentence already in the Text
|
||||
// field; Insert wraps it.
|
||||
await clickOn(page, dash.getByRole('button', { name: 'Insert', exact: true }), { after: 2600 });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// ---- the screencast ------------------------------------------------------
|
||||
|
||||
/** Record `clip.run` and write it out as constant-rate h264.
|
||||
*
|
||||
* CDP emits a frame when the page paints and not otherwise, so what comes back
|
||||
* is a variable-rate stream with a timestamp on each frame. Resampling it onto
|
||||
* a fixed 30 fps grid here — rather than handing ffmpeg the timestamps — is
|
||||
* what makes a frame number in the timeline mean a fixed number of
|
||||
* milliseconds, which is the whole basis of the cut. */
|
||||
async function record(page, name, clip) {
|
||||
await clip.prepare?.(page);
|
||||
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
const frames = [];
|
||||
cdp.on('Page.screencastFrame', async ({ data, sessionId, metadata }) => {
|
||||
frames.push({ t: metadata.timestamp, data });
|
||||
await cdp.send('Page.screencastFrameAck', { sessionId }).catch(() => {});
|
||||
});
|
||||
|
||||
await showPointer(page);
|
||||
await cdp.send('Page.startScreencast', {
|
||||
format: 'jpeg',
|
||||
quality: 92,
|
||||
maxWidth: 4096,
|
||||
maxHeight: 4096,
|
||||
everyNthFrame: 1,
|
||||
});
|
||||
await clip.run(page);
|
||||
await pause(page, 500);
|
||||
await cdp.send('Page.stopScreencast');
|
||||
await cdp.detach().catch(() => {});
|
||||
|
||||
if (frames.length < 2) {
|
||||
throw new Error(`${name}: the page painted ${frames.length} times — nothing to cut`);
|
||||
}
|
||||
|
||||
const src = join(TMP, name, 'src');
|
||||
const seq = join(TMP, name, 'seq');
|
||||
rmSync(join(TMP, name), { recursive: true, force: true });
|
||||
mkdirSync(src, { recursive: true });
|
||||
mkdirSync(seq, { recursive: true });
|
||||
|
||||
frames.sort((a, b) => a.t - b.t);
|
||||
const paths = frames.map((frame, i) => {
|
||||
const path = join(src, `${String(i).padStart(5, '0')}.jpg`);
|
||||
writeFileSync(path, Buffer.from(frame.data, 'base64'));
|
||||
return path;
|
||||
});
|
||||
|
||||
const t0 = frames[0].t;
|
||||
const count = Math.max(2, Math.round((frames[frames.length - 1].t - t0) * CLIP_FPS));
|
||||
let cursor = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const at = t0 + i / CLIP_FPS;
|
||||
while (cursor + 1 < frames.length && frames[cursor + 1].t <= at) {
|
||||
cursor++;
|
||||
}
|
||||
// Hard links rather than copies: most frames of a beat are repeats.
|
||||
linkSync(paths[cursor], join(seq, `${String(i).padStart(5, '0')}.jpg`));
|
||||
}
|
||||
|
||||
mkdirSync(CLIPS_DIR, { recursive: true });
|
||||
const out = join(CLIPS_DIR, `${name}.mp4`);
|
||||
execFileSync('ffmpeg', [
|
||||
'-y', '-hide_banner', '-loglevel', 'error',
|
||||
'-framerate', String(CLIP_FPS),
|
||||
'-i', join(seq, '%05d.jpg'),
|
||||
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2',
|
||||
'-c:v', 'libx264', '-crf', '15', '-preset', 'slow',
|
||||
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
||||
out,
|
||||
], { stdio: 'inherit' });
|
||||
rmSync(join(TMP, name), { recursive: true, force: true });
|
||||
|
||||
console.log(` ${name}.mp4 — ${count} frames, ${(count / CLIP_FPS).toFixed(1)}s (${frames.length} painted)`);
|
||||
return { frames: count, fps: CLIP_FPS };
|
||||
}
|
||||
|
||||
// ---- run -----------------------------------------------------------------
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const wants = (kind, name) => argv.length === 0 || argv.includes(kind) || argv.includes(name);
|
||||
|
||||
const shotNames = Object.keys(SHOTS).filter((n) => wants('shots', n));
|
||||
if (shotNames.length) {
|
||||
console.log('shots');
|
||||
mkdirSync(SHOTS_DIR, { recursive: true });
|
||||
const { app, page } = await openApp({ pointer: true });
|
||||
for (const name of shotNames) {
|
||||
await showPointer(page);
|
||||
await SHOTS[name](page);
|
||||
// Nothing under the pointer: the row it was left on keeps its hover state
|
||||
// even after the dot is hidden.
|
||||
await park(page);
|
||||
await hidePointer(page);
|
||||
await settle(page, 400);
|
||||
await page.screenshot({ path: join(SHOTS_DIR, `${name}.png`) });
|
||||
console.log(` ${name}.png`);
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
|
||||
const clipNames = Object.keys(CLIPS).filter((n) => wants('clips', n));
|
||||
if (clipNames.length) {
|
||||
console.log('clips');
|
||||
mkdirSync(TMP, { recursive: true });
|
||||
const manifestPath = join(PUBLIC, 'clips.json');
|
||||
// Merged rather than replaced, so re-recording one beat by name leaves the
|
||||
// lengths of the others alone.
|
||||
const manifest = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
|
||||
for (const name of clipNames) {
|
||||
const { app, page } = await openApp({ pointer: true });
|
||||
try {
|
||||
manifest[name] = await record(page, name, CLIPS[name]);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
||||
rmSync(TMP, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (existsSync(SHOTS_DIR)) {
|
||||
console.log(`\n${readdirSync(SHOTS_DIR).length} stills in public/shots`);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Draws the demo site's images — the post covers and the author avatars — into
|
||||
// demo/tidewater/public/images. They are committed; this is here so they can be
|
||||
// redrawn, not something the capture runs.
|
||||
//
|
||||
// node scripts/covers.mjs
|
||||
//
|
||||
// Abstract on purpose: a stock photo carries somebody's licence and somebody's
|
||||
// face into a video that is hard to take back. Every picture is seeded by its
|
||||
// file name, so a redraw gives the same pixels.
|
||||
|
||||
import { chromium } from 'playwright';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = resolve(HERE, '../demo/tidewater/public/images');
|
||||
|
||||
const COVERS = [
|
||||
'astro-move', 'colour-tokens', 'homepage-budget', 'alt-text', 'checklist', 'type-specimen',
|
||||
'museum', 'image-formats', 'empty-states', 'migration-map', 'markdown-model', 'offsite',
|
||||
'preview-deploys', 'type-scale', 'audit', 'studio-desk', 'harbour-office',
|
||||
'handbook-voice', 'handbook-images', 'handbook-publish',
|
||||
];
|
||||
const AVATARS = { mara: 'ML', jonas: 'JP', aiko: 'AT', sam: 'SO' };
|
||||
|
||||
const PALETTES = [
|
||||
['#0f3b4c', '#1f6f78', '#f2c14e', '#f7ede2'],
|
||||
['#1b2a41', '#324a5f', '#e4572e', '#f3e9dc'],
|
||||
['#12355b', '#4f7cac', '#c0e0de', '#f5f1e8'],
|
||||
['#2d3142', '#4f5d75', '#ef8354', '#f4f1ea'],
|
||||
['#0b4f6c', '#01baef', '#fbfbff', '#20bf55'],
|
||||
['#264653', '#2a9d8f', '#e9c46a', '#f4a261'],
|
||||
['#3d315b', '#444b6e', '#708b75', '#f2e8cf'],
|
||||
['#1d3557', '#457b9d', '#a8dadc', '#f1faee'],
|
||||
];
|
||||
|
||||
function page(seed, kind, label) {
|
||||
return `<!doctype html><html><head><style>
|
||||
html,body{margin:0;width:100%;height:100%;overflow:hidden}
|
||||
canvas{display:block}
|
||||
</style></head><body><canvas id="c"></canvas><script>(() => {
|
||||
let s = ${seed};
|
||||
const r = () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296);
|
||||
const P = ${JSON.stringify(PALETTES)}[${seed} % ${PALETTES.length}];
|
||||
const c = document.getElementById('c');
|
||||
const W = c.width = innerWidth, H = c.height = innerHeight;
|
||||
const g = c.getContext('2d');
|
||||
const bg = g.createLinearGradient(0, 0, W, H);
|
||||
bg.addColorStop(0, P[0]); bg.addColorStop(1, P[1]);
|
||||
g.fillStyle = bg; g.fillRect(0, 0, W, H);
|
||||
if (${JSON.stringify(kind)} === 'cover') {
|
||||
// a sun or a moon
|
||||
g.globalAlpha = 0.95; g.fillStyle = P[2];
|
||||
g.beginPath(); g.arc(W * (0.2 + r() * 0.6), H * (0.2 + r() * 0.25), H * (0.12 + r() * 0.1), 0, 7); g.fill();
|
||||
// bands of water, back to front
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const y0 = H * (0.42 + i * 0.1);
|
||||
const amp = H * (0.02 + r() * 0.04), freq = 1.5 + r() * 2.5, ph = r() * 6;
|
||||
g.globalAlpha = 0.28 + i * 0.11;
|
||||
g.fillStyle = i % 2 ? P[3] : P[1];
|
||||
if (i === 5) g.fillStyle = P[0];
|
||||
g.beginPath(); g.moveTo(0, H);
|
||||
for (let x = 0; x <= W; x += 8) g.lineTo(x, y0 + Math.sin(x / W * Math.PI * freq + ph) * amp);
|
||||
g.lineTo(W, H); g.closePath(); g.fill();
|
||||
}
|
||||
// a few boats of pure geometry
|
||||
g.globalAlpha = 0.9; g.fillStyle = P[3];
|
||||
for (let i = 0; i < 2 + Math.floor(r() * 3); i++) {
|
||||
const x = W * r(), y = H * (0.5 + r() * 0.2), k = H * (0.03 + r() * 0.03);
|
||||
g.beginPath(); g.moveTo(x, y); g.lineTo(x + k, y - k * 2.2); g.lineTo(x + k, y); g.closePath(); g.fill();
|
||||
}
|
||||
} else {
|
||||
g.fillStyle = P[3]; g.globalAlpha = 0.95;
|
||||
g.font = '600 ' + (H * 0.38) + 'px -apple-system, system-ui, sans-serif';
|
||||
g.textAlign = 'center'; g.textBaseline = 'middle';
|
||||
g.fillText(${JSON.stringify(label)}, W / 2, H / 2 + H * 0.02);
|
||||
}
|
||||
// grain, so gradients do not band after a video encode
|
||||
const d = g.getImageData(0, 0, W, H);
|
||||
for (let i = 0; i < d.data.length; i += 4) {
|
||||
const n = (r() - 0.5) * 14;
|
||||
d.data[i] += n; d.data[i + 1] += n; d.data[i + 2] += n;
|
||||
}
|
||||
g.putImageData(d, 0, 0);
|
||||
})();</script></body></html>`;
|
||||
}
|
||||
|
||||
const hash = (name) => [...name].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 7);
|
||||
|
||||
mkdirSync(join(OUT, 'avatars'), { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const tab = await browser.newPage({ viewport: { width: 1200, height: 675 }, deviceScaleFactor: 1 });
|
||||
for (const name of COVERS) {
|
||||
await tab.setContent(page(hash(name), 'cover', ''));
|
||||
await tab.screenshot({ path: join(OUT, `${name}.jpg`), type: 'jpeg', quality: 82 });
|
||||
}
|
||||
await tab.setViewportSize({ width: 256, height: 256 });
|
||||
for (const [name, initials] of Object.entries(AVATARS)) {
|
||||
await tab.setContent(page(hash(name), 'avatar', initials));
|
||||
await tab.screenshot({ path: join(OUT, 'avatars', `${name}.jpg`), type: 'jpeg', quality: 85 });
|
||||
}
|
||||
await browser.close();
|
||||
console.log(`${COVERS.length} covers, ${Object.keys(AVATARS).length} avatars in ${OUT}`);
|
||||
@@ -0,0 +1,349 @@
|
||||
// Opens the real extension in real VS Code, against the demo site in
|
||||
// ../demo/tidewater, and hands back the workbench page for the shot list to
|
||||
// drive. Called only by capture.mjs.
|
||||
//
|
||||
// Everything that could differ between two runs is pinned or thrown away:
|
||||
//
|
||||
// - VS Code itself is a pinned build, downloaded once into .cache/vscode, not
|
||||
// whatever is installed on the machine doing the capture.
|
||||
// - The extension is the packaged VSIX (`npm run vsix`), installed into an
|
||||
// empty extensions folder. That is what runs in production mode and loads
|
||||
// the webviews from dist/ — exactly what someone installing it from the
|
||||
// Marketplace gets. `--extensionDevelopmentPath` would load them from the
|
||||
// webpack dev server instead.
|
||||
// - The profile, the extensions folder and the workspace are fresh copies in
|
||||
// the temp dir on every run, so no state leaks from one capture to the next
|
||||
// and nothing a beat writes ever lands in demo/.
|
||||
// - Every file in the workspace gets the same mtime, DEMO_NOW, so nothing the
|
||||
// dashboard sorts or labels by modification date depends on when the
|
||||
// checkout happened.
|
||||
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { downloadAndUnzipVSCode, resolveCliArgsFromVSCodeExecutablePath } from '@vscode/test-electron';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(HERE, '..');
|
||||
export const DEMO_DIR = join(ROOT, 'demo/tidewater');
|
||||
const CACHE = join(ROOT, '.cache');
|
||||
export const VSIX = join(CACHE, 'front-matter.vsix');
|
||||
|
||||
/// The VS Code build every capture is taken in. Bump it deliberately: a new
|
||||
/// build moves the workbench chrome around, and every crop is measured off it.
|
||||
export const VSCODE_VERSION = process.env.PROMO_VSCODE ?? '1.139.1';
|
||||
|
||||
/// The window, in CSS pixels. Captured at 2x. Narrower than a real laptop on
|
||||
/// purpose — the workbench lays out the same, and a narrower window makes 13px
|
||||
/// editor text a larger share of a 1080p frame.
|
||||
export const VIEWPORT = { width: 1280, height: 820 };
|
||||
|
||||
/// Every date in ../demo is written against this day, and every file's mtime is
|
||||
/// set to it. The drafts are dated after it; everything published, before.
|
||||
export const DEMO_NOW = new Date('2026-09-24T10:24:00Z');
|
||||
|
||||
/// The user settings of the throwaway profile. Everything here is something a
|
||||
/// real user could have set; nothing reaches into the extension.
|
||||
const USER_SETTINGS = {
|
||||
'workbench.colorTheme': 'Default Dark Modern',
|
||||
'workbench.startupEditor': 'none',
|
||||
'workbench.tips.enabled': false,
|
||||
'workbench.enableExperiments': false,
|
||||
'workbench.layoutControl.enabled': false,
|
||||
'workbench.secondarySideBar.defaultVisibility': 'hidden',
|
||||
'workbench.editor.empty.hint': 'hidden',
|
||||
'window.restoreWindows': 'none',
|
||||
'window.commandCenter': false,
|
||||
'window.title': '${rootName}',
|
||||
'telemetry.telemetryLevel': 'off',
|
||||
'update.mode': 'none',
|
||||
'extensions.autoUpdate': false,
|
||||
'extensions.autoCheckUpdates': false,
|
||||
'extensions.ignoreRecommendations': true,
|
||||
'security.workspace.trust.enabled': false,
|
||||
'chat.disableAIFeatures': true,
|
||||
'chat.commandCenter.enabled': false,
|
||||
'git.enabled': false,
|
||||
'editor.minimap.enabled': false,
|
||||
'editor.wordWrap': 'on',
|
||||
'editor.fontSize': 13,
|
||||
'editor.lineHeight': 21,
|
||||
'editor.renderWhitespace': 'none',
|
||||
'editor.stickyScroll.enabled': false,
|
||||
'editor.lightbulb.enabled': 'off',
|
||||
// A blinking caret makes every recorded frame differ from the last, and a
|
||||
// still can land on either phase.
|
||||
'editor.cursorBlinking': 'solid',
|
||||
'files.autoSave': 'off',
|
||||
'markdown.validate.enabled': false,
|
||||
};
|
||||
|
||||
// ---- the pointer ---------------------------------------------------------
|
||||
//
|
||||
// CDP's screencast composites the page and not the cursor, so a recording of a
|
||||
// click is a recording of something happening for no visible reason. This puts
|
||||
// a dot back. It is the only thing in any frame that the app did not draw.
|
||||
//
|
||||
// Unlike a plain web app, most of what gets clicked here is inside a webview —
|
||||
// an iframe in another process — and its mouse events never reach the
|
||||
// workbench document. So the dot does not listen for mousemove; `pointTo` below
|
||||
// sets it to the same coordinates it hands `page.mouse`, step for step, and it
|
||||
// can no more drift from the click than listening could.
|
||||
|
||||
const POINTER = (accent) => {
|
||||
const make = (id, css) => {
|
||||
const el = document.createElement('div');
|
||||
el.id = id;
|
||||
el.style.cssText = css.join(';');
|
||||
document.body.append(el);
|
||||
return el;
|
||||
};
|
||||
const base = ['position:fixed', 'left:0', 'top:0', 'width:22px', 'height:22px', 'margin:-11px 0 0 -11px',
|
||||
'border-radius:50%', 'pointer-events:none', 'opacity:0'];
|
||||
const dot = make('__promo_pointer', [...base, 'z-index:2147483647',
|
||||
'background:rgba(255,255,255,0.35)', 'border:2px solid #fff', 'box-shadow:0 2px 10px rgba(0,0,0,0.55)',
|
||||
'transition:opacity .2s, transform .12s']);
|
||||
const ring = make('__promo_ring', [...base, 'z-index:2147483646', `border:3px solid ${accent}`]);
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
window.__promo = {
|
||||
move(nx, ny) {
|
||||
x = nx;
|
||||
y = ny;
|
||||
dot.style.opacity = '1';
|
||||
dot.style.transform = `translate(${x}px, ${y}px)`;
|
||||
ring.style.transform = `translate(${x}px, ${y}px)`;
|
||||
},
|
||||
down() {
|
||||
dot.style.transform = `translate(${x}px, ${y}px) scale(0.75)`;
|
||||
ring.style.transition = 'none';
|
||||
ring.style.opacity = '0.9';
|
||||
ring.style.transform = `translate(${x}px, ${y}px) scale(1)`;
|
||||
requestAnimationFrame(() => {
|
||||
ring.style.transition = 'opacity .45s ease-out, transform .45s ease-out';
|
||||
ring.style.opacity = '0';
|
||||
ring.style.transform = `translate(${x}px, ${y}px) scale(2.6)`;
|
||||
});
|
||||
},
|
||||
up() {
|
||||
dot.style.transform = `translate(${x}px, ${y}px) scale(1)`;
|
||||
},
|
||||
show(v) {
|
||||
dot.style.display = v ? '' : 'none';
|
||||
ring.style.display = v ? '' : 'none';
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const current = { x: 0, y: 0 };
|
||||
|
||||
/** Move the real mouse and the drawn dot together, in `steps` steps. */
|
||||
export async function pointTo(page, x, y, { steps = 26 } = {}) {
|
||||
const from = { ...current };
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
// Ease in and out, so a move reads as a hand rather than a conveyor.
|
||||
const t = i / steps;
|
||||
const e = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
|
||||
const px = from.x + (x - from.x) * e;
|
||||
const py = from.y + (y - from.y) * e;
|
||||
await page.mouse.move(px, py);
|
||||
await page.evaluate(([a, b]) => window.__promo?.move(a, b), [px, py]);
|
||||
}
|
||||
current.x = x;
|
||||
current.y = y;
|
||||
}
|
||||
|
||||
export async function pressMouse(page) {
|
||||
await page.evaluate(() => window.__promo?.down());
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
export async function releaseMouse(page) {
|
||||
await page.mouse.up();
|
||||
await page.evaluate(() => window.__promo?.up());
|
||||
}
|
||||
|
||||
/** Hide the dot for the stills. */
|
||||
export const hidePointer = (page) => page.evaluate(() => window.__promo?.show(false));
|
||||
export const showPointer = (page) => page.evaluate(() => window.__promo?.show(true));
|
||||
|
||||
// ---- setting up ----------------------------------------------------------
|
||||
|
||||
function freshDir(path) {
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
mkdirSync(path, { recursive: true });
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Stamp every file and folder with DEMO_NOW. */
|
||||
function pinMtimes(dir) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
if (statSync(path).isDirectory()) pinMtimes(path);
|
||||
utimesSync(path, DEMO_NOW, DEMO_NOW);
|
||||
}
|
||||
utimesSync(dir, DEMO_NOW, DEMO_NOW);
|
||||
}
|
||||
|
||||
/** The environment VS Code is launched with. When the capture is itself run
|
||||
* from a VS Code terminal, the parent's VSCODE_* variables would make the child
|
||||
* try to talk to the parent instead of starting on its own. */
|
||||
function cleanEnv() {
|
||||
const env = { ...process.env };
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key.startsWith('VSCODE_') || key === 'ELECTRON_RUN_AS_NODE' || key === 'ELECTRON_NO_ATTACH_CONSOLE') {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** Launch VS Code on a fresh copy of the demo site, ready to drive. */
|
||||
export async function openApp({ pointer = false, accent = '#15c2cb', ready = true } = {}) {
|
||||
if (!existsSync(VSIX)) {
|
||||
throw new Error(`no ${VSIX} — run npm run vsix first`);
|
||||
}
|
||||
|
||||
let executable = await downloadAndUnzipVSCode({ version: VSCODE_VERSION, cachePath: join(CACHE, 'vscode') });
|
||||
// Recent macOS builds name the binary `Code`; test-electron still guesses
|
||||
// `Electron`.
|
||||
if (!existsSync(executable) && existsSync(join(dirname(executable), 'Code'))) {
|
||||
executable = join(dirname(executable), 'Code');
|
||||
}
|
||||
|
||||
// Short paths in the temp dir: VS Code puts IPC sockets under the profile, and
|
||||
// macOS caps a socket path at 104 bytes.
|
||||
const run = join(tmpdir(), 'fm-promo');
|
||||
const profile = freshDir(join(run, 'u'));
|
||||
const extensions = freshDir(join(run, 'x'));
|
||||
const workspace = join(freshDir(join(run, 'w')), 'tidewater');
|
||||
|
||||
mkdirSync(join(profile, 'User'), { recursive: true });
|
||||
writeFileSync(join(profile, 'User/settings.json'), JSON.stringify(USER_SETTINGS, null, 2));
|
||||
|
||||
const [cli, ...cliArgs] = resolveCliArgsFromVSCodeExecutablePath(executable);
|
||||
execFileSync(cli, [...cliArgs, '--user-data-dir', profile, '--extensions-dir', extensions,
|
||||
'--install-extension', VSIX, '--force'], { stdio: 'ignore', env: cleanEnv() });
|
||||
|
||||
cpSync(DEMO_DIR, workspace, { recursive: true });
|
||||
pinMtimes(workspace);
|
||||
|
||||
const app = await electron.launch({
|
||||
executablePath: executable,
|
||||
env: cleanEnv(),
|
||||
args: [
|
||||
workspace,
|
||||
`--user-data-dir=${profile}`,
|
||||
`--extensions-dir=${extensions}`,
|
||||
'--skip-welcome',
|
||||
'--skip-release-notes',
|
||||
'--disable-workspace-trust',
|
||||
'--disable-telemetry',
|
||||
'--disable-updates',
|
||||
'--disable-crash-reporter',
|
||||
'--new-window',
|
||||
// The screencast composites at CSS resolution, so the surface is forced
|
||||
// to 2x here rather than trusting whatever display the window opens on.
|
||||
'--force-device-scale-factor=2',
|
||||
'--force-color-profile=srgb',
|
||||
],
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('.monaco-workbench', { timeout: 120_000 });
|
||||
|
||||
// The window, sized from the main process: the renderer cannot resize itself.
|
||||
await app.evaluate(({ BrowserWindow }, size) => {
|
||||
const win = BrowserWindow.getAllWindows()[0];
|
||||
win.setContentSize(size.width, size.height);
|
||||
win.center();
|
||||
}, VIEWPORT);
|
||||
|
||||
if (pointer) {
|
||||
await page.evaluate(POINTER, accent);
|
||||
}
|
||||
|
||||
// The dashboard opens itself on a Front Matter workspace (openOnStart in the
|
||||
// demo's frontmatter.json). Its content cards are the sign that the extension
|
||||
// is up and has read the site.
|
||||
if (ready) {
|
||||
const dash = await dashboard(page);
|
||||
await dash.locator('img').first().waitFor({ state: 'visible', timeout: 120_000 });
|
||||
await settle(page, 1500);
|
||||
await clearNotifications(page);
|
||||
}
|
||||
await settle(page, 1500);
|
||||
|
||||
return { app, page, workspace };
|
||||
}
|
||||
|
||||
// ---- finding things ------------------------------------------------------
|
||||
|
||||
/// A webview is two iframes deep — VS Code's host frame and the document the
|
||||
/// extension rendered inside it — and the host frames do not live in the editor
|
||||
/// or the sidebar's DOM at all: they sit in an overlay laid over them. So the
|
||||
/// dashboard and the panel are found by what they rendered, not where. Both
|
||||
/// render an `#app`; only the dashboard's carries `data-webview-url`.
|
||||
///
|
||||
/// Looked up afresh on every call. A webview that is hidden and shown again can
|
||||
/// come back as a new frame, and a stale handle fails silently.
|
||||
async function findFrame(page, selector, timeout = 60_000) {
|
||||
const until = Date.now() + timeout;
|
||||
while (Date.now() < until) {
|
||||
for (const frame of page.frames()) {
|
||||
if (frame === page.mainFrame()) continue;
|
||||
const hit = await frame.$(selector).catch(() => null);
|
||||
if (hit) return frame;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
throw new Error(`no webview with ${selector}`);
|
||||
}
|
||||
|
||||
export const dashboard = (page) => findFrame(page, '#app[data-webview-url]');
|
||||
export const panel = (page) => findFrame(page, '#app:not([data-webview-url])');
|
||||
|
||||
/** Close every toast. A fresh profile always gets the extension's "updated to"
|
||||
* notification, and VS Code has its own. */
|
||||
export async function clearNotifications(page) {
|
||||
await runCommand(page, 'Notifications: Clear All Notifications');
|
||||
}
|
||||
|
||||
/** Wait for the app to stop moving. */
|
||||
export async function settle(page, ms = 500) {
|
||||
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/** Run a VS Code command through the palette. Only ever used while setting up a
|
||||
* shot, never inside a recording: in a video it would read as a step the viewer
|
||||
* has to perform. */
|
||||
export async function runCommand(page, title) {
|
||||
await page.keyboard.press('Meta+Shift+P');
|
||||
const input = page.locator('.quick-input-widget input');
|
||||
await input.waitFor({ state: 'visible' });
|
||||
await input.fill(`>${title}`);
|
||||
// The list filters a beat after the input changes. Pressing Enter on the
|
||||
// first row before then runs whatever matched the previous text — which is
|
||||
// how a "close the sidebar" once opened the secondary one instead.
|
||||
const first = page.locator('.quick-input-list .monaco-list-row').first();
|
||||
await page.waitForFunction(
|
||||
([sel, want]) => {
|
||||
const row = document.querySelector(sel);
|
||||
const label = row?.querySelector('.label-name')?.textContent?.trim() ?? '';
|
||||
return label.replace(/\s+/g, ' ') === want;
|
||||
},
|
||||
['.quick-input-list .monaco-list-row', title],
|
||||
{ timeout: 10_000 },
|
||||
).catch(async (error) => {
|
||||
const seen = await page.locator('.quick-input-list .monaco-list-row .label-name').allTextContents();
|
||||
throw new Error(`runCommand: no "${title}" in the palette; it lists ${JSON.stringify(seen.slice(0, 6))}`, { cause: error });
|
||||
});
|
||||
await first.waitFor({ state: 'visible' });
|
||||
await page.waitForTimeout(150);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Re-encodes the two renders for the places they are served from, into
|
||||
// out/web/:
|
||||
//
|
||||
// npm run web
|
||||
//
|
||||
// promo.mp4 the 30s cut for frontmatter.codes — 1080p, silent, faststart
|
||||
// tour.mp4 the 90s cut, at 720p: it plays into a box that is never wider
|
||||
// than half the page
|
||||
// poster.jpg the promo's poster frame
|
||||
// readme.gif the edit beat on its own, looping, for the README and the
|
||||
// Marketplace page — neither plays video inline, both play a GIF
|
||||
//
|
||||
// The renders in out/ are the masters (CRF 17) — upload those anywhere that
|
||||
// re-encodes for you. These copies are sized for a first visit.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, statSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = resolve(HERE, '../out');
|
||||
const WEB = join(OUT, 'web');
|
||||
const CLIPS = resolve(HERE, '../public/clips');
|
||||
|
||||
mkdirSync(WEB, { recursive: true });
|
||||
|
||||
const ffmpeg = (args) => execFileSync('ffmpeg', ['-y', '-hide_banner', '-loglevel', 'error', ...args], { stdio: 'inherit' });
|
||||
const mb = (path) => `${(statSync(path).size / 1024 / 1024).toFixed(1)} MB`;
|
||||
|
||||
const encode = (from, to, scale) => {
|
||||
ffmpeg([
|
||||
'-i', join(OUT, from),
|
||||
...(scale ? ['-vf', `scale=${scale}:flags=lanczos`] : []),
|
||||
'-c:v', 'libx264', '-crf', '25', '-preset', 'slow',
|
||||
'-profile:v', 'high', '-level', '4.0',
|
||||
'-pix_fmt', 'yuv420p', '-movflags', '+faststart',
|
||||
'-an',
|
||||
join(WEB, to),
|
||||
]);
|
||||
console.log(`out/web/${to} — ${mb(join(WEB, to))}`);
|
||||
};
|
||||
|
||||
encode('promo.mp4', 'promo.mp4', null);
|
||||
encode('tour.mp4', 'tour.mp4', '1280:720');
|
||||
|
||||
// The poster: the dashboard under its caption, fully on screen — not the title
|
||||
// card, which is a picture of the logo the page above it already shows.
|
||||
ffmpeg([
|
||||
'-i', join(OUT, 'promo.mp4'),
|
||||
'-vf', "select='eq(n\\,160)'", '-frames:v', '1',
|
||||
'-q:v', '3',
|
||||
join(WEB, 'poster.jpg'),
|
||||
]);
|
||||
console.log(`out/web/poster.jpg — ${mb(join(WEB, 'poster.jpg'))}`);
|
||||
|
||||
// The README loop: the raw edit beat, no captions — the README's own text is
|
||||
// the caption. The left 1908px (the same crop the promo uses) at 960 wide,
|
||||
// 12 fps, one palette for the whole clip so the dark UI does not dither.
|
||||
ffmpeg([
|
||||
'-i', join(CLIPS, 'edit.mp4'),
|
||||
'-vf', [
|
||||
'fps=12',
|
||||
'crop=1908:1640:0:0',
|
||||
'scale=960:-1:flags=lanczos',
|
||||
'split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle',
|
||||
].join(','),
|
||||
'-loop', '0',
|
||||
join(WEB, 'readme.gif'),
|
||||
]);
|
||||
console.log(`out/web/readme.gif — ${mb(join(WEB, 'readme.gif'))}`);
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { Backdrop, Progress } from './components/Backdrop';
|
||||
import { Cut, Part } from './cut';
|
||||
import { Outro } from './scenes/Outro';
|
||||
import { Title } from './scenes/Title';
|
||||
import MANIFEST from '../public/clips.json';
|
||||
import { EditBeat, editLength, StillShot } from './shots';
|
||||
import { DURATION } from './theme';
|
||||
|
||||
// Thirty seconds, one argument: your Markdown site gets a CMS without leaving
|
||||
// the editor or the repository. The edit beat carries it; everything around it
|
||||
// is there so the beat has somewhere to happen.
|
||||
|
||||
const clips = MANIFEST as Record<string, { frames: number }>;
|
||||
|
||||
const PARTS: Part[] = [
|
||||
{ name: 'title', duration: 90, render: () => <Title /> },
|
||||
{
|
||||
name: 'contents',
|
||||
duration: 126,
|
||||
render: (d) => (
|
||||
<StillShot
|
||||
name="contents"
|
||||
duration={d}
|
||||
line={{
|
||||
kicker: 'Dashboard',
|
||||
headline: 'Every post on your site, with its status, date and tags.',
|
||||
note: 'Read straight from the Markdown files in your repository.',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ name: 'edit', duration: editLength(clips.edit.frames), render: (d) => <EditBeat duration={d} /> },
|
||||
{
|
||||
name: 'media',
|
||||
duration: 110,
|
||||
render: (d) => (
|
||||
<StillShot
|
||||
name="media"
|
||||
duration={d}
|
||||
line={{ kicker: 'Media', headline: 'Your images, where your site keeps them.' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{ name: 'outro', render: () => <Outro /> },
|
||||
];
|
||||
|
||||
export const Promo: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<Backdrop total={DURATION} />
|
||||
<Cut parts={PARTS} total={DURATION} />
|
||||
<Progress total={DURATION} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Composition } from 'remotion';
|
||||
import { Promo } from './Promo';
|
||||
import { Tour } from './Tour';
|
||||
import { DURATION, FPS, HEIGHT, TOUR_DURATION, WIDTH } from './theme';
|
||||
|
||||
export const RemotionRoot: React.FC = () => (
|
||||
<>
|
||||
{/* Thirty seconds: the pitch. */}
|
||||
<Composition id="Promo" component={Promo} durationInFrames={DURATION} fps={FPS} width={WIDTH} height={HEIGHT} />
|
||||
{/* Ninety seconds: every screen. */}
|
||||
<Composition id="Tour" component={Tour} durationInFrames={TOUR_DURATION} fps={FPS} width={WIDTH} height={HEIGHT} />
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill } from 'remotion';
|
||||
import { Backdrop, Progress } from './components/Backdrop';
|
||||
import { Cut, Part } from './cut';
|
||||
import { Outro } from './scenes/Outro';
|
||||
import { Title } from './scenes/Title';
|
||||
import MANIFEST from '../public/clips.json';
|
||||
import {
|
||||
CREATE, CreateBeat, EditBeat, editLength, LEFT, SNIPPET, SnippetBeat, StillShot,
|
||||
} from './shots';
|
||||
import { TOUR_DURATION } from './theme';
|
||||
|
||||
// Ninety seconds, every screen, in the order someone new to it would meet them:
|
||||
// the content, editing it, making more of it, then everything around it.
|
||||
|
||||
const clips = MANIFEST as Record<string, { frames: number }>;
|
||||
const beat = (name: string, b: { holdIn: number; rate: number; tail: number }) =>
|
||||
b.holdIn + Math.round(clips[name].frames / b.rate) + b.tail;
|
||||
|
||||
const still = (name: string, duration: number, line: { kicker: string; headline: string; note?: string }, rect?: typeof LEFT): Part => ({
|
||||
name,
|
||||
duration,
|
||||
render: (d) => <StillShot name={name} duration={d} line={line} rect={rect} />,
|
||||
});
|
||||
|
||||
const PARTS: Part[] = [
|
||||
{ name: 'title', duration: 96, render: () => <Title /> },
|
||||
still('contents', 220, {
|
||||
kicker: 'Dashboard',
|
||||
headline: 'Every post on your site, with its status, date and tags.',
|
||||
note: 'Read straight from the Markdown files in your repository.',
|
||||
}),
|
||||
still('drafts', 170, {
|
||||
kicker: 'Dashboard',
|
||||
headline: 'Filter down to what is still in draft.',
|
||||
note: 'Search, sort, group and filter by any tag.',
|
||||
}),
|
||||
{ name: 'edit', duration: editLength(clips.edit.frames), render: (d) => <EditBeat duration={d} /> },
|
||||
still('editor', 220, {
|
||||
kicker: 'Content health',
|
||||
headline: 'Readability checks, while you write.',
|
||||
note: 'A readability score, sentence length and word length for the open post.',
|
||||
}, LEFT),
|
||||
{ name: 'create', duration: beat('create', CREATE), render: (d) => <CreateBeat duration={d} /> },
|
||||
still('media', 210, {
|
||||
kicker: 'Media',
|
||||
headline: 'Your images, where your site keeps them.',
|
||||
note: 'Browse the folders, search them, and see every image\'s size.',
|
||||
}),
|
||||
still('snippets', 160, {
|
||||
kicker: 'Snippets',
|
||||
headline: 'The components your posts use, as snippets.',
|
||||
note: 'Content snippets and media snippets, each with a description.',
|
||||
}),
|
||||
{ name: 'snippet', duration: beat('snippet', SNIPPET), render: (d) => <SnippetBeat duration={d} /> },
|
||||
still('data', 210, {
|
||||
kicker: 'Data',
|
||||
headline: 'Data files, edited with a form.',
|
||||
note: 'Pick an entry and it opens as a form, one field per property.',
|
||||
}),
|
||||
still('taxonomy', 210, {
|
||||
kicker: 'Taxonomies',
|
||||
headline: 'Every tag, and how many posts use it.',
|
||||
}),
|
||||
{ name: 'outro', render: () => <Outro /> },
|
||||
];
|
||||
|
||||
export const Tour: React.FC = () => (
|
||||
<AbsoluteFill>
|
||||
<Backdrop total={TOUR_DURATION} />
|
||||
<Cut parts={PARTS} total={TOUR_DURATION} />
|
||||
<Progress total={TOUR_DURATION} />
|
||||
</AbsoluteFill>
|
||||
);
|
||||
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { DURATION, T } from '../theme';
|
||||
|
||||
/// The ground the whole piece sits on. It never cuts — scenes dissolve over it —
|
||||
/// so the accent drifting across the runtime is the only thing that says the
|
||||
/// frame is still alive during a long hold.
|
||||
///
|
||||
/// Take the glow from whatever the product's own page puts behind its hero, at
|
||||
/// the same corners. `T.glow` is that colour as an rgb triple.
|
||||
export const Backdrop: React.FC<{ total?: number }> = ({ total = DURATION }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const drift = interpolate(frame, [0, total], [0, 1]);
|
||||
const glowX = interpolate(drift, [0, 1], [74, 62]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: T.ground }}>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(1150px 640px at ${glowX}% -10%, rgba(${T.glow},0.20), transparent 66%)`,
|
||||
}}
|
||||
/>
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(950px 620px at ${100 - glowX}% 108%, rgba(${T.glow},0.10), transparent 64%)`,
|
||||
}}
|
||||
/>
|
||||
{/* A faint pull on the corners, so the frame has an edge. */}
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: 'radial-gradient(1500px 900px at 50% 46%, transparent 45%, rgba(14,19,31,0.08) 100%)',
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/// A hairline filling across the bottom edge over the full runtime. It is the
|
||||
/// only element that never dissolves, so it doubles as a progress bar and as the
|
||||
/// one piece of gold that is always on screen.
|
||||
export const Progress: React.FC<{ total?: number }> = ({ total = DURATION }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const pct = interpolate(frame, [0, total - 1], [0, 100], { extrapolateRight: 'clamp' });
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 3,
|
||||
backgroundColor: 'rgba(14,19,31,0.06)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: '100%',
|
||||
backgroundColor: T.accent,
|
||||
boxShadow: `0 0 14px ${T.accent}`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Easing, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { CAPTION_BASELINE, COLUMN, HEIGHT, SANS, T } from '../theme';
|
||||
|
||||
const rise = (frame: number, delay: number) => {
|
||||
const t = interpolate(frame, [delay, delay + 16], [0, 1], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
});
|
||||
return { opacity: t, transform: `translateY(${(1 - t) * 16}px)` };
|
||||
};
|
||||
|
||||
/// Above the card, or in a column beside it. On the `wide` stage the block is
|
||||
/// bottom-aligned to `CAPTION_BASELINE` rather than top-aligned, so a headline
|
||||
/// that wraps grows up into the empty frame instead of down into the picture.
|
||||
export const Caption: React.FC<{
|
||||
kicker: string;
|
||||
headline: string;
|
||||
note?: string;
|
||||
where?: 'wide' | 'side';
|
||||
/// Fades the block out again this many frames before the shot ends, for the
|
||||
/// shots where the caption changes while the camera does not.
|
||||
outAt?: number;
|
||||
}> = ({ kicker, headline, note, where = 'wide', outAt }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const side = where === 'side';
|
||||
const out =
|
||||
outAt === undefined
|
||||
? 1
|
||||
: interpolate(frame, [outAt, outAt + 12], [1, 0], {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: side ? COLUMN.left : 0,
|
||||
width: side ? COLUMN.width : '100%',
|
||||
top: 0,
|
||||
height: side ? HEIGHT : CAPTION_BASELINE,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: side ? 'center' : 'flex-end',
|
||||
alignItems: side ? 'flex-start' : 'center',
|
||||
textAlign: side ? 'left' : 'center',
|
||||
fontFamily: SANS,
|
||||
opacity: out,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
...rise(frame, 0),
|
||||
fontSize: side ? 19 : 21,
|
||||
fontWeight: 600,
|
||||
letterSpacing: side ? 3.2 : 3.6,
|
||||
textTransform: 'uppercase',
|
||||
color: T.accentInk,
|
||||
}}
|
||||
>
|
||||
{kicker}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
...rise(frame, 4),
|
||||
marginTop: side ? 20 : 16,
|
||||
maxWidth: side ? COLUMN.width : 1420,
|
||||
fontSize: side ? 44 : 50,
|
||||
lineHeight: 1.14,
|
||||
fontWeight: 600,
|
||||
letterSpacing: -0.7,
|
||||
color: T.bright,
|
||||
// Chrome balances the lines rather than leaving one word stranded on
|
||||
// the last one, which a 50px headline makes very obvious.
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{headline}
|
||||
</div>
|
||||
{note ? (
|
||||
<div
|
||||
style={{
|
||||
...rise(frame, 10),
|
||||
marginTop: side ? 20 : 12,
|
||||
maxWidth: side ? COLUMN.width : 1120,
|
||||
fontSize: side ? 23 : 24,
|
||||
lineHeight: side ? 1.45 : 1.35,
|
||||
color: T.muted,
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{note}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { interpolate } from 'remotion';
|
||||
import { SOURCE } from '../theme';
|
||||
|
||||
/// A rectangle in the capture's own coordinates — 2560x1640, the numbers you
|
||||
/// would read off a file in public/shots in Preview. Every shot says what part
|
||||
/// of the window it is looking at in these terms and `Framed` works out the
|
||||
/// scale, so a crop can be re-measured without touching any layout.
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
/// The whole window.
|
||||
export const WINDOW: Rect = { x: 0, y: 0, w: SOURCE.width, h: SOURCE.height };
|
||||
|
||||
// Name the crops your own cut uses in the composition files, not here — they are
|
||||
// measured off the files in public/shots and mean nothing outside this project.
|
||||
|
||||
export const aspect = (r: Rect) => r.w / r.h;
|
||||
|
||||
/// Moves between two crops. Everything eases together, so a push in reads as one
|
||||
/// continuous move rather than four numbers sliding apart.
|
||||
export const between = (a: Rect, b: Rect, t: number): Rect => ({
|
||||
x: interpolate(t, [0, 1], [a.x, b.x]),
|
||||
y: interpolate(t, [0, 1], [a.y, b.y]),
|
||||
w: interpolate(t, [0, 1], [a.w, b.w]),
|
||||
h: interpolate(t, [0, 1], [a.h, b.h]),
|
||||
});
|
||||
|
||||
/// Grows `rect` about its own centre — the push that keeps a long hold from
|
||||
/// going dead. It scales the *crop*, so unlike scaling the mounted card it can
|
||||
/// walk off the edge of the picture; keep the factor small.
|
||||
export const zoom = (rect: Rect, factor: number): Rect => ({
|
||||
x: rect.x + (rect.w * (1 - factor)) / 2,
|
||||
y: rect.y + (rect.h * (1 - factor)) / 2,
|
||||
w: rect.w * factor,
|
||||
h: rect.h * factor,
|
||||
});
|
||||
|
||||
/// Shows `rect` of the source at `width` x `height`. The child is laid out at
|
||||
/// the source's full size and then scaled and shifted underneath a window that
|
||||
/// clips it, which is why a crop can move without the video ever being
|
||||
/// re-encoded and why a still and a recording can share one component.
|
||||
export const Framed: React.FC<{
|
||||
rect: Rect;
|
||||
width: number;
|
||||
height: number;
|
||||
/// The natural size of what is being cropped. The desktop capture by default;
|
||||
/// the phone shot is 804x1748 and says so.
|
||||
natural?: { w: number; h: number };
|
||||
children: React.ReactNode;
|
||||
}> = ({ rect, width, height, natural, children }) => {
|
||||
const full = natural ?? { w: SOURCE.width, h: SOURCE.height };
|
||||
const scale = width / rect.w;
|
||||
return (
|
||||
<div style={{ width, height, overflow: 'hidden', position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: full.w,
|
||||
height: full.h,
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${scale}) translate(${-rect.x}px, ${-rect.y}px)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/// The largest box of this aspect that fits inside `maxW` x `maxH`. Shots come
|
||||
/// in shapes the app chose, not shapes the promo did — the whole window is
|
||||
/// 1.56:1, a phone is 0.46:1, a crop of the day bar is nearly 4:1 — and a stage
|
||||
/// that fits each of them rather than stretching them is what lets one layout
|
||||
/// carry all three.
|
||||
export const fit = (ratio: number, maxW: number, maxH: number) => {
|
||||
const byHeight = { w: maxH * ratio, h: maxH };
|
||||
return byHeight.w <= maxW ? byHeight : { w: maxW, h: maxW / ratio };
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';
|
||||
import { XFADE } from '../theme';
|
||||
|
||||
/// Scenes are laid end to end with `XFADE` frames of overlap and dissolve
|
||||
/// through it. The backdrop underneath never cuts, so what the eye sees is the
|
||||
/// content changing rather than the picture being replaced.
|
||||
export const Scene: React.FC<{ duration: number; children: React.ReactNode }> = ({
|
||||
duration,
|
||||
children,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const clamp = { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' } as const;
|
||||
const enter = interpolate(frame, [0, XFADE], [0, 1], clamp);
|
||||
const leave = interpolate(frame, [duration - XFADE, duration], [1, 0], clamp);
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
opacity: Math.min(enter, leave),
|
||||
transform: `scale(${interpolate(enter, [0, 1], [0.988, 1])})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, Freeze, Img, OffthreadVideo, Sequence, staticFile, useCurrentFrame } from 'remotion';
|
||||
import { SOURCE, STAGE, T, WIDTH } from '../theme';
|
||||
import { fit, Framed, Rect } from './Framed';
|
||||
import MANIFEST from '../../public/clips.json';
|
||||
|
||||
/// The picture, mounted. A dark window on a light ground separates on its own;
|
||||
/// the shadow is what lifts it off the page, and the hairline keeps the corner
|
||||
/// crisp where the window's own dark edge meets the shadow.
|
||||
const Card: React.FC<{
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scale: number;
|
||||
children: React.ReactNode;
|
||||
}> = ({ top, left, width, height, scale, children }) => (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
height,
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: '50% 50%',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -1,
|
||||
borderRadius: 19,
|
||||
background: 'linear-gradient(180deg, rgba(14,19,31,0.35), rgba(14,19,31,0.6))',
|
||||
boxShadow: '0 50px 110px rgba(14,19,31,0.28), 0 10px 30px rgba(14,19,31,0.22)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: T.window,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/// The natural size of a still, where it is not the shared viewport. Everything
|
||||
/// in public/shots is a 1280x820 window at 2x except the phone, which is the one
|
||||
/// screen the app lays out differently rather than merely narrower.
|
||||
export const SHOT_SIZE: Record<string, { w: number; h: number }> = {
|
||||
mobile: { w: 804, h: 1748 },
|
||||
};
|
||||
|
||||
export const naturalOf = (name?: string) =>
|
||||
(name && SHOT_SIZE[name]) || { w: SOURCE.width, h: SOURCE.height };
|
||||
|
||||
/// A still out of public/shots, at the size it was captured.
|
||||
export const Still: React.FC<{ name: string }> = ({ name }) => {
|
||||
const size = naturalOf(name);
|
||||
return (
|
||||
<Img
|
||||
src={staticFile(`shots/${name}.png`)}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: size.w, height: size.h }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const layer: React.CSSProperties = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: SOURCE.width,
|
||||
height: SOURCE.height,
|
||||
};
|
||||
|
||||
/// One frame of a recording, held. `Freeze` pins the clock for its children, so
|
||||
/// the video seeks to `frame` and stays there — the same pixels the moving clip
|
||||
/// would show, which is what keeps a hold and the play either side of it from
|
||||
/// flickering as they hand over.
|
||||
const Held: React.FC<{ src: string; frame: number }> = ({ src, frame }) => (
|
||||
<Freeze frame={0}>
|
||||
<OffthreadVideo src={src} trimBefore={frame} muted style={layer} />
|
||||
</Freeze>
|
||||
);
|
||||
|
||||
export type Beat = {
|
||||
/// How long to sit on the first frame while the caption is read.
|
||||
holdIn: number;
|
||||
/// Where in the recording the moving part starts. Left out, from the top.
|
||||
from?: number;
|
||||
/// How much of the recording to use. Left out, all of it — which is almost
|
||||
/// always right, because `prepare` in capture.mjs keeps the setting-up out of
|
||||
/// the file in the first place.
|
||||
play?: number;
|
||||
/// Below 1 slows it down. The app was driven at working speed and a promo
|
||||
/// needs longer than that to land.
|
||||
rate: number;
|
||||
/// Total length of the shot, cross-dissolve included. Whatever is left after
|
||||
/// the hold and the play is spent holding on where it came to rest.
|
||||
total: number;
|
||||
};
|
||||
|
||||
/// How long each recording is, written by `npm run capture`.
|
||||
///
|
||||
/// Read from the manifest rather than copied into the tables below: a beat
|
||||
/// re-recorded a second slower would otherwise cut before its own payoff, and
|
||||
/// nothing about the composition would say so.
|
||||
const lengthOf = (name: string): number => {
|
||||
const entry = (MANIFEST as Record<string, { frames: number } | undefined>)[name];
|
||||
if (!entry) {
|
||||
throw new Error(`no clip "${name}" in public/clips.json — run npm run capture`);
|
||||
}
|
||||
return entry.frames;
|
||||
};
|
||||
|
||||
/// Hold, play, hold — the shape every recorded beat is cut to. Slowing the whole
|
||||
/// shot down instead would make the pointer crawl; holding at either end buys
|
||||
/// reading time without touching the pace of the part where something happens.
|
||||
export const Clip: React.FC<{ name: string; beat: Beat }> = ({ name, beat }) => {
|
||||
const src = staticFile(`clips/${name}.mp4`);
|
||||
const from = beat.from ?? 0;
|
||||
const play = beat.play ?? lengthOf(name) - from;
|
||||
const restFrom = beat.holdIn + Math.round(play / beat.rate);
|
||||
return (
|
||||
<>
|
||||
<Sequence durationInFrames={beat.holdIn} layout="none">
|
||||
<Held src={src} frame={from} />
|
||||
</Sequence>
|
||||
<Sequence from={beat.holdIn} durationInFrames={restFrom - beat.holdIn} layout="none">
|
||||
<OffthreadVideo src={src} trimBefore={from} playbackRate={beat.rate} muted style={layer} />
|
||||
</Sequence>
|
||||
<Sequence from={restFrom} durationInFrames={Math.max(1, beat.total - restFrom)} layout="none">
|
||||
<Held src={src} frame={from + play - 1} />
|
||||
</Sequence>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/// Where a shot sits in the frame, and what part of the window it is looking at.
|
||||
///
|
||||
/// `rectAt` is the camera: it says what the crop is on any given frame, which is
|
||||
/// how a push in and a shot that never moves come out of the same component.
|
||||
/// Every rect it returns has to share an aspect with the first one, or the
|
||||
/// picture stretches on the way between them.
|
||||
export const Shot: React.FC<{
|
||||
stage: keyof typeof STAGE;
|
||||
/// Names the still or clip being mounted, so the default crop and the scale
|
||||
/// are its own size rather than the shared viewport's. Only the phone shot
|
||||
/// needs it; everything else is captured through the same window.
|
||||
of?: string;
|
||||
rectAt?: (frame: number) => Rect;
|
||||
/// A whisper of a push, 1 to about 1.02 across the shot. It scales the mounted
|
||||
/// card rather than the crop, so the framing can never creep off the picture.
|
||||
scaleAt?: (frame: number) => number;
|
||||
children: React.ReactNode;
|
||||
/// Drawn over the picture, in the capture's own pixels, so it scales with it.
|
||||
overlay?: React.ReactNode;
|
||||
/// The caption, and anything else that belongs to the frame rather than the
|
||||
/// picture.
|
||||
aside?: React.ReactNode;
|
||||
}> = ({ stage, of, rectAt, scaleAt, children, overlay, aside }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const natural = naturalOf(of);
|
||||
const camera = rectAt ?? (() => ({ x: 0, y: 0, w: natural.w, h: natural.h }));
|
||||
const rect = camera(frame);
|
||||
const geometry = STAGE[stage];
|
||||
// The box is worked out from the *first* frame's crop, not this one: a push
|
||||
// that changes the aspect by a rounding error would otherwise resize the card
|
||||
// under the picture and shimmer its edges for the length of the move.
|
||||
const box = fit(camera(0).w / camera(0).h, geometry.maxW, geometry.maxH);
|
||||
const left = geometry.left === null ? (WIDTH - box.w) / 2 : geometry.left + (geometry.maxW - box.w) / 2;
|
||||
const top = geometry.top + (geometry.maxH - box.h) / 2;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{aside}
|
||||
<Card top={top} left={left} width={box.w} height={box.h} scale={scaleAt?.(frame) ?? 1}>
|
||||
<Framed rect={rect} width={box.w} height={box.h} natural={natural}>
|
||||
{children}
|
||||
{overlay}
|
||||
</Framed>
|
||||
</Card>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
/// A gentle push, for the shots that hold still long enough to go dead.
|
||||
export const creep =
|
||||
(from: number, to: number, over: number) =>
|
||||
(frame: number): number =>
|
||||
frame <= 0 ? from : frame >= over ? to : from + ((to - from) * frame) / over;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Sequence } from 'remotion';
|
||||
import { Scene } from './components/Scene';
|
||||
import { XFADE } from './theme';
|
||||
|
||||
/// A scene in a cut: how long it runs (cross-dissolve into the next included)
|
||||
/// and what it shows. The last scene of a cut takes whatever is left.
|
||||
export type Part = { name: string; duration?: number; render: (duration: number) => React.ReactNode };
|
||||
|
||||
/// Lays the scenes end to end, each starting XFADE frames before the previous
|
||||
/// one ends, and gives the last one the remainder of `total`. Throws rather
|
||||
/// than letting the outro shrink to nothing when a beat grows after a
|
||||
/// re-capture.
|
||||
export const lay = (parts: Part[], total: number) => {
|
||||
let from = 0;
|
||||
return parts.map((part, i) => {
|
||||
const last = i === parts.length - 1;
|
||||
const duration = last ? total - from : part.duration!;
|
||||
if (last && duration < 120) {
|
||||
throw new Error(`${part.name}: ${duration} frames left for it — shorten something earlier`);
|
||||
}
|
||||
const placed = { ...part, from, duration };
|
||||
from += duration - XFADE;
|
||||
return placed;
|
||||
});
|
||||
};
|
||||
|
||||
export const Cut: React.FC<{ parts: Part[]; total: number }> = ({ parts, total }) => (
|
||||
<>
|
||||
{lay(parts, total).map((p) => (
|
||||
<Sequence key={p.name} name={p.name} from={p.from} durationInFrames={p.duration}>
|
||||
<Scene duration={p.duration}>{p.render(p.duration)}</Scene>
|
||||
</Sequence>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from 'remotion';
|
||||
import { RemotionRoot } from './Root';
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { AbsoluteFill, Easing, Img, interpolate, staticFile, useCurrentFrame } from 'remotion';
|
||||
import { SANS, T } from '../theme';
|
||||
|
||||
const ease = {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
} as const;
|
||||
|
||||
export const Outro: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const line = (delay: number) => ({
|
||||
opacity: interpolate(frame, [delay, delay + 18], [0, 1], ease),
|
||||
transform: `translateY(${interpolate(frame, [delay, delay + 18], [16, 0], ease)}px)`,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ justifyContent: 'center', alignItems: 'center', fontFamily: SANS, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ ...line(0), display: 'flex', alignItems: 'center', gap: 24 }}>
|
||||
<Img src={staticFile('logo.svg')} style={{ width: 84, height: 84 }} />
|
||||
<span style={{ fontSize: 66, fontWeight: 700, letterSpacing: -1.8, color: T.bright }}>
|
||||
Front Matter CMS
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ ...line(8), marginTop: 30, fontSize: 42, fontWeight: 600, color: T.bright }}>
|
||||
Your content stays Markdown, in your repository.
|
||||
</div>
|
||||
|
||||
{/* The URL is the one thing anyone needs to take away, so it gets a card of
|
||||
its own rather than another line of copy. */}
|
||||
<div
|
||||
style={{
|
||||
...line(16),
|
||||
marginTop: 44,
|
||||
padding: '22px 44px',
|
||||
borderRadius: 14,
|
||||
backgroundColor: T.panel,
|
||||
border: `1px solid ${T.line}`,
|
||||
boxShadow: '0 24px 60px rgba(14,19,31,0.14)',
|
||||
fontSize: 36,
|
||||
fontWeight: 600,
|
||||
color: T.bright,
|
||||
}}
|
||||
>
|
||||
frontmatter<span style={{ color: T.accentInk }}>.codes</span>
|
||||
</div>
|
||||
|
||||
<div style={{ ...line(24), marginTop: 26, fontSize: 25, color: T.muted }}>
|
||||
Install it from the Visual Studio Code Marketplace.
|
||||
</div>
|
||||
|
||||
<div style={{ ...line(32), marginTop: 40, fontSize: 22, color: T.faint }}>
|
||||
Free · Open source · MIT licensed
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Easing,
|
||||
Img,
|
||||
interpolate,
|
||||
spring,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from 'remotion';
|
||||
import { SANS, T } from '../theme';
|
||||
|
||||
const ease = {
|
||||
extrapolateLeft: 'clamp',
|
||||
extrapolateRight: 'clamp',
|
||||
easing: Easing.bezier(0.22, 1, 0.36, 1),
|
||||
} as const;
|
||||
|
||||
export const Title: React.FC<{ tagline?: string; note?: string }> = ({
|
||||
tagline = 'A CMS that runs inside VS Code.',
|
||||
note = 'For Markdown sites built with Astro, Hugo, Jekyll, Next.js and more',
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const pop = spring({ frame, fps, config: { damping: 200, mass: 0.7 } });
|
||||
const rule = interpolate(frame, [16, 40], [0, 176], ease);
|
||||
const line = (delay: number) => ({
|
||||
opacity: interpolate(frame, [delay, delay + 18], [0, 1], ease),
|
||||
transform: `translateY(${interpolate(frame, [delay, delay + 18], [18, 0], ease)}px)`,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{ justifyContent: 'center', alignItems: 'center', fontFamily: SANS, textAlign: 'center' }}
|
||||
>
|
||||
<div style={{ position: 'relative', transform: `scale(${interpolate(pop, [0, 1], [0.86, 1])})` }}>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: -70,
|
||||
borderRadius: '50%',
|
||||
background: `radial-gradient(closest-side, rgba(${T.glow},0.35), transparent 70%)`,
|
||||
filter: 'blur(10px)',
|
||||
}}
|
||||
/>
|
||||
<Img
|
||||
src={staticFile('logo.svg')}
|
||||
style={{ position: 'relative', width: 150, height: 150, opacity: pop }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
...line(10),
|
||||
marginTop: 38,
|
||||
fontSize: 100,
|
||||
fontWeight: 700,
|
||||
letterSpacing: -2.6,
|
||||
color: T.bright,
|
||||
}}
|
||||
>
|
||||
Front Matter CMS
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 26,
|
||||
width: rule,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
backgroundColor: T.accent,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ ...line(26), marginTop: 30, fontSize: 38, color: T.text }}>{tagline}</div>
|
||||
<div style={{ ...line(34), marginTop: 14, fontSize: 25, color: T.muted }}>{note}</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import React from 'react';
|
||||
import { Sequence } from 'remotion';
|
||||
import { Caption } from './components/Caption';
|
||||
import { Rect, WINDOW } from './components/Framed';
|
||||
import { Clip, creep, Shot, Still } from './components/Shot';
|
||||
|
||||
// Every shot both cuts use, in one place, so the promo and the tour say the
|
||||
// same thing about the same screen.
|
||||
//
|
||||
// Crops are in the capture's own 2560x1640, read off the files in public/shots.
|
||||
|
||||
/// The whole window. The only crop whose every edge is the window's own.
|
||||
export const DESK: Rect = WINDOW;
|
||||
|
||||
/// The left three-quarters: the panel and the start of every editor line, or
|
||||
/// on the dashboard, the first three columns of cards. The right edge sits in
|
||||
/// the gutter between the third and fourth card (x 1892–1924) — on the editor
|
||||
/// it cuts running text, which reads as a zoom rather than a fault.
|
||||
export const LEFT: Rect = { x: 0, y: 0, w: 1908, h: 1640 };
|
||||
|
||||
type Line = { kicker: string; headline: string; note?: string };
|
||||
|
||||
/// A caption that hands over to the next one at the given scene frames. The
|
||||
/// switch frames are the numbers to re-check after every capture: nothing
|
||||
/// recomputes them.
|
||||
const Captions: React.FC<{ lines: (Line & { at: number })[]; total: number; where: 'wide' | 'side' }> = ({
|
||||
lines,
|
||||
total,
|
||||
where,
|
||||
}) => (
|
||||
<>
|
||||
{lines.map((line, i) => {
|
||||
const end = i + 1 < lines.length ? lines[i + 1].at : total;
|
||||
return (
|
||||
<Sequence key={i} from={line.at} durationInFrames={end - line.at} layout="none">
|
||||
<Caption where={where} {...line} outAt={i + 1 < lines.length ? end - line.at - 12 : undefined} />
|
||||
</Sequence>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
||||
export const StillShot: React.FC<{ name: string; line: Line; duration: number; rect?: Rect }> = ({
|
||||
name,
|
||||
line,
|
||||
duration,
|
||||
rect = DESK,
|
||||
}) => {
|
||||
const side = rect.w / rect.h < 1.4;
|
||||
return (
|
||||
<Shot
|
||||
stage={side ? 'side' : 'wide'}
|
||||
rectAt={() => rect}
|
||||
scaleAt={creep(1, 1.018, duration)}
|
||||
aside={<Caption where={side ? 'side' : 'wide'} {...line} />}
|
||||
>
|
||||
<Still name={name} />
|
||||
</Shot>
|
||||
);
|
||||
};
|
||||
|
||||
// ---- the recorded beats --------------------------------------------------
|
||||
//
|
||||
// Scene frame = holdIn + clip frame / rate. The `at` values below are scene
|
||||
// frames, set against where things happen in each clip:
|
||||
//
|
||||
// edit card clicked ~40, panel open ~64, typing ~135–245, the YAML shows
|
||||
// it ~250, Draft switched off ~345, clip ends 374
|
||||
// create (whole window: VS Code's quick input and its toasts sit across
|
||||
// LEFT's right edge) folder picked ~70, title typed ~110–200, the new file
|
||||
// open ~230, its panel filled ~235
|
||||
// snippet dashboard ~40, Callout's insert ~110, Insert pressed ~205, the
|
||||
// paragraph wrapped ~215
|
||||
|
||||
export const EDIT = { holdIn: 30, rate: 1, tail: 70 };
|
||||
export const editLength = (clip: number) => EDIT.holdIn + clip + EDIT.tail;
|
||||
|
||||
export const EditBeat: React.FC<{ duration: number }> = ({ duration }) => (
|
||||
<Shot
|
||||
stage="side"
|
||||
rectAt={() => LEFT}
|
||||
aside={
|
||||
<Captions
|
||||
where="side"
|
||||
total={duration}
|
||||
lines={[
|
||||
{ at: 0, kicker: 'Editing', headline: 'Open a post from the dashboard.', note: 'Its fields open in the panel beside it.' },
|
||||
{ at: 160, kicker: 'Editing', headline: 'Fill in fields, not YAML.', note: 'Every change is written to the Markdown file as you type.' },
|
||||
{ at: 362, kicker: 'Publishing', headline: 'One switch takes it out of draft.', note: 'draft: false, in the same file your site builds from.' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Clip name="edit" beat={{ holdIn: EDIT.holdIn, rate: EDIT.rate, total: duration }} />
|
||||
</Shot>
|
||||
);
|
||||
|
||||
export const CREATE = { holdIn: 24, rate: 1, tail: 60 };
|
||||
|
||||
export const CreateBeat: React.FC<{ duration: number }> = ({ duration }) => (
|
||||
<Shot
|
||||
stage="wide"
|
||||
rectAt={() => DESK}
|
||||
aside={
|
||||
<Captions
|
||||
where="wide"
|
||||
total={duration}
|
||||
lines={[
|
||||
{ at: 0, kicker: 'New content', headline: 'Pick a folder, give it a title.' },
|
||||
{ at: 258, kicker: 'New content', headline: 'It opens with its front matter filled in.', note: 'Every field from the content type, ready to edit.' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Clip name="create" beat={{ holdIn: CREATE.holdIn, rate: CREATE.rate, total: duration }} />
|
||||
</Shot>
|
||||
);
|
||||
|
||||
export const SNIPPET = { holdIn: 24, rate: 1, tail: 64 };
|
||||
|
||||
export const SnippetBeat: React.FC<{ duration: number }> = ({ duration }) => (
|
||||
<Shot
|
||||
stage="wide"
|
||||
rectAt={() => DESK}
|
||||
aside={
|
||||
<Captions
|
||||
where="wide"
|
||||
total={duration}
|
||||
lines={[
|
||||
{ at: 0, kicker: 'Snippets', headline: 'Select a paragraph, pick a snippet.' },
|
||||
{ at: 238, kicker: 'Snippets', headline: 'It comes back wrapped in your component.' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Clip name="snippet" beat={{ holdIn: SNIPPET.holdIn, rate: SNIPPET.rate, total: duration }} />
|
||||
</Shot>
|
||||
);
|
||||
@@ -0,0 +1,80 @@
|
||||
// Borrow the product's palette rather than inventing one. The accent is the
|
||||
// brand colour off its own landing page; the client/entity colours, if the app
|
||||
// has any, come out of the fixture in ../demo.
|
||||
//
|
||||
// The ground is the one thing that is *not* the app's, and the choice is worth
|
||||
// making deliberately: a dark app on ink disappears into it. VS Code is captured
|
||||
// in Dark Modern, so the ground is light and the window is the one dark thing in
|
||||
// the frame.
|
||||
export const T = {
|
||||
ground: '#eef1f4',
|
||||
panel: '#ffffff',
|
||||
line: '#d5dbe2',
|
||||
/// Headlines, and the one dark thing on the ground: the window itself.
|
||||
bright: '#0e131f',
|
||||
text: '#1f2733',
|
||||
muted: '#525d6e',
|
||||
faint: '#7d8898',
|
||||
/// Front Matter's teal (tailwind `teal-500`). As text on the light ground it is
|
||||
/// too pale to read, so kickers use `accentInk`, the same hue taken darker.
|
||||
accent: '#15c2cb',
|
||||
accentInk: '#067c84',
|
||||
accentDeep: '#0bb8c1',
|
||||
/// The accent as an `r,g,b` triple, for the backdrop's glows.
|
||||
glow: '21,194,203',
|
||||
/// What shows under a crop's edge while a frame loads: VS Code's own
|
||||
/// editor background, so a late frame never flashes white.
|
||||
window: '#1f1f1f',
|
||||
} as const;
|
||||
|
||||
export const SANS =
|
||||
'Inter, -apple-system, "SF Pro Display", "SF Pro Text", system-ui, "Segoe UI", sans-serif';
|
||||
export const MONO = '"SF Mono", ui-monospace, Menlo, "JetBrains Mono", "Roboto Mono", monospace';
|
||||
|
||||
export const WIDTH = 1920;
|
||||
export const HEIGHT = 1080;
|
||||
export const FPS = 30;
|
||||
export const DURATION = 900; // the short promo, 30 seconds
|
||||
export const TOUR_DURATION = 2700; // the tour, 90 seconds
|
||||
|
||||
/// Scenes overlap by this much and cross-dissolve through it.
|
||||
export const XFADE = 12;
|
||||
|
||||
/// What the capture is, in its own pixels: the viewport in harness.mjs at 2x.
|
||||
/// Every crop in the compositions is written in these numbers — the ones you
|
||||
/// would read off a file in public/shots in Preview — and `Framed` works out the
|
||||
/// scale.
|
||||
export const SOURCE = { width: 2560, height: 1640 } as const;
|
||||
|
||||
/// The two stages a shot can be mounted on. Both cuts use the same two, so a cut
|
||||
/// between a screenshot and a recording does not shift the picture sideways.
|
||||
///
|
||||
/// `wide` is for the whole window and for crops wider than about 2:1 — the
|
||||
/// caption goes above and the picture gets the width of the frame.
|
||||
///
|
||||
/// `side` puts the caption in a column on the left and gives the picture nearly
|
||||
/// the height of the frame. It is for anything taller than it is wide, which
|
||||
/// includes every full-height crop — and full-height crops are common, because a
|
||||
/// list that runs to the bottom of the window has no gap left to end a crop in
|
||||
/// and the window's own edge is the only clean one.
|
||||
///
|
||||
/// Work out the scale before choosing: `cardWidth / (cropWidth / 2)`. Below
|
||||
/// about 1.0x the app's own body text stops being comfortable at 1080p.
|
||||
export const STAGE = {
|
||||
wide: { top: 258, maxW: 1560, maxH: 772, left: null },
|
||||
side: { top: 45, maxW: 1220, maxH: 990, left: 686 },
|
||||
} as const;
|
||||
|
||||
/// The caption block is bottom-aligned to this line on the `wide` stage, so a
|
||||
/// headline that wraps to two lines grows upwards and the gap above the card
|
||||
/// never changes.
|
||||
export const CAPTION_BASELINE = 222;
|
||||
|
||||
/// The left-hand caption column of the `side` stage.
|
||||
export const COLUMN = { left: 92, width: 540 };
|
||||
|
||||
/// A file in `public/` to play under the whole thing, or `null` for silence.
|
||||
/// Both cuts are made to read without sound — every claim either makes is on
|
||||
/// screen — so a track is a choice rather than something the piece leans on. It
|
||||
/// fades out over the last second either way.
|
||||
export const MUSIC: string | null = null;
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"lib": ["DOM", "ES2022"]
|
||||
},
|
||||
"include": ["src", "scripts", "public/clips.json"]
|
||||
}
|
||||
@@ -113,76 +113,79 @@ export class SsgListener extends BaseListener {
|
||||
return;
|
||||
}
|
||||
|
||||
// https://github.com/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/src/content/utils.ts#L450
|
||||
let contentConfig = await workspace.findFiles(`**/src/content/config.*`);
|
||||
|
||||
// Also search for content.config.* files (newer pattern)
|
||||
if (contentConfig.length === 0) {
|
||||
contentConfig = await workspace.findFiles(`**/content.config.*`);
|
||||
}
|
||||
|
||||
if (contentConfig.length === 0) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentConfigFile = contentConfig[0];
|
||||
if (
|
||||
!contentConfigFile.fsPath.endsWith('.js') &&
|
||||
!contentConfigFile.fsPath.endsWith('.mjs') &&
|
||||
!contentConfigFile.fsPath.endsWith('.ts') &&
|
||||
!contentConfigFile.fsPath.endsWith('.mts')
|
||||
) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = Uri.joinPath(
|
||||
Extension.getInstance().extensionPath,
|
||||
SsgScripts.folder,
|
||||
SsgScripts.astroContentCollectionScript
|
||||
);
|
||||
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
if (!wsFolder) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const tempLocation = Uri.joinPath(wsFolder, '/.frontmatter/temp');
|
||||
const tempScriptPath = Uri.joinPath(tempLocation, SsgScripts.astroContentCollectionScript);
|
||||
await workspace.fs.createDirectory(tempLocation);
|
||||
|
||||
// Check if the workspace uses pnpm
|
||||
if (await existsAsync(Uri.joinPath(wsFolder, 'node_modules/.pnpm').fsPath)) {
|
||||
const vitePackageFiles = await workspace.findFiles(
|
||||
`**/node_modules/.pnpm/vite@*/node_modules/vite/package.json`
|
||||
);
|
||||
if (vitePackageFiles.length > 0) {
|
||||
const vitePackageFile = vitePackageFiles[0];
|
||||
const vitePackage = JSON.parse(await readFileAsync(vitePackageFile.fsPath, 'utf8')) as {
|
||||
main: string;
|
||||
};
|
||||
const viteFolder = vitePackageFile.fsPath.replace('/package.json', '');
|
||||
const vitePath = join(viteFolder, vitePackage.main).replace(wsFolder.fsPath, '../..');
|
||||
|
||||
// Update the vite reference, as it is not a direct dependency of the project
|
||||
let scriptContents = await readFileAsync(scriptPath.fsPath, 'utf8');
|
||||
scriptContents = scriptContents.replace(`'vite'`, `'${vitePath}'`);
|
||||
await workspace.fs.writeFile(tempScriptPath, Buffer.from(scriptContents, 'utf8'));
|
||||
}
|
||||
} else {
|
||||
workspace.fs.copy(scriptPath, tempScriptPath, { overwrite: true });
|
||||
}
|
||||
|
||||
let nodeExecPath = 'node';
|
||||
const platform = getPlatform();
|
||||
if (platform !== 'windows') {
|
||||
nodeExecPath = await evaluateCommand('node');
|
||||
}
|
||||
const fullScript = `${nodeExecPath} "${tempScriptPath.fsPath}" "${contentConfigFile.fsPath}"`;
|
||||
let tempLocation: Uri | undefined;
|
||||
|
||||
try {
|
||||
// https://github.com/withastro/astro/blob/defab70cb2a0c67d5e9153542490d2749046b151/packages/astro/src/content/utils.ts#L450
|
||||
let contentConfig = await workspace.findFiles(`**/src/content/config.*`);
|
||||
|
||||
// Also search for content.config.* files (newer pattern)
|
||||
if (contentConfig.length === 0) {
|
||||
contentConfig = await workspace.findFiles(`**/content.config.*`);
|
||||
}
|
||||
|
||||
if (contentConfig.length === 0) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const contentConfigFile = contentConfig[0];
|
||||
if (
|
||||
!contentConfigFile.fsPath.endsWith('.js') &&
|
||||
!contentConfigFile.fsPath.endsWith('.mjs') &&
|
||||
!contentConfigFile.fsPath.endsWith('.ts') &&
|
||||
!contentConfigFile.fsPath.endsWith('.mts')
|
||||
) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptPath = Uri.joinPath(
|
||||
Extension.getInstance().extensionPath,
|
||||
SsgScripts.folder,
|
||||
SsgScripts.astroContentCollectionScript
|
||||
);
|
||||
|
||||
const wsFolder = Folders.getWorkspaceFolder();
|
||||
if (!wsFolder) {
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
}
|
||||
|
||||
const preparedLocation = Uri.joinPath(wsFolder, '/.frontmatter/temp');
|
||||
const tempScriptPath = Uri.joinPath(preparedLocation, SsgScripts.astroContentCollectionScript);
|
||||
await workspace.fs.createDirectory(preparedLocation);
|
||||
tempLocation = preparedLocation;
|
||||
|
||||
// Check if the workspace uses pnpm
|
||||
if (await existsAsync(Uri.joinPath(wsFolder, 'node_modules/.pnpm').fsPath)) {
|
||||
const vitePackageFiles = await workspace.findFiles(
|
||||
`**/node_modules/.pnpm/vite@*/node_modules/vite/package.json`
|
||||
);
|
||||
if (vitePackageFiles.length > 0) {
|
||||
const vitePackageFile = vitePackageFiles[0];
|
||||
const vitePackage = JSON.parse(await readFileAsync(vitePackageFile.fsPath, 'utf8')) as {
|
||||
main: string;
|
||||
};
|
||||
const viteFolder = vitePackageFile.fsPath.replace('/package.json', '');
|
||||
const vitePath = join(viteFolder, vitePackage.main).replace(wsFolder.fsPath, '../..');
|
||||
|
||||
// Update the vite reference, as it is not a direct dependency of the project
|
||||
let scriptContents = await readFileAsync(scriptPath.fsPath, 'utf8');
|
||||
scriptContents = scriptContents.replace(`'vite'`, `'${vitePath}'`);
|
||||
await workspace.fs.writeFile(tempScriptPath, Buffer.from(scriptContents, 'utf8'));
|
||||
}
|
||||
} else {
|
||||
await workspace.fs.copy(scriptPath, tempScriptPath, { overwrite: true });
|
||||
}
|
||||
|
||||
let nodeExecPath = 'node';
|
||||
const platform = getPlatform();
|
||||
if (platform !== 'windows') {
|
||||
nodeExecPath = await evaluateCommand('node');
|
||||
}
|
||||
const fullScript = `${nodeExecPath} "${tempScriptPath.fsPath}" "${contentConfigFile.fsPath}"`;
|
||||
|
||||
const result: string = await SsgListener.executeScript(fullScript, wsFolder?.fsPath || '');
|
||||
if (result) {
|
||||
const tempJsonPath = Uri.joinPath(tempLocation, SsgScripts.astroContentCollectionJSON);
|
||||
@@ -191,14 +194,22 @@ export class SsgListener extends BaseListener {
|
||||
let collections: AstroCollection[] = JSON.parse(contents);
|
||||
collections = collections.filter((c) => c.type === 'content');
|
||||
SsgListener.sendRequest(command as any, requestId, collections);
|
||||
return;
|
||||
}
|
||||
}
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
} catch (error) {
|
||||
Logger.error((error as Error).message);
|
||||
SsgListener.sendRequest(command as any, requestId, []);
|
||||
return;
|
||||
} finally {
|
||||
await workspace.fs.delete(tempLocation, { recursive: true, useTrash: false });
|
||||
if (tempLocation) {
|
||||
try {
|
||||
await workspace.fs.delete(tempLocation, { recursive: true, useTrash: false });
|
||||
} catch (error) {
|
||||
Logger.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1424,6 +1424,10 @@ export enum LocalizationKey {
|
||||
* Clear value
|
||||
*/
|
||||
panelFieldsChoiceFieldClear = 'panel.fields.choiceField.clear',
|
||||
/**
|
||||
* Search {0}
|
||||
*/
|
||||
panelFieldsChoiceFieldSearch = 'panel.fields.choiceField.search',
|
||||
/**
|
||||
* Fetching possible values...
|
||||
*/
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface Field {
|
||||
single?: boolean;
|
||||
wysiwyg?: boolean | string;
|
||||
multiple?: boolean;
|
||||
searchable?: boolean;
|
||||
isPreviewImage?: boolean;
|
||||
hidden?: boolean;
|
||||
taxonomyId?: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ import useDropdownStyle from '../../hooks/useDropdownStyle';
|
||||
export interface IChoiceFieldProps extends BaseFieldProps<string | string[]> {
|
||||
choices: string[] | Choice[];
|
||||
multiSelect?: boolean;
|
||||
searchable?: boolean;
|
||||
onChange: (value: string | string[]) => void;
|
||||
}
|
||||
|
||||
@@ -23,10 +24,12 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
|
||||
value,
|
||||
choices,
|
||||
multiSelect,
|
||||
searchable,
|
||||
onChange,
|
||||
required
|
||||
}: React.PropsWithChildren<IChoiceFieldProps>) => {
|
||||
const [crntSelected, setCrntSelected] = React.useState<string | string[] | null>(value);
|
||||
const [inputValue, setInputValue] = React.useState<string>('');
|
||||
const dsRef = React.useRef<Downshift<string> | null>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const { getDropdownStyle } = useDropdownStyle(inputRef as any);
|
||||
@@ -84,6 +87,18 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
|
||||
return true;
|
||||
});
|
||||
|
||||
/**
|
||||
* Filters the choices by the typed search value (matches on title and ID)
|
||||
*/
|
||||
const search = searchable ? (inputValue || '').toLowerCase() : '';
|
||||
const filteredChoices = !search
|
||||
? (availableChoices as Array<string | Choice>)
|
||||
: (availableChoices as Array<string | Choice>).filter(
|
||||
(choice) =>
|
||||
getValue(choice, 'title').toLowerCase().includes(search) ||
|
||||
getValue(choice, 'id').toLowerCase().includes(search)
|
||||
);
|
||||
|
||||
const showRequiredState = useMemo(() => {
|
||||
return (
|
||||
required && ((crntSelected instanceof Array && crntSelected.length === 0) || !crntSelected)
|
||||
@@ -102,24 +117,62 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
|
||||
|
||||
<Downshift
|
||||
ref={dsRef}
|
||||
onSelect={(selected) => onValueChange(selected || '')}
|
||||
onSelect={(selected) => {
|
||||
onValueChange(selected || '');
|
||||
setInputValue('');
|
||||
}}
|
||||
itemToString={(item) => (item ? item : '')}
|
||||
inputValue={inputValue}
|
||||
onStateChange={(changes) => {
|
||||
// Only track what the user types, ignore the value Downshift sets on selection
|
||||
if (changes.type === Downshift.stateChangeTypes.changeInput) {
|
||||
setInputValue(changes.inputValue || '');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ getToggleButtonProps, getItemProps, getMenuProps, isOpen, getRootProps }) => (
|
||||
{({
|
||||
getToggleButtonProps,
|
||||
getInputProps,
|
||||
getItemProps,
|
||||
getMenuProps,
|
||||
isOpen,
|
||||
getRootProps,
|
||||
openMenu,
|
||||
closeMenu
|
||||
}) => (
|
||||
<div
|
||||
{...getRootProps(undefined, { suppressRefError: true })}
|
||||
ref={inputRef}
|
||||
className={`metadata_field__choice`}
|
||||
>
|
||||
<button
|
||||
{...getToggleButtonProps({
|
||||
className: `metadata_field__choice__toggle`,
|
||||
disabled: availableChoices.length === 0
|
||||
})}
|
||||
>
|
||||
<span>{l10n.t(LocalizationKey.panelFieldsChoiceFieldSelect, label)}</span>
|
||||
<ChevronDownIcon className="icon" />
|
||||
</button>
|
||||
{searchable ? (
|
||||
<div
|
||||
className={`article__tags__input metadata_field__choice__search ${showRequiredState ? 'required' : ''}`}
|
||||
>
|
||||
<input
|
||||
{...getInputProps({
|
||||
onFocus: openMenu as () => void,
|
||||
onClick: openMenu as () => void,
|
||||
onBlur: () => {
|
||||
closeMenu();
|
||||
setInputValue('');
|
||||
},
|
||||
disabled: availableChoices.length === 0
|
||||
})}
|
||||
placeholder={l10n.t(LocalizationKey.panelFieldsChoiceFieldSearch, label)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
{...getToggleButtonProps({
|
||||
className: `metadata_field__choice__toggle`,
|
||||
disabled: availableChoices.length === 0
|
||||
})}
|
||||
>
|
||||
<span>{l10n.t(LocalizationKey.panelFieldsChoiceFieldSelect, label)}</span>
|
||||
<ChevronDownIcon className="icon" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ul
|
||||
className={`field_dropdown metadata_field__choice_list ${isOpen ? 'open' : 'closed'}`}
|
||||
@@ -129,7 +182,7 @@ export const ChoiceField: React.FunctionComponent<IChoiceFieldProps> = ({
|
||||
{...getMenuProps()}
|
||||
>
|
||||
{
|
||||
availableChoices.map((choice, index) => (
|
||||
filteredChoices.map((choice, index) => (
|
||||
<li
|
||||
{...getItemProps({
|
||||
key: getValue(choice, 'id'),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import * as React from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { BaseFieldProps, CustomScript, PanelSettings } from '../../../models';
|
||||
import { RequiredFieldsAtom } from '../../state';
|
||||
@@ -13,6 +13,12 @@ import { useDebounce } from '../../../hooks/useDebounce';
|
||||
import { CopilotIcon } from '../Icons';
|
||||
|
||||
const DEBOUNCE_TIME = 300;
|
||||
// How long a local edit keeps precedence over incoming values that are not the echo of our own update.
|
||||
// This covers slow round trips (the value we get back could be the echo of an older update) without
|
||||
// blocking external changes (ex. editing the front matter in the editor) forever.
|
||||
const EXTERNAL_UPDATE_GRACE = 1500;
|
||||
|
||||
const normalize = (val: string | null | undefined): string => val || '';
|
||||
|
||||
export interface ITextFieldProps extends BaseFieldProps<string> {
|
||||
singleLine: boolean | undefined;
|
||||
@@ -42,12 +48,25 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
|
||||
const [, setRequiredFields] = useRecoilState(RequiredFieldsAtom);
|
||||
const [text, setText] = React.useState<string | null | undefined>(undefined);
|
||||
const [loading, setLoading] = React.useState<string | undefined>(undefined);
|
||||
const [lastUpdated, setLastUpdated] = React.useState<number | null>(null);
|
||||
// Timestamp of the last local edit, null when the field is in sync with the front matter
|
||||
const lastTypedRef = useRef<number | null>(null);
|
||||
// Last value we sent to, or accepted from, the extension host. Used to recognize the echo of our own update.
|
||||
const lastSentRef = useRef<string | undefined>(undefined);
|
||||
const debouncedText = useDebounce<string | null | undefined>(text, DEBOUNCE_TIME);
|
||||
|
||||
const onTextChange = (txtValue: string) => {
|
||||
setText(txtValue);
|
||||
setLastUpdated(Date.now());
|
||||
lastTypedRef.current = Date.now();
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a value that was generated for the user (ex. AI suggestion) and send it right away
|
||||
*/
|
||||
const applyValue = (txtValue: string) => {
|
||||
setText(txtValue);
|
||||
lastTypedRef.current = null;
|
||||
lastSentRef.current = normalize(txtValue);
|
||||
onChange(txtValue);
|
||||
};
|
||||
|
||||
let isValid = true;
|
||||
@@ -96,8 +115,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
|
||||
setLoading(undefined);
|
||||
|
||||
if (suggestion) {
|
||||
setText(suggestion);
|
||||
onChange(suggestion);
|
||||
applyValue(suggestion);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -116,8 +134,7 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
|
||||
setLoading(undefined);
|
||||
|
||||
if (suggestion) {
|
||||
setText(suggestion);
|
||||
onChange(suggestion);
|
||||
applyValue(suggestion);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -158,18 +175,50 @@ export const TextField: React.FunctionComponent<ITextFieldProps> = ({
|
||||
}, [showRequiredState, isValid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (text !== value && (lastUpdated === null || Date.now() - DEBOUNCE_TIME > lastUpdated)) {
|
||||
const incoming = normalize(value);
|
||||
|
||||
if (lastSentRef.current !== undefined && incoming === lastSentRef.current) {
|
||||
// Echo of our own update. When nothing was typed since, the field is in sync again.
|
||||
if (normalize(text) === incoming) {
|
||||
lastTypedRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Could be the (stale) echo of an older update or an external change. While the user is
|
||||
// typing, the local text wins; otherwise the incoming value is applied.
|
||||
const hasPendingEdit =
|
||||
lastTypedRef.current !== null && Date.now() - lastTypedRef.current < EXTERNAL_UPDATE_GRACE;
|
||||
if (hasPendingEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept the incoming value as the new baseline for local edits
|
||||
lastTypedRef.current = null;
|
||||
lastSentRef.current = incoming;
|
||||
if (normalize(text) !== incoming) {
|
||||
setText(value || null);
|
||||
}
|
||||
setLastUpdated(null);
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText !== undefined && value !== debouncedText && lastUpdated !== null) {
|
||||
onChange(debouncedText || '');
|
||||
setLastUpdated(null);
|
||||
if (debouncedText === undefined || lastTypedRef.current === null) {
|
||||
// Not a local edit (ex. text was set from the incoming value)
|
||||
return;
|
||||
}
|
||||
}, [debouncedText, value, lastUpdated, onChange]);
|
||||
|
||||
const outgoing = normalize(debouncedText);
|
||||
const baseline = lastSentRef.current !== undefined ? lastSentRef.current : normalize(value);
|
||||
if (outgoing === baseline) {
|
||||
if (baseline === normalize(value)) {
|
||||
lastTypedRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastSentRef.current = outgoing;
|
||||
onChange(outgoing);
|
||||
}, [debouncedText]);
|
||||
|
||||
return (
|
||||
<div className={`metadata_field`}>
|
||||
|
||||
@@ -326,6 +326,7 @@ export const WrapperField: React.FunctionComponent<IWrapperFieldProps> = ({
|
||||
required={!!field.required}
|
||||
choices={choices}
|
||||
multiSelect={field.multiple}
|
||||
searchable={!!field.searchable}
|
||||
onChange={onFieldChange}
|
||||
/>
|
||||
</FieldBoundary>
|
||||
|
||||
@@ -15,7 +15,6 @@ import rehypeParse from "rehype-parse";
|
||||
import rehypeRemark from "rehype-remark";
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
|
||||
import { differenceInSeconds } from "date-fns";
|
||||
import { BaseFieldProps } from '../../../models';
|
||||
import { FieldMessage, FieldTitle } from '.';
|
||||
import { LocalizationKey, localize } from '../../../localization';
|
||||
@@ -24,6 +23,10 @@ import { RequiredFieldsAtom } from '../../state';
|
||||
import { useDebounce } from '../../../hooks/useDebounce';
|
||||
|
||||
const DEBOUNCE_TIME = 500;
|
||||
// How long a local edit keeps precedence over incoming values that are not the echo of our own update
|
||||
const EXTERNAL_UPDATE_GRACE = 2000;
|
||||
|
||||
const normalize = (val: string | null | undefined): string => val || '';
|
||||
|
||||
function markdownToHtml(markdownText: string) {
|
||||
const file = unified()
|
||||
@@ -66,13 +69,16 @@ const WysiwygField: React.FunctionComponent<IWysiwygFieldProps> = ({
|
||||
required
|
||||
}: React.PropsWithChildren<IWysiwygFieldProps>) => {
|
||||
const [, setRequiredFields] = useRecoilState(RequiredFieldsAtom);
|
||||
const [lastUpdated, setLastUpdated] = React.useState<number | null>(null);
|
||||
// Timestamp of the last local edit, null when the field is in sync with the front matter
|
||||
const lastTypedRef = React.useRef<number | null>(null);
|
||||
// Last value we sent to, or accepted from, the extension host. Used to recognize the echo of our own update.
|
||||
const lastSentRef = React.useRef<string | undefined>(undefined);
|
||||
const [text, setText] = React.useState<string | null | undefined>(type === "html" ? value : markdownToHtml(value || ""));
|
||||
const debouncedText = useDebounce<string | null | undefined>(text, DEBOUNCE_TIME);
|
||||
|
||||
const onTextChange = (newValue: string) => {
|
||||
setText(newValue);
|
||||
setLastUpdated(Date.now());
|
||||
lastTypedRef.current = Date.now();
|
||||
}
|
||||
|
||||
const modules = React.useMemo(() => {
|
||||
@@ -138,20 +144,47 @@ const WysiwygField: React.FunctionComponent<IWysiwygFieldProps> = ({
|
||||
* Update the text value when the value changes
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (text !== value && (lastUpdated === null || differenceInSeconds(Date.now(), lastUpdated) > 2)) {
|
||||
setText(type === "html" ? value : markdownToHtml(value || ""));
|
||||
const incoming = normalize(value);
|
||||
|
||||
if (lastSentRef.current !== undefined && incoming === lastSentRef.current) {
|
||||
// Echo of our own update, the local text is at least as recent
|
||||
lastTypedRef.current = null;
|
||||
return;
|
||||
}
|
||||
setLastUpdated(null);
|
||||
|
||||
// Could be the (stale) echo of an older update or an external change. While the user is
|
||||
// typing, the local text wins; otherwise the incoming value is applied.
|
||||
const hasPendingEdit =
|
||||
lastTypedRef.current !== null && Date.now() - lastTypedRef.current < EXTERNAL_UPDATE_GRACE;
|
||||
if (hasPendingEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastTypedRef.current = null;
|
||||
lastSentRef.current = incoming;
|
||||
setText(type === "html" ? value : markdownToHtml(value || ""));
|
||||
}, [value]);
|
||||
|
||||
/**
|
||||
* Update the value when the debounced text changes
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (debouncedText !== undefined && value !== debouncedText && lastUpdated !== null) {
|
||||
const valueToUpdate = type === "html" ? debouncedText : htmlToMarkdown(debouncedText || "");
|
||||
onChange(valueToUpdate || "");
|
||||
if (debouncedText === undefined || lastTypedRef.current === null) {
|
||||
// Not a local edit (ex. text was set from the incoming value)
|
||||
return;
|
||||
}
|
||||
|
||||
const outgoing = normalize(type === "html" ? debouncedText : htmlToMarkdown(debouncedText || ""));
|
||||
const baseline = lastSentRef.current !== undefined ? lastSentRef.current : normalize(value);
|
||||
if (outgoing === baseline) {
|
||||
if (baseline === normalize(value)) {
|
||||
lastTypedRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastSentRef.current = outgoing;
|
||||
onChange(outgoing);
|
||||
}, [debouncedText]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
".vscode-test",
|
||||
"docs"
|
||||
"docs",
|
||||
"promo"
|
||||
]
|
||||
}
|
||||