Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion db/seeds/development.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ module DevelopmentSeed
{
key: "long-mobile-toc", author: "priya", type: "Product Brief", title: "The complete guide to launching shared workspaces across web, iOS, and Android",
tags: %w[collaboration mobile launch], visibility: "published", folder: "Product/Launches/Shared workspace", long: true
},
{
key: "code-walkthrough", author: "sam", type: "Design Doc", title: "Order discount engine: implementation walkthrough",
tags: %w[pricing api design], visibility: "published", folder: "Engineering/Active projects", fixture: :code_walkthrough
}
].freeze

Expand Down Expand Up @@ -116,6 +120,127 @@ module DevelopmentSeed
| Success | 61.2% | 62.9% |
| Errors | 18.4% | 17.7% |
MARKDOWN
code_walkthrough: <<~'MARKDOWN',
How the discount engine decides what every line item costs. Each stage is
shown in the language it ships in — service code, client hook, schema,
rollout commands — so this doc doubles as a demo of syntax highlighting.

## Where discounts happen

```mermaid
flowchart LR
POS[POS client] --> API
API --> Engine[Discount engine]
Engine --> Rules[(Rule store)]
Engine --> Ledger[(Price ledger)]
```

## The engine core

The engine walks each cart once, folding applicable rules into a final
per-line price. Rules never see the running total — that keeps them pure
and independently testable.

```ruby
# Applies every eligible rule to a cart, cheapest-first.
class DiscountEngine
MAX_STACK = 3

def initialize(rules:, clock: Time)
@rules = rules.sort_by(&:priority)
@clock = clock
end

def price(cart)
cart.line_items.map do |item|
applied = @rules
.select { |rule| rule.eligible?(item, at: @clock.now) }
.first(MAX_STACK)

total = applied.reduce(item.amount) { |amount, rule| rule.apply(amount) }
PricedItem.new(item:, total:, applied_rules: applied.map(&:code))
end
end
end
```

## Client hook

The POS reads priced carts through a small hook — no pricing logic
client-side, ever.

```typescript
interface PricedItem {
name: string;
total: number;
appliedRules: string[];
}

export function usePricedCart(cartId: string): PricedItem[] {
const { data, error } = useSWR<PricedItem[]>(`/api/carts/${cartId}/pricing`);
if (error) throw new PricingUnavailableError(cartId);
return data ?? [];
}
```

## Rule storage

```sql
CREATE TABLE discount_rules (
id CHAR(36) PRIMARY KEY,
code VARCHAR(64) NOT NULL UNIQUE,
priority INT NOT NULL DEFAULT 100,
percent_off DECIMAL(5, 2) NOT NULL,
starts_at DATETIME NOT NULL,
ends_at DATETIME
);

-- Most queries are "which rules are live right now?"
CREATE INDEX idx_rules_window ON discount_rules (starts_at, ends_at);
```

## Rollout

Ship dark, then ramp by merchant cohort:

```bash
bin/rails discounts:backfill_rules DRY_RUN=1
bin/rails discounts:backfill_rules
curl -sf "$API/flags/discount-engine" -d 'cohort=1' | jq .rollout
```

The flag change that turned it on for the pilot cohort:

```diff
flags:
discount-engine:
- enabled: false
+ enabled: true
+ cohorts: [pilot-merchants]
```

## Ledger spot-check

Raw output pasted straight from the console — no language tag, so no
header and no highlighting:

```
cart 8842 → "espresso" base 450 applied [SUMMER10, LOYALTY5] total 384
cart 8842 → "croissant" base 375 applied [] total 375
cart 8842 → "cold brew" base 525 applied [SUMMER10] total 472
```

As the payments team put it during review:

> Pricing bugs are the only bugs customers find before your tests do.
> Fold the ledger check into CI before cohort 3, not after.

## Open questions

- Should `MAX_STACK` be a merchant setting instead of a constant?
- The ledger write is synchronous — acceptable at pilot volume, but see
the latency budget before cohort 3.
MARKDOWN
spanish: "## Problema\n\nLas personas nuevas necesitan saber qué paso completar.\n\n## Resultado\n\nUna lista breve muestra el siguiente paso.",
japanese: "## 目標\n\n障害の影響を小さくし、復旧までの時間を短縮します。\n\n## 次のステップ\n\n復旧手順を自動で検証します。",
arabic: "## الملخص\n\nتقارن هذه المذكرة بين الجلسات قصيرة العمر وتدوير الرموز.\n\n## الخطوة التالية\n\nتشغيل تجربة محكومة لقياس الأمان."
Expand Down Expand Up @@ -220,7 +345,8 @@ def document_content(definition)
fixture = CONTENT_FIXTURES[definition[:fixture]]
parts = [ "# #{definition.fetch(:title)}" ]

