diff --git a/TODO.md b/TODO.md index 7b242fd..34fa0d1 100644 --- a/TODO.md +++ b/TODO.md @@ -30,6 +30,25 @@ themes, DesignSync-ready), run the briefs in Claude Design, port the results, then the code-side consolidation refactor the sheet's backlog section lists. +Primitives consolidation (2026-07-29, branch +`feat/primitives-consolidation`). The fourth brief's result, ported. +New `src/lib/styles/primitives.css`, loaded last, owns the families that +had no single owner: the modal, the empty state, the chip remove +affordance, and the shared focus ring. `.btn` is now the complete family +(states plus `.btn-accept`), `.icon-btn` gains `.sm` and `.danger`, +`.seg` gains `.seg-count`, and `tokens.css` gains the seven-step type +ramp, `--danger-contrast`, and a per-theme `--select-caret`. Migrated +across the app: the `.rv-btn` family, `.rv-quick-btn`, `.rb-btn`, +`.mini-btn`, `.tool-btn`, `.send-btn` and `.pop-open` onto `.btn`/ +`.icon-btn`; `.rtabs`, `.rv-filters`, `.rv-mtabs` and +`.revision-filter-chip` onto `.seg`; the three hand-rolled modals onto +one primitive; twelve empty-state spellings onto `.empty-state`. About +1,400 lines of CSS deleted, including the drift sheet's dead blocks +(`.ctx-menu`, `.settings-nav`, `.note-card`, `.icon-button`, `.kbd`). +Remaining from the sheet's backlog: the duplicated component blocks +(assistant chat, `.insp-*`, kanban), the residual bare `#fff`, and focus +reachability on the sidebar rows. + Dev environment automation (2026-07-28, branch `feat/dev-env-automation`). Setting the project up on a fresh macOS machine turned up three things CI never sees, all fixed here. diff --git a/e2e/account.spec.ts b/e2e/account.spec.ts index 4ed3df2..0c65027 100644 --- a/e2e/account.spec.ts +++ b/e2e/account.spec.ts @@ -173,7 +173,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({ // With the account on, the Assistant tab shows; opening it reveals the chat. // The click retries: right after the create-story navigation the page may // not be hydrated yet, and a pre-hydration click goes nowhere. - const tab = page.locator('.rtab', { hasText: 'Assistant' }); + const tab = page.locator('.right-head .seg-btn', { hasText: 'Assistant' }); await expect(tab).toBeVisible(); await expect(async () => { await tab.click(); @@ -237,10 +237,10 @@ test('assistant tab: gated by the account switch and muted per story', async ({ // The command palette carries the Assistant's quick actions while it is on. await page.keyboard.press('ControlOrMeta+k'); - await expect(page.locator('.palette')).toBeVisible(); - await expect(page.locator('.palette-item', { hasText: 'Catch me up' })).toBeVisible(); + await expect(page.locator('.modal-panel')).toBeVisible(); + await expect(page.locator('.modal-panel .menu-item', { hasText: 'Catch me up' })).toBeVisible(); await expect( - page.locator('.palette-item', { hasText: 'Review with the Assistant' }) + page.locator('.modal-panel .menu-item', { hasText: 'Review with the Assistant' }) ).toBeVisible(); await page.keyboard.press('Escape'); @@ -257,7 +257,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({ await expect(status).toHaveText('Assistant off'); await gotoReady(page, storyUrl); await expect(page.locator('.story-title')).toHaveText('Gatekeeper'); - await expect(page.locator('.rtab', { hasText: 'Assistant' })).toHaveCount(0); + await expect(page.locator('.right-head .seg-btn', { hasText: 'Assistant' })).toHaveCount(0); // And the menus carry no Assistant entries while it is off. await expect(page.locator('.cm-content')).toBeVisible(); @@ -277,7 +277,7 @@ test('assistant tab: gated by the account switch and muted per story', async ({ // And the palette drops the Assistant commands. await page.keyboard.press('ControlOrMeta+k'); - await expect(page.locator('.palette')).toBeVisible(); - await expect(page.locator('.palette-item', { hasText: 'Focus mode' })).toBeVisible(); - await expect(page.locator('.palette-item', { hasText: 'Catch me up' })).toHaveCount(0); + await expect(page.locator('.modal-panel')).toBeVisible(); + await expect(page.locator('.modal-panel .menu-item', { hasText: 'Focus mode' })).toBeVisible(); + await expect(page.locator('.modal-panel .menu-item', { hasText: 'Catch me up' })).toHaveCount(0); }); diff --git a/e2e/core-flow.spec.ts b/e2e/core-flow.spec.ts index 4ba4916..75dee02 100644 --- a/e2e/core-flow.spec.ts +++ b/e2e/core-flow.spec.ts @@ -266,7 +266,10 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows // Alice joined the Factions category above; the kind line carries it. await expect(page.locator('.entity-card .pop-role')).toHaveText('Character ยท Factions'); await expect(page.locator('.entity-card .pop-summary')).toHaveText('A toll-road smuggler.'); - await expect(page.locator('.entity-card .pop-open')).toHaveAttribute('href', /\/plan\?entity=/); + await expect(page.locator('.entity-card .btn-primary')).toHaveAttribute( + 'href', + /\/plan\?entity=/ + ); await fenwickSave; // The worker indexes the mention asynchronously; once it has, the scene's @@ -609,7 +612,7 @@ test('sign in, create a universe and a story, and open it', async ({ page, brows const relDelete = page.waitForResponse( (r) => r.url().includes('/api/relationships/') && r.request().method() === 'DELETE' && r.ok() ); - await page.locator('.rel-remove').click(); + await page.locator('.rel-row .icon-btn').click(); await relDelete; await expect(page.locator('.rel-row')).toHaveCount(0); diff --git a/e2e/entity-card.spec.ts b/e2e/entity-card.spec.ts index 71a9f0c..4822125 100644 --- a/e2e/entity-card.spec.ts +++ b/e2e/entity-card.spec.ts @@ -48,10 +48,10 @@ test('entity card: open from a mention, then back to the tabs', async ({ page }) await expect(card.locator('.insp-name')).toHaveText('Veylan'); await expect(card.locator('.insp-open')).toBeVisible(); // The tabs are replaced while the card is open. - await expect(page.locator('.rtabs')).toHaveCount(0); + await expect(page.locator('.right-head .seg')).toHaveCount(0); // Back returns to the tabbed panel. await card.locator('.back-btn').click(); await expect(card).toHaveCount(0); - await expect(page.locator('.rtabs')).toBeVisible(); + await expect(page.locator('.right-head .seg')).toBeVisible(); }); diff --git a/e2e/find-and-search.spec.ts b/e2e/find-and-search.spec.ts index 208d2c3..45aaea0 100644 --- a/e2e/find-and-search.spec.ts +++ b/e2e/find-and-search.spec.ts @@ -55,7 +55,7 @@ test('find in the editor and search the prose from the palette', async ({ page } // The palette finds the prose and lands on the scene. await page.keyboard.press('ControlOrMeta+k'); await page.getByPlaceholder('Search, or type a command...').fill(`pin${stamp}`); - const passage = page.locator('.palette-item', { hasText: 'In the text' }); + const passage = page.locator('.modal-panel .menu-item', { hasText: 'In the text' }); await expect(passage).toHaveCount(1); await expect(passage).toContainText(`pin${stamp} near the well`); await passage.click(); @@ -76,7 +76,7 @@ test('find in the editor and search the prose from the palette', async ({ page } await page.keyboard.press('ControlOrMeta+k'); await page.getByPlaceholder('Search, or type a command...').fill(`pin${stamp}`); - const jump = page.locator('.palette-item', { hasText: 'In the text' }); + const jump = page.locator('.modal-panel .menu-item', { hasText: 'In the text' }); await expect(jump).toHaveCount(1); await jump.click(); await expect(page.locator('.cm-content')).toContainText(`pin${stamp}`); diff --git a/e2e/review-entity-card.spec.ts b/e2e/review-entity-card.spec.ts index e47b7ac..81aab4b 100644 --- a/e2e/review-entity-card.spec.ts +++ b/e2e/review-entity-card.spec.ts @@ -44,7 +44,7 @@ test('review: hovering an entity mention opens its quick card', async ({ page }) const card = page.locator('.entity-card'); await expect(card).toBeVisible(); await expect(card.locator('.pop-name')).toHaveText('Veylan'); - await expect(card.locator('.pop-open')).toBeVisible(); + await expect(card.locator('.btn-primary')).toBeVisible(); // Moving the pointer away dismisses it. await page.mouse.move(2, 2); diff --git a/e2e/review.spec.ts b/e2e/review.spec.ts index 108b636..a3a31db 100644 --- a/e2e/review.spec.ts +++ b/e2e/review.spec.ts @@ -134,7 +134,7 @@ test('guest review: invite, comment as a guest, reply and resolve as the author' // Resolve the thread, then the Done filter shows both outcomes. await page.getByRole('button', { name: 'Resolve comment' }).click(); - await page.locator('.rv-filter', { hasText: 'Done' }).click(); + await page.locator('.rv-panel-head .seg-btn', { hasText: 'Done' }).click(); await expect(page.locator('.rv-status.resolved')).toBeVisible(); await expect(page.locator('.rv-status.accepted')).toBeVisible(); diff --git a/e2e/scene-board.spec.ts b/e2e/scene-board.spec.ts index ba3c20e..7209e14 100644 --- a/e2e/scene-board.spec.ts +++ b/e2e/scene-board.spec.ts @@ -77,7 +77,7 @@ test('scene board: a card moves along the status ladder and stays there', async await expect(page.locator('.ent-row')).toHaveCount(1); await expect(page.locator('.ent-row .name')).toHaveText('Ferry'); await page.getByLabel('Filter characters, places, lore...').fill('zzz-nobody'); - await expect(page.locator('.search-empty')).toBeVisible(); + await expect(page.locator('.empty-state-text', { hasText: 'Nothing matches.' })).toBeVisible(); await page.getByRole('button', { name: 'Clear' }).click(); // The universe plan shows the story board: this story sits in the lane diff --git a/e2e/scene-title.spec.ts b/e2e/scene-title.spec.ts index b5adc0c..cfb4fbd 100644 --- a/e2e/scene-title.spec.ts +++ b/e2e/scene-title.spec.ts @@ -72,7 +72,9 @@ test('opening a story resumes the last-edited scene', async ({ page }) => { await expect(page.locator('.scene-row')).toHaveCount(1); await expect(page.locator('.scene-row .scene-name')).toHaveText('Second thoughts'); await page.getByLabel('Filter chapters and scenes...').fill('zzz-no-such-scene'); - await expect(page.locator('.search-empty')).toBeVisible(); + await expect( + page.locator('.empty-state-text', { hasText: 'No chapters or scenes match.' }) + ).toBeVisible(); await page.getByRole('button', { name: 'Clear' }).click(); await expect(page.locator('.scene-row')).toHaveCount(2); }); diff --git a/e2e/universe-settings.spec.ts b/e2e/universe-settings.spec.ts index ddc8de3..6d8980b 100644 --- a/e2e/universe-settings.spec.ts +++ b/e2e/universe-settings.spec.ts @@ -61,7 +61,7 @@ test('universe settings: contents, categories, history, export, and the trash', await page.getByRole('link', { name: 'History' }).click(); const entry = page.locator('.revision-entry', { hasText: 'Histor' }).first(); await expect(entry).toBeVisible(); - await expect(entry.locator('.revision-source-kind')).toHaveText('Character'); + await expect(entry.locator('.pill')).toHaveText('Character'); await page.getByRole('button', { name: 'Checkpoints only' }).click(); await expect(page.locator('.revision-entry', { hasText: 'Histor' })).toHaveCount(0); await page.getByRole('button', { name: 'All', exact: true }).click(); diff --git a/scratch/design-kit/README.md b/scratch/design-kit/README.md index 9ca42f6..ab869a6 100644 --- a/scratch/design-kit/README.md +++ b/scratch/design-kit/README.md @@ -54,4 +54,4 @@ repo to project; never edit the system inside the project. Foundations: `colors.html`, `type.html`, `spacing.html`. Components: `buttons.html`, `forms.html`, `menus.html`, `badges.html`, -`seg.html`, `cards.html`, `empty-states.html`. +`seg.html`, `cards.html`, `empty-states.html`, `modal.html`. diff --git a/scratch/design-kit/buttons.html b/scratch/design-kit/buttons.html index f12f3ab..8c8678f 100644 --- a/scratch/design-kit/buttons.html +++ b/scratch/design-kit/buttons.html @@ -17,10 +17,12 @@ +
+
@@ -49,11 +51,49 @@ + + +

The only general button system. One primary action per view; ghost for quiet actions - inside panels; danger only for destructive actions. Icon-only buttons use icon-btn with an - aria-label. Do not invent a new button skin. + inside panels; danger only for destructive actions; accept for the affirmative decision. + Icon-only buttons use icon-btn with an aria-label, at 32px in chrome and 28px (icon-btn + sm) inside cards, panel heads and modal heads. Disabled buttons drop to 0.45 opacity and + do not light up on hover. Do not invent a new button skin.

diff --git a/scratch/design-kit/empty-states.html b/scratch/design-kit/empty-states.html index 512ad17..eec5c16 100644 --- a/scratch/design-kit/empty-states.html +++ b/scratch/design-kit/empty-states.html @@ -6,6 +6,8 @@ Empty states + + + + +
+ + + + diff --git a/scratch/design-kit/seg.html b/scratch/design-kit/seg.html index 2c58f8d..0326e99 100644 --- a/scratch/design-kit/seg.html +++ b/scratch/design-kit/seg.html @@ -28,10 +28,17 @@ +
With counts (seg-count)
+
+ + +

The exclusive-choice strip: modes, tab pairs, filters. Items are links when they navigate and buttons when they select in place; an unavailable item is disabled, not hidden - mid-session. The review workspace's rtabs duplicate is marked for consolidation onto this. + mid-session. A seg-count rides inside a segment when the choice has a tally behind it; it + is not a second badge family and only ever appears inside a seg-btn. Multi-select filters + are chips, not a seg.

diff --git a/scratch/system-design/design-system.md b/scratch/system-design/design-system.md index de4ac9d..9d8c9f4 100644 --- a/scratch/system-design/design-system.md +++ b/scratch/system-design/design-system.md @@ -15,9 +15,10 @@ Companion documents: `design.md` (product intent and page semantics), Use tokens for every colour, radius, and font. Wrap every page in one of the four shells. Build controls from the canonical primitives below (`.btn`, `.icon-btn`, `.field`/`.input`, `.popover`/`.menu-item`, `.seg`, `.badge`, -`.chip`, `.pill`, `.empty`, `Icon.svelte`). Check all three themes. Do not -invent a new button skin, menu system, modal, or empty state; the codebase -already has too many, and the list of offenders is at the end of this sheet. +`.chip`, `.pill`, `.modal-panel`, `.empty-state`, `Icon.svelte`). Check all +three themes. Do not invent a new button skin, menu system, modal, or empty +state; the consolidation that removed the previous crop is described at the +end of this sheet. ## Tokens @@ -34,6 +35,17 @@ Fonts: content face is `--font-content`, which the user can switch. - `--font-mono` (JetBrains Mono) for code and identifiers. +Type ramp, seven steps covering every chrome and prose size. Use a step +rather than a raw pixel value; the serif display sizes stay per-surface +decisions, because a story title is not a card title. + +- `--text-micro` (11px) - segment counts, uppercase eyebrows. +- `--text-meta` (11.5px) - field hints, timestamps. +- `--text-sm` (12.5px) - small buttons, labels, secondary rows. +- `--text-base` (13.5px) - the default chrome size. +- `--text-lg` (15px) - modal titles, search fields. +- `--text-prose-sm` (14.5px) / `--text-prose` (19px) - reading surfaces. + Surfaces, from back to front: - `--bg` - the page background. @@ -53,6 +65,14 @@ Accent family: `--accent` (fills), `--accent-contrast` (text on accent fills; use this, never `#fff`), `--accent-soft` (selection, focus glow, soft fills), `--accent-line` (focused borders). +Assets and contrast: + +- `--danger-contrast` - text on a `--danger` fill (`.btn-danger`). The + danger twin of `--accent-contrast`; never write `#fff` on danger. +- `--select-caret` - the `.select` caret, declared per theme. A data URL + cannot read a custom property, so each theme carries its own faint stroke + colour in the asset. `.select` is the only consumer. + Meaning colours: - Status: `--status-outline`, `--status-draft`, `--status-revised`, @@ -113,19 +133,35 @@ The rule for all of them: if the control you need is close to one of these, use or extend the primitive; if it is genuinely new, add it to the shared CSS with a considered name and add it to this sheet. +Forced-state hooks: every state rule in the shared CSS also matches +`.is-hover`, `.is-active`, or `.is-focus` beside the real pseudo-state. +These exist for one reason only, so the primitives sheet can render a full +state matrix from the shipped declarations instead of restating them, which +is how the drift started. Product Svelte code must never use them; if a +component needs to look hovered, that is a real state and needs a real +class. One pre-existing exception is grandfathered: `EditorToolbar` and +`ViewMenu` put `.is-active` on `.md-tool`, which predates the hooks. It +does not collide, because every hook selector is compound and needs the +primitive's own class too, but do not copy the pattern. + ### Buttons - `.btn` with `.btn-primary`, `.btn-secondary`, `.btn-ghost`, `.btn-danger`, - size modifier `.btn-sm`. Defined in `pages.css`. This is the only general - button system. -- `.icon-btn` for icon-only buttons (top bars, panel headers). Defined in - `theme.css`; also styles `a.icon-btn`. + `.btn-accept`, size modifier `.btn-sm`. Defined in `pages.css`. This is + the only general button system. +- `.btn-accept` is the affirmative decision (accept a suggestion, approve a + pass), built on `--status-final`. It is a variant, not a second family. +- Every variant carries `:hover:not(:disabled)`, `:active`, + `:focus-visible`, and `:disabled` at 0.45 opacity. Disabled buttons do + not light up on hover. +- `.icon-btn` for icon-only buttons, 32px in chrome (top bars, panel + headers). `.icon-btn.sm` is 28px, for inside cards, panel headers and + modal headers where a 32px target would crowd the row. `.icon-btn.danger` + tints the hover for a destructive action. Defined in `theme.css`; also + styles `a.icon-btn`. There are two icon sizes and no third. - Scoped exceptions that stay: `.md-tool` (editor toolbar), `.seg-btn` - (segmented strips), `.rv-btn` family (review surfaces, pending - consolidation). -- Everything else (`.tool-btn`, `.mini-btn`, `.rb-btn`, `.send-btn`, - `.card-add`, `.outline-add`, and the rest of the 20+ skins) is legacy. - Do not use them in new code and do not create new ones. + (segmented strips). +- An icon-only button always carries an `aria-label`. ### Forms @@ -150,10 +186,26 @@ CSS with a considered name and add it to this sheet. ### Modals -There is no shared modal primitive yet; three hand-rolled ones exist. Until -one is extracted, model a new modal on `ReviewModal.svelte`: token-based -backdrop (`color-mix` over `--bg-canvas`), panel on `--bg-elevated`, footer -buttons using `.btn`. Do not copy `HelpModal`'s hard-coded black backdrop. +`.modal-backdrop` wrapping a `.modal-panel`, defined in `primitives.css`. +Two sizes and no third: 420px by default, 640px with `.modal-lg` for a list +to scan or a passage to read. The backdrop is derived from `--bg`, so all +three themes dim in their own key; there is no backdrop token and none is +needed. The panel centres by default; `.modal-backdrop.top` anchors it at +14vh for an overlay whose content height changes while open (the command +palette), so the top edge stays still as results come and go. + +Structure: `.modal-head` (with `.modal-head-main`, `.modal-title`, +`.modal-sub`, `.modal-kind`), `.modal-body` (add `.rows` when it holds +`.menu-item` rows rather than prose), `.modal-foot` (with +`.modal-foot-note` on the left for a hint or shortcut legend). The close +control is an `.icon-btn.sm`; the primary action sits last in the footer. +A head can carry a `.modal-search` field instead of a title, with +`.modal-head.searching`. + +The behaviour is the component's, not the CSS's: Esc closes, focus is +trapped in the panel and returned to the opener, and a backdrop click +closes only when nothing is unsaved. `CommandPalette`, `ReviewModal` and +`HelpModal` are the three reference uses. ### Badges, chips, pills @@ -162,22 +214,27 @@ Three words, three meanings; keep them straight: - `.badge` - an entity identity mark (image or initial), sized `dot`, `sm`, `lg`, rendered by `EntityBadge.svelte`. - `.chip` - an interactive token: tags, removable filters, add-affordances - (`.muted`, `.dashed`, `.link`). + (`.muted`, `.dashed`, `.link`). `.chip-x` is the remove affordance inside + a removable chip; it only ever appears inside a `.chip`. A chip whose + removal needs confirming is not a chip, it is a row with a danger action. - `.pill` - a small static status label (`.pill-accent`). -The dozen other spellings (`.role-tag`, `.nav-badge`, `.rv-type-pill`, -`.tfa-badge`, ...) are scoped legacy; do not add new ones. +A thing that is both a label and a control is a chip, not a pill. + +The remaining scoped spellings (`.role-tag`, `.nav-badge`, `.tfa-badge`, +...) are legacy; do not add new ones. ### Segmented strips and filters -- `.seg` container with `.seg-btn` items (`.active`, `:disabled`) - the - mode strip and any exclusive-choice strip. `ModeSwitcher.svelte` is the - reference use. -- `.rtabs`/`.rtab` in the review workspace is a duplicate of `.seg` and is - marked for consolidation. Known bug to not replicate: `.seg-btn.active` - has a light-theme shadow soften; `.rtab.active` lacks it. -- Filter pill rows also exist twice (`.rv-filters` and `.revision-filters`); - prefer extending `.seg` semantics rather than adding a third. +- `.seg` container with `.seg-btn` items (`.active`, `:disabled`, + `:focus-visible`) - the mode strip, the right-panel tabs, the review + tabs, and any exclusive choice of two to four peers. `.seg.full` stretches + the items to fill the row. `ModeSwitcher.svelte` is the reference use. +- `.seg-count` is a tally riding inside a `.seg-btn` (open notes, filtered + results) at `--text-micro`. It is not a second badge family and appears + nowhere else. +- The line: an exclusive choice is a `.seg`; a multi-select filter row is + chips. ### Lists, rows, tables @@ -198,9 +255,12 @@ new settings content composes `.admin-card`. ### Empty states -`.empty` (centred, `--text-faint`, 13px) is the primitive. The other eleven -spellings are legacy. An empty state is one short sentence telling the user -what to do, per the writing rules. +`.empty-state` is the primitive, defined in `primitives.css`: a centred +column at `--text-faint`, with an optional `.empty-state-icon`, a required +`.empty-state-text`, and an optional `.empty-state-action`. Add `.tight` +inside a right-panel card or a sidebar list, where the full padding would +push the panel into a scroll. An empty state is one short sentence telling +the user what to do, per the writing rules. ### Icons @@ -234,9 +294,9 @@ From CLAUDE.md, repeated here because every screen touches it: ## CSS file map Load order in `src/routes/+layout.svelte`: `tokens.css`, `theme.css`, -`pages.css`, `admin.css`, `editor.css`, `review.css`, `menus.css`. All -global and unscoped, so a later file silently wins a specificity tie; check -for an existing definition before adding a class. +`pages.css`, `admin.css`, `editor.css`, `review.css`, `menus.css`, +`primitives.css`. All global and unscoped, so a later file silently wins a +specificity tie; check for an existing definition before adding a class. - `tokens.css` - tokens and base element rules only. No components. - `theme.css` - the workspace shell and its components (outline rows, @@ -247,11 +307,14 @@ for an existing definition before adding a class. cards, autocomplete popup). - `review.css` - review surfaces. - `menus.css` - the shared popover/menu skin. +- `primitives.css` - loaded last, so it wins over any screen-local skin. + Holds the families that had no single owner before: the modal, the empty + state, the chip remove affordance, and the shared focus ring. Component ` diff --git a/src/lib/components/EntityQuickCard.svelte b/src/lib/components/EntityQuickCard.svelte index 2eb1a84..8b7b700 100644 --- a/src/lib/components/EntityQuickCard.svelte +++ b/src/lib/components/EntityQuickCard.svelte @@ -46,7 +46,7 @@ {#if entity.related && entity.related.length > 0} diff --git a/src/lib/components/EntityRelationships.svelte b/src/lib/components/EntityRelationships.svelte index b954da9..51958cf 100644 --- a/src/lib/components/EntityRelationships.svelte +++ b/src/lib/components/EntityRelationships.svelte @@ -129,7 +129,7 @@ {relationship.notesMd} {/if} -
{@html renderMarkdown(article.body)}
-