@acornly/ui 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +401 -2
- package/dist/brand.d.ts +50 -0
- package/dist/brand.d.ts.map +1 -0
- package/dist/brand.js +84 -0
- package/dist/brand.js.map +1 -0
- package/dist/card.d.ts +127 -0
- package/dist/card.d.ts.map +1 -0
- package/dist/card.js +415 -0
- package/dist/card.js.map +1 -0
- package/dist/chat-model.d.ts +165 -0
- package/dist/chat-model.d.ts.map +1 -0
- package/dist/chat-model.js +576 -0
- package/dist/chat-model.js.map +1 -0
- package/dist/chat-panel.d.ts +36 -0
- package/dist/chat-panel.d.ts.map +1 -0
- package/dist/chat-panel.js +760 -0
- package/dist/chat-panel.js.map +1 -0
- package/dist/chat-picker.d.ts +56 -0
- package/dist/chat-picker.d.ts.map +1 -0
- package/dist/chat-picker.js +284 -0
- package/dist/chat-picker.js.map +1 -0
- package/dist/chat-protocol.d.ts +151 -0
- package/dist/chat-protocol.d.ts.map +1 -0
- package/dist/chat-protocol.js +113 -0
- package/dist/chat-protocol.js.map +1 -0
- package/dist/chat-speech.d.ts +61 -0
- package/dist/chat-speech.d.ts.map +1 -0
- package/dist/chat-speech.js +119 -0
- package/dist/chat-speech.js.map +1 -0
- package/dist/chat.d.ts +148 -0
- package/dist/chat.d.ts.map +1 -0
- package/dist/chat.js +119 -0
- package/dist/chat.js.map +1 -0
- package/dist/clock.d.ts +3 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +9 -0
- package/dist/clock.js.map +1 -0
- package/dist/compose.d.ts +8 -0
- package/dist/compose.d.ts.map +1 -0
- package/dist/compose.js +7 -0
- package/dist/compose.js.map +1 -0
- package/dist/composer.d.ts +191 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +629 -0
- package/dist/composer.js.map +1 -0
- package/dist/dom.d.ts +20 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +77 -0
- package/dist/dom.js.map +1 -0
- package/dist/float.d.ts +27 -0
- package/dist/float.d.ts.map +1 -0
- package/dist/float.js +76 -0
- package/dist/float.js.map +1 -0
- package/dist/focus.d.ts +25 -0
- package/dist/focus.d.ts.map +1 -0
- package/dist/focus.js +165 -0
- package/dist/focus.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/lazy.d.ts +20 -0
- package/dist/lazy.d.ts.map +1 -0
- package/dist/lazy.js +23 -0
- package/dist/lazy.js.map +1 -0
- package/dist/markdown/index.d.ts +8 -0
- package/dist/markdown/index.d.ts.map +1 -0
- package/dist/markdown/index.js +8 -0
- package/dist/markdown/index.js.map +1 -0
- package/dist/markdown/markdown.d.ts +33 -0
- package/dist/markdown/markdown.d.ts.map +1 -0
- package/dist/markdown/markdown.js +200 -0
- package/dist/markdown/markdown.js.map +1 -0
- package/dist/markdown/render.d.ts +25 -0
- package/dist/markdown/render.d.ts.map +1 -0
- package/dist/markdown/render.js +127 -0
- package/dist/markdown/render.js.map +1 -0
- package/dist/markdown/sanitize.d.ts +8 -0
- package/dist/markdown/sanitize.d.ts.map +1 -0
- package/dist/markdown/sanitize.js +145 -0
- package/dist/markdown/sanitize.js.map +1 -0
- package/dist/media.d.ts +45 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +206 -0
- package/dist/media.js.map +1 -0
- package/dist/painter.d.ts +132 -0
- package/dist/painter.d.ts.map +1 -0
- package/dist/painter.js +785 -0
- package/dist/painter.js.map +1 -0
- package/dist/panel.d.ts +135 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +541 -0
- package/dist/panel.js.map +1 -0
- package/dist/parts.d.ts +25 -0
- package/dist/parts.d.ts.map +1 -0
- package/dist/parts.js +25 -0
- package/dist/parts.js.map +1 -0
- package/dist/pins.d.ts +71 -0
- package/dist/pins.d.ts.map +1 -0
- package/dist/pins.js +661 -0
- package/dist/pins.js.map +1 -0
- package/dist/root.d.ts +83 -0
- package/dist/root.d.ts.map +1 -0
- package/dist/root.js +220 -0
- package/dist/root.js.map +1 -0
- package/dist/selection.d.ts +22 -0
- package/dist/selection.d.ts.map +1 -0
- package/dist/selection.js +81 -0
- package/dist/selection.js.map +1 -0
- package/dist/shortcuts.d.ts +64 -0
- package/dist/shortcuts.d.ts.map +1 -0
- package/dist/shortcuts.js +202 -0
- package/dist/shortcuts.js.map +1 -0
- package/dist/theme.d.ts +46 -0
- package/dist/theme.d.ts.map +1 -0
- package/dist/theme.js +91 -0
- package/dist/theme.js.map +1 -0
- package/dist/toolbar.d.ts +102 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +263 -0
- package/dist/toolbar.js.map +1 -0
- package/package.json +66 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Free Solo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,402 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @acornly/ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The UI layer of Acornly: Lit web components rendered inside one closed Shadow DOM. Most apps get it through `@acornly/sdk`; this package is the shell those surfaces render into.
|
|
4
|
+
|
|
5
|
+
## The shell
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { mountRoot } from "@acornly/ui";
|
|
9
|
+
|
|
10
|
+
const root = mountRoot({
|
|
11
|
+
theme: { accent: "#5b5bd6", radius: "6px" },
|
|
12
|
+
colorScheme: "auto", // or "light" / "dark"
|
|
13
|
+
zIndex: 1000,
|
|
14
|
+
shortcuts: { "note.create": "alt+n", "panel.toggle": false },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
root.destroy(); // removes the element, its listeners and everything inside it
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`mountRoot` appends one `<acornly-root>` element to `document.documentElement`. Everything Acornly draws lives in its closed shadow root:
|
|
21
|
+
|
|
22
|
+
- Page CSS cannot reach the UI. The host carries inline `all: initial !important`, so page rules can neither hide or move it nor pass inherited fonts and colors into it.
|
|
23
|
+
- The UI adds no global styles and no `window` globals. (Lit itself records its version on `globalThis` for duplicate detection.)
|
|
24
|
+
- Text normalization in `@acornly/core` skips `<acornly-root>` (landing with [#19](https://github.com/postagilab/acornly/issues/19)), so notes never anchor to Acornly's own text.
|
|
25
|
+
- Keys typed into an Acornly field stop at the shadow root, so the page's bubbling key listeners (most page shortcuts) never see them.
|
|
26
|
+
- Surfaces size text in `px` or `em`, never `rem`: `rem` follows the page's root font size, which the shell cannot isolate.
|
|
27
|
+
|
|
28
|
+
## Theming
|
|
29
|
+
|
|
30
|
+
Set any of these custom properties on `acornly-root` in your CSS, or pass them as `theme` (the option names are in parentheses). Unset tokens use built-in light or dark defaults.
|
|
31
|
+
|
|
32
|
+
| Property | Option | What it styles |
|
|
33
|
+
| ----------------------- | ---------------- | ----------------------------------- |
|
|
34
|
+
| `--ctx-accent` | `accent` | Buttons, links, focus rings |
|
|
35
|
+
| `--ctx-accent-contrast` | `accentContrast` | Text on the accent color |
|
|
36
|
+
| `--ctx-background` | `background` | Cards, toolbars, the panel, pins |
|
|
37
|
+
| `--ctx-foreground` | `foreground` | Main text |
|
|
38
|
+
| `--ctx-muted` | `muted` | Timestamps, hints, placeholders |
|
|
39
|
+
| `--ctx-border` | `border` | Borders and dividers |
|
|
40
|
+
| `--ctx-highlight` | `highlight` | Fill behind highlighted text |
|
|
41
|
+
| `--ctx-font` | `font` | Font stack |
|
|
42
|
+
| `--ctx-font-size` | `fontSize` | Base font size |
|
|
43
|
+
| `--ctx-radius` | `radius` | Corner radius |
|
|
44
|
+
| `--ctx-shadow` | `shadow` | Shadow of floating surfaces |
|
|
45
|
+
| `--ctx-z-index` | `zIndex` | Stacking layer relative to the page |
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
acornly-root {
|
|
49
|
+
--ctx-accent: #5b5bd6;
|
|
50
|
+
}
|
|
51
|
+
@media (prefers-color-scheme: dark) {
|
|
52
|
+
acornly-root {
|
|
53
|
+
--ctx-accent: #a5a5f0;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`colorScheme: "auto"` (the default) follows `prefers-color-scheme`; the `color-scheme` attribute on the element shows the current setting.
|
|
59
|
+
|
|
60
|
+
The defaults are Acornly's own palettes, exported from `@acornly/ui/brand` as `LIGHT` and `DARK`:
|
|
61
|
+
|
|
62
|
+
| Token | Light | Dark |
|
|
63
|
+
| ------------ | ----------------------- | ----------------------- |
|
|
64
|
+
| `accent` | `#6253a8` violet | `#9184d9` violet |
|
|
65
|
+
| `background` | `#fffdf9` cream | `#232532` Nocturne |
|
|
66
|
+
| `foreground` | `#30231b` | `#e9e9ed` |
|
|
67
|
+
| `muted` | `#645850` | `#b2b6ca` |
|
|
68
|
+
| `border` | `#d6d0c9` | `#3f424d` |
|
|
69
|
+
| `highlight` | `rgb(236 178 110 / .4)` | `rgb(193 129 84 / .34)` |
|
|
70
|
+
| `font` | Inter, then system-ui | the same |
|
|
71
|
+
| `radius` | `14px` (controls 8px) | the same |
|
|
72
|
+
|
|
73
|
+
`@acornly/ui/brand` also draws the acorn marks: `drawMark(document, SPROUT)` (the logo) or `ACORN` (the pin mark) returns an `<svg>` element built without parsing markup, so it works under Trusted Types.
|
|
74
|
+
|
|
75
|
+
### Parts
|
|
76
|
+
|
|
77
|
+
For styling beyond the tokens, these `::part()` names are stable:
|
|
78
|
+
|
|
79
|
+
| Part | Element |
|
|
80
|
+
| ------------------- | ------------------------------------------- |
|
|
81
|
+
| `layer` | The full-page layer every surface sits in. |
|
|
82
|
+
| `pins` | The box holding every margin pin. |
|
|
83
|
+
| `pin` | One margin pin button. |
|
|
84
|
+
| `card` | A note card. |
|
|
85
|
+
| `panel` | The side panel. |
|
|
86
|
+
| `selection-toolbar` | The toolbar next to a text selection. |
|
|
87
|
+
| `composer` | The note composer. |
|
|
88
|
+
| `launcher` | The SDK's notes button, fixed bottom right. |
|
|
89
|
+
| `chat` | The in-app chat panel (experimental). |
|
|
90
|
+
|
|
91
|
+
```css
|
|
92
|
+
acornly-root::part(layer) {
|
|
93
|
+
font-variant-numeric: tabular-nums;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Later surfaces add their parts to this table and to `PARTS`.
|
|
98
|
+
|
|
99
|
+
### Host CSS
|
|
100
|
+
|
|
101
|
+
For anything else, pass `css` (or call `root.setStyles(css)` later): your own rules, applied inside the shadow root after every built-in style, including those of surfaces that load later, so a rule as specific as the built-in one wins. Select by `[part~="..."]` where you can; the `.ctx-*` classes may change between minor versions. `@import` is not supported.
|
|
102
|
+
|
|
103
|
+
## Keyboard shortcuts
|
|
104
|
+
|
|
105
|
+
Every action has a shortcut the host can remap or turn off through `shortcuts`: a map from command id to a combo, `false` to disable one command, or `shortcuts: false` to disable all of them. A combo is modifiers plus one key joined by `+`, such as `mod+shift+c`; `mod` is ⌘ on Apple platforms and Ctrl elsewhere. Name Space and Escape as `space` and `escape`. Shortcuts do not fire while focus is in a text field, or again while a key is held, unless the command opts in.
|
|
106
|
+
|
|
107
|
+
## Building blocks
|
|
108
|
+
|
|
109
|
+
Exported for the surfaces built on the shell, and usable by apps rendering their own UI:
|
|
110
|
+
|
|
111
|
+
- `await layerOf(root)` is the layer inside the shell that pins, cards, the panel and the painter's outlines go in.
|
|
112
|
+
- `trapFocus(container, options)` keeps Tab inside a card or dialog and restores focus on release; traps stack.
|
|
113
|
+
- `createShortcutRegistry(options)` is the registry behind `root.shortcuts`.
|
|
114
|
+
- `lazyPart(() => import("./composer.js"))` loads a heavy surface as a separate chunk on first use.
|
|
115
|
+
|
|
116
|
+
## Highlights
|
|
117
|
+
|
|
118
|
+
`createHighlightPainter` draws notes on the page without fighting the host's rendering. Its inputs are plain `Range`s and elements, so it works with any anchoring.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { createHighlightPainter } from "@acornly/ui";
|
|
122
|
+
|
|
123
|
+
const painter = createHighlightPainter({
|
|
124
|
+
layer, // the shell's layer: element outlines go here
|
|
125
|
+
onActivate: (id) => openCard(id), // a click or tap on a highlight
|
|
126
|
+
onHover: (id) => showPreview(id), // null when the pointer leaves
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
painter.paint("note-1", range); // a Range or an array of Ranges
|
|
130
|
+
painter.paint("note-2", range2, { approximate: true }); // re-anchored by fuzzy matching
|
|
131
|
+
painter.paint("note-3", imageElement); // an outline over an element
|
|
132
|
+
painter.paintAll(anchored); // [{ id, target, approximate }], e.g. after one anchoring pass
|
|
133
|
+
painter.focus("note-1");
|
|
134
|
+
painter.clear("note-2");
|
|
135
|
+
painter.destroy(); // removes every highlight, listener and style
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
- Where the [CSS Custom Highlight API](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Custom_Highlight_API) exists, text is painted through `CSS.highlights` and the page's DOM is never changed, so React or Vue re-renders never fight with it. `painter.mode` is `"highlights"`.
|
|
139
|
+
- Elsewhere, the covered text nodes are wrapped in `<ctx-mark>` elements, which `clear` and `destroy` remove, merging split text back into the page's original text nodes. `painter.mode` is `"marks"`. Wrapping and unwrapping move text nodes. The painter puts the ranges of its notes (the ones you passed, and its own copies) back on the same text after every change, but any other live `Range` over painted text may shift, so paint ranges built in one pass with `paintAll`, which reads them all before changing the page. SVG and MathML text is left unpainted in this mode.
|
|
140
|
+
- Element anchors get an outline drawn by an absolutely positioned box in the shell's layer; the element's own style is never edited.
|
|
141
|
+
- Highlights receive no events, so clicks and hovers are found by hit-testing the pointer against each note's cached client rects, re-measured after scroll (the window or any scroller), resize and layout changes (`ResizeObserver`). `painter.idsAt(x, y)` exposes the same test, innermost note first. Call `painter.refresh()` after a change those signals miss.
|
|
142
|
+
|
|
143
|
+
Each note is in one state at a time, each with its own highlight name: `ctx-note` (exact anchor), `ctx-note-approximate` (dashed underline), `ctx-note-hover` and `ctx-note-focus`. Restyle them with `::highlight()` rules in your own CSS; the painter's defaults sit in a cascade layer, so your rules win. Highlights are painted in the page's own tree, so the fill reads `--ctx-highlight` and `--ctx-highlight-active` from the page (for example on `:root`), not from `<acornly-root>`; the `colors` option and `painter.setColors()` set them explicitly instead. Highlight names are shared by the whole page, so with several painters on one page the last one created sets the colors. In `marks` mode, two painters marking the same text restore it with the same text but possibly in more text nodes than before.
|
|
144
|
+
|
|
145
|
+
A painter keeps its own copy of each `Range`, so later changes to a passed `Range` do not move the highlight; call `paint` again to move it.
|
|
146
|
+
|
|
147
|
+
## Pins
|
|
148
|
+
|
|
149
|
+
`createPins` puts one focusable button in the margin beside each note. Pins are the only reading surface on the page-load path; the card and the panel below are separate entry points that load when first opened.
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { createPins, layerOf } from "@acornly/ui";
|
|
153
|
+
|
|
154
|
+
const layer = await layerOf(root); // the shell's layer, once it has rendered
|
|
155
|
+
const pins = createPins({
|
|
156
|
+
layer,
|
|
157
|
+
onActivate: (id, pin) => openNote(id, pin), // click, Enter or Space
|
|
158
|
+
onHover: (id) => painter.focus(id), // pointer or focus on a pin; null when it leaves
|
|
159
|
+
gutter: document.querySelector("article"), // optional: put pins in the page margin, past this element
|
|
160
|
+
side: "right", // or "left"
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pins.setAll([
|
|
164
|
+
{ id: "note-1", target: range }, // a Range, an element, or anything with getClientRects()
|
|
165
|
+
{ id: "note-2", target: { getClientRects: () => painter.rectsOf("note-2") }, approximate: true },
|
|
166
|
+
{ id: "note-3", target: image, quote: "Revenue chart", count: 3 },
|
|
167
|
+
]);
|
|
168
|
+
pins.setExpanded("note-1"); // aria-expanded on the pin whose card is open
|
|
169
|
+
pins.destroy();
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
- A pin sits beside the first line of its anchor, past the right (or left) edge of the anchor's nearest block-level ancestor: by default that is the paragraph, table cell or list item the anchor is in, so pins in a table sit inside it, beside their cells. Pass `gutter` (the article, say) to line every pin up past that element's edge instead, in the page margin. Pins are kept within the viewport's width, and a pin on text in a fixed box, or in a sticky box while it is stuck to the viewport, is fixed too, so it stays beside its text while the window scrolls. A sticky box's pin can still trail by a frame the moment the box sticks or unsticks. A pin is re-measured after resize and layout changes (`ResizeObserver`) and after scrolls that can move it: a scroller's own content, or, on a window scroll, anchors in fixed or sticky boxes and rect sources (every pin when the scroll is horizontal). Call `refresh()` after a change those miss: an ancestor made fixed or sticky, an anchor moved into another scroller, or content that moves without anything observed changing size (a block growing above the text inside a fixed-height scroller, say). Pins that would overlap stack instead, and a pin whose anchor is not rendered, or is scrolled out of a scroller, is hidden. Scroll events inside shadow roots do not reach the document, so pins on text in a shadow-DOM scroller need `refresh()` from the host.
|
|
173
|
+
- Each pin is a `<button>` whose accessible name quotes the anchored text: `Note on “quick brown fox”`, `3 notes on “…”`, with `(approximate)` for fuzzy anchors. Pass `label` to word it differently.
|
|
174
|
+
- Pins are kept in reading order (by the anchor's line, then left to right) in the DOM. They form one Tab stop, a `group` named `groupLabel` (default `Notes`): Tab reaches the last pin focused, or the first, and the arrow keys, Home and End move between pins.
|
|
175
|
+
- With the `<ctx-mark>` fallback of the painter, pass the painter's rects (`{ getClientRects: () => painter.rectsOf(id) }`) instead of a `Range`, since wrapping text can move other live ranges.
|
|
176
|
+
|
|
177
|
+
## Media markers
|
|
178
|
+
|
|
179
|
+
`@acornly/ui/media` draws notes on a moment or span of a `video` or `audio` element as markers on a thin strip under the player, along its timeline. It is its own entry point: load it with the first such note, so pages without one never fetch it.
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
const { createMediaStrips } = await import("@acornly/ui/media");
|
|
183
|
+
const strips = createMediaStrips({ layer, onActivate: (id, marker) => openNote(id, marker) });
|
|
184
|
+
strips.set({ id: "note-4", element: video, time: { start: 74, end: 91.5 } }); // a span
|
|
185
|
+
strips.set({ id: "note-5", element: video, time: { start: 12 } }); // a moment
|
|
186
|
+
strips.destroy();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- A strip is as wide as its player and sits just below it while the player is shown and its duration is known; markers on a stream whose length is not known yet appear once it is (`loadedmetadata`, `durationchange`). A note past the end is not drawn.
|
|
190
|
+
- A click, Enter or Space on a marker seeks the player to the note's start (`currentTime`), then calls `onActivate`. Each marker is a `<button>` named `Note at 1:05` or `Note from 1:05 to 1:40` (pass `label` to word it differently); the arrow keys, Home and End move between a strip's markers in time order.
|
|
191
|
+
- `resolve` in `@acornly/core` reports a match's `time` for such notes, and the SDK's built-in UI and the extension draw them this way.
|
|
192
|
+
|
|
193
|
+
## Note cards
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
const { openCard } = await import("@acornly/ui/card");
|
|
197
|
+
|
|
198
|
+
const card = openCard({
|
|
199
|
+
layer,
|
|
200
|
+
anchor: pins.pinOf(id)!, // or a virtual element: { getBoundingClientRect: () => rect }
|
|
201
|
+
thread, // assembleThread(await store.listThread(...), id)
|
|
202
|
+
approximate: true, // shows the "approximate" mark
|
|
203
|
+
viewerId, // marks the viewer's own reactions, and shows their notes as "Me"
|
|
204
|
+
viewerPicture, // optional: the viewer's photo (https), next to their own notes
|
|
205
|
+
registry: ctx.bodyTypes, // app-defined body types
|
|
206
|
+
audience: (note) => (note.ext.audience.kind === "circles" ? "Visible to Lab" : null), // optional mark
|
|
207
|
+
actions: {
|
|
208
|
+
// Reply, Edit and Delete buttons on each note; the card only reports the choice.
|
|
209
|
+
reply: (note, button) => openReplyComposer(note, pin),
|
|
210
|
+
edit: (note) => openEditComposer(note, pin),
|
|
211
|
+
remove: (note) => ctx.remove(note.id),
|
|
212
|
+
canChange: (note) => note.creator?.id === viewerId, // Edit and Delete on these only
|
|
213
|
+
report: (note) => ctx.reportUrl(note), // a Report link, opened in a new tab; null for none
|
|
214
|
+
},
|
|
215
|
+
onClose: () => pins.setExpanded(null),
|
|
216
|
+
});
|
|
217
|
+
card.update(newerThread); // after a reply arrives
|
|
218
|
+
card.close();
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
The card is placed with [Floating UI](https://floating-ui.com): below its anchor, flipped above when there is no room, kept inside the viewport and following the anchor as the page scrolls. It shows the note and its replies, parents before children: replies are indented up to three levels (`MAX_INDENT`), and deeper ones are shown flat with a "Replying to …" label. Reactions are shown as counts on the note they react to; an edited note carries an "edited" mark and a deleted note that still has replies shows as a placeholder. The card is a `dialog` that takes focus, keeps Tab inside it and closes on Escape, its close button or a pointer down outside it and its anchor (`closeOnOutsideClick: false` turns that off); focus then returns to the anchor when it is an element (the pin or panel row), else to where it was. If that element was re-rendered meanwhile, the element with the same `data-id` and class gets focus instead (a panel row; a pin carries no `data-id`, as page CSS could read it through `::part(pin)`). Every visible string can be replaced through `labels`. While its pin is hidden (its text scrolled out of a scroller, say), the card stays where the pin last was and follows the pin again once it shows. A removed pin does not come back as the same button, so close the card when its note goes away (`setAll` without it, say) and open a new one on the new pin. The card stacks above the side panel, whichever opened first. The card renders bodies with `@acornly/ui/markdown`, so loading it loads the Markdown renderer too.
|
|
222
|
+
|
|
223
|
+
## Side panel
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
const { openPanel } = await import("@acornly/ui/panel");
|
|
227
|
+
|
|
228
|
+
const panel = openPanel({
|
|
229
|
+
layer,
|
|
230
|
+
items: notes.map((note) => ({
|
|
231
|
+
id: note.id,
|
|
232
|
+
annotation: note,
|
|
233
|
+
state: manager.get(note.id).state, // pending, anchored, approximate or orphaned
|
|
234
|
+
target: rangeOf(note.id), // where it sits, for reading order
|
|
235
|
+
replyCount,
|
|
236
|
+
})),
|
|
237
|
+
onSelect: (id, item) => (item.state === "orphaned" ? offerReattach(id) : openNote(id)),
|
|
238
|
+
});
|
|
239
|
+
panel.update(items);
|
|
240
|
+
panel.close();
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The panel is a `complementary` landmark named by its heading. It lists the page's notes in reading order, the same rule the pins follow (by line, then left to right), then notes still being found or not rendered, and puts orphaned notes in a **Detached** list with the text they were on, oldest first. Each row shows the quoted text, the note as plain text (`markdownExcerpt` strips Markdown markers without loading the parser), its author, reply count and state. Rows keep their elements across `update`, so focus stays on them. The rows are one Tab stop (the row last focused); arrow keys, Home and End move between them; Escape closes the panel and returns focus to where it was. `orderPanelItems` gives the same order without the panel, for apps that render their own.
|
|
244
|
+
|
|
245
|
+
With `actions`, choosing a row opens that note in place instead: the row shows the note's whole text and a button for each action (`{ label, run(row) }`, such as Reply with a composer opened next to the row), and choosing it again closes it. `onSelect` is still called when a row opens. The SDK's built-in UI passes Reply, Edit and Delete as the note's card would offer them, and Open thread for the card with its replies.
|
|
246
|
+
|
|
247
|
+
## Selection toolbar and composer
|
|
248
|
+
|
|
249
|
+
The writing half of the UI is a separate entry point, `@acornly/ui/compose`, loaded the first time the user selects something; only `watchSelection` sits on the read path.
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { lazyPart, watchSelection } from "@acornly/ui";
|
|
253
|
+
|
|
254
|
+
const compose = lazyPart(() => import("@acornly/ui/compose"));
|
|
255
|
+
|
|
256
|
+
watchSelection({
|
|
257
|
+
root: document.querySelector("main"), // selections elsewhere are ignored
|
|
258
|
+
onChange: async (range) => {
|
|
259
|
+
if (!range) return toolbar?.close();
|
|
260
|
+
const { openSelectionToolbar, openComposer } = await compose.load();
|
|
261
|
+
toolbar = openSelectionToolbar({
|
|
262
|
+
layer,
|
|
263
|
+
range,
|
|
264
|
+
root: document.querySelector("main")!,
|
|
265
|
+
motivations: ["commenting", "highlighting"],
|
|
266
|
+
shortcuts: root.shortcuts,
|
|
267
|
+
onChoose: ({ action, target, range, quote, level }) => {
|
|
268
|
+
if (action === "highlight") return ctx.add({ target, motivation: "highlighting" });
|
|
269
|
+
openComposer({
|
|
270
|
+
layer,
|
|
271
|
+
anchor: range,
|
|
272
|
+
quote,
|
|
273
|
+
level,
|
|
274
|
+
shortcuts: root.shortcuts,
|
|
275
|
+
onSubmit: ({ markdown }) =>
|
|
276
|
+
ctx.add({ target, body: { value: markdown, format: "text/markdown" } }),
|
|
277
|
+
});
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
- `watchSelection` reports a selection once it settles (the pointer is released, or keyboard selection pauses) and `null` when it is cleared. Selections in form fields, editable content and Acornly's own UI are ignored.
|
|
285
|
+
- The toolbar asks `describeBest` from `@acornly/core` how the selection would be anchored. Words get **Comment** and **Highlight**; a selection over a video or audio element gets **Note on this moment** at the player's current time (the composer's badge shows it, `Note on this moment (1:05)`), a selection with no words (an image, an empty cell) gets **Note on this region**, and one with nothing more precise than the page gets **Note on this page**. Buttons follow `motivations` (`commenting`, `highlighting`), and the chosen `target` is what the SDK's `add` takes: the Range, the element, `{ element, time }` for a moment, an anchor key, or `{ page: true }`. The toolbar does not take focus or clear the selection.
|
|
286
|
+
- The composer is a dialog with a Markdown text area and a preview, in `new`, `reply` or `edit` mode, with an optional reaction row (`onReact`). It holds no storage logic: `onSubmit` saves, and the composer closes when it resolves or stays open with the text and an error when it throws. It takes focus, keeps Tab inside and returns focus on close. The preview loads `@acornly/ui/markdown` on first use.
|
|
287
|
+
- Both follow their anchor as the page scrolls. While the anchor is hidden (the selected text re-rendered away, or a pin scrolled out of a scroller), they stay where it last was, like the card, and follow it again once it shows.
|
|
288
|
+
- Every string can be replaced through `labels`.
|
|
289
|
+
|
|
290
|
+
Keyboard shortcuts, registered in the shell's registry while the surface is open (remap or disable them with `shortcuts` on `mountRoot`):
|
|
291
|
+
|
|
292
|
+
| Command | Default | Does |
|
|
293
|
+
| --------------------- | ----------- | --------------------------------------------- |
|
|
294
|
+
| `selection.comment` | `mod+alt+m` | Comment on the selection (or its region/page) |
|
|
295
|
+
| `selection.highlight` | `mod+alt+h` | Highlight the selection |
|
|
296
|
+
| `selection.toolbar` | `alt+f10` | Move focus into the toolbar |
|
|
297
|
+
| `composer.submit` | `mod+enter` | Save the note |
|
|
298
|
+
| `composer.cancel` | `escape` | Close without saving |
|
|
299
|
+
| `composer.preview` | `mod+alt+p` | Switch between writing and the preview |
|
|
300
|
+
|
|
301
|
+
Once focus is in the toolbar (Alt+F10 or Tab), the arrow keys, Home and End move between buttons and Escape closes it and gives focus back. When two open surfaces bind the same key, the newest one wins while its `when` guard allows it (the composer's keys apply only while focus is inside it).
|
|
302
|
+
|
|
303
|
+
## In-app chat (experimental)
|
|
304
|
+
|
|
305
|
+
`@acornly/ui/chat` is the chat an app on Roots shows beside itself, on the app context protocol 0.1 ([docs/design/app-context-protocol.md](../../docs/design/app-context-protocol.md)), with **Comment** beside **Ask**: a note on the item in focus, from the same chip and Change picker. It is experimental: it may change in any minor release, and only math, and the Bible and history trial behind flights (postagilab/ops#651), adopt it until the checks in #695 pass.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { contextAnchorProvider, contextNote } from "@acornly/sdk";
|
|
309
|
+
import { createChat } from "@acornly/ui/chat";
|
|
310
|
+
|
|
311
|
+
const rect = (id) => pinRect(id); // where a canvas or map item is on screen, or null
|
|
312
|
+
const places = contextAnchorProvider({ rect, host: (id) => mapElementFor(id) });
|
|
313
|
+
const acornly = await Acornly.init({ ...config, anchorProviders: [places] }, { ui: builtInUi });
|
|
314
|
+
map.on("move", () => places.moved()); // pins on rect items follow a pan
|
|
315
|
+
|
|
316
|
+
const chat = createChat(root, {
|
|
317
|
+
model: "claude-haiku-5-5",
|
|
318
|
+
proxy: { url: workerUrl, appId, token: () => appToken }, // or relay: { endpoint: "/api/chat" }
|
|
319
|
+
load: (id) => loadItem(id), // read_context: an item's content, or null
|
|
320
|
+
rect,
|
|
321
|
+
guard: (sentence) => leaks(sentence), // with rules.guard "callback"
|
|
322
|
+
onHandBack: (id, itemId) => run(id, itemId),
|
|
323
|
+
onKeep: (kept) => acornly.add(contextNote(kept)).then(() => {}),
|
|
324
|
+
onComment: (comment) => acornly.add(contextNote(comment)).then(() => {}),
|
|
325
|
+
onListening: (on) => (on ? guideVoice.pause() : guideVoice.resume()), // optional
|
|
326
|
+
renderMath: (tex, display) => katexNode(tex, display), // optional
|
|
327
|
+
});
|
|
328
|
+
chat.setContext(snapshot); // on every change; cheap while the chat is closed
|
|
329
|
+
askButton.onclick = () => chat.open(); // one "Comment / Ask about this" button
|
|
330
|
+
commentButton.onclick = () => chat.open(undefined, "comment"); // or a Comment button of its own
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- Up front an app loads only `createChat`, which keeps the latest snapshot. The panel is its own chunk, fetched on the first `open`, and replies are drawn by the Markdown renderer's chunk after it.
|
|
334
|
+
- The panel sits at the right, 380 px wide and full height, over the app; an app that wants it beside its own panels makes room while it is open (`onClose` says when it shuts). At 520 px wide and less it is a bottom sheet, 62% of the screen tall, so what it is about stays in view above it.
|
|
335
|
+
- The panel is a `complementary` landmark. Its chip names the focus; **Change** shows outlines over every item marked with `data-acornly-context="<id>"` (or `data-ctx-anchor` with an item's id) or reported by `rect`, and from the keyboard opens the text tree, an ARIA `tree` with arrow keys and type-ahead.
|
|
336
|
+
- The reply streams in as the model writes it, with its Markdown drawn as it comes. With `rules.guard: "callback"` it shows a sentence at a time instead, each checked first. Until the reply starts, and while the model reads an item, three dots say it is thinking (a `status` that reads `thinking`, still under `prefers-reduced-motion`); the log is `aria-busy` while a reply streams, so screen readers hear it whole.
|
|
337
|
+
- Every model call carries `Acornly-Feature: chat`. With `rules.guard: "relay"` the calls go to the app's `relay` endpoint, which must forward that header and pass the proxy's error responses on as they are (status and body), so the panel can say why an ask failed. Each turn takes at most three model rounds, and a conversation at most 20 turns.
|
|
338
|
+
- `onKeep` gets the takeaway, its item, a `target` (the item by id in a `ProviderSelector`, then its quote and source), the item's `quote` and its `about` keys; the app saves it as an acorn. Without `onKeep`, Keep is not offered. Chats themselves are never stored.
|
|
339
|
+
- `onComment` gets a comment in the same shape. With it, the form has **Ask** and **Comment**; with `onComment` and no `proxy` or `relay`, the panel is for comments only. A comment calls no model and costs nothing, and does not tie the conversation to its item. It is saved on the focus item, or on its nearest ancestor with an `anchor`. `open(focus, "comment")` opens straight into Comment, so a trial can compare one button for both with a button each.
|
|
340
|
+
- `contextNote(c)` from `@acornly/sdk` is the `add` input for either: the text, the `about` keys as tags, and the target `{ context: item, quote }`. With `contextAnchorProvider` in `anchorProviders`, the built-in UI finds the note again by the item's mark (a verse), or pins it at the item's `rect` inside its `host` (a place on a map), hiding the pin while `rect` answers `null`; call `moved()` when rect items move without the page changing. Give `host` when the app has rect items (without it, an item is placed only if `rect` answers when the note is resolved, so notes on a map that is not ready yet stay page notes); it answers `null` for an id that is not one of the app's rect items, so a note on a verse whose mark is not on the page falls back to its quote. `rect` and `host` get ids from stored notes, which may be other people's: look them up, never build a selector or HTML from them. The `{ context }` target is experimental and outside the [client API contract](../../docs/contracts/client-api.md), as the chat is.
|
|
341
|
+
- A failed ask says why when the model proxy said so (#740): no leave to use the person's AI in this app (`no_grant`), no AI key on their account (`no_key`), or the app's budget for the month spent (`budget_exhausted`), each with a link to the hub page that fixes it (Apps you use, or Your AI), opening in a new tab. Keep's draft says the same when it fails for one of them. `inactive` and `rate_limited` are named too, with no link. `hubUrl` is the hub's origin, for a local or staging Cloud (default `https://acornly.ai`; a path on it is dropped). The chat's own share running out (`feature_cap`) keeps its message; any other failure says only that the chat couldn't answer.
|
|
342
|
+
- Every string can be replaced through `labels`.
|
|
343
|
+
|
|
344
|
+
### Static apps: the vendored bundle
|
|
345
|
+
|
|
346
|
+
An app with no npm (a static site such as the Interactive Bible or history.scholeo.com) copies a built bundle into its repository:
|
|
347
|
+
|
|
348
|
+
```sh
|
|
349
|
+
# in a checkout of postagilab/acornly at the main commit to take
|
|
350
|
+
pnpm install --frozen-lockfile
|
|
351
|
+
pnpm vendor ../bible/vendor/acornly # an empty folder, or the bundle there before
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
The folder holds ES modules and nothing else to install:
|
|
355
|
+
|
|
356
|
+
- `acornly.js`: the SDK (`Acornly`, `contextAnchorProvider`, `contextNote`, the errors), `builtInUi`, `CloudAdapter`, and `loadIndexedDB()` for notes kept on the device.
|
|
357
|
+
- `acornly-chat.js`: `createChat`, the protocol's types and checks, and `mountRoot` for the chat's shell. An app that never opens the chat never loads it, and the panel and the Markdown renderer are chunks of their own, so the flight is one `import` to revert.
|
|
358
|
+
- `chunks/`, loaded from beside the two entries; `VERSION`, the commit and package versions it was built from; `LICENSE`; and `SHA256SUMS`, to check the copy (`sha256sum -c SHA256SUMS` in the folder).
|
|
359
|
+
|
|
360
|
+
Sign-in stays site-kit's `acornly-sign-in.js` (vendored by every Scholeo site): its `getToken` is both the `CloudAdapter`'s and the chat's `proxy.token`.
|
|
361
|
+
|
|
362
|
+
```js
|
|
363
|
+
import {
|
|
364
|
+
Acornly,
|
|
365
|
+
CloudAdapter,
|
|
366
|
+
builtInUi,
|
|
367
|
+
contextAnchorProvider,
|
|
368
|
+
contextNote,
|
|
369
|
+
} from "./vendor/acornly/acornly.js";
|
|
370
|
+
|
|
371
|
+
const places = contextAnchorProvider({ rect, host });
|
|
372
|
+
const acornly = await Acornly.init(
|
|
373
|
+
{
|
|
374
|
+
adapter: new CloudAdapter({ ...CLOUD, getToken: auth.getToken }),
|
|
375
|
+
scope: `app:${APP_ID}`,
|
|
376
|
+
user: auth.user,
|
|
377
|
+
anchorProviders: [places],
|
|
378
|
+
},
|
|
379
|
+
{ ui: builtInUi },
|
|
380
|
+
);
|
|
381
|
+
// Behind the flight only: the chat loads when the person first asks or comments.
|
|
382
|
+
askButton.onclick = async () => {
|
|
383
|
+
const { createChat, mountRoot } = await import("./vendor/acornly/acornly-chat.js");
|
|
384
|
+
chat ??= createChat(mountRoot(), {
|
|
385
|
+
model: MODEL,
|
|
386
|
+
proxy: { url: MODEL_PROXY_URL, appId: APP_ID, token: auth.getToken },
|
|
387
|
+
rect,
|
|
388
|
+
onComment: (c) => acornly.add(contextNote(c)).then(() => {}),
|
|
389
|
+
onKeep: (k) => acornly.add(contextNote(k)).then(() => {}),
|
|
390
|
+
});
|
|
391
|
+
chat.setContext(snapshot());
|
|
392
|
+
chat.open();
|
|
393
|
+
};
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
**Updating it.** Run `pnpm vendor` again from a later `main` and commit the whole folder in one change; never edit the files. The bundle is 0.x like the chat: read the changelogs of `@acornly/ui` and `@acornly/sdk` between the two `VERSION` commits before taking it. Nothing is published to npm for this.
|
|
397
|
+
|
|
398
|
+
**What each app sets up for the chat** (the model calls run on the person's own key and budget):
|
|
399
|
+
|
|
400
|
+
- The app is registered on Acornly Cloud with its origin (the hub's Build console; the Bible is `interactive-bible` already, history needs its own app id with `https://history.scholeo.com`).
|
|
401
|
+
- Each person who tries it, test accounts first, connects a model key on the hub and sets an AI budget under Your AI. Either they let every app they approve use it ("Let every app I approve use my AI"), and the chat may spend a quarter of that budget across every uncapped app's chat, or they give the app a cap of its own under Apps you use (Manage, AI), for example $2 a month at 10 requests a minute, and the chat may spend a quarter of it: $0.50 a month (#711).
|
|
402
|
+
- Comments need no grant: they are notes, saved like the app's other notes.
|
package/dist/brand.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acornly's look, in one place: the default palettes of the built-in UI and the acorn marks.
|
|
3
|
+
* `theme.ts` builds the shell's defaults from these, and the extension's pages and page layer
|
|
4
|
+
* read them too, so every surface matches. The marks come from the brand assets (the sprout is
|
|
5
|
+
* "2b", the default icon); their colours stay the same in both schemes, only the stem follows the
|
|
6
|
+
* text colour.
|
|
7
|
+
*/
|
|
8
|
+
/** One color scheme's defaults, by theme token, plus the colours only Acornly's own pages use. */
|
|
9
|
+
export interface Palette {
|
|
10
|
+
accent: string;
|
|
11
|
+
accentContrast: string;
|
|
12
|
+
background: string;
|
|
13
|
+
foreground: string;
|
|
14
|
+
muted: string;
|
|
15
|
+
border: string;
|
|
16
|
+
highlight: string;
|
|
17
|
+
shadow: string;
|
|
18
|
+
/** Behind cards on Acornly's own pages (the hub, the extension's side panel). */
|
|
19
|
+
ground: string;
|
|
20
|
+
/** Personal things: the quoted passage's rule, counts. */
|
|
21
|
+
acorn: string;
|
|
22
|
+
}
|
|
23
|
+
/** Warm cream, with violet for actions. */
|
|
24
|
+
export declare const LIGHT: Palette;
|
|
25
|
+
/** Nocturne: deep blue-grey, with a softer violet. */
|
|
26
|
+
export declare const DARK: Palette;
|
|
27
|
+
/** Inter where the reader has it, then the system face. */
|
|
28
|
+
export declare const FONT = "Inter, system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif";
|
|
29
|
+
/** Corner radius of cards; buttons and fields inside them take `RADIUS - 6px`, list rows `- 4px`. */
|
|
30
|
+
export declare const RADIUS = "14px";
|
|
31
|
+
/** Fills painted behind notes on any page. One pair for both schemes: the page sets the ground. */
|
|
32
|
+
export declare const HIGHLIGHT_FILL = "rgb(236 178 110 / 0.4)";
|
|
33
|
+
export declare const HIGHLIGHT_ACTIVE_FILL = "rgb(236 178 110 / 0.72)";
|
|
34
|
+
/** One SVG element of a mark: its tag and attributes. */
|
|
35
|
+
export type Shape = [tag: "path" | "circle", attrs: Record<string, string>];
|
|
36
|
+
/** A mark: its shapes on a 64 unit square, and the part of the square they fill. */
|
|
37
|
+
export interface Mark {
|
|
38
|
+
viewBox: string;
|
|
39
|
+
shapes: readonly Shape[];
|
|
40
|
+
}
|
|
41
|
+
/** The sprout acorn, the logo. Its stem is `currentColor`, so it reads on any ground. */
|
|
42
|
+
export declare const SPROUT: Mark;
|
|
43
|
+
/** A plain acorn without stem or leaf, for small marks such as pins. */
|
|
44
|
+
export declare const ACORN: Mark;
|
|
45
|
+
/**
|
|
46
|
+
* Draws a mark as an `<svg>` element of `doc`, hidden from assistive tech. Built node by node
|
|
47
|
+
* rather than parsed, so it works on pages that enforce Trusted Types.
|
|
48
|
+
*/
|
|
49
|
+
export declare function drawMark(doc: Document, mark: Mark): SVGSVGElement;
|
|
50
|
+
//# sourceMappingURL=brand.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brand.d.ts","sourceRoot":"","sources":["../src/brand.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,kGAAkG;AAClG,MAAM,WAAW,OAAO;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,iFAAiF;IACjF,MAAM,EAAE,MAAM,CAAC;IACf,0DAA0D;IAC1D,KAAK,EAAE,MAAM,CAAC;CACf;AAED,2CAA2C;AAC3C,eAAO,MAAM,KAAK,EAAE,OAWnB,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,IAAI,EAAE,OAWlB,CAAC;AAEF,2DAA2D;AAC3D,eAAO,MAAM,IAAI,iGAC2E,CAAC;AAE7F,qGAAqG;AACrG,eAAO,MAAM,MAAM,SAAS,CAAC;AAE7B,mGAAmG;AACnG,eAAO,MAAM,cAAc,2BAA2B,CAAC;AACvD,eAAO,MAAM,qBAAqB,4BAA4B,CAAC;AAO/D,yDAAyD;AACzD,MAAM,MAAM,KAAK,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;AAQ5E,oFAAoF;AACpF,MAAM,WAAW,IAAI;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,SAAS,KAAK,EAAE,CAAC;CAC1B;AAED,yFAAyF;AACzF,eAAO,MAAM,MAAM,EAAE,IAYpB,CAAC;AAEF,wEAAwE;AACxE,eAAO,MAAM,KAAK,EAAE,IAA+D,CAAC;AAIpF;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,GAAG,aAAa,CAWjE"}
|
package/dist/brand.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acornly's look, in one place: the default palettes of the built-in UI and the acorn marks.
|
|
3
|
+
* `theme.ts` builds the shell's defaults from these, and the extension's pages and page layer
|
|
4
|
+
* read them too, so every surface matches. The marks come from the brand assets (the sprout is
|
|
5
|
+
* "2b", the default icon); their colours stay the same in both schemes, only the stem follows the
|
|
6
|
+
* text colour.
|
|
7
|
+
*/
|
|
8
|
+
/** Warm cream, with violet for actions. */
|
|
9
|
+
export const LIGHT = {
|
|
10
|
+
accent: "#6253a8",
|
|
11
|
+
accentContrast: "#fffdf9",
|
|
12
|
+
background: "#fffdf9",
|
|
13
|
+
foreground: "#30231b",
|
|
14
|
+
muted: "#645850",
|
|
15
|
+
border: "#d6d0c9",
|
|
16
|
+
highlight: "rgb(236 178 110 / 0.4)",
|
|
17
|
+
shadow: "0 8px 24px rgb(48 35 27 / 0.1), 0 1px 2px rgb(48 35 27 / 0.08)",
|
|
18
|
+
ground: "#fbf6ee",
|
|
19
|
+
acorn: "#b47548",
|
|
20
|
+
};
|
|
21
|
+
/** Nocturne: deep blue-grey, with a softer violet. */
|
|
22
|
+
export const DARK = {
|
|
23
|
+
accent: "#9184d9",
|
|
24
|
+
accentContrast: "#161826",
|
|
25
|
+
background: "#232532",
|
|
26
|
+
foreground: "#e9e9ed",
|
|
27
|
+
muted: "#b2b6ca",
|
|
28
|
+
border: "#3f424d",
|
|
29
|
+
highlight: "rgb(193 129 84 / 0.34)",
|
|
30
|
+
shadow: "0 8px 24px rgb(0 0 0 / 0.5), 0 1px 3px rgb(0 0 0 / 0.4)",
|
|
31
|
+
ground: "#161826",
|
|
32
|
+
acorn: "#eeae7b",
|
|
33
|
+
};
|
|
34
|
+
/** Inter where the reader has it, then the system face. */
|
|
35
|
+
export const FONT = 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
|
|
36
|
+
/** Corner radius of cards; buttons and fields inside them take `RADIUS - 6px`, list rows `- 4px`. */
|
|
37
|
+
export const RADIUS = "14px";
|
|
38
|
+
/** Fills painted behind notes on any page. One pair for both schemes: the page sets the ground. */
|
|
39
|
+
export const HIGHLIGHT_FILL = "rgb(236 178 110 / 0.4)";
|
|
40
|
+
export const HIGHLIGHT_ACTIVE_FILL = "rgb(236 178 110 / 0.72)";
|
|
41
|
+
const NUT = "#c18154";
|
|
42
|
+
const CAP = "#84573e";
|
|
43
|
+
const LEAF = "#8dca80";
|
|
44
|
+
const CHEEK = "#dfb585";
|
|
45
|
+
const NUT_SHAPE = ["path", { d: "M13 30C13 48 22 58 32 58S51 48 51 30Z", fill: NUT }];
|
|
46
|
+
const CAP_SHAPE = [
|
|
47
|
+
"path",
|
|
48
|
+
{ d: "M9 31C9 18 19 11 32 11S55 18 55 31c0 2-2 3-4 3H13c-2 0-4-1-4-3Z", fill: CAP },
|
|
49
|
+
];
|
|
50
|
+
/** The sprout acorn, the logo. Its stem is `currentColor`, so it reads on any ground. */
|
|
51
|
+
export const SPROUT = {
|
|
52
|
+
viewBox: "0 0 64 64",
|
|
53
|
+
shapes: [
|
|
54
|
+
NUT_SHAPE,
|
|
55
|
+
CAP_SHAPE,
|
|
56
|
+
[
|
|
57
|
+
"path",
|
|
58
|
+
{ d: "M32 11V6", stroke: "currentColor", "stroke-width": "3", "stroke-linecap": "round" },
|
|
59
|
+
],
|
|
60
|
+
["path", { d: "M33 7c3-6 11-7 16-5-2 6-9 9-16 5Z", fill: LEAF }],
|
|
61
|
+
["circle", { cx: "23", cy: "42", r: "3.5", fill: CHEEK, opacity: ".6" }],
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
/** A plain acorn without stem or leaf, for small marks such as pins. */
|
|
65
|
+
export const ACORN = { viewBox: "8 8 48 52", shapes: [NUT_SHAPE, CAP_SHAPE] };
|
|
66
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
67
|
+
/**
|
|
68
|
+
* Draws a mark as an `<svg>` element of `doc`, hidden from assistive tech. Built node by node
|
|
69
|
+
* rather than parsed, so it works on pages that enforce Trusted Types.
|
|
70
|
+
*/
|
|
71
|
+
export function drawMark(doc, mark) {
|
|
72
|
+
const svg = doc.createElementNS(SVG_NS, "svg");
|
|
73
|
+
svg.setAttribute("viewBox", mark.viewBox);
|
|
74
|
+
svg.setAttribute("aria-hidden", "true");
|
|
75
|
+
svg.setAttribute("focusable", "false");
|
|
76
|
+
for (const [tag, attrs] of mark.shapes) {
|
|
77
|
+
const shape = doc.createElementNS(SVG_NS, tag);
|
|
78
|
+
for (const [key, value] of Object.entries(attrs))
|
|
79
|
+
shape.setAttribute(key, value);
|
|
80
|
+
svg.append(shape);
|
|
81
|
+
}
|
|
82
|
+
return svg;
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=brand.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"brand.js","sourceRoot":"","sources":["../src/brand.ts"],"names":[],"mappings":"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"}
|