if %i[spanish japanese arabic].include?(definition[:fixture])
# Fixtures that are complete document bodies — no lorem filler around them.
if %i[spanish japanese arabic code_walkthrough].include?(definition[:fixture])
parts << fixture
return parts.join("\n\n")
end
Expand Down
176 changes: 174 additions & 2 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
/* CoPlan — Design Tokens + Base Styles */

/* Hack — code font (regular, bold, italic; bold-italic is synthesized).
Self-declared instead of the upstream hack.css so we get
font-display: swap — code must render immediately in a fallback mono
font rather than blocking on the download. */
@font-face {
font-family: "Hack";
src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-regular-subset.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}

@font-face {
font-family: "Hack";
src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-bold-subset.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}

@font-face {
font-family: "Hack";
src: url("https://cdn.jsdelivr.net/npm/hack-font@3.3.0/build/web/fonts/hack-italic-subset.woff2") format("woff2");
font-weight: 400;
font-style: italic;
font-display: swap;
}

:root {
color-scheme: light;

Expand Down Expand Up @@ -37,6 +65,22 @@
--color-tag-active-bg: #374151;
--color-tag-active-hover-bg: #1f2937;
--color-code-bg: #f6f8fa;
/* Code blocks (fenced): block background + the terminal-style language
header. Inline code keeps --color-code-bg. */
--code-block-bg: #f6f8fa;
--code-header-bg: #e9edf3;
--code-header-text: #57606a;
/* Syntax highlighting tokens (highlight.js classes) — GitHub Light */
--code-comment: #6a737d;
--code-keyword: #d73a49;
--code-entity: #6f42c1;
--code-constant: #005cc5;
--code-string: #032f62;
--code-tag: #22863a;
--code-addition-fg: #22863a;
--code-addition-bg: #f0fff4;
--code-deletion-fg: #b31d28;
--code-deletion-bg: #ffeef0;
--color-overlay: rgba(0, 0, 0, 0.2);
--color-focus-ring: rgba(37, 99, 235, 0.12);

Expand Down Expand Up @@ -81,7 +125,7 @@

/* Typography */
--font-sans: 'Lexend', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--font-mono: "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
--font-mono: "Hack", "SF Mono", SFMono-Regular, ui-monospace, Menlo, monospace;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
Expand Down Expand Up @@ -150,6 +194,22 @@
--color-tag-active-bg: #475569;
--color-tag-active-hover-bg: #64748b;
--color-code-bg: #0f172a;
--code-block-bg: #0d0d0f;
--code-header-bg: #17171a;
--code-header-text: #8b8f98;
/* Syntax highlighting tokens — One Dark-inspired. Warmer than GitHub
Dark (which paints constants and strings in two shades of blue —
too much on our already-blue slate background). */
--code-comment: #7f848e;
--code-keyword: #c678dd;
--code-entity: #61afef;
--code-constant: #d19a66;
--code-string: #98c379;
--code-tag: #e06c75;
--code-addition-fg: #98c379;
--code-addition-bg: rgba(152, 195, 121, 0.14);
--code-deletion-fg: #e06c75;
--code-deletion-bg: rgba(224, 108, 117, 0.14);
--color-overlay: rgba(2, 6, 23, 0.4);
--color-focus-ring: rgba(96, 165, 250, 0.24);
--color-status-considering-bg: rgba(245, 158, 11, 0.16);
Expand Down Expand Up @@ -227,6 +287,21 @@
--color-tag-active-bg: #475569;
--color-tag-active-hover-bg: #64748b;
--color-code-bg: #0f172a;
--code-block-bg: #0d0d0f;
--code-header-bg: #17171a;
--code-header-text: #8b8f98;
/* Syntax highlighting tokens — One Dark-inspired (see the media-query
dark block above). */
--code-comment: #7f848e;
--code-keyword: #c678dd;
--code-entity: #61afef;
--code-constant: #d19a66;
--code-string: #98c379;
--code-tag: #e06c75;
--code-addition-fg: #98c379;
--code-addition-bg: rgba(152, 195, 121, 0.14);
--code-deletion-fg: #e06c75;
--code-deletion-bg: rgba(224, 108, 117, 0.14);
--color-overlay: rgba(2, 6, 23, 0.4);
--color-focus-ring: rgba(96, 165, 250, 0.24);
--color-status-considering-bg: rgba(245, 158, 11, 0.16);
Expand Down Expand Up @@ -1389,7 +1464,7 @@ img.avatar {
}

.markdown-rendered pre {
background: var(--color-code-bg);
background: var(--code-block-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: var(--space-md);
Expand All @@ -1400,6 +1475,32 @@ img.avatar {
line-height: 1.5;
}

/* Terminal-style header on fenced code blocks that declare a language:
traffic-light dots + the language name from the lang attribute. Pure CSS
(attr() in a pseudo-element) so it adds no DOM text — comment-anchor
matching against textContent never sees it. Mermaid blocks are excluded;
they get replaced by rendered diagrams. */
.markdown-rendered pre[lang]:not([lang="mermaid"])::before {
content: attr(lang);
display: block;
position: sticky; /* stay pinned when the code scrolls horizontally */
left: 0;
margin: calc(-1 * var(--space-md)) calc(-1 * var(--space-md)) var(--space-md);
padding: 0.5em var(--space-md) 0.5em 4.6em;
background-color: var(--code-header-bg);
background-image:
radial-gradient(circle at 1.1em 50%, #ff5f57 0.3em, transparent 0.36em),
radial-gradient(circle at 2.2em 50%, #febc2e 0.3em, transparent 0.36em),
radial-gradient(circle at 3.3em 50%, #28c840 0.3em, transparent 0.36em);
border-bottom: 1px solid var(--color-border);
border-radius: calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0 0;
color: var(--code-header-text);
font-size: 0.75rem;
line-height: 1.2;
letter-spacing: 0.04em;
user-select: none;
}

.markdown-rendered code {
font-family: var(--font-mono);
font-size: 0.875em;
Expand All @@ -1411,6 +1512,77 @@ img.avatar {
border-radius: 3px;
}

/* Syntax highlighting — highlight.js token classes mapped onto the
--code-* theme variables (GitHub Light/Dark palettes above). Applied by
syntax_highlight_controller, which loads grammars on demand. */
.markdown-rendered .hljs-comment,
.markdown-rendered .hljs-quote {
color: var(--code-comment);
}

.markdown-rendered .hljs-keyword,
.markdown-rendered .hljs-doctag,
.markdown-rendered .hljs-template-tag,
.markdown-rendered .hljs-type {
color: var(--code-keyword);
}

.markdown-rendered .hljs-title,
.markdown-rendered .hljs-title.class_,
.markdown-rendered .hljs-title.function_,
.markdown-rendered .hljs-section {
color: var(--code-entity);
}

.markdown-rendered .hljs-number,
.markdown-rendered .hljs-literal,
.markdown-rendered .hljs-symbol,
.markdown-rendered .hljs-variable,
.markdown-rendered .hljs-template-variable,
.markdown-rendered .hljs-attr,
.markdown-rendered .hljs-attribute,
.markdown-rendered .hljs-built_in,
.markdown-rendered .hljs-operator,
.markdown-rendered .hljs-meta,
.markdown-rendered .hljs-selector-attr,
.markdown-rendered .hljs-selector-pseudo,
.markdown-rendered .hljs-link {
color: var(--code-constant);
}

.markdown-rendered .hljs-string,
.markdown-rendered .hljs-regexp,
.markdown-rendered .hljs-char.escape_ {
color: var(--code-string);
}

.markdown-rendered .hljs-name,
.markdown-rendered .hljs-tag,
.markdown-rendered .hljs-selector-tag,
.markdown-rendered .hljs-selector-id,
.markdown-rendered .hljs-selector-class,
.markdown-rendered .hljs-bullet {
color: var(--code-tag);
}

.markdown-rendered .hljs-addition {
color: var(--code-addition-fg);
background: var(--code-addition-bg);
}

.markdown-rendered .hljs-deletion {
color: var(--code-deletion-fg);
background: var(--code-deletion-bg);
}

.markdown-rendered .hljs-emphasis {
font-style: italic;
}

.markdown-rendered .hljs-strong {
font-weight: 600;
}

.markdown-rendered .mermaid-diagram {
position: relative;
display: flex;
Expand Down
4 changes: 2 additions & 2 deletions engine/app/helpers/coplan/markdown_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ module MarkdownHelper
# version. Bump it whenever the rendering pipeline changes output for the
# same input (new tags, attribute changes, checkbox wiring, etc.), or
# stale HTML will be served from cache.
RENDER_CACHE_VERSION = 1
RENDER_CACHE_VERSION = 2

# Matches `[@username](mention:username)` where the bracket text and link
# target encode the same username. Username allows letters, digits, dots,
Expand All @@ -56,7 +56,7 @@ def render_markdown(content, interactive: true, footnote_prefix: nil)
sanitized = sanitize(with_chips, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES)
result = interactive ? make_checkboxes_interactive(sanitized, content) : sanitized
result = scope_footnote_ids(result, footnote_prefix) if footnote_prefix
tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid" })
tag.div(result.html_safe, class: "markdown-rendered", data: { controller: "coplan--mermaid coplan--syntax-highlight" })
end

# Replaces `<a href="mention:username">@username</a>` produced by
Expand Down
Loading
Loading