@lemonriver/core 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +13 -6
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- package/src/app/components/inspector/comment-widget.tsx +0 -119
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lemonriver/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Runtime and CLI for LemonRiver — write slides in slides/, we handle the rest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
"env.d.ts",
|
|
31
31
|
"src/app",
|
|
32
32
|
"src/locale",
|
|
33
|
+
"src/agent/prompt.ts",
|
|
34
|
+
"src/agent/provider.ts",
|
|
35
|
+
"src/agent/types.ts",
|
|
36
|
+
"src/editing/placeholder-id.ts",
|
|
37
|
+
"!src/**/*.test.ts",
|
|
38
|
+
"!src/**/*.test.tsx",
|
|
33
39
|
"skills",
|
|
34
40
|
"README.md"
|
|
35
41
|
],
|
|
@@ -70,7 +76,7 @@
|
|
|
70
76
|
"@tailwindcss/vite": "^4.3.3",
|
|
71
77
|
"@vitejs/plugin-react": "^6.1.1",
|
|
72
78
|
"@xyflow/react": "^12.12.0",
|
|
73
|
-
"chalk": "^6.0.
|
|
79
|
+
"chalk": "^6.0.1",
|
|
74
80
|
"class-variance-authority": "^0.7.1",
|
|
75
81
|
"clsx": "^2.1.1",
|
|
76
82
|
"cmdk": "^1.1.1",
|
|
@@ -84,14 +90,14 @@
|
|
|
84
90
|
"react": "^19.2.8",
|
|
85
91
|
"react-dom": "^19.2.8",
|
|
86
92
|
"react-image-crop": "^11.0.10",
|
|
87
|
-
"react-router-dom": "^7.18.
|
|
88
|
-
"shadcn": "^4.
|
|
93
|
+
"react-router-dom": "^7.18.4",
|
|
94
|
+
"shadcn": "^4.21.4",
|
|
89
95
|
"sonner": "^2.0.7",
|
|
90
96
|
"tailwind-merge": "^3.5.0",
|
|
91
97
|
"tailwindcss": "^4.3.3",
|
|
92
98
|
"tw-animate-css": "^1.4.0",
|
|
93
|
-
"use-sync-external-store": "^1.
|
|
94
|
-
"vite": "^8.3.
|
|
99
|
+
"use-sync-external-store": "^1.7.0",
|
|
100
|
+
"vite": "^8.3.3"
|
|
95
101
|
},
|
|
96
102
|
"devDependencies": {
|
|
97
103
|
"@playwright/test": "~1.63.0",
|
|
@@ -104,6 +110,7 @@
|
|
|
104
110
|
"scripts": {
|
|
105
111
|
"build": "tsdown",
|
|
106
112
|
"typecheck": "tsc --noEmit",
|
|
113
|
+
"check:pack": "node scripts/check-pack.mjs",
|
|
107
114
|
"test:e2e": "pnpm run build && playwright test"
|
|
108
115
|
}
|
|
109
116
|
}
|
|
@@ -5,7 +5,7 @@ description: Apply pending @slide-comment markers written by the LemonRiver insp
|
|
|
5
5
|
|
|
6
6
|
# Apply slide comments
|
|
7
7
|
|
|
8
|
-
The LemonRiver editor has
|
|
8
|
+
The LemonRiver editor has a comment mode that lets the user click a rendered page element, or drag across a region of the page, and attach a textual comment (e.g. *"make this red"*, *"change to 'Open Slide Rocks'"*). Each comment is persisted as an in-source JSX marker inside `slides/<slideId>/index.tsx`.
|
|
9
9
|
|
|
10
10
|
Your job: read those markers, perform the described edits, and delete the markers.
|
|
11
11
|
|
|
@@ -17,8 +17,10 @@ Your job: read those markers, perform the described edits, and delete the marker
|
|
|
17
17
|
{/* @slide-comment id="c-<8hex>" ts="<ISO>" text="<base64url(JSON)>" */}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
-
|
|
21
|
-
- `text` is base64url-encoded JSON: `{"note": "...", "hint"?: "..."}`.
|
|
20
|
+
- Sits as the **first child inside** the JSX element it refers to (i.e. between that element's opening `>` and its other children). The marker is dropped *into* its target, not floated above it. Newly written markers get a line of their own; older ones may share a line with the content that follows them.
|
|
21
|
+
- `text` is base64url-encoded JSON: `{"note": "...", "hint"?: "...", "rect"?: {"x", "y", "w", "h"}, "page"?: n}`.
|
|
22
|
+
- `rect` is present when the comment was left on a dragged region rather than on an element. Values are pixels on the 1920×1080 page canvas, measured from the page's top-left corner. The marker still sits inside the deepest element that contains the region, but the note refers to what is *inside* that region — possibly several children, or only part of the enclosing element.
|
|
23
|
+
- `page` is the 0-based index (into the default export array) of the page the comment was left on.
|
|
22
24
|
- Detection regex (authoritative — use exactly this):
|
|
23
25
|
|
|
24
26
|
```
|
|
@@ -33,13 +35,14 @@ Your job: read those markers, perform the described edits, and delete the marker
|
|
|
33
35
|
|
|
34
36
|
2. **Read the file and find all markers.**
|
|
35
37
|
- Run the regex above against the whole file.
|
|
36
|
-
- For each match, base64url-decode `text` and `JSON.parse` it to get `{ note, hint? }`.
|
|
37
|
-
- Record each hit as `{ id, lineIndex (0-based), note, hint }`.
|
|
38
|
+
- For each match, base64url-decode `text` and `JSON.parse` it to get `{ note, hint?, rect?, page? }`.
|
|
39
|
+
- Record each hit as `{ id, lineIndex (0-based), note, hint, rect, page }`.
|
|
38
40
|
- If there are no markers, tell the user and stop.
|
|
39
41
|
|
|
40
42
|
3. **Understand each comment in context.**
|
|
41
43
|
- The targeted JSX element is the **enclosing** element of the marker — i.e. read upward from the marker line until you reach the unclosed JSX opening tag whose body the marker lives in. That element is the target. (For self-closing elements like `<img />`, the inspector hoists the marker to the nearest non-self-closing ancestor; in that case the comment usually refers to a child of the enclosing element rather than the enclosing element itself — use the `note` text to disambiguate.)
|
|
42
44
|
- Read enough surrounding code (parent element, sibling elements, inline styles) to apply the change faithfully. A comment inside a `<div>` with an inline `background` style usually refers to that element's styling, for example.
|
|
45
|
+
- When the payload has a `rect`, work out which children of the enclosing element fall inside that region (compare their positions and sizes against the rect on the 1920×1080 canvas) and apply the note to those — not to the whole enclosing element.
|
|
43
46
|
- If the `note` is ambiguous, do the smallest reasonable interpretation and mention the assumption in your summary.
|
|
44
47
|
|
|
45
48
|
4. **Apply edits in reverse line order.**
|
|
@@ -47,12 +50,13 @@ Your job: read those markers, perform the described edits, and delete the marker
|
|
|
47
50
|
- Processing top-down would invalidate line numbers for later markers as the file shrinks/grows.
|
|
48
51
|
|
|
49
52
|
5. **Remove each marker after applying its edit.**
|
|
50
|
-
- Delete the
|
|
53
|
+
- Delete the marker token. If nothing else is left on that line, delete the whole line including its trailing `\n`; if content shares the line, keep that content.
|
|
51
54
|
- Never leave a marker behind for an edit you applied — that signals a failure. Markers deliberately skipped per the edge cases below stay in place.
|
|
52
55
|
|
|
53
56
|
6. **Verify.**
|
|
54
57
|
- After all edits, re-read the file and confirm the only remaining markers are ones you reported as skipped.
|
|
55
58
|
- Confirm the edited JSX is well-formed (balanced tags, no dangling attributes). If the project's `package.json` has typecheck/lint scripts, run them with the project's package manager; scaffolded projects ship neither TypeScript nor a linter — there, rely on the running dev server (or the `build` script) to surface compile errors. Fix any errors you introduced.
|
|
59
|
+
- If the dev server is running, screenshot each edited page via `/s/<id>/preview?p=N` to confirm the change looks right — see **Previewing a page visually** in `slide-authoring`.
|
|
56
60
|
|
|
57
61
|
7. **Report.**
|
|
58
62
|
- Summarise: `N applied, M skipped` plus a one-line description of each change (including the slide id).
|
|
@@ -82,6 +82,8 @@ If the user asked for a speech script / speaker notes (in the initial request or
|
|
|
82
82
|
|
|
83
83
|
Run the checklist in `slide-authoring` ("Self-review before finishing"). It covers structural correctness, layout discipline, and asset existence.
|
|
84
84
|
|
|
85
|
+
If the dev server is already running, screenshot each page through the preview route and fix what looks off — see **Previewing a page visually** in `slide-authoring`.
|
|
86
|
+
|
|
85
87
|
## Step 8 — Hand off to the user
|
|
86
88
|
|
|
87
89
|
Tell the user:
|
|
@@ -95,6 +95,7 @@ User: "tighten the spacing on this page"
|
|
|
95
95
|
3. Read `pagePath` (e.g. `slides/q2-roadmap/index.tsx`).
|
|
96
96
|
4. Identify the page at `pageIndex` in the default-exported array.
|
|
97
97
|
5. Consult the `slide-authoring` skill for spacing rules, then edit that page in place.
|
|
98
|
+
6. To check the result visually, screenshot `s/<slideId>/preview?p=<pageNumber>` on the dev server — see **Previewing a page visually** in `slide-authoring`.
|
|
98
99
|
|
|
99
100
|
If `current.json` is missing or stale, ask: "Which slide and page should I tighten? The dev server hasn't published a current page recently."
|
|
100
101
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: slide-authoring
|
|
3
|
-
description: Technical reference for writing or editing LemonRiver pages — file contract, 1920×1080 canvas, type scale, layout, palette/visual direction, assets, speaker notes, stepped reveals, page transitions, and morph transitions. Consult this whenever you are about to write or modify any file under `slides/<id>/`, including from inside the `create-slide` or `apply-comments` workflows, or for any ad-hoc slide edit. Triggers on phrases like "edit slide", "tweak this page", "fix the layout", "change the palette", "reveal one by one", "add a transition", "morph transition", "write the speaker notes", "generate the script / talk track", "investigate the slide framework", "how do slides work here".
|
|
3
|
+
description: Technical reference for writing or editing LemonRiver pages — file contract, 1920×1080 canvas, type scale, layout, palette/visual direction, assets, speaker notes, stepped reveals, page transitions, and morph transitions. Consult this whenever you are about to write or modify any file under `slides/<id>/`, including from inside the `create-slide` or `apply-comments` workflows, or for any ad-hoc slide edit. Triggers on phrases like "edit slide", "tweak this page", "fix the layout", "change the palette", "reveal one by one", "add a transition", "morph transition", "write the speaker notes", "generate the script / talk track", "preview / screenshot a page", "check how it looks", "investigate the slide framework", "how do slides work here".
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Authoring LemonRiver pages
|
|
@@ -335,6 +335,35 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
335
335
|
- Arrow keys / PageUp / PageDown navigate. `F` enters fullscreen play mode.
|
|
336
336
|
- In play mode: Space/→ next, ← prev, Esc exit.
|
|
337
337
|
- Hot reload: edit `index.tsx` and the browser updates live.
|
|
338
|
+
- Dev-only preview route `/s/<id>/preview?p=N` renders one page with no editor chrome — see **Previewing a page visually**.
|
|
339
|
+
|
|
340
|
+
## Previewing a page visually
|
|
341
|
+
|
|
342
|
+
After writing or editing a page, look at it — the vertical-budget math catches overflow, but not clashing colors, awkward wraps, or a broken image. While the dev server runs, every page has a chrome-less preview URL; don't drive the full editor UI (home → deck → thumbnail) to get there.
|
|
343
|
+
|
|
344
|
+
1. **Find the dev server.** Read `node_modules/.open-slide/server.json` (written on startup, removed on shutdown):
|
|
345
|
+
|
|
346
|
+
```json
|
|
347
|
+
{ "url": "http://localhost:5173/", "port": 5173, "pid": 41234, "startedAt": "…" }
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Missing file = the dev server isn't running. Don't start it yourself unless the user asked; skip the visual check and mention it in your hand-off.
|
|
351
|
+
|
|
352
|
+
2. **Open** `<url>s/<slideId>/preview?p=<pageNumber>` — `p` is 1-based. Add `&step=<n>` to show only the first `n` `<Step>`s of a stepped page (default: fully revealed).
|
|
353
|
+
|
|
354
|
+
3. **Wait** for `[data-osd-preview="ready"]` — set once fonts, `data-waitfor` targets, images, and entry animations have settled. It becomes `[data-osd-preview="error"]` instead when the slide id is unknown, the page is out of range, the page throws while rendering, or something hasn't settled within 10 s (a font or image never loaded, a `data-waitfor` target never appeared, an animation is still running); `[data-osd-preview-error]` holds the message. Fix the cause rather than screenshotting anyway.
|
|
355
|
+
|
|
356
|
+
4. **Screenshot** with a **1920×1080 viewport** so the page renders at native size (smaller viewports scale it down to fit). Use whatever browser tool you have (Playwright / Chrome MCP, a browser-automation tool). If the project already has Playwright installed, the CLI works too — `--no` stops `npx` from downloading it:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
npx --no playwright screenshot --viewport-size "1920,1080" \
|
|
360
|
+
--wait-for-selector '[data-osd-preview="ready"]' \
|
|
361
|
+
"http://localhost:5173/s/<slideId>/preview?p=3" /tmp/<slideId>-p3.png
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
If no browser tool is available, don't install one without asking — skip the visual check.
|
|
365
|
+
|
|
366
|
+
Then view the image and fix what you see. The preview hot-reloads like the editor, and it never moves the user's cursor in `current.json`.
|
|
338
367
|
|
|
339
368
|
## Self-review before finishing
|
|
340
369
|
|
|
@@ -353,6 +382,7 @@ This applies whenever the *visual element* repeats, not whenever the *data* does
|
|
|
353
382
|
- [ ] If a `SlideTransition` is declared, every page sits in one family — same duration band (200–280 ms), same easing pair, same hold-then-fade-in shape (no enter delay, no `throughBackground` outside a deliberate section break), magnitude under 12 px / 3%. No six-different-vocabularies decks. When in doubt, omit transitions entirely. (Pages that opt into `morph` may exceed the band to match the morph — see `references/morph.md`.)
|
|
354
383
|
- [ ] If a transition opts into `morph`: every morph `id` is unique per page and stable across the pair, morph geometry is pixel-constant (never measured after mount), no `transform` sits on the morph node, and entrance animations are gated behind `useIsActivePage()`.
|
|
355
384
|
- [ ] If the user asked for a speech script / speaker notes, it lives in `export const notes` (index-aligned with the page array) — not in a markdown or text file.
|
|
385
|
+
- [ ] If the dev server is running, you previewed every page you touched (see **Previewing a page visually**) and fixed what looked off.
|
|
356
386
|
- [ ] Nothing outside `slides/<id>/` was edited.
|
|
357
387
|
|
|
358
388
|
## Anti-patterns
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { UNTITLED_SLIDE_ID_RE } from '../editing/placeholder-id.ts';
|
|
2
|
+
import { parseQuestions } from './provider.ts';
|
|
3
|
+
import type { AgentQuestion, AgentTurnContext } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const PAGE_BY_PAGE =
|
|
6
|
+
'Write a new deck page by page so the user can follow along on the canvas: first replace the file with the imports, meta (keeping its existing createdAt), design and the cover page only, then add each remaining page with its own separate edit, keeping the file compiling after every write.';
|
|
7
|
+
|
|
8
|
+
const DECK_SLUG =
|
|
9
|
+
"This deck's folder still has a placeholder name. Never rename or move the folder yourself: once the deck has content, end your final reply with a slug for it on a line of its own as <deck-slug>short-kebab-case-english-slug</deck-slug>, and LemonRiver renames the folder from it.";
|
|
10
|
+
|
|
11
|
+
export function systemPromptAppend(askInstruction: string): string {
|
|
12
|
+
return [
|
|
13
|
+
'You are running inside the LemonRiver dev server, driven from its in-app agent panel in the browser rather than a terminal.',
|
|
14
|
+
'The workspace is a LemonRiver project: decks live under slides/<id>/index.tsx and the dev server hot-reloads every edit, so the user sees changes immediately.',
|
|
15
|
+
'Use the project skills: create-slide to draft a new deck, apply-comments to resolve @slide-comment markers left by the inspector, create-theme for themes, and slide-authoring as the technical reference before writing a page.',
|
|
16
|
+
PAGE_BY_PAGE,
|
|
17
|
+
'Keep replies short and concrete: the panel is narrow. Prefer doing the work over describing it, and finish with a one-line summary of what changed.',
|
|
18
|
+
askInstruction,
|
|
19
|
+
].join(' ');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function buildTurnPrompt(text: string, context: AgentTurnContext | undefined): string {
|
|
23
|
+
const lines = contextLines(context);
|
|
24
|
+
if (lines.length === 0) return text;
|
|
25
|
+
return `${text}\n\n<open-slide-context>\n${lines.join('\n')}\n</open-slide-context>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Slide titles and selected text come from deck content, so they sit in their
|
|
29
|
+
// own tags with `<` escaped and can't close the context block.
|
|
30
|
+
const escapeTag = (s: string) => s.replace(/</g, '<');
|
|
31
|
+
|
|
32
|
+
function contextLines(context: AgentTurnContext | undefined): string[] {
|
|
33
|
+
if (!context?.slideId) return [];
|
|
34
|
+
const file = `slides/${context.slideId}/index.tsx`;
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
if (context.newDeck) {
|
|
37
|
+
lines.push(
|
|
38
|
+
`${file} is a blank deck created for this request. Draft the deck the user describes into it with the create-slide skill: replace the placeholder page, keep the slide id, and do not create another folder.`,
|
|
39
|
+
'Ask the create-slide scoping questions in a single round, only for what the request leaves unclear; skip any it already answers.',
|
|
40
|
+
PAGE_BY_PAGE,
|
|
41
|
+
DECK_SLUG,
|
|
42
|
+
);
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
lines.push(`The user is viewing slide "${context.slideId}": ${file}.`);
|
|
46
|
+
if (UNTITLED_SLIDE_ID_RE.test(context.slideId)) lines.push(DECK_SLUG);
|
|
47
|
+
if (context.slideTitle) {
|
|
48
|
+
lines.push(`<slide-title>${escapeTag(context.slideTitle)}</slide-title>`);
|
|
49
|
+
}
|
|
50
|
+
if (context.pageNumber && context.totalPages) {
|
|
51
|
+
lines.push(
|
|
52
|
+
`They are on page ${context.pageNumber} of ${context.totalPages} (0-based index ${context.pageNumber - 1} in the default export array).`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (context.selection) {
|
|
56
|
+
const { line, column, tagName, text } = context.selection;
|
|
57
|
+
lines.push(
|
|
58
|
+
`They have selected a <${escapeTag(tagName)}> element at ${file}:${line}:${column}.${text ? ' Its text is in <selected-text>.' : ''} "This element" refers to it.`,
|
|
59
|
+
);
|
|
60
|
+
if (text) lines.push(`<selected-text>${escapeTag(text)}</selected-text>`);
|
|
61
|
+
}
|
|
62
|
+
if (context.pendingComments) {
|
|
63
|
+
lines.push(
|
|
64
|
+
`There are ${context.pendingComments} pending @slide-comment markers in this slide's source.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return lines;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const DECK_SLUG_RE = /\s*<deck-slug>\s*([^<]*?)\s*<\/deck-slug>\s*/gi;
|
|
71
|
+
|
|
72
|
+
export function extractDeckSlug(text: string): string | null {
|
|
73
|
+
let slug: string | null = null;
|
|
74
|
+
for (const match of text.matchAll(DECK_SLUG_RE)) slug = match[1] || slug;
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Also drops a tag that is still streaming in, so the marker never flashes
|
|
79
|
+
// in the transcript.
|
|
80
|
+
export function stripDeckSlug(text: string): string {
|
|
81
|
+
return text
|
|
82
|
+
.replace(DECK_SLUG_RE, '\n')
|
|
83
|
+
.replace(/\s*<deck-slug>[^<]*$/i, '')
|
|
84
|
+
.trimEnd();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function applyCommentsPrompt(slideId: string): string {
|
|
88
|
+
return `Use the apply-comments skill to apply every pending @slide-comment marker in slides/${slideId}/index.tsx, then remove the markers.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const QUESTIONS_RE = /<open-slide-questions>([\s\S]*?)<\/open-slide-questions>/i;
|
|
92
|
+
|
|
93
|
+
export const TEXT_QUESTIONS_INSTRUCTION =
|
|
94
|
+
'You cannot ask the user anything interactively here, and nothing you print in the middle of a turn can be answered. When you need answers before continuing, stop working and end your turn with a final message holding only an <open-slide-questions> block: a JSON array of at most 4 questions shaped like [{"header":"Pages","question":"How many pages?","multiSelect":false,"options":[{"label":"6-10 (Recommended)","description":"Standard length"}]}], each with 2 to 4 options, written in the user\'s language. The panel shows them as a form and sends the answers back as your next message. Write nothing after the block and do not continue the task in the same turn.';
|
|
95
|
+
|
|
96
|
+
// Splits a reply written under TEXT_QUESTIONS_INSTRUCTION into its prose and
|
|
97
|
+
// questions. Returns null when the reply holds no usable block.
|
|
98
|
+
export function extractQuestionBlock(
|
|
99
|
+
text: string,
|
|
100
|
+
): { text: string; questions: AgentQuestion[] } | null {
|
|
101
|
+
const match = QUESTIONS_RE.exec(text);
|
|
102
|
+
if (!match) return null;
|
|
103
|
+
const body = match[1]
|
|
104
|
+
.trim()
|
|
105
|
+
.replace(/^```(?:json)?\s*/i, '')
|
|
106
|
+
.replace(/\s*```$/, '');
|
|
107
|
+
let raw: unknown;
|
|
108
|
+
try {
|
|
109
|
+
raw = JSON.parse(body);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const questions = parseQuestions({
|
|
114
|
+
questions: Array.isArray(raw) ? raw : (raw as { questions?: unknown } | null)?.questions,
|
|
115
|
+
});
|
|
116
|
+
if (questions.length === 0) return null;
|
|
117
|
+
return { text: text.replace(QUESTIONS_RE, '').trim(), questions };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function answersPrompt(
|
|
121
|
+
questions: AgentQuestion[],
|
|
122
|
+
answers: Record<string, string> | null,
|
|
123
|
+
): string {
|
|
124
|
+
if (!answers) {
|
|
125
|
+
return 'I skipped your questions. Continue the task with your best judgement and sensible defaults.';
|
|
126
|
+
}
|
|
127
|
+
const lines = questions.map((q) => `- ${q.question} ${answers[q.question] || '(no answer)'}`);
|
|
128
|
+
return `My answers:\n${lines.join('\n')}\n\nContinue the task with these answers.`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// For agents whose history can't be rewound: the edited message goes on top
|
|
132
|
+
// of the old conversation, so the agent is told to set that part aside.
|
|
133
|
+
export function editedPrompt(original: string, text: string): string {
|
|
134
|
+
return `I went back and edited an earlier message of mine. Treat everything after it in this conversation, including your replies, as discarded, and respond to the edited version instead. Files keep whatever changes were already made.\n\n<original-message>\n${escapeTag(original)}\n</original-message>\n\n${text}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// For agents rewound to before the edited message: their history forgets the
|
|
138
|
+
// later turns, but the files on disk still hold what those turns wrote.
|
|
139
|
+
export function rewoundPrompt(text: string): string {
|
|
140
|
+
return `${text}\n\n<open-slide-note>I went back and edited this message. Files keep the changes made after it in the discarded part of this conversation, so read the current files before editing them.</open-slide-note>`;
|
|
141
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import type { AgentEffort, AgentModelOption, AgentProviderId, AgentQuestion } from './types.ts';
|
|
3
|
+
|
|
4
|
+
export type ProviderEvent =
|
|
5
|
+
| { type: 'session-id'; id: string }
|
|
6
|
+
// A point in the provider's own history that a later edit can rewind to.
|
|
7
|
+
| { type: 'checkpoint'; id: string }
|
|
8
|
+
| { type: 'text-start'; id: string }
|
|
9
|
+
| { type: 'text-delta'; id: string; text: string }
|
|
10
|
+
| { type: 'text-end'; id: string; text: string | null }
|
|
11
|
+
| { type: 'tool-start'; id: string; name: string; title: string; detail: string | null }
|
|
12
|
+
| { type: 'tool-end'; id: string; ok: boolean }
|
|
13
|
+
| { type: 'error'; text: string }
|
|
14
|
+
| { type: 'result'; durationMs: number | null; costUsd: number | null }
|
|
15
|
+
// Questions the agent wrote into its reply instead of asking through a
|
|
16
|
+
// tool: the turn ends, and the answers arrive as the next prompt.
|
|
17
|
+
| { type: 'questions'; questions: AgentQuestion[] };
|
|
18
|
+
|
|
19
|
+
export type PermissionRequest = { tool: string; title: string; detail: string | null };
|
|
20
|
+
|
|
21
|
+
export type ProviderRunOptions = {
|
|
22
|
+
cwd: string;
|
|
23
|
+
// Directories the agent may write to without asking: the deck and asset
|
|
24
|
+
// folders. Edits anywhere else (configs, package.json) need approval.
|
|
25
|
+
writableRoots: string[];
|
|
26
|
+
prompt: string;
|
|
27
|
+
systemPromptAppend: string;
|
|
28
|
+
resume: string | null;
|
|
29
|
+
resumeAt: string | null;
|
|
30
|
+
model: string | null;
|
|
31
|
+
effort: AgentEffort | null;
|
|
32
|
+
signal: AbortSignal;
|
|
33
|
+
emit: (event: ProviderEvent) => void;
|
|
34
|
+
askUser: (questions: AgentQuestion[]) => Promise<Record<string, string> | null>;
|
|
35
|
+
requestPermission: (request: PermissionRequest) => Promise<boolean>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export interface AgentProvider {
|
|
39
|
+
id: AgentProviderId;
|
|
40
|
+
name: string;
|
|
41
|
+
sdkPackage: string;
|
|
42
|
+
cliCommand: string;
|
|
43
|
+
askInstruction: string;
|
|
44
|
+
canRewind: boolean;
|
|
45
|
+
models(): Promise<AgentModelOption[]>;
|
|
46
|
+
run(opts: ProviderRunOptions): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ProviderUnavailableError extends Error {
|
|
50
|
+
constructor(provider: AgentProvider) {
|
|
51
|
+
super(
|
|
52
|
+
`${provider.name} is not connected: install ${provider.sdkPackage} in this workspace to use it from the agent panel.`,
|
|
53
|
+
);
|
|
54
|
+
this.name = 'ProviderUnavailableError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DETAIL_MAX = 400;
|
|
59
|
+
|
|
60
|
+
export function clipDetail(value: unknown): string | null {
|
|
61
|
+
if (value === undefined || value === null) return null;
|
|
62
|
+
const text = typeof value === 'string' ? value : safeJson(value);
|
|
63
|
+
if (!text) return null;
|
|
64
|
+
const oneLine = text.replace(/\s+/g, ' ').trim();
|
|
65
|
+
return oneLine.length > DETAIL_MAX ? `${oneLine.slice(0, DETAIL_MAX - 1)}…` : oneLine;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeJson(value: unknown): string {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.stringify(value);
|
|
71
|
+
} catch {
|
|
72
|
+
return String(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function isInsideRoots(cwd: string, file: unknown, roots: string[]): boolean {
|
|
77
|
+
if (typeof file !== 'string' || !file) return false;
|
|
78
|
+
const resolved = path.resolve(cwd, file);
|
|
79
|
+
return roots.some((root) => {
|
|
80
|
+
const rel = path.relative(root, resolved);
|
|
81
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function relativeToCwd(cwd: string, file: unknown): string | null {
|
|
86
|
+
if (typeof file !== 'string' || !file) return null;
|
|
87
|
+
const normalizedCwd = cwd.replace(/[\\/]+$/, '');
|
|
88
|
+
if (file.startsWith(normalizedCwd)) {
|
|
89
|
+
const rest = file.slice(normalizedCwd.length).replace(/^[\\/]+/, '');
|
|
90
|
+
return rest || '.';
|
|
91
|
+
}
|
|
92
|
+
return file;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseQuestions(input: Record<string, unknown>): AgentQuestion[] {
|
|
96
|
+
const raw = Array.isArray(input.questions) ? input.questions : [];
|
|
97
|
+
const questions: AgentQuestion[] = [];
|
|
98
|
+
for (const q of raw) {
|
|
99
|
+
if (!q || typeof q !== 'object') continue;
|
|
100
|
+
const r = q as Record<string, unknown>;
|
|
101
|
+
const options = Array.isArray(r.options)
|
|
102
|
+
? r.options
|
|
103
|
+
.map((o) => {
|
|
104
|
+
if (!o || typeof o !== 'object') return null;
|
|
105
|
+
const opt = o as Record<string, unknown>;
|
|
106
|
+
if (typeof opt.label !== 'string') return null;
|
|
107
|
+
return {
|
|
108
|
+
label: opt.label,
|
|
109
|
+
description: typeof opt.description === 'string' ? opt.description : undefined,
|
|
110
|
+
};
|
|
111
|
+
})
|
|
112
|
+
.filter((o): o is { label: string; description: string | undefined } => o !== null)
|
|
113
|
+
: [];
|
|
114
|
+
if (typeof r.question !== 'string') continue;
|
|
115
|
+
questions.push({
|
|
116
|
+
header: typeof r.header === 'string' ? r.header : '',
|
|
117
|
+
question: r.question,
|
|
118
|
+
options,
|
|
119
|
+
multiSelect: r.multiSelect === true,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
return questions;
|
|
123
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
export const AGENT_PROVIDER_IDS = ['claude', 'codex'] as const;
|
|
2
|
+
|
|
3
|
+
export type AgentProviderId = (typeof AGENT_PROVIDER_IDS)[number];
|
|
4
|
+
|
|
5
|
+
export const AGENT_EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
6
|
+
|
|
7
|
+
export type AgentEffort = (typeof AGENT_EFFORTS)[number];
|
|
8
|
+
|
|
9
|
+
export function isAgentProviderId(value: unknown): value is AgentProviderId {
|
|
10
|
+
return typeof value === 'string' && (AGENT_PROVIDER_IDS as readonly string[]).includes(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isAgentEffort(value: unknown): value is AgentEffort {
|
|
14
|
+
return typeof value === 'string' && (AGENT_EFFORTS as readonly string[]).includes(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AgentModelOption = {
|
|
18
|
+
// Empty when the option stands for the CLI's own default model.
|
|
19
|
+
id: string;
|
|
20
|
+
label: string;
|
|
21
|
+
efforts: AgentEffort[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type AgentProviderStatus = {
|
|
25
|
+
id: AgentProviderId;
|
|
26
|
+
name: string;
|
|
27
|
+
sdkPackage: string;
|
|
28
|
+
sdkVersion: string | null;
|
|
29
|
+
cliCommand: string;
|
|
30
|
+
cliFound: boolean;
|
|
31
|
+
cliVersion: string | null;
|
|
32
|
+
ready: boolean;
|
|
33
|
+
models: AgentModelOption[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type AgentStatus = {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
packageManager: string;
|
|
39
|
+
providers: AgentProviderStatus[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type AgentQuestionOption = { label: string; description?: string };
|
|
43
|
+
|
|
44
|
+
export type AgentQuestion = {
|
|
45
|
+
header: string;
|
|
46
|
+
question: string;
|
|
47
|
+
options: AgentQuestionOption[];
|
|
48
|
+
multiSelect: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type AgentItem =
|
|
52
|
+
| {
|
|
53
|
+
kind: 'user';
|
|
54
|
+
id: string;
|
|
55
|
+
ts: string;
|
|
56
|
+
text: string;
|
|
57
|
+
// The provider history point just before this message, so editing it
|
|
58
|
+
// can rewind the agent's conversation there.
|
|
59
|
+
resumeAt?: string;
|
|
60
|
+
newDeck?: boolean;
|
|
61
|
+
}
|
|
62
|
+
| { kind: 'assistant'; id: string; ts: string; text: string; streaming: boolean }
|
|
63
|
+
| {
|
|
64
|
+
kind: 'tool';
|
|
65
|
+
id: string;
|
|
66
|
+
ts: string;
|
|
67
|
+
name: string;
|
|
68
|
+
title: string;
|
|
69
|
+
detail: string | null;
|
|
70
|
+
status: 'running' | 'done' | 'error';
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
kind: 'question';
|
|
74
|
+
id: string;
|
|
75
|
+
ts: string;
|
|
76
|
+
questions: AgentQuestion[];
|
|
77
|
+
answers: Record<string, string> | null;
|
|
78
|
+
status: 'pending' | 'answered' | 'cancelled';
|
|
79
|
+
}
|
|
80
|
+
| {
|
|
81
|
+
kind: 'permission';
|
|
82
|
+
id: string;
|
|
83
|
+
ts: string;
|
|
84
|
+
tool: string;
|
|
85
|
+
title: string;
|
|
86
|
+
detail: string | null;
|
|
87
|
+
status: 'pending' | 'allowed' | 'denied';
|
|
88
|
+
}
|
|
89
|
+
| { kind: 'error'; id: string; ts: string; text: string }
|
|
90
|
+
| {
|
|
91
|
+
kind: 'result';
|
|
92
|
+
id: string;
|
|
93
|
+
ts: string;
|
|
94
|
+
durationMs: number | null;
|
|
95
|
+
costUsd: number | null;
|
|
96
|
+
aborted: boolean;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
type AgentSessionStatus = 'idle' | 'running';
|
|
100
|
+
|
|
101
|
+
export type AgentSessionSummary = {
|
|
102
|
+
id: string;
|
|
103
|
+
provider: AgentProviderId;
|
|
104
|
+
title: string;
|
|
105
|
+
createdAt: string;
|
|
106
|
+
updatedAt: string;
|
|
107
|
+
status: AgentSessionStatus;
|
|
108
|
+
slideId: string | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export type AgentSession = AgentSessionSummary & {
|
|
112
|
+
providerSessionId: string | null;
|
|
113
|
+
providerCheckpoint?: string | null;
|
|
114
|
+
// Set once the oldest items were dropped to stay under the item cap.
|
|
115
|
+
trimmed?: boolean;
|
|
116
|
+
items: AgentItem[];
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export type AgentTurnContext = {
|
|
120
|
+
slideId?: string;
|
|
121
|
+
slideTitle?: string;
|
|
122
|
+
pageNumber?: number;
|
|
123
|
+
totalPages?: number;
|
|
124
|
+
pendingComments?: number;
|
|
125
|
+
selection?: { line: number; column: number; tagName: string; text: string } | null;
|
|
126
|
+
newDeck?: boolean;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export type RespondPayload =
|
|
130
|
+
| { answers: Record<string, string> }
|
|
131
|
+
| { allow: boolean }
|
|
132
|
+
| { cancel: true };
|
|
133
|
+
|
|
134
|
+
export type AgentStreamEvent =
|
|
135
|
+
| { type: 'session'; session: AgentSessionSummary }
|
|
136
|
+
| { type: 'session-removed'; sessionId: string }
|
|
137
|
+
| { type: 'item'; sessionId: string; item: AgentItem }
|
|
138
|
+
| { type: 'delta'; sessionId: string; itemId: string; text: string }
|
|
139
|
+
| { type: 'truncate'; sessionId: string; itemId: string }
|
|
140
|
+
| { type: 'slide-renamed'; from: string; to: string };
|
|
141
|
+
|
|
142
|
+
export function sessionSummary(session: AgentSession): AgentSessionSummary {
|
|
143
|
+
const {
|
|
144
|
+
providerSessionId: _p,
|
|
145
|
+
providerCheckpoint: _c,
|
|
146
|
+
trimmed: _t,
|
|
147
|
+
items: _i,
|
|
148
|
+
...summary
|
|
149
|
+
} = session;
|
|
150
|
+
return summary;
|
|
151
|
+
}
|
package/src/app/app.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { AssetsPage } from './routes/assets';
|
|
|
8
8
|
import { Home } from './routes/home';
|
|
9
9
|
import { HomeShell } from './routes/home-shell';
|
|
10
10
|
import { Presenter } from './routes/presenter';
|
|
11
|
+
import { Preview } from './routes/preview';
|
|
11
12
|
import { Slide } from './routes/slide';
|
|
12
13
|
import { ThemeDetailPage, ThemesGalleryPage } from './routes/themes';
|
|
13
14
|
|
|
@@ -43,6 +44,7 @@ export function App() {
|
|
|
43
44
|
}
|
|
44
45
|
/>
|
|
45
46
|
)}
|
|
47
|
+
{import.meta.env.DEV && <Route path="/s/:slideId/preview" element={<Preview />} />}
|
|
46
48
|
<Route path="*" element={<NotFound />} />
|
|
47
49
|
</Routes>
|
|
48
50
|
</TooltipProvider>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" fill="none">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="tile" x1="256" y1="16" x2="256" y2="496" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop offset="0" stop-color="#FFFFFF"/>
|
|
5
|
+
<stop offset="1" stop-color="#EFEBE6"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<linearGradient id="tile-edge" x1="256" y1="16" x2="256" y2="496" gradientUnits="userSpaceOnUse">
|
|
8
|
+
<stop offset="0" stop-color="#FFFFFF"/>
|
|
9
|
+
<stop offset="0.5" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
10
|
+
<stop offset="1" stop-color="#000000" stop-opacity="0.08"/>
|
|
11
|
+
</linearGradient>
|
|
12
|
+
<linearGradient id="slide" x1="256" y1="172" x2="256" y2="340" gradientUnits="userSpaceOnUse">
|
|
13
|
+
<stop offset="0" stop-color="#FF8A5E"/>
|
|
14
|
+
<stop offset="0.45" stop-color="#FE5226"/>
|
|
15
|
+
<stop offset="1" stop-color="#EE4316"/>
|
|
16
|
+
</linearGradient>
|
|
17
|
+
<linearGradient id="slide-edge" x1="256" y1="172" x2="256" y2="340" gradientUnits="userSpaceOnUse">
|
|
18
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.55"/>
|
|
19
|
+
<stop offset="0.35" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
20
|
+
<stop offset="0.8" stop-color="#B8300C" stop-opacity="0"/>
|
|
21
|
+
<stop offset="1" stop-color="#B8300C" stop-opacity="0.5"/>
|
|
22
|
+
</linearGradient>
|
|
23
|
+
<radialGradient id="slide-sheen" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(196 184) rotate(32) scale(200 110)">
|
|
24
|
+
<stop offset="0" stop-color="#FFFFFF" stop-opacity="0.26"/>
|
|
25
|
+
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0"/>
|
|
26
|
+
</radialGradient>
|
|
27
|
+
<filter id="lift" x="60" y="140" width="392" height="260" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
28
|
+
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#FE5226" flood-opacity="0.32"/>
|
|
29
|
+
<feDropShadow dx="0" dy="3" stdDeviation="3" flood-color="#8A2306" flood-opacity="0.22"/>
|
|
30
|
+
</filter>
|
|
31
|
+
</defs>
|
|
32
|
+
<rect x="16" y="16" width="480" height="480" rx="112" fill="url(#tile)"/>
|
|
33
|
+
<rect x="17.5" y="17.5" width="477" height="477" rx="110.5" stroke="url(#tile-edge)" stroke-width="3"/>
|
|
34
|
+
<g filter="url(#lift)">
|
|
35
|
+
<rect x="112" y="172" width="288" height="168" rx="34" fill="url(#slide)"/>
|
|
36
|
+
</g>
|
|
37
|
+
<rect x="112" y="172" width="288" height="168" rx="34" fill="url(#slide-sheen)"/>
|
|
38
|
+
<rect x="113.5" y="173.5" width="285" height="165" rx="32.5" stroke="url(#slide-edge)" stroke-width="3"/>
|
|
39
|
+
</svg>
|